wilenPxs
Declinaisons: originalExerciseId systematique + grille miroir + bouton Stop
77c855f
Raw
History Blame Contribute Delete
13.6 kB
"""
generate.py
───────────
Découpage de l'exercice source (métadonnées / énoncé / segments de questions)
et génération par paires de questions (LLM). Code déplacé par blocs depuis
routes/pythonise_routes_v2.py ; les paires restent SÉQUENTIELLES (contexte
partagé : chaque appel reçoit les blocs déjà générés).
"""
from __future__ import annotations
import json
import logging
import re
from typing import Callable, Optional
from app.config import EXERCISE_FENCE_BACKTICKS, USE_REASONING
from app.llm.client import process_with_openrouter
from app.pipeline.postprocess import (
PYTHON_FENCE_RE,
normalize_python_fences,
strip_fences,
)
from app.pipeline.prompts import (
FGQ_SPEC,
MCQ_SPEC,
STEP_DECLINAISON_PROMPT,
STEP_PAIR_PROMPT,
SYSTEM_PROMPT,
)
logger = logging.getLogger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Découpage du source MyST
# ─────────────────────────────────────────────────────────────────────────────
def strip_python_block_from_text(text: str) -> str:
"""Retire les blocs {python} d'un texte (le Python est régénéré par paire)."""
cleaned = PYTHON_FENCE_RE.sub("", text)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
return cleaned.strip()
def split_metadata_and_enonce(header: str) -> tuple[str, str]:
"""Sépare l'en-tête en (métadonnées directives, énoncé libre)."""
lines = header.splitlines()
OPTION_RE = re.compile(r"^\s*:[A-Za-z_][\w-]*:")
DIRECTIVE_OPEN_RE = re.compile(r"^\s*(?:`{3,}|:{3,}|\\begin)")
metadata_end = 0
found_enonce = False
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
metadata_end = i + 1
continue
if (OPTION_RE.match(line) or DIRECTIVE_OPEN_RE.match(line)
or stripped in ("}", "{")):
metadata_end = i + 1
continue
found_enonce = True
break
if not found_enonce:
return header.rstrip(), ""
while metadata_end > 0 and not lines[metadata_end - 1].strip():
metadata_end -= 1
return "\n".join(lines[:metadata_end]), "\n".join(lines[metadata_end:]).strip()
def split_original_questions(content: str) -> tuple[str, str, list[str]]:
"""
Découpe l'exercice en (métadonnées, énoncé, segments de questions).
Chaque segment = [texte inter-question éventuel] + bloc :::::{question}.
Entrée non-MyST (pas de :::::{question}) → ("", content, []).
"""
if ":::::{question}" not in content:
return "", content, []
first_q = content.index(":::::{question}")
metadata, enonce = split_metadata_and_enonce(content[:first_q])
enonce = strip_python_block_from_text(enonce)
body_part = content[first_q:]
pattern = re.compile(r":::::\{question\}.*?:::::", re.DOTALL)
segments: list[str] = []
last_end = 0
for m in pattern.finditer(body_part):
inter_text = strip_python_block_from_text(body_part[last_end:m.start()].strip())
segments.append(f"{inter_text}\n\n{m.group(0)}" if inter_text else m.group(0))
last_end = m.end()
return metadata, enonce, segments
# Le sélecteur UI envoie ""/"Intermediate"/"Advanced"/"Elementary" ; la
# plateforme attend Elementary/Intermediary/Advanced (noter Intermediary).
_LEVEL_MAP = {
"": "",
"elementary": "Elementary",
"intermediate": "Intermediary",
"intermediary": "Intermediary",
"advanced": "Advanced",
}
# Ordre canonique des options de l'en-tête {exercise} (gabarit plateforme).
_HEADER_FIELDS = [
"id", "title", "modules", "recommendedExecutionTime", "level",
"chap", "involvedConcepts", "originalSource", "visibility",
]
def _parse_source_options(metadata: str) -> dict:
"""Extrait les `:option: valeur` de l'en-tête source (s'il en a un)."""
opts: dict[str, str] = {}
for m in re.finditer(r"^[ \t]*:([A-Za-z_][\w-]*):[ \t]*(.*?)[ \t]*$",
metadata or "", re.MULTILINE):
opts[m.group(1)] = m.group(2).strip()
return opts
def _norm_level(value: str) -> str:
return _LEVEL_MAP.get((value or "").strip().lower(), (value or "").strip())
def build_exercise_metadata(
metadata: str,
lists_of_notions: str,
analysis: dict | None = None,
level: str = "",
decl_type: str | None = None,
) -> str:
"""Construit TOUJOURS un en-tête `{exercise}` complet et bien formé
(5 backticks englobant tout l'exercice — le bloc Python à 4 backticks vient
juste après). Règles par champ (consigne utilisateur) :
• :id: vide (auto-attribution plateforme)
• :title: titre de la source sinon titre déduit de l'analyse
• :modules: / :chap: repris de la source si présents, sinon vides
• :recommendedExecutionTime: source sinon défaut (≈ 3 min/question)
• :level: selon le niveau SÉLECTIONNÉ dans l'UI (mappé Intermediary),
sinon niveau de la source, sinon Elementary
• :involvedConcepts: notions retrouvées (RAG) sinon concepts de la source
• :originalSource: repris de la source si présent
• :visibility: All par défaut
Reprend le MAXIMUM des métadonnées présentes dans la source."""
analysis = analysis or {}
src = _parse_source_options(metadata)
title = src.get("title") or analysis.get("exercise_title") or "Exercice"
if decl_type:
# Suffixe de traçabilité (convention des exemples validés : « - MCQ »).
suffix = " - MCQ" if decl_type == "qcm" else " - QAT"
if not title.rstrip().endswith(suffix.strip()):
title = title.rstrip() + suffix
nb_q = analysis.get("nb_questions") or 0
try:
nb_q = int(nb_q)
except (TypeError, ValueError):
nb_q = 0
ret = src.get("recommendedExecutionTime") or str(max(5, nb_q * 3) if nb_q else 10)
lvl = _norm_level(level) or _norm_level(src.get("level", "")) or "Elementary"
concepts = (lists_of_notions or "").strip() or src.get("involvedConcepts", "")
# "TYPE_BAC" est un placeholder de gabarit, pas un vrai concept → on l'ôte.
concepts = concepts.replace("TYPE_BAC,", "").replace("TYPE_BAC", "").strip().strip(",")
values = {
"id": "", # vide → la plateforme l'attribue
"title": title,
"modules": src.get("modules", ""), # repris si présent, sinon vide
"recommendedExecutionTime": ret,
"level": lvl,
"chap": src.get("chap", ""), # repris si présent, sinon vide
"involvedConcepts": concepts,
"originalSource": src.get("originalSource", ""),
"visibility": src.get("visibility") or "All",
}
fence = "`" * EXERCISE_FENCE_BACKTICKS
lines = [f"{fence}{{exercise}}"]
for k in _HEADER_FIELDS:
lines.append(f":{k}: {values[k]}".rstrip())
# Déclinaison : tracer l'id de l'exercice SOURCE juste après :id:
# (convention des 33 exemples validés : :originalExerciseId: TOUJOURS
# présent — vide si la source n'a pas d'id, à compléter côté plateforme).
if k == "id" and decl_type:
lines.append(f":originalExerciseId: {src.get('id', '')}".rstrip())
return "\n".join(lines)
_ENVELOPE_FENCE_RE = re.compile(r"(?m)^`{5,}(\{exercise\})?[ \t]*$")
def assemble_exercise(metadata_header: str, pair_blocks: list[str]) -> str:
"""En-tête `{exercise}` (posé par build_exercise_metadata) + blocs de paires
+ fence finale 5 backticks qui referme l'enveloppe APRÈS la dernière question.
Les fences {python} sont normalisées à 4 backticks (convention plateforme).
Filet : on retire toute ligne d'enveloppe 5-backticks que le LLM aurait
reproduite dans un bloc (on possède l'enveloppe nous-mêmes) — sinon on
obtiendrait une double enveloppe / des backticks non décroissants."""
parts = [metadata_header.rstrip()]
for block in pair_blocks:
cleaned = _ENVELOPE_FENCE_RE.sub("", block).strip()
if cleaned:
parts.append(cleaned)
parts.append("`````")
assembled = "\n\n".join(parts)
assembled = assembled.replace("{align*}", "{equation*}")
return normalize_python_fences(assembled)
# ─────────────────────────────────────────────────────────────────────────────
# Génération par paires
# ─────────────────────────────────────────────────────────────────────────────
_LANG_DIRECTIVES = {
"fr": "Langue cible de CETTE sortie : FRANÇAIS uniquement (décimales à virgule).",
"en": "Langue cible de CETTE sortie : ANGLAIS uniquement (décimales à point).",
"both": ("Langue cible de CETTE sortie : BILINGUE — chaque prose en rôles "
"{fr}`…`{en}`…` symétriques (cf. règle bilingue ci-dessus)."),
"auto": "Langue cible : MÊME langue(s) que la source (ne traduis rien).",
}
_DECL_LABELS = {"qcm": "QCM (MCQ)", "qat": "QAT (FGQ)"}
_DECL_SPECS = {"qcm": MCQ_SPEC, "qat": FGQ_SPEC}
def generate_pair_blocks(
content: str,
exercise_header: str,
enonce: str,
question_segments: list[str],
analysis: dict,
functions_ctx: str,
fewshot: str,
targeted_rules_digest: str,
property_constraints_text: str,
level: str,
model_idx: int,
lang: str = "auto",
set_step: Optional[Callable[[str], None]] = None,
decl_type: Optional[str] = None,
model: Optional[str] = None,
) -> list[str]:
"""Boucle de génération par paires (séquentielle). Retourne les blocs.
`decl_type` (qcm|qat) bascule sur le prompt Déclinaisons — même mécanique
par paires, 1 question source → 1 question déclinée."""
nb_questions = analysis.get("nb_questions", max(1, len(question_segments)))
lang_directive = _LANG_DIRECTIVES.get(lang, _LANG_DIRECTIVES["auto"])
if decl_type:
prompt_tmpl = STEP_DECLINAISON_PROMPT
common = dict(
analysis=json.dumps(analysis, ensure_ascii=False, indent=2),
functions=functions_ctx or "Aucune fonction spécifique détectée.",
niveau=level or "non précisé",
fewshot=fewshot,
lang_directive=lang_directive,
nb_total=nb_questions,
decl_label=_DECL_LABELS[decl_type],
decl_spec=_DECL_SPECS[decl_type],
)
else:
prompt_tmpl = STEP_PAIR_PROMPT
common = dict(
analysis=json.dumps(analysis, ensure_ascii=False, indent=2),
functions=functions_ctx or "Aucune fonction spécifique détectée.",
niveau=level or "non précisé",
targeted_rules=targeted_rules_digest,
property_constraints=property_constraints_text,
fewshot=fewshot,
lang_directive=lang_directive,
nb_total=nb_questions,
)
generated: list[str] = []
if not question_segments:
# Entrée texte brut → une seule génération.
if set_step:
set_step("Génération (bloc Python + toutes les questions)…")
raw = process_with_openrouter(
prompt=prompt_tmpl.format(
content=content,
previous_blocks="(aucun — première génération)",
nb_current=nb_questions,
range_label=f"1–{nb_questions}",
current_segment=content,
**common,
),
model_idx=model_idx,
model=model,
max_tokens=30000,
system_prompt=SYSTEM_PROMPT,
reasoning=USE_REASONING,
)
return [strip_fences(raw)]
pairs = [question_segments[i: i + 2] for i in range(0, len(question_segments), 2)]
for pair_idx, pair in enumerate(pairs):
q_start = pair_idx * 2 + 1
q_end = min(pair_idx * 2 + len(pair), nb_questions)
range_label = str(q_start) if q_start == q_end else f"{q_start}{q_end}"
if set_step:
set_step(f"Génération questions {range_label} / {nb_questions}…")
current_segment = "\n\n".join(pair)
if pair_idx == 0 and enonce:
current_segment = enonce + "\n\n" + current_segment
raw_pair = process_with_openrouter(
prompt=prompt_tmpl.format(
content=exercise_header,
previous_blocks=("\n\n".join(generated) if generated
else "(aucun — première paire)"),
nb_current=len(pair),
range_label=range_label,
current_segment=current_segment,
**common,
),
model_idx=model_idx,
model=model,
temperature=0.4,
max_tokens=16384,
system_prompt=SYSTEM_PROMPT,
reasoning=USE_REASONING,
)
generated.append(strip_fences(raw_pair))
return generated