Spaces:
Starting
Starting
| """ | |
| 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 | |