wilenPyxi commited on
Commit
9346c36
·
1 Parent(s): 54b6073

Nettoyage : retire 7 fichiers deposes par erreur a la racine du clone (doublons de app/ et tests/)

Browse files
Files changed (7) hide show
  1. audit.py +0 -331
  2. config.py +0 -124
  3. gen_decl_sample.py +0 -36
  4. index.html +0 -1048
  5. orchestrator.py +0 -695
  6. prompts.py +0 -999
  7. smoke.py +0 -581
audit.py DELETED
@@ -1,331 +0,0 @@
1
- """
2
- audit.py
3
- ────────
4
- Passe d'audit LLM (≤ MAX_AUDIT_ITERATIONS) + filet de sécurité des patches.
5
- Code déplacé depuis routes/pythonise_routes_v2.py avec deux corrections :
6
- • un patch sûr est appliqué à TOUTES les occurrences identiques de
7
- `location` (la v1 ne corrigeait que la première — un problème répété
8
- subsistait N-1 fois) ;
9
- • gestion d'erreurs ciblée (plus d'`except (json.JSONDecodeError, Exception)`).
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import json
15
- import logging
16
- import re
17
- from typing import Callable, Optional
18
-
19
- from app.config import MAX_AUDIT_ITERATIONS
20
- from app.knowledge.rules_digest import AUDIT_RULES_ALWAYS, build_rules_digest
21
- from app.llm.client import process_with_openrouter
22
- from app.pipeline.postprocess import insert_python_lines, strip_fences
23
- from app.pipeline.prompts import (
24
- PEDAGOGICAL_AUDIT_PROMPT,
25
- STEP_AUDIT_PROMPT,
26
- SYSTEM_PROMPT,
27
- )
28
-
29
- logger = logging.getLogger(__name__)
30
-
31
- _DECL_LABELS_PED = {"qcm": "QCM (MCQ)", "qat": "QAT (FGQ)"}
32
-
33
-
34
- def run_pedagogical_audit(exercise: str, decl_type: str,
35
- model: Optional[str] = None) -> dict:
36
- """Juge LLM de la QUALITÉ PÉDAGOGIQUE d'une déclinaison (au-delà du harnais
37
- mécanique). Retourne {verdict: OK|A_REVOIR|INCONNU, score, issues:[…], model}.
38
- Ne lève JAMAIS (dégrade en verdict INCONNU) — l'audit ne doit pas casser un
39
- pipeline dont la sortie est déjà VERTE au harnais.
40
-
41
- Robustesse JSON : essaie le modèle demandé (défaut PEDAGO_AUDIT_MODEL), puis
42
- RETOMBE sur NOTIONS_MODEL (OpenAI, JSON fiable) — un juge fort peut renvoyer
43
- une complétion vide (vu : deepseek-v4-pro content=null, 2026-07-06)."""
44
- from app.config import NOTIONS_MODEL, PEDAGO_AUDIT_MODEL
45
-
46
- label = _DECL_LABELS_PED.get(decl_type, "QCM (MCQ)")
47
- prompt = PEDAGOGICAL_AUDIT_PROMPT.format(decl_label=label, exercise=exercise)
48
- primary = model or PEDAGO_AUDIT_MODEL
49
- last_err = None
50
- seen: list = []
51
- for cand in (primary, NOTIONS_MODEL):
52
- if not cand or cand in seen:
53
- continue
54
- seen.append(cand)
55
- try:
56
- raw = process_with_openrouter(
57
- prompt=prompt, model=cand, temperature=0.0, max_tokens=2000,
58
- system_prompt=SYSTEM_PROMPT,
59
- )
60
- data = json.loads(strip_fences(raw))
61
- except (RuntimeError, ValueError, OSError) as e: # inclut JSONDecodeError
62
- last_err = e
63
- logger.warning("Audit pédagogique via %s en échec : %s", cand, e)
64
- continue
65
- issues = [i for i in (data.get("issues") or []) if isinstance(i, dict)]
66
- high = [i for i in issues if str(i.get("gravite", "")).lower().startswith("haut")]
67
- verdict = str(data.get("verdict", "")).upper()
68
- ok = verdict.startswith("OK") and not high # OK ⇔ aucune gravité haute
69
- return {"verdict": "OK" if ok else "A_REVOIR", "score": data.get("score"),
70
- "issues": issues, "model": cand, "error": None}
71
- return {"verdict": "INCONNU", "score": None, "issues": [],
72
- "model": None, "error": str(last_err)}
73
-
74
-
75
- def pedagogical_badness(ped: Optional[dict]) -> int:
76
- """Score de gravité (pour ne garder une réparation que si elle AMÉLIORE)."""
77
- if not ped:
78
- return 0
79
- issues = ped.get("issues") or []
80
- high = sum(1 for i in issues if str(i.get("gravite", "")).lower().startswith("haut"))
81
- return high * 100 + len(issues)
82
-
83
-
84
- def format_pedagogical_issues(issues: list) -> str:
85
- lines = [f" • [{i.get('gravite', '?')}] {i.get('ou', '')} — "
86
- f"{i.get('probleme', '')} → {i.get('correction', '')}"
87
- for i in issues]
88
- return "\n".join(lines) or " (aucun détail fourni)"
89
-
90
-
91
- # ─────────────────────────────────────────────────────────────────────────────
92
- # Filet de sécurité des patches (déplacé tel quel, noms inchangés)
93
- # ─────────────────────────────────────────────────────────────────────────────
94
-
95
- _KNOWN_FREE_NAMES = frozenset({
96
- "True", "False", "None", "and", "or", "not", "in", "is", "if", "else",
97
- "elif", "for", "while", "def", "class", "return", "lambda", "yield",
98
- "with", "as", "from", "import", "pass", "break", "continue", "try",
99
- "except", "finally", "raise", "global", "nonlocal", "assert", "del",
100
- "self", "cls",
101
- "pi", "e", "oo", "abs", "min", "max", "sum", "range", "len", "int",
102
- "float", "str", "bool", "list", "tuple", "dict", "set", "round", "pow",
103
- "all", "any", "map", "filter", "zip", "enumerate", "sorted", "reversed",
104
- "print", "isinstance", "type", "repr", "hash",
105
- "x", "y", "z", "t", "n", "config_standard",
106
- })
107
-
108
-
109
- def _patch_introduces_unbound_name(
110
- myst_exercise: str,
111
- location: str,
112
- fix: str,
113
- python_insert: Optional[str],
114
- ) -> Optional[str]:
115
- """Nom INJECTÉ ({{var}}) introduit par `fix` qui ne serait lié nulle part
116
- après patch (NameError au rendu). None = sûr sur ce critère.
117
- Volontairement limité aux placeholders : les autres mots d'un patch
118
- markdown sont de la prose/du LaTeX, pas des variables Python (la v2 du
119
- filet flaguait à tort `id`, `f`, `align`… dans des patches purement texte)."""
120
- fix_names = set(re.findall(r"\{\{\s*([a-zA-Z_]\w*)\s*\}\}", fix))
121
- loc_names = set(re.findall(r"\{\{\s*([a-zA-Z_]\w*)\s*\}\}", location))
122
- new_names = (fix_names - loc_names) - _KNOWN_FREE_NAMES
123
- if not new_names:
124
- return None
125
-
126
- after_patch = myst_exercise.replace(location, fix)
127
- if python_insert:
128
- after_patch += "\n" + python_insert
129
-
130
- for name in new_names:
131
- patterns = (
132
- rf"\b{re.escape(name)}\s*=(?!=)",
133
- rf"\bdef\s+{re.escape(name)}\b",
134
- rf"\bclass\s+{re.escape(name)}\b",
135
- rf"\bfor\s+{re.escape(name)}\b",
136
- rf"\bas\s+{re.escape(name)}\b",
137
- rf"\bimport\s+(?:\w+\s*,\s*)*{re.escape(name)}\b",
138
- rf"\bfrom\s+[\w.]+\s+import\s+(?:[^,\n]*,\s*)*{re.escape(name)}\b",
139
- rf"\bdef\s+\w+\([^)]*\b{re.escape(name)}\b[^)]*\)",
140
- )
141
- if not any(re.search(p, after_patch) for p in patterns):
142
- return name
143
- return None
144
-
145
-
146
- def _is_patch_safe(
147
- myst_exercise: str,
148
- location: str,
149
- fix: str,
150
- python_insert: Optional[str] = None,
151
- ) -> tuple[bool, str]:
152
- """Validation défensive avant application d'un patch d'audit."""
153
- # 1) Alias d'import supprimé mais encore utilisé.
154
- alias_re = re.compile(r"\bimport\b[^\n]*?\bas\s+(\w+)")
155
- dropped = set(alias_re.findall(location)) - set(alias_re.findall(fix))
156
- if dropped:
157
- rest = myst_exercise.replace(location, "", 1)
158
- for alias in dropped:
159
- if re.search(rf"\b{re.escape(alias)}\s*\(", rest):
160
- return False, f"Alias `{alias}` est utilisé ailleurs dans le code — patch refusé."
161
-
162
- # 1bis) Import supprimé mais encore référencé.
163
- def _extract_imported_names(text: str) -> set[str]:
164
- names: set[str] = set()
165
- for m in re.finditer(r"^\s*import\s+([\w.]+)(?:\s+as\s+(\w+))?", text, re.MULTILINE):
166
- names.add(m.group(2) or m.group(1).split(".")[0])
167
- for m in re.finditer(r"^\s*from\s+[\w.]+\s+import\s+(.+?)\s*$", text, re.MULTILINE):
168
- for piece in m.group(1).split(","):
169
- am = re.match(r"^(\w+)(?:\s+as\s+(\w+))?$", piece.strip())
170
- if am:
171
- names.add(am.group(2) or am.group(1))
172
- return names
173
-
174
- dropped_imports = _extract_imported_names(location) - _extract_imported_names(fix)
175
- if dropped_imports:
176
- rest = myst_exercise.replace(location, fix, 1)
177
- for name in dropped_imports:
178
- if re.search(rf"\b{re.escape(name)}\b(?:\s*[(.])", rest):
179
- return False, (f"L'import `{name}` est encore utilisé ailleurs après le patch "
180
- "— refus pour éviter une NameError au runtime.")
181
-
182
- # 2) Suppression de globals().
183
- if "globals()" in location and "globals()" not in fix:
184
- return False, "Le patch supprimerait `globals()` (requis en fin de bloc python)."
185
-
186
- # 2bis) Règle 6.4 — **config_standard sur un helper PyxiScience (réservé à
187
- # sympy.latex). Liste alignée sur le catalogue curé app/knowledge.
188
- _PYXISCIENCE_HELPERS = (
189
- "pxsl_format_number", "pxsl_res_num", "pxsl_matrix", "pxsl_pow",
190
- "pxsl_latex_coefficient", "pxsl_par", "pxsl_mult", "pxsl_choose_udv", "lc",
191
- "pxsl_latex", "pxsl_sign", "pxsl_latex_with_formatting", "pxsl_Rational",
192
- "pxsl_sum_matrix", "pxsl_prod_matrix", "pxsl_prod_scalar_matrix",
193
- "pxsl_ax", "pxsl_system_lin", "pxsl_double_matrix", "pxsl_lines_op",
194
- "pxsl_resol_system", "pxsl_pow_matrix", "pxsl_law", "pxsl_moment",
195
- "pxsl_scalar_product", "pxsl_sum_vector", "pxs_explain_IBP",
196
- )
197
-
198
- def _splat_pattern(name: str) -> str:
199
- return (rf"\b{re.escape(name)}\s*\("
200
- r"(?:[^()]|\([^()]*\))*"
201
- r"\*\*\s*config_standard"
202
- r"(?:[^()]|\([^()]*\))*\)")
203
-
204
- for helper in _PYXISCIENCE_HELPERS:
205
- pat = _splat_pattern(helper)
206
- if re.search(pat, fix) and not re.search(pat, location):
207
- return False, (f"Le patch ajoute `**config_standard` à `{helper}(...)` — "
208
- "ce helper PyxiScience ne l'accepte pas et plante (règle 6.4). "
209
- "Réserve `**config_standard` à `sympy.latex(...)` uniquement.")
210
-
211
- # 3) Variable jamais définie.
212
- unbound = _patch_introduces_unbound_name(myst_exercise, location, fix, python_insert)
213
- if unbound:
214
- return False, (f"Le patch introduit la variable `{unbound}` qui n'est définie nulle part "
215
- "(NameError à l'exécution). Si une définition Python est nécessaire, "
216
- "utilise `python_insert` dans l'audit.")
217
- return True, ""
218
-
219
-
220
- def _replace_everywhere(text: str, location: str, fix: str) -> tuple[str, int]:
221
- """Remplace TOUTES les occurrences de location (1 seule si fix ⊇ location,
222
- pour éviter la ré-expansion infinie)."""
223
- if location in fix:
224
- return text.replace(location, fix, 1), 1
225
- n = text.count(location)
226
- return text.replace(location, fix), n
227
-
228
-
229
- # ─────────────────────────────────────────────────────────────────────────────
230
- # Boucle d'audit
231
- # ─────────────────────────────────────────────────────────────────────────────
232
-
233
- def run_audit(
234
- myst_exercise: str,
235
- step1_targets: list[str],
236
- model_idx: int,
237
- set_step: Optional[Callable[[str], None]] = None,
238
- model: Optional[str] = None,
239
- ) -> tuple[str, list[dict], list[dict]]:
240
- """Audit LLM en boucle (≤ MAX_AUDIT_ITERATIONS). Retourne
241
- (exercice patché, patches appliqués, warnings)."""
242
- audit_rule_ids = list(dict.fromkeys(AUDIT_RULES_ALWAYS + step1_targets))
243
- audit_digest = build_rules_digest(audit_rule_ids)
244
-
245
- patches: list[dict] = []
246
- warnings: list[dict] = []
247
-
248
- for audit_iter in range(MAX_AUDIT_ITERATIONS):
249
- if set_step:
250
- set_step(f"Audit {audit_iter + 1}/{MAX_AUDIT_ITERATIONS}…")
251
- try:
252
- audit_raw = process_with_openrouter(
253
- prompt=STEP_AUDIT_PROMPT.format(
254
- audit_rules=audit_digest,
255
- exercise=myst_exercise,
256
- ),
257
- model_idx=model_idx,
258
- model=model,
259
- temperature=0.0,
260
- max_tokens=8192,
261
- system_prompt=SYSTEM_PROMPT,
262
- )
263
- except (RuntimeError, ValueError, OSError) as audit_err:
264
- logger.warning("Appel d'audit en échec : %s", audit_err)
265
- warnings.append({"rule": "?", "message": f"Audit pass failed: {audit_err}"})
266
- break
267
-
268
- try:
269
- audit_data = json.loads(strip_fences(audit_raw))
270
- except json.JSONDecodeError:
271
- warnings.append({
272
- "rule": "?",
273
- "message": "Audit LLM did not return valid JSON; skipping further iterations.",
274
- })
275
- break
276
-
277
- if audit_data.get("verdict") == "OK":
278
- break
279
-
280
- applied_this_iter = 0
281
- for issue in audit_data.get("issues") or []:
282
- if not isinstance(issue, dict):
283
- continue
284
- rule = str(issue.get("rule", "?"))
285
- location = issue.get("location") or ""
286
- fix = issue.get("fix")
287
- python_insert = issue.get("python_insert")
288
- can_patch = bool(issue.get("can_patch", True))
289
- message = issue.get("message") or ""
290
- applied = False
291
-
292
- if (can_patch and isinstance(location, str) and isinstance(fix, str)
293
- and location and location in myst_exercise):
294
- safe, reason = _is_patch_safe(myst_exercise, location, fix, python_insert)
295
- if safe:
296
- myst_exercise, n_occ = _replace_everywhere(myst_exercise, location, fix)
297
- applied = True
298
- if n_occ > 1:
299
- message += f" ({n_occ} occurrences corrigées)"
300
- else:
301
- warnings.append({
302
- "rule": rule,
303
- "location": location,
304
- "message": f"Patch refusé par filet de sécurité : {reason} (suggestion: {message})",
305
- })
306
- continue
307
-
308
- if applied and isinstance(python_insert, str) and python_insert.strip():
309
- myst_exercise = insert_python_lines(myst_exercise, [python_insert.strip()])
310
-
311
- if applied:
312
- patches.append({
313
- "rule": rule,
314
- "location": location,
315
- "fix": fix,
316
- "python_insert": python_insert or None,
317
- "message": message,
318
- "iteration": audit_iter + 1,
319
- })
320
- applied_this_iter += 1
321
- else:
322
- warnings.append({
323
- "rule": rule,
324
- "location": location if isinstance(location, str) else "",
325
- "message": message,
326
- })
327
-
328
- if applied_this_iter == 0:
329
- break
330
-
331
- return myst_exercise, patches, warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py DELETED
@@ -1,124 +0,0 @@
1
- """
2
- config.py
3
- ─────────
4
- Configuration centrale de l'app « Pythonise Exercice v2 ».
5
- Toutes les constantes réglables vivent ici — une seule source de vérité.
6
- """
7
-
8
- import os
9
- from pathlib import Path
10
-
11
- # ── Chemins ──────────────────────────────────────────────────────────────────
12
- PACKAGE_DIR = Path(__file__).resolve().parent # …/pythonisation_app/app
13
- BASE_DIR = PACKAGE_DIR.parent # …/pythonisation_app
14
- DATA_DIR = BASE_DIR / "data"
15
- NOTIONS_XLSX = DATA_DIR / "notions.xlsx"
16
- FAISS_CACHE = DATA_DIR / "faiss_cache" / "sources"
17
- CORPUS_DIR = PACKAGE_DIR / "corpus" # 5 fichiers de fonctions PyxiScience
18
- KNOWLEDGE_DIR = PACKAGE_DIR / "knowledge"
19
- RULES_MD = KNOWLEDGE_DIR / "pythonisation_rules.md"
20
- FEWSHOTS_DIR = KNOWLEDGE_DIR / "fewshots"
21
- TEMPLATES_DIR = PACKAGE_DIR / "web" / "templates"
22
-
23
- # ── Version applicative (exposée par /health pour vérifier un déploiement) ───
24
- # Bumper à chaque déploiement significatif : permet de répondre « à jour ? »
25
- # sans se connecter (curl /health → champ "version").
26
- APP_VERSION = "2026-07-06 — audit pédagogique + MCQ_SPEC v3 + Stop + Render"
27
-
28
- # ── Convention MyST (vérifiée empiriquement : 222/222 exemples plateforme) ───
29
- # Bloc {python} = 4 backticks ; enveloppe {exercise} = 5 backticks.
30
- PYTHON_FENCE_BACKTICKS = 4
31
- EXERCISE_FENCE_BACKTICKS = 5
32
-
33
- # ── Modèles LLM (IDs vérifiés sur l'API OpenRouter le 2026-07-02) ────────────
34
- # NOTE : claude-fable-5 retiré volontairement (§7 du prompt banc multi-modèles).
35
- AVAILABLE_MODELS = {
36
- 0: "anthropic/claude-opus-4.8",
37
- 1: "anthropic/claude-sonnet-5",
38
- 2: "anthropic/claude-haiku-4.5",
39
- 3: "google/gemini-2.5-pro",
40
- 4: "openai/gpt-5.4",
41
- }
42
- DEFAULT_MODEL_IDX = 1 # claude-sonnet-5
43
-
44
- # Modèle de l'étape d'analyse : None = suivre le modèle choisi par l'utilisateur
45
- # (corrige le model_idx=2 codé en dur de l'ancienne version) ; un int force un
46
- # modèle dédié pour l'analyse.
47
- ANALYSIS_MODEL_IDX: int | None = None
48
-
49
- # Modèle du juge de notions (appel léger, JSON court). DOIT supporter
50
- # response_format=json_object côté OpenRouter (modèles OpenAI — les Claude
51
- # le rejettent et le retriever dégrade en contexte vide).
52
- NOTIONS_MODEL = "openai/gpt-5-mini"
53
-
54
- # Prix $/M tokens (fallback si l'API generation ne renvoie pas le coût réel).
55
- # Relevés sur openrouter.ai le 2026-07-02. Source détaillée (cache/batch) :
56
- # app/models/prices.json — à re-vérifier avant prod, ça bouge chaque semaine.
57
- MODEL_PRICING = {
58
- "anthropic/claude-opus-4.8": {"input": 5.0, "output": 25.0},
59
- "anthropic/claude-sonnet-5": {"input": 2.0, "output": 10.0},
60
- "anthropic/claude-haiku-4.5": {"input": 1.0, "output": 5.0},
61
- "google/gemini-2.5-pro": {"input": 1.25, "output": 10.0},
62
- "google/gemini-2.5-flash": {"input": 0.3, "output": 2.5},
63
- "openai/gpt-5.4": {"input": 2.5, "output": 15.0},
64
- "openai/gpt-5.4-nano": {"input": 0.2, "output": 1.25},
65
- "x-ai/grok-4.3": {"input": 1.25, "output": 2.5},
66
- "moonshotai/kimi-k2.6": {"input": 0.55, "output": 3.2},
67
- "z-ai/glm-5.2": {"input": 0.93, "output": 3.0},
68
- "z-ai/glm-4.7-flash": {"input": 0.06, "output": 0.4},
69
- "deepseek/deepseek-v4-pro": {"input": 0.435, "output": 0.87},
70
- "deepseek/deepseek-v4-flash": {"input": 0.089, "output": 0.18},
71
- "mistralai/mistral-large-2512": {"input": 0.5, "output": 1.5},
72
- "mistralai/mistral-small-3.2-24b-instruct": {"input": 0.075, "output": 0.2},
73
- "minimax/minimax-m3": {"input": 0.3, "output": 1.2},
74
- }
75
-
76
- # ── Politique de sélection de modèle (banc multi-modèles, §5) ────────────────
77
- DEFAULT_POLICY = "auto" # auto | best | cheap | manual
78
- SEUIL_VERT = 0.90 # taux VERT minimal pour qu'un modèle « tienne »
79
- MAX_ESCALADES = 3 # plafond d'échelons gravis en mode auto
80
- PRICES_PATH = PACKAGE_DIR / "models" / "prices.json"
81
- RECOMMENDED_PATH = PACKAGE_DIR / "models" / "recommended.json"
82
- # Choix explicites du mode `manual` (clés du catalogue app/models/catalog.py).
83
- MODEL_GENERATE = "claude-sonnet-5"
84
- MODEL_AUDIT = "claude-opus-4-8"
85
- MODEL_MECANIQUE = "claude-haiku-4-5"
86
-
87
- # ── Pipeline ─────────────────────────────────────────────────────────────────
88
- RAG_TOP_K = 10 # catalogue RAG (était 3 — trop étroit)
89
- RAG_EMBEDDING_MODEL = "openai-3-small"
90
- MAX_AUDIT_ITERATIONS = 2
91
- USE_REASONING = False # extended thinking sur les appels de génération
92
- REASONING_CONFIG = {"max_tokens": 4000} # utilisé seulement si USE_REASONING
93
- MULTI_SEED_NUM = 100 # graines de la validation d'invariants (règle 4.3)
94
- HARNESS_GATE_SEEDS = 100 # graines de la porte harnais en fin de pipeline
95
- HARNESS_REPAIR_MAX = 2 # boucles de réparation LLM si la porte est rouge
96
-
97
- # ── Audit pédagogique des déclinaisons (au-delà du harnais mécanique) ────────
98
- # Juge LLM de la QUALITÉ (distracteurs cohérents, indevinabilité, consignes)
99
- # après une sortie VERTE au harnais. Coût : +1 appel LLM/déclinaison (+1 si
100
- # réparation). Mettre PEDAGO_AUDIT_ENABLED=False pour revenir au harnais seul.
101
- PEDAGO_AUDIT_ENABLED = True
102
- PEDAGO_REPAIR_MAX = 1 # réparations pédagogiques ciblées (structure préservée)
103
- PEDAGO_ESCALATE_IN_AUTO = True # mode auto : escalade de modèle si qualité insuffisante
104
- # Modèle du JUGE pédagogique (constant, indépendant du modèle de génération qui
105
- # escalade). Exige un fort raisonnement ET un JSON fiable — deepseek-v4-pro (rôle
106
- # audit) renvoyait content=null sur ce prompt (2026-07-06). Repli = NOTIONS_MODEL
107
- # (OpenAI, JSON garanti) si le primaire échoue encore. IDs OpenRouter en chaîne.
108
- PEDAGO_AUDIT_MODEL = "openai/gpt-5.4"
109
-
110
- # ── Langue cible ─────────────────────────────────────────────────────────────
111
- DEFAULT_LANG = "fr" # "fr" | "en" | "both"
112
-
113
- # ── Modes (pythonisation / déclinaisons QCM-QAT) ─────────────────────────────
114
- DEFAULT_MODE = "pythonise" # "pythonise" | "declinaisons"
115
- DECLINAISON_TYPES = ("qcm", "qat")
116
- MCQ_NUM_OPTIONS = 5 # 1 correcte + 3 distracteurs + « None » en dernier
117
- QAT_FALLBACK_TO_MCQ = True # question non auto-corrigeable en champ libre → MCQ
118
-
119
- # ── Serveur / jobs ───────────────────────────────────────────────────────────
120
- JOB_TTL = 1800 # s avant purge d'un job terminé
121
- # HOST/PORT pilotables par l'environnement (déploiement). En local : 127.0.0.1.
122
- # En conteneur (Hugging Face Spaces) : HOST=0.0.0.0, PORT=7860 (imposé par HF).
123
- HOST = os.getenv("HOST", "127.0.0.1")
124
- PORT = int(os.getenv("PORT", "5000"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
gen_decl_sample.py DELETED
@@ -1,36 +0,0 @@
1
- """Génère une déclinaison réelle et sauvegarde la sortie pour inspection.
2
-
3
- PYTHONPATH=. .venv/bin/python tests/gen_decl_sample.py [qcm|qat] [exo]
4
- """
5
- import sys
6
- from pathlib import Path
7
-
8
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
9
-
10
- from app import _load_env, _setup_logging
11
-
12
- _setup_logging()
13
- _load_env()
14
-
15
- from app.pipeline.orchestrator import run_declinaisons
16
-
17
- decl = sys.argv[1] if len(sys.argv) > 1 else "qcm"
18
- SRC = Path(sys.argv[2]) if len(sys.argv) > 2 else \
19
- Path(__file__).resolve().parent.parent / "bench" / "corpus" / "trinome_pythonise.md"
20
-
21
- content = SRC.read_text(encoding="utf-8")
22
- (dt, res), = run_declinaisons(content, filename=SRC.name, lang="fr",
23
- types=[decl])
24
- out = Path(f"/tmp/decl_{decl}_sample.md")
25
- out.write_text(res["exercise"], encoding="utf-8")
26
- print(f"harnais : {'VERT' if res['harness']['ok'] else 'ROUGE'} → {out}")
27
- ped = res.get("pedagogical")
28
- if ped:
29
- print(f"pédagogique : verdict={ped.get('verdict')} score={ped.get('score')} "
30
- f"issues={len(ped.get('issues') or [])}")
31
- for it in (ped.get("issues") or [])[:5]:
32
- print(f" - [{it.get('gravite')}] {it.get('ou')} : {it.get('probleme')}")
33
- tel = res.get("policy_telemetry")
34
- if tel:
35
- print(f"policy : échelons={len(tel.get('tried') or [])} "
36
- f"gagnant={tel.get('winning_model')} pédago={tel.get('pedago_verdict')}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html DELETED
@@ -1,1048 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="fr">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>Pythonise Exercice — v2</title>
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
-
8
- <link rel="preconnect" href="https://fonts.googleapis.com">
9
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
- <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
11
-
12
- <style>
13
- :root {
14
- --bg: #f7f7f8;
15
- --panel: #ffffff;
16
- --panel-alt: #fafafb;
17
- --panel-hover: #f4f4f5;
18
- --border: #e4e4e7;
19
- --border-soft: #eeeef0;
20
- --border-strong: #d4d4d8;
21
- --text: #09090b;
22
- --text-2: #27272a;
23
- --text-muted: #71717a;
24
- --text-soft: #a1a1aa;
25
- --primary: #18181b;
26
- --primary-hover: #27272a;
27
- --primary-fg: #fafafa;
28
- --accent: #2563eb;
29
- --success: #16a34a;
30
- --success-bg: #f0fdf4;
31
- --success-border:#bbf7d0;
32
- --error: #dc2626;
33
- --error-bg: #fef2f2;
34
- --error-border: #fecaca;
35
- --warn: #ca8a04;
36
- --warn-bg: #fefce8;
37
- --sans: 'Geist', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, sans-serif;
38
- --mono: 'Geist Mono', 'JetBrains Mono', "SF Mono", Menlo, monospace;
39
- --radius: 10px;
40
- --radius-sm: 6px;
41
- --radius-xs: 4px;
42
- --shadow-xs: 0 1px 2px rgba(9, 9, 11, 0.04);
43
- --shadow-sm: 0 1px 2px rgba(9, 9, 11, 0.04), 0 2px 8px rgba(9, 9, 11, 0.03);
44
- --shadow-focus: 0 0 0 3px rgba(37, 99, 235, 0.12);
45
- }
46
-
47
- * { box-sizing: border-box; }
48
- html, body { margin: 0; padding: 0; }
49
- [hidden] { display: none !important; }
50
-
51
- body {
52
- background: var(--bg);
53
- color: var(--text);
54
- font-family: var(--sans);
55
- font-size: 14px;
56
- line-height: 1.55;
57
- -webkit-font-smoothing: antialiased;
58
- }
59
- .container { max-width: 1320px; margin: 0 auto; padding: 40px 32px 56px; }
60
-
61
- .header {
62
- display: flex; align-items: center; justify-content: space-between; gap: 16px;
63
- margin-bottom: 28px; padding-bottom: 24px;
64
- border-bottom: 1px solid var(--border-soft); flex-wrap: wrap;
65
- }
66
- .header-left { display: flex; align-items: center; gap: 14px; }
67
- .header-mark {
68
- width: 36px; height: 36px; border-radius: 8px;
69
- background: var(--primary); color: var(--primary-fg);
70
- display: flex; align-items: center; justify-content: center;
71
- font-family: var(--mono); font-weight: 500; font-size: 13px;
72
- }
73
- .header-text h1 { margin: 0 0 2px; font-size: 17px; font-weight: 600; }
74
- .header-text p { margin: 0; color: var(--text-muted); font-size: 13px; }
75
- .header-status {
76
- display: inline-flex; align-items: center; gap: 8px; font-size: 12px;
77
- color: var(--text-muted); font-weight: 500; padding: 6px 12px;
78
- background: var(--panel); border: 1px solid var(--border); border-radius: 999px;
79
- }
80
- .header-status .dot {
81
- width: 6px; height: 6px; border-radius: 50%; background: var(--success);
82
- box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15);
83
- }
84
-
85
- .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
86
- @media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
87
-
88
- .panel {
89
- background: var(--panel); border: 1px solid var(--border);
90
- border-radius: var(--radius); box-shadow: var(--shadow-sm);
91
- padding: 24px; display: flex; flex-direction: column; min-width: 0;
92
- }
93
- .panel-title {
94
- font-size: 11px; font-weight: 600; color: var(--text-muted);
95
- margin: 0 0 16px; letter-spacing: 0.08em; text-transform: uppercase;
96
- }
97
-
98
- .field { margin-bottom: 14px; }
99
- .field-label {
100
- display: block; font-size: 12px; font-weight: 500; color: var(--text-2);
101
- margin-bottom: 6px;
102
- }
103
- textarea, input[type="text"], select {
104
- width: 100%; background: var(--panel); border: 1px solid var(--border);
105
- border-radius: var(--radius-sm); padding: 10px 12px; font-family: inherit;
106
- font-size: 13px; line-height: 1.55; color: var(--text);
107
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
108
- }
109
- textarea {
110
- font-family: var(--mono); font-size: 12.5px; line-height: 1.6;
111
- height: 320px; resize: vertical; min-height: 160px;
112
- }
113
- textarea:focus, input[type="text"]:focus, select:focus {
114
- outline: none; border-color: var(--accent); box-shadow: var(--shadow-focus);
115
- }
116
- select {
117
- appearance: none; -webkit-appearance: none;
118
- background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3 5 L6 8 L9 5'/></svg>");
119
- background-repeat: no-repeat; background-position: right 10px center;
120
- background-size: 12px; padding-right: 32px; cursor: pointer; font-weight: 500;
121
- }
122
-
123
- .meta { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px 14px; margin: 18px 0 20px; }
124
- .meta-full { grid-column: 1 / -1; }
125
-
126
- .actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
127
- .btn {
128
- display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;
129
- border-radius: var(--radius-sm); border: 1px solid var(--border);
130
- background: var(--panel); color: var(--text); font-family: inherit;
131
- font-size: 13px; font-weight: 500; cursor: pointer;
132
- transition: all 0.15s ease; box-shadow: var(--shadow-xs);
133
- }
134
- .btn:hover:not(:disabled) { background: var(--panel-hover); border-color: var(--border-strong); }
135
- .btn:disabled { opacity: 0.45; cursor: not-allowed; }
136
- .btn--primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
137
- .btn--primary:hover:not(:disabled) { background: var(--primary-hover); }
138
- .btn--ghost { background: transparent; border-color: transparent; color: var(--text-muted); box-shadow: none; padding: 7px 12px; }
139
- .btn--ghost:hover:not(:disabled) { background: var(--panel-hover); color: var(--text); }
140
- .btn--small { padding: 5px 11px; font-size: 12px; }
141
-
142
- /* Batch file list */
143
- .filelist { list-style: none; padding: 0; margin: 10px 0 0; }
144
- .filelist li {
145
- display: flex; align-items: center; gap: 8px;
146
- font-family: var(--mono); font-size: 12px; color: var(--text-2);
147
- padding: 5px 8px; border: 1px solid var(--border-soft);
148
- border-radius: var(--radius-xs); margin-bottom: 4px; background: var(--panel-alt);
149
- }
150
- .filelist .rm { margin-left: auto; cursor: pointer; color: var(--text-soft); border: none; background: none; font-size: 13px; }
151
- .filelist .rm:hover { color: var(--error); }
152
-
153
- .progress { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border-soft); }
154
- .progress-line { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-2); }
155
- .progress-spinner {
156
- width: 12px; height: 12px; border: 1.5px solid var(--border-strong);
157
- border-top-color: var(--text); border-radius: 50%;
158
- animation: spin 0.7s linear infinite; flex-shrink: 0;
159
- }
160
- .progress-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
161
- .progress-step {
162
- font-family: var(--mono); font-size: 11px; color: var(--text-muted);
163
- padding: 2px 7px; background: var(--bg); border: 1px solid var(--border-soft);
164
- border-radius: var(--radius-xs); white-space: nowrap;
165
- }
166
- .progress-bar { margin-top: 10px; height: 3px; background: var(--border-soft); border-radius: 3px; overflow: hidden; position: relative; }
167
- .progress-bar-fill { position: absolute; inset: 0; background: var(--text); transform: translateX(-100%); border-radius: 3px; }
168
- .progress.is-running .progress-bar-fill { animation: slide 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
169
- .progress.is-done .progress-bar-fill { transform: translateX(0); background: var(--success); }
170
- .progress.is-error .progress-bar-fill { transform: translateX(0); background: var(--error); }
171
- .progress.is-idle .progress-spinner, .progress.is-done .progress-spinner, .progress.is-error .progress-spinner { display: none; }
172
-
173
- @keyframes spin { to { transform: rotate(360deg); } }
174
- @keyframes slide {
175
- 0% { transform: translateX(-100%) scaleX(0.35); }
176
- 50% { transform: translateX(0%) scaleX(0.65); }
177
- 100% { transform: translateX(100%) scaleX(0.35); }
178
- }
179
- @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
180
-
181
- .status {
182
- margin-top: 12px; padding: 10px 14px; border-radius: var(--radius-sm);
183
- font-size: 13px; border: 1px solid transparent; animation: fadeIn 0.2s ease-out;
184
- display: flex; align-items: center; gap: 8px;
185
- }
186
- .status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
187
- .status.is-error { background: var(--error-bg); border-color: var(--error-border); color: #991b1b; }
188
- .status.is-error::before { background: var(--error); }
189
- .status.is-done { background: var(--success-bg); border-color: var(--success-border); color: #166534; }
190
- .status.is-done::before { background: var(--success); }
191
- .status.is-warn { background: var(--warn-bg); border-color: #fde68a; color: #854d0e; }
192
- .status.is-warn::before { background: var(--warn); }
193
-
194
- /* Résumé batch + sélecteur de fichier résultat */
195
- .result-files { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
196
- .result-file {
197
- display: inline-flex; align-items: center; gap: 6px;
198
- font-family: var(--mono); font-size: 11.5px; padding: 4px 10px;
199
- border: 1px solid var(--border); border-radius: 999px;
200
- background: var(--panel); cursor: pointer; color: var(--text-2);
201
- }
202
- .result-file.is-active { border-color: var(--text); background: var(--panel-hover); }
203
- .verdict-dot { width: 7px; height: 7px; border-radius: 50%; }
204
- .verdict-dot.ok { background: var(--success); }
205
- .verdict-dot.ko { background: var(--error); }
206
- .verdict-dot.err { background: var(--warn); }
207
-
208
- .tabs {
209
- display: flex; align-items: center; gap: 2px; margin-bottom: 14px;
210
- padding: 4px; background: var(--bg); border: 1px solid var(--border-soft);
211
- border-radius: var(--radius-sm); flex-wrap: wrap;
212
- }
213
- .tab {
214
- background: transparent; border: none; padding: 6px 12px; font-family: inherit;
215
- font-size: 13px; font-weight: 500; color: var(--text-muted); cursor: pointer;
216
- border-radius: var(--radius-xs); transition: all 0.15s ease;
217
- }
218
- .tab.is-active { color: var(--text); background: var(--panel); box-shadow: var(--shadow-xs); }
219
- .tab-badge {
220
- display: inline-block; margin-left: 5px; padding: 0 6px; min-width: 18px;
221
- height: 16px; font-size: 10px; font-family: var(--mono); font-weight: 500;
222
- background: var(--text); color: var(--primary-fg); border-radius: 8px;
223
- line-height: 16px; text-align: center; vertical-align: 1px;
224
- }
225
- .tab-spacer { flex: 1; }
226
- .tab-panels { flex: 1; min-height: 0; display: flex; flex-direction: column; }
227
- .tab-panel { display: none; flex: 1; min-height: 0; flex-direction: column; }
228
- .tab-panel.is-active { display: flex; animation: fadeIn 0.18s ease-out; }
229
-
230
- .output {
231
- background: var(--panel-alt); border: 1px solid var(--border-soft);
232
- border-radius: var(--radius-sm); padding: 16px 18px; margin: 0;
233
- font-family: var(--mono); font-size: 12.5px; line-height: 1.65;
234
- color: var(--text-2); min-height: 380px; max-height: 620px;
235
- overflow: auto; white-space: pre-wrap; word-break: break-word;
236
- }
237
- .output .empty { color: var(--text-soft); font-family: var(--sans); font-size: 13px; }
238
- .output--audit { font-family: var(--sans); font-size: 13px; line-height: 1.6; white-space: normal; color: var(--text); }
239
-
240
- .output::-webkit-scrollbar { width: 10px; height: 10px; }
241
- .output::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid var(--panel-alt); }
242
-
243
- .audit-section + .audit-section { margin-top: 24px; }
244
- .audit-heading {
245
- font-size: 11px; font-weight: 600; color: var(--text-muted);
246
- text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 12px;
247
- display: flex; align-items: center; gap: 8px;
248
- }
249
- .audit-heading .count {
250
- color: var(--text-soft); font-weight: 500; font-family: var(--mono);
251
- font-size: 10px; padding: 1px 6px; background: var(--bg); border-radius: 999px;
252
- }
253
- .audit-list { list-style: none; padding: 0; margin: 0; }
254
- .audit-patch { padding: 14px 0; border-top: 1px solid var(--border-soft); }
255
- .audit-patch:first-child { border-top: none; padding-top: 0; }
256
- .audit-rule {
257
- display: inline-flex; font-family: var(--mono); font-size: 11px; font-weight: 500;
258
- color: var(--text-2); background: var(--bg); border: 1px solid var(--border);
259
- padding: 2px 8px; border-radius: var(--radius-xs); margin-bottom: 10px;
260
- }
261
- .audit-diff { display: grid; grid-template-columns: 1fr; gap: 6px; }
262
- .audit-before, .audit-after {
263
- font-family: var(--mono); font-size: 12px; padding: 10px 12px; margin: 0;
264
- border-radius: var(--radius-xs); white-space: pre-wrap; line-height: 1.55;
265
- border-left: 2px solid;
266
- }
267
- .audit-before { background: var(--error-bg); border-left-color: var(--error); color: #991b1b; text-decoration: line-through; }
268
- .audit-after { background: var(--success-bg); border-left-color: var(--success); color: #166534; }
269
- .audit-warnings { list-style: none; padding: 0; margin: 0; }
270
- .audit-warnings li {
271
- padding: 10px 0 10px 18px; border-top: 1px solid var(--border-soft);
272
- position: relative; color: var(--text-2); font-size: 13px;
273
- }
274
- .audit-warnings li:first-child { border-top: none; }
275
- .audit-warnings li::before {
276
- content: ''; position: absolute; left: 0; top: 17px; width: 5px; height: 5px;
277
- border-radius: 50%; background: var(--warn); box-shadow: 0 0 0 3px rgba(202, 138, 4, 0.15);
278
- }
279
- .audit-warnings .rule-ref {
280
- display: inline-block; font-family: var(--mono); font-size: 11px; font-weight: 500;
281
- color: var(--text-muted); background: var(--bg); padding: 1px 6px;
282
- border-radius: var(--radius-xs); margin-right: 6px;
283
- }
284
-
285
- /* Bandeau verdict + coût */
286
- .meta-result {
287
- display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
288
- margin: 0 0 12px;
289
- }
290
- .pill {
291
- display: inline-flex; align-items: center; gap: 6px;
292
- font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 999px;
293
- border: 1px solid;
294
- }
295
- .pill--ok { color: #166534; background: var(--success-bg); border-color: var(--success-border); }
296
- .pill--ko { color: #991b1b; background: var(--error-bg); border-color: var(--error-border); }
297
- .pill--info { color: var(--text-2); background: var(--bg); border-color: var(--border); font-weight: 500; font-family: var(--mono); font-size: 11.5px; }
298
-
299
- .token.comment { color: #9ca3af; font-style: italic; }
300
- .token.keyword, .token.boolean, .token.important { color: #9333ea; font-weight: 500; }
301
- .token.string, .token.attr-value { color: #15803d; }
302
- .token.function, .token.class-name { color: #1d4ed8; }
303
- .token.number, .token.constant { color: #c2410c; }
304
- .token.operator, .token.punctuation { color: #52525b; }
305
- .token.title, .token.heading { font-weight: 600; color: var(--text); }
306
-
307
- .footer {
308
- margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border-soft);
309
- text-align: center; font-size: 12px; color: var(--text-soft);
310
- }
311
- .footer code {
312
- font-family: var(--mono); font-size: 11px; padding: 1px 6px;
313
- background: var(--panel); border: 1px solid var(--border-soft);
314
- border-radius: var(--radius-xs); color: var(--text-muted);
315
- }
316
- </style>
317
- </head>
318
- <body>
319
-
320
- <div class="container">
321
-
322
- <header class="header">
323
- <div class="header-left">
324
- <div class="header-mark">Py</div>
325
- <div class="header-text">
326
- <h1>Pythonise Exercice</h1>
327
- <p>PyxiScience MyST → version pythonisée · validée au harnais</p>
328
- </div>
329
- </div>
330
- <div class="header-status"><span class="dot"></span> service actif</div>
331
- </header>
332
-
333
- <div class="grid">
334
-
335
- <!-- INPUT PANEL -->
336
- <section class="panel">
337
- <h2 class="panel-title">Source</h2>
338
-
339
- <div class="field">
340
- <textarea id="input" placeholder="Collez ici l'énoncé MyST à pythoniser… (ou chargez un/des fichiers .md ci-dessous)"></textarea>
341
- </div>
342
-
343
- <ul class="filelist" id="filelist" hidden></ul>
344
-
345
- <div class="meta">
346
- <div>
347
- <label class="field-label" for="mode-select">Mode</label>
348
- <select id="mode-select" onchange="onModeChange()">
349
- <option value="pythonise">Pythonisation</option>
350
- <option value="declinaisons">Déclinaisons (QCM/QAT)</option>
351
- </select>
352
- </div>
353
- <div id="decl-types" hidden>
354
- <label class="field-label">Types de déclinaison</label>
355
- <div style="display:flex;gap:14px;padding:9px 2px;font-size:13px;">
356
- <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
357
- <input type="checkbox" id="type-qcm" checked onchange="updateRunLabel()"> QCM
358
- </label>
359
- <label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
360
- <input type="checkbox" id="type-qat" onchange="updateRunLabel()"> QAT
361
- </label>
362
- </div>
363
- </div>
364
- <div>
365
- <label class="field-label" for="policy-select">Politique de modèle</label>
366
- <select id="policy-select" onchange="onPolicyChange()">
367
- <option value="auto">Auto (l'app décide)</option>
368
- <option value="best">Meilleur (qualité max)</option>
369
- <option value="cheap">Économique</option>
370
- <option value="manual">Manuel</option>
371
- </select>
372
- </div>
373
- <div id="manual-models" class="meta-full" hidden>
374
- <label class="field-label">Modèles par rôle (mode Manuel)</label>
375
- <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;">
376
- <div><label class="field-label" for="model-generate">Génération</label>
377
- <select id="model-generate"></select></div>
378
- <div><label class="field-label" for="model-audit">Audit</label>
379
- <select id="model-audit"></select></div>
380
- <div><label class="field-label" for="model-mecanique">Mécanique</label>
381
- <select id="model-mecanique"></select></div>
382
- </div>
383
- </div>
384
- <div>
385
- <label class="field-label" for="lang-select">Langue cible</label>
386
- <select id="lang-select">
387
- <option value="fr">Français</option>
388
- <option value="en">Anglais</option>
389
- <option value="both">Les deux (bilingue)</option>
390
- </select>
391
- </div>
392
- <div>
393
- <label class="field-label" for="level">Niveau</label>
394
- <select id="level">
395
- <option value="">—</option>
396
- <option value="Elementary">Élémentaire</option>
397
- <option value="Intermediate">Intermédiaire</option>
398
- <option value="Advanced">Avancé</option>
399
- </select>
400
- </div>
401
- <div class="meta-full">
402
- <label class="field-label" for="filename">Nom du fichier <span style="opacity:0.6;font-weight:400">(si saisie manuelle)</span></label>
403
- <input type="text" id="filename" value="exercise.md" placeholder="exercise.md">
404
- </div>
405
- </div>
406
-
407
- <div class="actions">
408
- <button class="btn btn--primary" id="run-btn" type="button" onclick="run()">Lancer la pythonisation</button>
409
- <button class="btn" id="stop-btn" type="button" onclick="stopJob()" hidden
410
- style="border-color:#c0392b;color:#c0392b;">⏹ Arrêter</button>
411
- <button class="btn" type="button" onclick="document.getElementById('file-input').click()">Charger des fichiers .md</button>
412
- <input type="file" id="file-input" accept=".md,.txt,text/markdown,text/plain" multiple hidden onchange="loadFiles(event)">
413
- <button class="btn btn--ghost" type="button" onclick="clearAll()">Effacer</button>
414
- </div>
415
-
416
- <div class="progress is-idle" id="progress">
417
- <div class="progress-line">
418
- <span class="progress-spinner"></span>
419
- <span class="progress-label" id="step-label">En attente</span>
420
- <span class="progress-step" id="step-counter">—</span>
421
- </div>
422
- <div class="progress-bar"><span class="progress-bar-fill"></span></div>
423
- </div>
424
-
425
- <div id="status" class="status" hidden></div>
426
- </section>
427
-
428
- <!-- OUTPUT PANEL -->
429
- <section class="panel">
430
- <h2 class="panel-title">Résultat</h2>
431
-
432
- <div class="result-files" id="result-files" hidden></div>
433
- <div class="meta-result" id="meta-result" hidden></div>
434
-
435
- <nav class="tabs" id="tabs">
436
- <button class="tab is-active" type="button" data-tab="exercice">Exercice</button>
437
- <button class="tab" type="button" data-tab="audit">Audit<span class="tab-badge" id="audit-badge" hidden>0</span></button>
438
- <button class="tab" type="button" data-tab="analysis">Analyse</button>
439
- <button class="tab" type="button" data-tab="notions">Notions</button>
440
- <span class="tab-spacer"></span>
441
- <button class="btn btn--ghost btn--small" id="dl-btn" type="button" onclick="downloadActiveMd()" disabled>Télécharger .md</button>
442
- <button class="btn btn--ghost btn--small" id="dlzip-btn" type="button" onclick="downloadZip()" hidden>Tout (.zip)</button>
443
- <button class="btn btn--ghost btn--small" id="copy-btn" type="button" onclick="copyActive()" disabled>Copier</button>
444
- </nav>
445
-
446
- <div class="tab-panels">
447
- <div class="tab-panel is-active" id="panel-exercice">
448
- <pre class="output" id="output-exercice"><code class="empty language-markdown">L'exercice pythonisé apparaîtra ici.</code></pre>
449
- </div>
450
- <div class="tab-panel" id="panel-audit">
451
- <div class="output output--audit" id="output-audit"><p class="empty">Aucun audit pour le moment.</p></div>
452
- </div>
453
- <div class="tab-panel" id="panel-analysis">
454
- <pre class="output" id="output-analysis"><code class="empty language-json">{}</code></pre>
455
- </div>
456
- <div class="tab-panel" id="panel-notions">
457
- <pre class="output" id="output-notions"><code class="empty">—</code></pre>
458
- </div>
459
- </div>
460
- </section>
461
-
462
- </div>
463
-
464
- <footer class="footer">
465
- <code>v2</code> · Pythonisation PyxiScience · porte harnais déterministe · OpenRouter
466
- </footer>
467
-
468
- </div>
469
-
470
- <script src="https://unpkg.com/prismjs@1.29.0/components/prism-core.min.js"></script>
471
- <script src="https://unpkg.com/prismjs@1.29.0/components/prism-markup.min.js"></script>
472
- <script src="https://unpkg.com/prismjs@1.29.0/components/prism-python.min.js"></script>
473
- <script src="https://unpkg.com/prismjs@1.29.0/components/prism-json.min.js"></script>
474
- <script src="https://unpkg.com/prismjs@1.29.0/components/prism-markdown.min.js"></script>
475
-
476
- <script>
477
- 'use strict';
478
-
479
- const POLL_MS = 1000;
480
- const POLL_SLOW_AFTER = 12; // au-delà : mode lent (10 s) — on n'abandonne PAS
481
- const POLL_SLOW_MS = 10000;
482
- const POLL_GIVEUP_MS = 45 * 60 * 1000; // plafond absolu de reconnexion (45 min)
483
- let pollFirstFailureAt = null;
484
- let currentJobId = null;
485
- let pollTimer = null;
486
- let pollFailures = 0;
487
- let activeTab = "exercice";
488
- let loadedFiles = []; // [{filename, content}]
489
- let jobResults = []; // résultats par fichier
490
- let activeResultIdx = 0;
491
-
492
- document.addEventListener("DOMContentLoaded", () => {
493
- fetchModels(); bindTabs();
494
- // Reprise après rechargement : si un job était en cours, on se raccroche
495
- // (le serveur le garde JOB_TTL=30 min après sa fin ; 404 → suivi perdu).
496
- const saved = localStorage.getItem("pyxi_job_id");
497
- if (saved) {
498
- currentJobId = saved;
499
- document.getElementById("run-btn").disabled = true;
500
- const sb = document.getElementById("stop-btn");
501
- sb.hidden = false; sb.disabled = false; sb.textContent = "⏹ Arrêter";
502
- setProgress("running", "Reprise du suivi du job en cours…", "…");
503
- poll();
504
- }
505
- });
506
-
507
- async function fetchModels() {
508
- try {
509
- const res = await fetch("/api/models");
510
- if (!res.ok) return;
511
- const { default_lang, default_policy, catalog, recommended } = await res.json();
512
- // Dropdowns par rôle (mode Manuel), alimentés par le catalogue (sans Fable).
513
- const roleDefaults = {
514
- generate: recommended?.generate?.cheap,
515
- audit: recommended?.audit?.best,
516
- mecanique: recommended?.mecanique?.cheap,
517
- };
518
- for (const role of ["generate", "audit", "mecanique"]) {
519
- const sel = document.getElementById(`model-${role}`);
520
- if (!sel || !catalog?.[role]) continue;
521
- sel.innerHTML = "";
522
- for (const key of catalog[role]) {
523
- const opt = document.createElement("option");
524
- opt.value = key;
525
- opt.textContent = prettyModelName(key);
526
- if (key === roleDefaults[role]) opt.selected = true;
527
- sel.appendChild(opt);
528
- }
529
- }
530
- if (default_policy) document.getElementById("policy-select").value = default_policy;
531
- if (default_lang) document.getElementById("lang-select").value = default_lang;
532
- onPolicyChange();
533
- } catch (e) { console.warn("/api/models:", e); }
534
- }
535
-
536
- function prettyModelName(slug) {
537
- const name = slug.includes("/") ? slug.split("/")[1] : slug;
538
- return name
539
- .replace(/^claude-/, "Claude ").replace(/^gpt-/, "GPT-")
540
- .replace(/^gemini-/, "Gemini ").replace(/-/g, " ")
541
- .replace(/\b\w/g, c => c.toUpperCase()).replace(/Gpt/g, "GPT");
542
- }
543
-
544
- /* ─── Mode (pythonisation / déclinaisons) + politique de modèle ─── */
545
- function onModeChange() {
546
- const decl = document.getElementById("mode-select").value === "declinaisons";
547
- document.getElementById("decl-types").hidden = !decl;
548
- updateRunLabel();
549
- }
550
-
551
- function onPolicyChange() {
552
- const manual = document.getElementById("policy-select").value === "manual";
553
- document.getElementById("manual-models").hidden = !manual;
554
- }
555
-
556
- function updateRunLabel() {
557
- const btn = document.getElementById("run-btn");
558
- if (document.getElementById("mode-select").value !== "declinaisons") {
559
- btn.textContent = "Lancer la pythonisation";
560
- return;
561
- }
562
- const qcm = document.getElementById("type-qcm").checked;
563
- const qat = document.getElementById("type-qat").checked;
564
- const what = (qcm && qat) ? "QCM + QAT" : qat ? "QAT" : qcm ? "QCM" : "…";
565
- btn.textContent = `Générer les déclinaisons ${what}`;
566
- }
567
-
568
- /* ─── Tabs ─── */
569
- function bindTabs() {
570
- document.querySelectorAll("#tabs .tab").forEach(btn => {
571
- btn.addEventListener("click", () => switchTab(btn.dataset.tab));
572
- });
573
- }
574
- function switchTab(name) {
575
- activeTab = name;
576
- document.querySelectorAll("#tabs .tab").forEach(btn =>
577
- btn.classList.toggle("is-active", btn.dataset.tab === name));
578
- document.querySelectorAll(".tab-panel").forEach(panel =>
579
- panel.classList.toggle("is-active", panel.id === `panel-${name}`));
580
- updateCopyState();
581
- }
582
- function activeDoneResult() {
583
- const e = jobResults[activeResultIdx];
584
- return (e && e.status === "done" && e.result) ? e : null;
585
- }
586
-
587
- function updateCopyState() {
588
- // Copier (panneau actif)
589
- const text = currentPanelText();
590
- document.getElementById("copy-btn").disabled =
591
- !text || !text.trim() || text.trim() === "—" || text.trim() === "{}";
592
- // Télécharger .md (résultat affiché)
593
- const r = activeDoneResult();
594
- const dl = document.getElementById("dl-btn");
595
- if (dl) dl.disabled = !(r && r.result.exercise);
596
- // Tout (.zip) — seulement en batch (≥ 2 fichiers prêts)
597
- const doneCount = jobResults.filter(x => x.status === "done").length;
598
- const zip = document.getElementById("dlzip-btn");
599
- if (zip) zip.hidden = !(doneCount >= 2 && currentJobId);
600
- }
601
-
602
- function triggerDownload(blob, filename) {
603
- const url = URL.createObjectURL(blob);
604
- const a = document.createElement("a");
605
- a.href = url; a.download = filename;
606
- document.body.appendChild(a); a.click(); a.remove();
607
- setTimeout(() => URL.revokeObjectURL(url), 1000);
608
- }
609
-
610
- function downloadActiveMd() {
611
- const r = activeDoneResult();
612
- if (!r || !r.result.exercise) return;
613
- const base = (r.filename || "exercice").replace(/\.(md|txt)$/i, "");
614
- triggerDownload(
615
- new Blob([r.result.exercise], { type: "text/markdown;charset=utf-8" }),
616
- base + "_pythonise.md"
617
- );
618
- }
619
-
620
- function downloadZip() {
621
- if (!currentJobId) return;
622
- const a = document.createElement("a");
623
- a.href = `/api/jobs/${currentJobId}/download`;
624
- a.download = ""; // nom fixé par le serveur (Content-Disposition)
625
- document.body.appendChild(a); a.click(); a.remove();
626
- }
627
- function currentPanelText() {
628
- const map = { exercice: "output-exercice", audit: "output-audit",
629
- analysis: "output-analysis", notions: "output-notions" };
630
- const el = document.getElementById(map[activeTab]);
631
- if (!el) return "";
632
- if (activeTab === "audit") return el.innerText || "";
633
- const code = el.querySelector("code");
634
- return code ? code.textContent : (el.textContent || "");
635
- }
636
-
637
- /* ─── Progress + status ─── */
638
- function setProgress(state, label, step) {
639
- const prog = document.getElementById("progress");
640
- prog.classList.remove("is-idle", "is-running", "is-done", "is-error");
641
- prog.classList.add(`is-${state}`);
642
- if (label !== undefined) document.getElementById("step-label").textContent = label;
643
- if (step !== undefined) document.getElementById("step-counter").textContent = step;
644
- }
645
- function setStatus(state, text) {
646
- const el = document.getElementById("status");
647
- el.className = "status" + (state ? ` is-${state}` : "");
648
- el.textContent = text;
649
- el.hidden = false;
650
- }
651
- function hideStatus() { document.getElementById("status").hidden = true; }
652
-
653
- /* ─── Fichiers (batch) ─── */
654
- function loadFiles(event) {
655
- const files = Array.from(event.target.files || []);
656
- if (!files.length) return;
657
- let pending = files.length;
658
- files.forEach(file => {
659
- const reader = new FileReader();
660
- reader.onload = (e) => {
661
- loadedFiles.push({ filename: file.name, content: String(e.target.result || "") });
662
- if (--pending === 0) renderFileList();
663
- };
664
- reader.onerror = () => { if (--pending === 0) renderFileList(); };
665
- reader.readAsText(file, "UTF-8");
666
- });
667
- event.target.value = "";
668
- }
669
- function renderFileList() {
670
- const ul = document.getElementById("filelist");
671
- ul.innerHTML = "";
672
- ul.hidden = loadedFiles.length === 0;
673
- loadedFiles.forEach((f, i) => {
674
- const li = document.createElement("li");
675
- li.innerHTML = `📄 ${escapeHtml(f.filename)} <span style="color:var(--text-soft)">(${(f.content.length / 1024).toFixed(1)} ko)</span>
676
- <button class="rm" title="Retirer" onclick="removeFile(${i})">✕</button>`;
677
- ul.appendChild(li);
678
- });
679
- }
680
- function removeFile(i) { loadedFiles.splice(i, 1); renderFileList(); }
681
-
682
- /* ─── Rendu des sorties ─── */
683
- function setExerciseOutput(text) {
684
- const pre = document.getElementById("output-exercice");
685
- pre.innerHTML = "";
686
- const code = document.createElement("code");
687
- code.className = "language-markdown";
688
- if (!text) { code.classList.add("empty"); code.textContent = "L'exercice pythonisé apparaîtra ici."; }
689
- else { code.textContent = text; if (window.Prism) Prism.highlightElement(code); }
690
- pre.appendChild(code);
691
- updateCopyState();
692
- }
693
- function setAnalysisOutput(obj) {
694
- const pre = document.getElementById("output-analysis");
695
- pre.innerHTML = "";
696
- const code = document.createElement("code");
697
- code.className = "language-json";
698
- if (!obj) { code.classList.add("empty"); code.textContent = "{}"; }
699
- else { code.textContent = JSON.stringify(obj, null, 2); if (window.Prism) Prism.highlightElement(code); }
700
- pre.appendChild(code);
701
- }
702
- function setNotionsOutput(text) {
703
- const pre = document.getElementById("output-notions");
704
- pre.innerHTML = "";
705
- const code = document.createElement("code");
706
- if (!text || text === "—") { code.classList.add("empty"); code.textContent = "—"; }
707
- else { code.textContent = text; }
708
- pre.appendChild(code);
709
- }
710
- function setAuditOutput(result) {
711
- const container = document.getElementById("output-audit");
712
- const patches = result.audit_patches || [];
713
- const warnings = result.warnings || [];
714
- const badge = document.getElementById("audit-badge");
715
- if (!patches.length && !warnings.length) {
716
- container.innerHTML = `<p class="empty">Aucun patch ni warning — l'audit a validé l'exercice.</p>`;
717
- badge.hidden = true;
718
- return;
719
- }
720
- badge.hidden = false;
721
- badge.textContent = String(patches.length + warnings.length);
722
- let html = "";
723
- if (patches.length) {
724
- html += `<section class="audit-section"><h3 class="audit-heading">Patches appliqués <span class="count">${patches.length}</span></h3><ul class="audit-list">`;
725
- for (const p of patches) {
726
- html += `<li class="audit-patch">
727
- <span class="audit-rule">règle ${escapeHtml(p.rule || "?")}</span>
728
- <div class="audit-diff">
729
- <pre class="audit-before"><code>${escapeHtml(p.location || "")}</code></pre>
730
- <pre class="audit-after"><code>${escapeHtml(p.fix || "")}</code></pre>
731
- </div></li>`;
732
- }
733
- html += `</ul></section>`;
734
- }
735
- if (warnings.length) {
736
- html += `<section class="audit-section"><h3 class="audit-heading">Warnings <span class="count">${warnings.length}</span></h3><ul class="audit-warnings">`;
737
- for (const w of warnings) {
738
- let ruleRef = "", txt = w;
739
- if (typeof w === "object" && w !== null) {
740
- if (w.rule) ruleRef = `<span class="rule-ref">règle ${escapeHtml(w.rule)}</span>`;
741
- txt = w.message || JSON.stringify(w);
742
- }
743
- html += `<li>${ruleRef}${escapeHtml(String(txt))}</li>`;
744
- }
745
- html += `</ul></section>`;
746
- }
747
- container.innerHTML = html;
748
- }
749
-
750
- /* Bandeau verdict + coût + langue pour le fichier affiché */
751
- function setMetaResult(entry) {
752
- const el = document.getElementById("meta-result");
753
- if (!entry) { el.hidden = true; return; }
754
- el.hidden = false;
755
- if (entry.status === "error") {
756
- el.innerHTML = `<span class="pill pill--ko">✗ Erreur pipeline</span>
757
- <span class="pill pill--info">${escapeHtml(entry.error || "")}</span>`;
758
- return;
759
- }
760
- const r = entry.result;
761
- const h = r.harness || {};
762
- const verdict = h.ok
763
- ? `<span class="pill pill--ok">✓ Harnais VERT (${h.seeds} graines)</span>`
764
- : `<span class="pill pill--ko">✗ Harnais ROUGE (${h.seeds} graines)</span>`;
765
- // Audit pédagogique (déclinaisons) : qualité au-delà du harnais mécanique.
766
- let ped = "";
767
- const p = r.pedagogical;
768
- if (p && p.verdict) {
769
- if (p.verdict === "OK") {
770
- const sc = (p.score != null) ? ` (${p.score}/100)` : "";
771
- ped = `<span class="pill pill--ok">✓ Qualité pédagogique${sc}</span>`;
772
- } else if (p.verdict === "A_REVOIR") {
773
- const n = (p.issues || []).length;
774
- ped = `<span class="pill pill--ko">⚠ Qualité pédagogique à revoir (${n})</span>`;
775
- } else {
776
- ped = `<span class="pill pill--info">audit pédagogique indisponible</span>`;
777
- }
778
- }
779
- const cost = r.cost ? `<span class="pill pill--info">${r.cost.usd.toFixed(4)} $ · ${r.cost.requests} appels</span>` : "";
780
- const lang = r.lang ? `<span class="pill pill--info">langue : ${escapeHtml(r.lang.source)} → ${escapeHtml(r.lang.target)} (${escapeHtml(r.lang.action)})</span>` : "";
781
- const dur = r.duration_s ? `<span class="pill pill--info">${r.duration_s}s</span>` : "";
782
- let decl = "";
783
- if (r.decl_type) {
784
- const label = r.decl_type === "qcm" ? "QCM" : "QAT";
785
- decl = `<span class="pill pill--info">déclinaison : ${label}</span>`;
786
- if (r.decl_type === "qat" && (r.exercise || "").includes(":questionType: MCQ")) {
787
- decl += `<span class="pill pill--info">repli MCQ partiel</span>`;
788
- }
789
- }
790
- let pol = "";
791
- const t = r.policy_telemetry;
792
- if (t) {
793
- const esc = (t.tried || []).length > 1 ? ` (${t.tried.length} échelons)` : "";
794
- pol = `<span class="pill pill--info">modèle : ${escapeHtml(prettyModelName(t.winning_model || r.model_used || "?"))}${esc} · ${escapeHtml(t.mode)}/${escapeHtml(t.difficulty)}</span>`;
795
- if (t.needs_review) pol += `<span class="pill pill--ko">revue humaine requise</span>`;
796
- } else if (r.model_used) {
797
- pol = `<span class="pill pill--info">modèle : ${escapeHtml(prettyModelName(r.model_used))}</span>`;
798
- }
799
- el.innerHTML = verdict + ped + decl + pol + cost + lang + dur;
800
- }
801
-
802
- /* Sélecteur de fichier (batch) */
803
- function renderResultFiles() {
804
- const el = document.getElementById("result-files");
805
- if (jobResults.length <= 1) { el.hidden = true; return; }
806
- el.hidden = false;
807
- el.innerHTML = "";
808
- jobResults.forEach((r, i) => {
809
- const cls = r.status === "error" ? "err" : (r.result?.harness?.ok ? "ok" : "ko");
810
- const span = document.createElement("span");
811
- span.className = "result-file" + (i === activeResultIdx ? " is-active" : "");
812
- span.innerHTML = `<span class="verdict-dot ${cls}"></span>${escapeHtml(r.filename)}`;
813
- span.onclick = () => { activeResultIdx = i; showResult(i); renderResultFiles(); };
814
- el.appendChild(span);
815
- });
816
- }
817
- function showResult(i) {
818
- const entry = jobResults[i];
819
- if (!entry) return;
820
- setMetaResult(entry);
821
- if (entry.status === "error") {
822
- setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
823
- return;
824
- }
825
- const r = entry.result;
826
- setExerciseOutput(r.exercise || "");
827
- setAnalysisOutput(r.analysis || null);
828
- setNotionsOutput(r.notions || "—");
829
- setAuditOutput(r);
830
- }
831
-
832
- /* ─── Run + poll ─── */
833
- async function run() {
834
- let files = loadedFiles.slice();
835
- const manual = document.getElementById("input").value.trim();
836
- if (!files.length && manual) {
837
- files = [{ filename: document.getElementById("filename").value.trim() || "exercise.md",
838
- content: manual }];
839
- }
840
- if (!files.length) { setStatus("error", "Saisissez un énoncé ou chargez des fichiers .md."); return; }
841
-
842
- const mode = document.getElementById("mode-select").value;
843
- const policyMode = document.getElementById("policy-select").value;
844
- const payload = {
845
- files,
846
- level: document.getElementById("level").value,
847
- lang: document.getElementById("lang-select").value,
848
- mode,
849
- policy: policyMode,
850
- };
851
- if (policyMode === "manual") {
852
- payload.models = {
853
- generate: document.getElementById("model-generate").value,
854
- audit: document.getElementById("model-audit").value,
855
- mecanique: document.getElementById("model-mecanique").value,
856
- };
857
- }
858
- if (mode === "declinaisons") {
859
- payload.types = {
860
- qcm: document.getElementById("type-qcm").checked,
861
- qat: document.getElementById("type-qat").checked,
862
- };
863
- if (!payload.types.qcm && !payload.types.qat) {
864
- setStatus("error", "Mode Déclinaisons : coche au moins un type (QCM ou QAT).");
865
- return;
866
- }
867
- }
868
-
869
- if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
870
- currentJobId = null;
871
- pollFailures = 0;
872
- jobResults = []; activeResultIdx = 0;
873
- document.getElementById("run-btn").disabled = true;
874
- const stopBtn = document.getElementById("stop-btn");
875
- stopBtn.hidden = false; stopBtn.disabled = false;
876
- stopBtn.textContent = "⏹ Arrêter";
877
- hideStatus();
878
- setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
879
- setMetaResult(null);
880
- document.getElementById("result-files").hidden = true;
881
- document.getElementById("audit-badge").hidden = true;
882
- switchTab("exercice");
883
- setProgress("running", "Soumission du job…", "0/" + files.length);
884
-
885
- try {
886
- const res = await fetch("/api/jobs", {
887
- method: "POST",
888
- headers: { "Content-Type": "application/json" },
889
- body: JSON.stringify(payload),
890
- });
891
- if (!res.ok) {
892
- const err = await res.json().catch(() => ({ error: res.statusText }));
893
- throw new Error(err.error || `HTTP ${res.status}`);
894
- }
895
- currentJobId = (await res.json()).job_id;
896
- localStorage.setItem("pyxi_job_id", currentJobId);
897
- poll();
898
- } catch (e) {
899
- setProgress("error", "Échec", "—");
900
- setStatus("error", "Erreur : " + e.message);
901
- jobFinished();
902
- }
903
- }
904
-
905
- /* Fin de job (quel que soit l'état terminal) : run réactivé, stop masqué. */
906
- function jobFinished() {
907
- document.getElementById("run-btn").disabled = false;
908
- document.getElementById("stop-btn").hidden = true;
909
- localStorage.removeItem("pyxi_job_id");
910
- }
911
-
912
- /* Arrêt coopératif : le serveur stoppe au prochain point d'étape (l'appel
913
- LLM en cours se termine, rien ne s'enchaîne) ; résultats partiels gardés. */
914
- async function stopJob() {
915
- if (!currentJobId) return;
916
- const stopBtn = document.getElementById("stop-btn");
917
- stopBtn.disabled = true;
918
- stopBtn.textContent = "Arrêt en cours…";
919
- try {
920
- const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: "POST" });
921
- if (res.status === 404 || res.status === 409) {
922
- // Job déjà terminé/perdu : le prochain poll affichera l'état final.
923
- stopBtn.hidden = true;
924
- return;
925
- }
926
- setStatus("warn", "Arrêt demandé — fin de l'étape en cours…");
927
- } catch (e) {
928
- stopBtn.disabled = false;
929
- stopBtn.textContent = "⏹ Arrêter";
930
- setStatus("error", "Impossible de demander l'arrêt : " + e.message);
931
- }
932
- }
933
-
934
- async function poll() {
935
- if (!currentJobId) return;
936
- try {
937
- const res = await fetch(`/api/jobs/${currentJobId}`, { headers: { "Accept": "application/json" } });
938
- // Lire en texte d'abord : sous forte charge, le proxy HF peut renvoyer une
939
- // page HTML (502/504) au lieu du JSON → on traite ça comme un hoquet
940
- // transitoire et on RÉESSAIE, au lieu de tuer tout le suivi.
941
- const raw = await res.text();
942
- let data;
943
- try { data = JSON.parse(raw); }
944
- catch (_) { throw new Error(`réponse non-JSON (HTTP ${res.status})`); }
945
-
946
- if (res.status === 404) {
947
- // Job inconnu : le serveur a probablement redémarré (perte du suivi en mémoire).
948
- setProgress("error", "Suivi perdu", "—");
949
- setStatus("error", "Le serveur a perdu ce job (redémarrage). Le traitement est interrompu — relance.");
950
- jobFinished();
951
- return;
952
- }
953
-
954
- pollFailures = 0; // réponse valide → on repart à zéro
955
- pollFirstFailureAt = null;
956
- jobResults = data.results || [];
957
- if (data.status === "running") {
958
- setProgress("running", data.step_label || "…", `${data.files_done}/${data.files_total}`);
959
- if (jobResults.length && activeResultIdx < jobResults.length) {
960
- renderResultFiles(); showResult(Math.min(activeResultIdx, jobResults.length - 1));
961
- }
962
- pollTimer = setTimeout(poll, POLL_MS);
963
- } else if (data.status === "done") {
964
- const s = data.summary || {};
965
- setProgress(s.rouges || s.erreurs ? "error" : "done", "Terminé", `${data.files_done}/${data.files_total}`);
966
- const msg = `Terminé : ${s.verts ?? 0} VERT(S), ${s.rouges ?? 0} ROUGE(S), ${s.erreurs ?? 0} erreur(s) · ${s.cost_usd ?? 0} $`;
967
- setStatus(s.rouges || s.erreurs ? "warn" : "done", msg);
968
- activeResultIdx = 0;
969
- renderResultFiles();
970
- showResult(0);
971
- jobFinished();
972
- } else if (data.status === "cancelled") {
973
- const s = data.summary || {};
974
- setProgress("error", "Arrêté", `${data.files_done}/${data.files_total}`);
975
- setStatus("warn",
976
- `Génération arrêtée. Résultats partiels conservés : ${s.verts ?? 0} VERT(S), `
977
- + `${s.rouges ?? 0} ROUGE(S), ${s.erreurs ?? 0} erreur(s) · ${s.cost_usd ?? 0} $`);
978
- if (jobResults.length) { activeResultIdx = 0; renderResultFiles(); showResult(0); }
979
- jobFinished();
980
- } else if (data.status === "error") {
981
- setProgress("error", "Erreur", "—");
982
- setStatus("error", "Erreur : " + (data.error || "inconnue"));
983
- jobFinished();
984
- }
985
- } catch (e) {
986
- // Erreur TRANSITOIRE : pendant les phases de calcul (harnais 100 graines),
987
- // le serveur mono-process peut être injoignable PLUSIEURS MINUTES — le job
988
- // continue. On ne renonce donc JAMAIS avant le plafond absolu : backoff
989
- // léger, puis mode lent (10 s) ; le suivi se recale à la 1re réponse.
990
- pollFailures++;
991
- if (!pollFirstFailureAt) pollFirstFailureAt = Date.now();
992
- if (Date.now() - pollFirstFailureAt > POLL_GIVEUP_MS) {
993
- setProgress("error", "Connexion interrompue", "—");
994
- setStatus("error",
995
- "Suivi injoignable depuis 45 min (" + e.message + "). "
996
- + "Recharge la page ; si le serveur a redémarré, relance le job.");
997
- jobFinished();
998
- return;
999
- }
1000
- const slow = pollFailures >= POLL_SLOW_AFTER;
1001
- setStatus("warn",
1002
- slow
1003
- ? "Serveur occupé (phase de calcul) — le traitement CONTINUE côté serveur, "
1004
- + `reconnexion toutes les 10 s… (${Math.round((Date.now() - pollFirstFailureAt) / 1000)} s)`
1005
- : `Reconnexion au suivi… (${pollFailures})`);
1006
- pollTimer = setTimeout(
1007
- poll, slow ? POLL_SLOW_MS : Math.min(6000, POLL_MS * (1 + pollFailures)));
1008
- }
1009
- }
1010
-
1011
- function clearAll() {
1012
- if (pollTimer) clearTimeout(pollTimer);
1013
- currentJobId = null;
1014
- localStorage.removeItem("pyxi_job_id");
1015
- document.getElementById("stop-btn").hidden = true;
1016
- document.getElementById("run-btn").disabled = false;
1017
- loadedFiles = []; jobResults = []; activeResultIdx = 0;
1018
- renderFileList();
1019
- document.getElementById("input").value = "";
1020
- setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
1021
- setMetaResult(null);
1022
- document.getElementById("result-files").hidden = true;
1023
- document.getElementById("audit-badge").hidden = true;
1024
- setProgress("idle", "En attente", "—");
1025
- hideStatus();
1026
- document.getElementById("run-btn").disabled = false;
1027
- }
1028
-
1029
- function copyActive() {
1030
- const text = currentPanelText();
1031
- if (!text) return;
1032
- navigator.clipboard.writeText(text).then(() => {
1033
- const btn = document.getElementById("copy-btn");
1034
- const old = btn.textContent;
1035
- btn.textContent = "Copié";
1036
- setTimeout(() => { btn.textContent = old; }, 1400);
1037
- });
1038
- }
1039
-
1040
- function escapeHtml(s) {
1041
- return String(s)
1042
- .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
1043
- .replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1044
- }
1045
- </script>
1046
-
1047
- </body>
1048
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
orchestrator.py DELETED
@@ -1,695 +0,0 @@
1
- """
2
- orchestrator.py
3
- ───────────────
4
- Chef d'orchestre du pipeline pour UN exercice. (Le job/threading vit dans
5
- server.py ; le mode batch boucle simplement sur run_exercise.)
6
-
7
- Ordre des étapes :
8
- 1. Analyse + notions + RAG fonctions (PARALLÈLE — indépendants)
9
- 2. Génération par paires (séquentielle, contexte partagé)
10
- 3. Post-traitements déterministes (config_standard, assemblage 4-backticks,
11
- dédoublonnage)
12
- 4. Substitution des solutions validées (si présentes dans la source)
13
- 5. Audit LLM (≤ 2 itérations, patches toutes-occurrences sécurisés)
14
- 6. Post-traitements déterministes finaux : auto-lift GÉNÉRALISÉ des
15
- injections non nues, renommage underscores, auto-correctif $+chiffre,
16
- :id: vide, diff solutions, décimales (langue), invariants multi-seed,
17
- contrôles matplotlib
18
- 7. Langue cible (déterministe ou LLM masqué)
19
- 8. PORTE HARNAIS (HARNESS_GATE_SEEDS graines) + 1 boucle de réparation LLM
20
- max ; verdict exposé dans le résultat.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import json
26
- import logging
27
- import time
28
- from typing import Callable, Optional
29
-
30
- from app.config import (
31
- HARNESS_GATE_SEEDS,
32
- HARNESS_REPAIR_MAX,
33
- MAX_ESCALADES,
34
- MULTI_SEED_NUM,
35
- PEDAGO_AUDIT_ENABLED,
36
- PEDAGO_ESCALATE_IN_AUTO,
37
- PEDAGO_REPAIR_MAX,
38
- )
39
- from app.knowledge.rules_digest import build_rules_digest
40
- from app.llm.client import process_with_openrouter
41
- from app.llm.cost import cost_delta, cost_snapshot
42
- from app.rag.catalogue import catalogue_for
43
- from app.pipeline import postprocess as pp
44
- from app.pipeline.analyze import run_analysis_phase
45
- from app.pipeline.audit import (
46
- format_pedagogical_issues,
47
- pedagogical_badness,
48
- run_audit,
49
- run_pedagogical_audit,
50
- )
51
- from app.pipeline.fewshots import fewshot_for, fewshot_for_declinaison
52
- from app.pipeline.generate import (
53
- assemble_exercise,
54
- build_exercise_metadata,
55
- generate_pair_blocks,
56
- split_original_questions,
57
- )
58
- from app.pipeline.prompts import (
59
- PEDAGOGICAL_REPAIR_PROMPT,
60
- REPAIR_PROMPT,
61
- SYSTEM_PROMPT,
62
- TRANSLATE_CONSTRAINTS_PROMPT,
63
- )
64
- from app.pipeline.solutions import replace_gen_solutions_with_source
65
- from app.pipeline.translate import ensure_language
66
- from app.validation import harness
67
- from app.validation.sandbox import (
68
- dynamic_check_matplotlib,
69
- extract_all_python_blocks,
70
- extract_main_python_block,
71
- multi_seed_validate,
72
- static_check_rational_numpy_mix,
73
- static_check_unused_random_vars,
74
- )
75
-
76
- logger = logging.getLogger(__name__)
77
-
78
- TRUNK_RULES = ["2.1", "3.1", "3.2", "6.1", "6.3", "8.1"]
79
-
80
-
81
- def _translate_constraints_to_assertions(code: str, constraints: list[str],
82
- model_idx: int,
83
- model: str | None = None) -> list[dict]:
84
- """Mini appel LLM : contrainte FR → expression booléenne Python."""
85
- if not constraints or not code.strip():
86
- return []
87
- try:
88
- raw = process_with_openrouter(
89
- prompt=TRANSLATE_CONSTRAINTS_PROMPT.format(
90
- code=code,
91
- constraints="\n".join(f" • {c}" for c in constraints
92
- if isinstance(c, str) and c.strip()),
93
- ),
94
- model_idx=model_idx,
95
- model=model,
96
- temperature=0.0,
97
- max_tokens=2048,
98
- system_prompt=SYSTEM_PROMPT,
99
- )
100
- except (RuntimeError, ValueError, OSError) as e:
101
- logger.warning("Traduction des contraintes en échec : %s", e)
102
- return []
103
- try:
104
- data = json.loads(pp.strip_fences(raw))
105
- except json.JSONDecodeError:
106
- logger.warning("Traduction des contraintes : JSON invalide.")
107
- return []
108
- if not isinstance(data, list):
109
- return []
110
- return [
111
- {"description": str(d.get("description", "")), "assertion": d.get("assertion")}
112
- for d in data
113
- if isinstance(d, dict) and d.get("assertion")
114
- ]
115
-
116
-
117
- def _apply_deterministic_nets(candidate: str, decl_type: Optional[str]) -> str:
118
- """Séquence des filets déterministes appliquée à toute sortie LLM (candidat
119
- de génération OU de réparation harnais/pédagogique). Idempotente."""
120
- candidate, _ = pp.fix_orphan_python_openers(candidate)
121
- candidate = pp.normalize_python_fences(candidate)
122
- candidate, _ = pp.drop_empty_python_blocks(candidate)
123
- candidate, _ = pp.fix_triple_braces(candidate)
124
- candidate, _ = pp.fix_superscript_double_brace(candidate)
125
- candidate, _ = pp.unwrap_latex_injections(candidate)
126
- candidate, _ = pp.auto_lift_injections(candidate)
127
- candidate, _ = pp.rename_underscore_injections(candidate)
128
- candidate, _ = pp.fix_dollar_digit(candidate)
129
- if decl_type:
130
- candidate, _ = pp.fix_mcq_answer_aliases(candidate)
131
- candidate, _ = pp.merge_decl_python_blocks(candidate)
132
- if decl_type == "qcm":
133
- candidate, _ = pp.fix_none_option_last(candidate)
134
- candidate, _ = pp.aerate_blocks(candidate)
135
- candidate, _ = pp.renumber_question_ids(candidate)
136
- return candidate
137
-
138
-
139
- def run_exercise(
140
- content: str,
141
- filename: str = "exercise.md",
142
- level: str = "",
143
- model_idx: int = 1,
144
- lang: str = "fr",
145
- set_step: Optional[Callable[[str], None]] = None,
146
- decl_type: Optional[str] = None,
147
- shared_phase: Optional[tuple] = None,
148
- forced_models: Optional[dict] = None,
149
- ) -> dict:
150
- """
151
- Traite UN exercice. `decl_type=None` = pythonisation (flux historique) ;
152
- `decl_type ∈ {"qcm","qat"}` = mode déclinaisons (même pipeline, prompt et
153
- harnais étendus). `shared_phase` = résultat de run_analysis_phase à
154
- RÉUTILISER (déclinaisons QCM+QAT d'une même source : une seule analyse).
155
-
156
- Retourne le dict résultat (contrat UI) :
157
- exercise, pair_blocks, analysis, functions, notions, audit_patches,
158
- warnings, harness {ok, summary, seeds}, lang {source, target, action},
159
- cost {usd, eur, requests}, duration_s [, decl_type]
160
- """
161
- t0 = time.time()
162
- cost_before = cost_snapshot()
163
- _step = set_step or (lambda label: None)
164
- # Modèles par rôle (IDs OpenRouter en chaîne), résolus par la policy ;
165
- # None → comportement legacy (model_idx partout).
166
- fm = forced_models or {}
167
- m_gen = fm.get("generate")
168
- m_audit = fm.get("audit")
169
- m_meca = fm.get("mecanique")
170
-
171
- # ── 1. Analyse + notions + RAG (parallèle ; partagée en mode QCM+QAT) ────
172
- if shared_phase is not None:
173
- analysis, notions_ctx, lists_of_notions, functions_ctx = shared_phase
174
- else:
175
- _step("Analyse + notions + catalogue RAG (en parallèle)…")
176
- analysis, notions_ctx, lists_of_notions, functions_ctx = run_analysis_phase(
177
- content, model_idx)
178
-
179
- step1_targets = [r for r in (analysis.get("target_rules") or []) if isinstance(r, str)]
180
- target_rules = list(dict.fromkeys(TRUNK_RULES + step1_targets))
181
- targeted_rules_digest = build_rules_digest(target_rules) or "(aucune règle spécifique ciblée)"
182
-
183
- constraints = [c for c in (analysis.get("property_constraints") or [])
184
- if isinstance(c, str) and c.strip()]
185
- property_constraints_text = ("\n".join(f" • {c}" for c in constraints)
186
- if constraints
187
- else " (aucun invariant explicite — tirages libres)")
188
-
189
- # ── 2. Génération par paires ─────────────────────────────────────────────
190
- metadata, enonce, question_segments = split_original_questions(content)
191
- exercise_header = build_exercise_metadata(metadata, lists_of_notions, analysis,
192
- level, decl_type=decl_type)
193
-
194
- # Contexte fonctions = catalogue CURÉ (domaine détecté) + hits RAG FAISS.
195
- # Le catalogue curé donne « quel helper pour quel besoin » + couvre les
196
- # domaines absents du corpus livré (matrices, proba, IBP).
197
- catalogue_ctx = catalogue_for(analysis)
198
- functions_combined = "\n\n".join(filter(None, [
199
- catalogue_ctx,
200
- ("CATALOGUE RAG (hits spécifiques sur le code réel) :\n" + functions_ctx)
201
- if functions_ctx else "",
202
- ])) or "Aucune fonction spécifique détectée."
203
-
204
- fewshot = (fewshot_for_declinaison(decl_type) if decl_type
205
- else fewshot_for(analysis))
206
- pair_blocks = generate_pair_blocks(
207
- content=content,
208
- exercise_header=exercise_header,
209
- enonce=enonce,
210
- question_segments=question_segments,
211
- analysis=analysis,
212
- functions_ctx=functions_combined,
213
- fewshot=fewshot,
214
- targeted_rules_digest=targeted_rules_digest,
215
- property_constraints_text=property_constraints_text,
216
- level=level,
217
- model_idx=model_idx,
218
- lang=lang,
219
- set_step=_step,
220
- decl_type=decl_type,
221
- model=m_gen,
222
- )
223
-
224
- # ── 3. Post-traitements déterministes ────────────────────────────────────
225
- _step("Post-traitements déterministes…")
226
- pair_blocks = [pp.inject_config_standard_in_pair_block(b) for b in pair_blocks]
227
- myst_exercise = assemble_exercise(exercise_header, pair_blocks)
228
-
229
- audit_patches: list[dict] = []
230
- audit_warnings: list[dict] = []
231
-
232
- myst_exercise, orphan_py = pp.fix_orphan_python_openers(myst_exercise)
233
- if orphan_py:
234
- audit_patches.append({
235
- "rule": "3.1", "location": "(orphan python opener)",
236
- "fix": f"{orphan_py} opener(s) orphelin(s) supprimé(s)",
237
- "message": f"{orphan_py} fence(s) {{python}} orpheline(s) supprimée(s) (opener doublé).",
238
- "iteration": 0,
239
- })
240
- myst_exercise, dup_q = pp.dedupe_question_blocks(myst_exercise)
241
- myst_exercise, dup_py = pp.dedupe_python_blocks(myst_exercise)
242
- myst_exercise, empty_py = pp.drop_empty_python_blocks(myst_exercise)
243
- if empty_py:
244
- audit_patches.append({
245
- "rule": "3.1", "location": "(empty python blocks)",
246
- "fix": f"{empty_py} bloc(s) vide(s) supprimé(s)",
247
- "message": f"{empty_py} bloc(s) {{python}} vide(s) (globals() seul) supprimé(s).",
248
- "iteration": 0,
249
- })
250
- if dup_q:
251
- audit_patches.append({
252
- "rule": "9.4", "location": "(duplicate question blocks)",
253
- "fix": f"{dup_q} bloc(s) dédupliqué(s)",
254
- "message": f"{dup_q} `:::::{{question}}` redondant(s) supprimé(s).",
255
- "iteration": 0,
256
- })
257
- if dup_py:
258
- audit_patches.append({
259
- "rule": "3.1", "location": "(duplicate python blocks)",
260
- "fix": f"{dup_py} bloc(s) dédupliqué(s)",
261
- "message": f"{dup_py} bloc(s) {{python}} redondant(s) supprimé(s).",
262
- "iteration": 0,
263
- })
264
-
265
- # ── 4. Solutions validées (règle 8.1) ────────────────────────────────────
266
- if analysis.get("has_validated_solution_in_input"):
267
- _step("Substitution déterministe des solutions validées…")
268
- myst_exercise, sol_patches = replace_gen_solutions_with_source(
269
- myst_exercise, content, analysis, model_idx, model=m_meca)
270
- audit_patches.extend(sol_patches)
271
-
272
- # ── 5. Audit LLM ─────────────────────────────────────────────────────────
273
- myst_exercise, llm_patches, llm_warnings = run_audit(
274
- myst_exercise, step1_targets, model_idx, set_step=_step, model=m_audit)
275
- audit_patches.extend(llm_patches)
276
- audit_warnings.extend(llm_warnings)
277
-
278
- # ── 6. Filets déterministes finaux ───────────────────────────────────────
279
- _step("Filets déterministes (injections, $, id, décimales)…")
280
- myst_exercise, brace_patches = pp.fix_triple_braces(myst_exercise)
281
- audit_patches.extend(brace_patches)
282
-
283
- myst_exercise, sup_fixed = pp.fix_superscript_double_brace(myst_exercise)
284
- if sup_fixed:
285
- audit_patches.append({
286
- "rule": "6.1", "location": "^{{\\latex / _{{\\latex",
287
- "fix": "^{ {\\latex / _{ {\\latex",
288
- "message": f"{sup_fixed} double-accolade de superscript/indice désambiguïsée(s) (espace inséré).",
289
- "iteration": 0,
290
- })
291
-
292
- myst_exercise, unwrapped = pp.unwrap_latex_injections(myst_exercise)
293
- if unwrapped:
294
- audit_patches.append({
295
- "rule": "6.1", "location": "{{ \\latex … }}",
296
- "fix": f"{unwrapped} enveloppe(s) {{{{ }}}} externe(s) retirée(s)",
297
- "message": f"{unwrapped} injection(s) enveloppant du LaTeX déballée(s) (l'injection interne est la vraie).",
298
- "iteration": 0,
299
- })
300
-
301
- myst_exercise, lift_patches = pp.auto_lift_injections(myst_exercise)
302
- audit_patches.extend(lift_patches)
303
-
304
- myst_exercise, rename_patches = pp.rename_underscore_injections(myst_exercise)
305
- audit_patches.extend(rename_patches)
306
-
307
- myst_exercise, dollar_patches = pp.fix_dollar_digit(myst_exercise)
308
- audit_patches.extend(dollar_patches)
309
-
310
- if decl_type:
311
- # Filet : alias d'option MCQ mal nommés / :isRightAnswer: manquant
312
- # (le repli MCQ en QAT est concerné aussi).
313
- myst_exercise, alias_fixed = pp.fix_mcq_answer_aliases(myst_exercise)
314
- if alias_fixed:
315
- audit_patches.append({
316
- "rule": "MCQ", "location": "(mcqOption / :isRightAnswer:)",
317
- "fix": f"{alias_fixed} bloc(s) d'option normalisé(s)",
318
- "message": "Blocs d'options MCQ normalisés (mcqOption→mcqAnswer, :isRightAnswer: false par défaut).",
319
- "iteration": 0,
320
- })
321
- # Déclinaisons : UN SEUL bloc {python} — fusion des blocs additionnels
322
- # sans re-tirage (re-tirage → laissé au harnais + réparation LLM).
323
- myst_exercise, merged = pp.merge_decl_python_blocks(myst_exercise)
324
- if merged:
325
- audit_patches.append({
326
- "rule": "3.1", "location": "(blocs python additionnels)",
327
- "fix": f"{merged} bloc(s) fusionné(s) dans le bloc principal",
328
- "message": "Déclinaison : blocs {python} additionnels fusionnés (un seul bloc, spec).",
329
- "iteration": 0,
330
- })
331
-
332
- if decl_type == "qcm":
333
- # Filet MCQ : l'option « None/Aucune » doit être le dernier mcqAnswer.
334
- myst_exercise, none_moved = pp.fix_none_option_last(myst_exercise)
335
- if none_moved:
336
- audit_patches.append({
337
- "rule": "MCQ", "location": "(option None)",
338
- "fix": f"{none_moved} option(s) « None » déplacée(s) en dernier",
339
- "message": "Option « Aucune de ces réponses / None » repositionnée en dernière position.",
340
- "iteration": 0,
341
- })
342
-
343
- # Les warnings 6.1 du LLM deviennent du bruit une fois l'auto-lift passé.
344
- if not pp.INJECTION_RE.search(myst_exercise) or not any(
345
- "(" in tok or "**" in tok for tok in pp.INJECTION_RE.findall(myst_exercise)
346
- ):
347
- audit_warnings = [w for w in audit_warnings
348
- if not (isinstance(w, dict) and w.get("rule") == "6.1")]
349
-
350
- myst_exercise, id_patched = pp.force_empty_id(myst_exercise)
351
- if id_patched and not any(p.get("rule") == "2.1" for p in audit_patches):
352
- audit_patches.append({
353
- "rule": "2.1", "location": "(metadata header)", "fix": ":id:",
354
- "message": "ID vidé par post-process déterministe.", "iteration": 0,
355
- })
356
-
357
- audit_warnings.extend(pp.diff_solutions(content, myst_exercise))
358
- audit_warnings.extend(pp.check_hardcoded_decimals_in_solutions(myst_exercise))
359
-
360
- # Invariants multi-seed (règle 4.3).
361
- main_code = extract_main_python_block(myst_exercise)
362
- if constraints and main_code:
363
- _step("Validation multi-seed des invariants…")
364
- assertions = _translate_constraints_to_assertions(main_code, constraints, model_idx, model=m_meca)
365
- if assertions:
366
- seed_report = multi_seed_validate(
367
- main_code, assertions, num_seeds=MULTI_SEED_NUM, timeout_per_seed=3.0)
368
- if seed_report["num_exec_errors"] > 0:
369
- audit_warnings.append({
370
- "rule": "4.3",
371
- "message": (f"Bloc Python : {seed_report['num_exec_errors']}/{MULTI_SEED_NUM} "
372
- f"exécutions ont échoué. Première erreur : "
373
- f"{seed_report.get('first_exec_error') or '?'}."),
374
- })
375
- for a in assertions:
376
- summary = seed_report["summary_per_assertion"].get(a["assertion"], {})
377
- viol = summary.get("violations", 0) + summary.get("errors", 0)
378
- if viol:
379
- audit_warnings.append({
380
- "rule": "4.3",
381
- "message": (f"Invariant « {a['description']} » violé sur "
382
- f"{viol}/{MULTI_SEED_NUM} seeds. "
383
- f"Assertion : `{a['assertion']}`."),
384
- })
385
-
386
- # Contrôles matplotlib (règles 11.x).
387
- if main_code:
388
- all_python_code = "\n".join(extract_all_python_blocks(myst_exercise))
389
- audit_warnings.extend(static_check_rational_numpy_mix(all_python_code))
390
- if "matplotlib" in all_python_code or analysis.get("needs_matplotlib"):
391
- random_var_names = [v.get("nom") for v in (analysis.get("variables") or [])
392
- if isinstance(v, dict) and isinstance(v.get("nom"), str)]
393
- unused = static_check_unused_random_vars(
394
- all_python_code, random_var_names, markdown_text=myst_exercise)
395
- if unused:
396
- audit_warnings.append({
397
- "rule": "11.1",
398
- "message": ("Variables aléatoires non utilisées dans le tracé : "
399
- f"{', '.join(unused)}."),
400
- })
401
- _step("Validation matplotlib (labels in-bounds)…")
402
- try:
403
- audit_warnings.extend(dynamic_check_matplotlib(all_python_code, timeout=8.0))
404
- except Exception as e:
405
- audit_warnings.append({
406
- "rule": "11.3",
407
- "message": f"Audit matplotlib impossible : {type(e).__name__}: {e}.",
408
- })
409
-
410
- # ── 7. Langue cible ──────────────────────────────────────────────────────
411
- _step("Langue cible…")
412
- myst_exercise, lang_warnings, lang_info = ensure_language(myst_exercise, lang, model_idx, model=m_meca)
413
- audit_warnings.extend(lang_warnings)
414
- effective_lang = lang if lang_info["action"] != "aucune" else lang_info["source"]
415
- audit_warnings.extend(pp.check_decimals_for_lang(myst_exercise, effective_lang))
416
-
417
- # ── 8. Porte harnais + réparation ────────────────────────────────────────
418
- myst_exercise, _aer = pp.aerate_blocks(myst_exercise) # lisibilité (exemples)
419
- myst_exercise, renum = pp.renumber_question_ids(myst_exercise)
420
- if renum:
421
- audit_patches.append({
422
- "rule": "2.x", "location": ":questionId:/:questionIndex:",
423
- "fix": "renumérotation 0..N-1",
424
- "message": f"{renum} questionId/questionIndex renuméroté(s) (contiguïté plateforme).",
425
- "iteration": 0,
426
- })
427
- _step(f"Porte harnais ({HARNESS_GATE_SEEDS} graines)…")
428
- report = harness.validate_text(myst_exercise, seeds=HARNESS_GATE_SEEDS)
429
-
430
- for attempt in range(HARNESS_REPAIR_MAX):
431
- if report["ok"]:
432
- break
433
- _step(f"Harnais ROUGE — réparation LLM {attempt + 1}/{HARNESS_REPAIR_MAX}…")
434
- try:
435
- repaired = process_with_openrouter(
436
- prompt=REPAIR_PROMPT.format(
437
- failures=harness.format_report(report),
438
- exercise=myst_exercise,
439
- ),
440
- model_idx=model_idx,
441
- model=m_gen,
442
- temperature=0.0,
443
- max_tokens=30000,
444
- system_prompt=SYSTEM_PROMPT,
445
- )
446
- except (RuntimeError, ValueError, OSError) as e:
447
- audit_warnings.append({"rule": "harnais",
448
- "message": f"Réparation LLM en échec : {e}."})
449
- break
450
- # Re-passe des filets déterministes sur le candidat réparé.
451
- candidate = _apply_deterministic_nets(pp.strip_fences(repaired), decl_type)
452
- candidate_report = harness.validate_text(candidate, seeds=HARNESS_GATE_SEEDS)
453
-
454
- def _badness(r: dict) -> int:
455
- return (len(r["static_errors"]) + r["n_exec_errors"]
456
- + r["n_unresolved"] + r["n_forbidden"]
457
- + r.get("n_mcq_collisions", 0))
458
-
459
- if candidate_report["ok"] or _badness(candidate_report) < _badness(report):
460
- myst_exercise, report = candidate, candidate_report
461
- audit_patches.append({
462
- "rule": "harnais", "location": "(exercice complet)",
463
- "fix": "réparation LLM post-harnais",
464
- "message": "Sortie réparée suite au verdict rouge du harnais.",
465
- "iteration": attempt + 1,
466
- })
467
-
468
- if not report["ok"]:
469
- audit_warnings.append({
470
- "rule": "harnais",
471
- "message": ("⚠️ SORTIE NON VERTE AU HARNAIS — à corriger avant soumission. "
472
- + harness.format_report(report)[:600]),
473
- })
474
-
475
- # ── 9. Audit pédagogique (déclinaisons, sortie VERTE) ────────────────────
476
- # Au-delà du harnais MÉCANIQUE : un juge LLM évalue la finesse pédagogique et
477
- # le respect des consignes (distracteurs cohérents, indevinabilité…), puis
478
- # une réparation ciblée qui ne doit JAMAIS casser le harnais.
479
- pedagogical = None
480
- if decl_type and PEDAGO_AUDIT_ENABLED and report["ok"]:
481
- _step("Audit pédagogique (finesse + respect des consignes)…")
482
- # Juge sur son modèle dédié (PEDAGO_AUDIT_MODEL) — constant entre échelons,
483
- # fort + JSON fiable, indépendant du modèle de génération qui escalade.
484
- pedagogical = run_pedagogical_audit(myst_exercise, decl_type)
485
- for attempt in range(PEDAGO_REPAIR_MAX):
486
- if pedagogical.get("verdict") != "A_REVOIR" or not pedagogical.get("issues"):
487
- break
488
- _step(f"Réparation pédagogique {attempt + 1}/{PEDAGO_REPAIR_MAX}…")
489
- try:
490
- repaired = process_with_openrouter(
491
- prompt=PEDAGOGICAL_REPAIR_PROMPT.format(
492
- decl_label="QCM (MCQ)" if decl_type == "qcm" else "QAT (FGQ)",
493
- issues=format_pedagogical_issues(pedagogical["issues"]),
494
- exercise=myst_exercise,
495
- ),
496
- model=m_gen, temperature=0.0, max_tokens=30000,
497
- system_prompt=SYSTEM_PROMPT,
498
- )
499
- except (RuntimeError, ValueError, OSError) as e:
500
- audit_warnings.append({"rule": "pédagogie",
501
- "message": f"Réparation pédagogique en échec : {e}."})
502
- break
503
- cand = _apply_deterministic_nets(pp.strip_fences(repaired), decl_type)
504
- cand_report = harness.validate_text(cand, seeds=HARNESS_GATE_SEEDS)
505
- if not cand_report["ok"]:
506
- audit_warnings.append({"rule": "pédagogie",
507
- "message": "Réparation pédagogique rejetée (casserait le harnais) "
508
- "— version précédente conservée."})
509
- break
510
- new_ped = run_pedagogical_audit(cand, decl_type)
511
- if pedagogical_badness(new_ped) < pedagogical_badness(pedagogical):
512
- myst_exercise, report, pedagogical = cand, cand_report, new_ped
513
- audit_patches.append({"rule": "pédagogie", "location": "(exercice complet)",
514
- "fix": "réparation pédagogique LLM",
515
- "message": "Distracteurs/consignes améliorés suite à l'audit pédagogique.",
516
- "iteration": attempt + 1})
517
- else:
518
- break # n'améliore pas → on garde l'existant
519
- if pedagogical.get("verdict") == "A_REVOIR":
520
- audit_warnings.append({"rule": "pédagogie",
521
- "message": "⚠️ QUALITÉ PÉDAGOGIQUE à revoir : "
522
- + format_pedagogical_issues(pedagogical.get("issues") or [])[:500]})
523
-
524
- # ── Résultat ──────────────────────────���──────────────────────────────────
525
- return {
526
- "exercise": myst_exercise,
527
- "pair_blocks": pair_blocks,
528
- "analysis": analysis,
529
- "functions": functions_ctx,
530
- "notions": (notions_ctx + "\n" + lists_of_notions).strip(),
531
- "audit_patches": audit_patches,
532
- "warnings": audit_warnings,
533
- "harness": {
534
- "ok": report["ok"],
535
- "seeds": report["seeds"],
536
- "summary": harness.format_report(report),
537
- },
538
- "pedagogical": pedagogical,
539
- "lang": lang_info,
540
- "decl_type": decl_type,
541
- "model_used": m_gen,
542
- "cost": cost_delta(cost_before),
543
- "duration_s": round(time.time() - t0, 1),
544
- }
545
-
546
-
547
- def run_with_policy(
548
- content: str,
549
- filename: str = "exercise.md",
550
- level: str = "",
551
- lang: str = "fr",
552
- policy: str = "auto",
553
- manual_models: Optional[dict] = None,
554
- decl_type: Optional[str] = None,
555
- shared_phase: Optional[tuple] = None,
556
- set_step: Optional[Callable[[str], None]] = None,
557
- ) -> dict:
558
- """
559
- Traite UN exercice sous POLITIQUE de sélection de modèle (§5) :
560
- auto : pré-classifieur de difficulté → départ sur l'échelle `auto` ;
561
- génération → harnais → ≤HARNESS_REPAIR_MAX réparations (même
562
- modèle) → si toujours ROUGE, ESCALADE d'un échelon et retente
563
- (analyse/RAG PARTAGÉS entre tentatives) → si `best` échoue,
564
- marque l'exo pour revue humaine.
565
- best / cheap / manual : un seul échelon (le modèle du preset).
566
- Télémétrie dans result["policy_telemetry"].
567
- """
568
- from app.models import policy as mp
569
-
570
- _step = set_step or (lambda label: None)
571
- manual = manual_models or {}
572
- m_audit = mp.openrouter_id(mp.resolve("audit", policy, manual))
573
- m_meca = mp.openrouter_id(mp.resolve("mecanique", policy, manual))
574
-
575
- cost_before_all = cost_snapshot() # coût HONNÊTE : analyse + tous échelons
576
- difficulty = mp.classify_difficulty(content)
577
- if policy == "auto":
578
- steps = mp.ladder("generate")
579
- start = mp.start_rung("generate", difficulty)
580
- rungs = steps[start:start + MAX_ESCALADES + 1] or steps[-1:]
581
- else:
582
- rungs = [mp.resolve("generate", policy, manual)]
583
- if not rungs:
584
- raise RuntimeError("Aucun modèle utilisable pour le rôle generate "
585
- "(clés API absentes) — vérifier OPENROUTER_API_KEY.")
586
-
587
- shared = shared_phase
588
- if shared is None:
589
- _step("Analyse + notions + catalogue RAG (en parallèle)…")
590
- shared = run_analysis_phase(content, 0, model=m_meca)
591
-
592
- tried: list[dict] = []
593
- result: dict = {}
594
- key = rungs[0]
595
- for i, key in enumerate(rungs):
596
- _step(f"Échelon {i + 1}/{len(rungs)} — {key}…")
597
- result = run_exercise(
598
- content=content, filename=filename, level=level,
599
- lang=lang, set_step=_step, decl_type=decl_type,
600
- shared_phase=shared,
601
- forced_models={"generate": mp.openrouter_id(key),
602
- "audit": m_audit, "mecanique": m_meca},
603
- )
604
- harness_ok = result["harness"]["ok"]
605
- ped = result.get("pedagogical") or {}
606
- ped_verdict = ped.get("verdict") # OK / A_REVOIR / INCONNU / None
607
- tried.append({"rung": i, "model": key, "ok": harness_ok,
608
- "pedago": ped_verdict})
609
- is_last = (i == len(rungs) - 1)
610
- # Acceptation d'un échelon : harnais VERT ET (qualité pédagogique OK, ou
611
- # on n'escalade pas sur la pédagogie, ou dernier échelon). Sinon on
612
- # gravit l'échelon suivant — c'est le « meilleur modèle selon l'exo ».
613
- pedago_ok = ped_verdict != "A_REVOIR"
614
- escalate_pedago = (policy == "auto" and PEDAGO_ESCALATE_IN_AUTO
615
- and not pedago_ok and not is_last)
616
- if harness_ok and not escalate_pedago:
617
- break
618
- if not harness_ok:
619
- logger.info("Échelon %s ROUGE (harnais) sur %s — escalade.", key, filename)
620
- else:
621
- logger.info("Échelon %s VERT mais qualité pédagogique à revoir sur %s "
622
- "— escalade de modèle.", key, filename)
623
-
624
- result["policy_telemetry"] = {
625
- "mode": policy,
626
- "difficulty": difficulty,
627
- "tried": tried,
628
- "winning_model": key,
629
- "pedago_verdict": (result.get("pedagogical") or {}).get("verdict"),
630
- "needs_review": (not result["harness"]["ok"]
631
- or (result.get("pedagogical") or {}).get("verdict") == "A_REVOIR"),
632
- }
633
- # Coût honnête : inclut l'analyse partagée (si calculée ici) ET les
634
- # échelons perdants — pas seulement la tentative gagnante.
635
- result["cost"] = cost_delta(cost_before_all)
636
- return result
637
-
638
-
639
- def run_declinaisons(
640
- content: str,
641
- filename: str = "exercise.md",
642
- level: str = "",
643
- model_idx: int = 1,
644
- lang: str = "fr",
645
- types: Optional[list] = None,
646
- set_step: Optional[Callable[[str], None]] = None,
647
- policy: str = "auto",
648
- manual_models: Optional[dict] = None,
649
- ) -> list[tuple[str, dict]]:
650
- """
651
- Mode `declinaisons` : produit une déclinaison par type coché (qcm/qat),
652
- sous politique de sélection de modèle. L'analyse + notions + RAG sont
653
- calculées UNE SEULE fois et partagées entre les types ET les échelons
654
- (aucun appel LLM redondant). Retourne [(decl_type, result), …].
655
- """
656
- from app.models import policy as mp
657
-
658
- _step = set_step or (lambda label: None)
659
- types = [t for t in (types or []) if t in ("qcm", "qat")] or ["qcm"]
660
-
661
- m_meca = mp.openrouter_id(mp.resolve("mecanique", policy, manual_models))
662
- _step("Analyse + notions + catalogue RAG (partagés QCM/QAT)…")
663
- cost_before_analysis = cost_snapshot()
664
- shared = run_analysis_phase(content, model_idx, model=m_meca)
665
- analysis_cost = cost_delta(cost_before_analysis)
666
-
667
- out: list[tuple[str, dict]] = []
668
- for decl_type in types:
669
- label = "QCM" if decl_type == "qcm" else "QAT"
670
-
671
- def step_with_type(msg: str, _label=label):
672
- _step(f"[{_label}] {msg}")
673
-
674
- result = run_with_policy(
675
- content=content,
676
- filename=filename,
677
- level=level,
678
- lang=lang,
679
- policy=policy,
680
- manual_models=manual_models,
681
- decl_type=decl_type,
682
- shared_phase=shared,
683
- set_step=step_with_type,
684
- )
685
- out.append((decl_type, result))
686
- # L'analyse partagée tombe HORS des fenêtres de coût de run_with_policy :
687
- # on l'impute au premier type pour que le total du job reste honnête.
688
- if out and analysis_cost["requests"]:
689
- c = out[0][1].get("cost") or {"usd": 0.0, "eur": 0.0, "requests": 0}
690
- out[0][1]["cost"] = {
691
- "usd": round(c["usd"] + analysis_cost["usd"], 6),
692
- "eur": round(c["eur"] + analysis_cost["eur"], 6),
693
- "requests": c["requests"] + analysis_cost["requests"],
694
- }
695
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
prompts.py DELETED
@@ -1,999 +0,0 @@
1
- """
2
- prompts.py
3
- ──────────
4
- Tous les prompts LLM du pipeline.
5
-
6
- # v1 → les versions antérieures (STEP1_PROMPT, STEP_PAIR_PROMPT, SYSTEM_PROMPT,
7
- # STEP_AUDIT_PROMPT) sont archivées VERBATIM dans
8
- # app/knowledge/prompts_v1_archive.md (extraites de routes/pythonise_routes_v2.py
9
- # avant la refonte du 2026-06-12).
10
- #
11
- # Changements majeurs v1 → v2 (alignement sur les conventions RÉELLES de la
12
- # plateforme, vérifiées sur les 222 exemples pythonisés livrés + skill
13
- # pyxiscience-pythonisation) :
14
- # • Injections {{ }} = UNIQUEMENT des noms de variables nus, camelCase,
15
- # suffixe Aff, sans underscore. La v1 enseignait {{latex(expr)}},
16
- # {{lc(a, sign=True)}}, {{pxsl_res_num(...)}} — tous absents des exemples
17
- # validés et refusés par le harnais.
18
- # • Bloc {python} à 4 backticks (la v1 montrait 3) ; enveloppe exercise à 5.
19
- # • Bilingue = rôles inline {fr}`…`{en}`…` UNIQUEMENT (aucun bloc
20
- # :::{fr}/:::{en} dans les 222 exemples — la v1 les enseignait).
21
- # • Règle du `$` collé à un chiffre (préfixe ${}) — absente de la v1.
22
- # • Fraction (module fractions) interdit en sortie de formateur ;
23
- # pxsl_format_number SANS kwargs ; décimales localisées FR/EN.
24
- # • Few-shot du type détecté injecté ({fewshot}) au lieu de 5 exemples
25
- # génériques pavés dans le prompt.
26
- # • SYSTEM_PROMPT raccourci (le dump de 180 lignes du source de
27
- # pxsl_res_num doublonnait le catalogue RAG).
28
- """
29
-
30
- # ─────────────────────────────────────────────────────────────────────────────
31
- # STEP 1 — Analyse (variables + règles à risque + invariants)
32
- # v1 → app/knowledge/prompts_v1_archive.md §STEP1_PROMPT
33
- # (v2 : typo needs_matplolib corrigée → needs_matplotlib ; mention du type
34
- # pour la sélection de few-shot ; sinon structure conservée)
35
- # ─────────────────────────────────────────────────────────────────────────────
36
- STEP1_PROMPT = """\
37
- Tu es un expert en analyse d'exercices mathématiques & détection des variables Python pour PyxiScience,
38
- pour passer d'un exercice à valeurs statiques à un exercice à valeurs aléatoires **correctes**.
39
-
40
- EXERCICE :
41
- {content}
42
-
43
- ─────────────────────────────────────────────────────
44
- MISSION : analyse cet exercice et identifie **TOUTES** les entités mathématiques
45
- qui devront être générées aléatoirement en Python.
46
-
47
- Couvre TOUS les types possibles : scalaires entiers/réels, fractions, listes,
48
- vecteurs, matrices, ensembles, polynômes, fonctions, pourcentages, angles, intervalles…
49
-
50
- Pour chaque variable :
51
- • nom : nom Python valide court (ex: a, b, n, matA, listeNotes),
52
- partageable entre questions
53
- • type_python : "int"|"float"|"Fraction"|"list"|"matrix"|"set"|"vector"|"other"
54
- • description : rôle dans l'énoncé (1 phrase)
55
- • contraintes : contraintes mathématiques (ex: a ≠ 0, n ∈ [2,10])
56
- • plage_python : expression Python exacte de génération aléatoire
57
- • location : "énoncé"|"inter-question"|"question"|"solution 1"|…|"solution 5"
58
- • valeur_exemple: valeur typique
59
-
60
- ─────────────────────────────────────────────────────
61
- RÈGLES DE PYTHONISATION (catalogue) — choisis dans "target_rules" celles qui
62
- sont LE PLUS À RISQUE pour CET exercice (5 à 12 IDs maximum).
63
-
64
- {available_rules_menu}
65
-
66
- ─────────────────────────────────────────────────────
67
- Réponds UNIQUEMENT en JSON valide :
68
- {{
69
- "exercise_type": "...",
70
- "exercise_title": "...",
71
- "exercise_summary": "...",
72
- "suggested_concepts": ["..."],
73
- "nb_questions": 1,
74
- "variables": [
75
- {{
76
- "nom": "...",
77
- "type_python": "...",
78
- "description": "...",
79
- "contraintes": "...",
80
- "location": "énoncé|question|solution 1",
81
- "plage_python": "...",
82
- "valeur_exemple": "..."
83
- }}
84
- ],
85
- "needs_fraction": false,
86
- "needs_sympy": false,
87
- "needs_numpy": false,
88
- "needs_matplotlib": false,
89
- "mathematical_structure": "...",
90
- "target_rules": ["3.1", "4.1", "6.1"],
91
- "property_constraints": [
92
- "<invariant mathématique en français — ex: w_n ≥ n pour tout n>"
93
- ],
94
- "has_validated_solution_in_input": false
95
- }}
96
-
97
- Notes :
98
- • "exercise_type" : type court et standard (ex: "équation linéaire",
99
- "trinôme/discriminant", "fonction avec figure", "logarithme/exponentielle",
100
- "système linéaire", "intégration par parties", "probabilités/binomiale",
101
- "suites", "finance/intérêts") — il sert à choisir un exemple canonique.
102
- • "target_rules" : uniquement des IDs du catalogue ci-dessus (top 5-12 à risque).
103
- • "property_constraints" : invariants à préserver au tirage (règle 4.3). Liste vide si aucun.
104
- • "has_validated_solution_in_input" : true SI l'énoncé contient déjà des
105
- blocs `::::{{detailedSolution}}` rédigés (règles 8.1–8.3).
106
- """
107
-
108
-
109
- # ─────────────────────────────────────────────────────────────────────────────
110
- # SYSTEM — rôle + sémantique du runtime {{ }}
111
- # v1 → app/knowledge/prompts_v1_archive.md §SYSTEM_PROMPT
112
- # ─────────────────────────────────────────────────────────────────────────────
113
- SYSTEM_PROMPT = """\
114
- Tu es professeur de mathématiques, expert en Python scientifique (sympy, random,
115
- math, matplotlib, numpy) et en exercices PyxiScience randomisés (MyST + KaTeX).
116
-
117
- Le code Python vit dans UN bloc ````{python} … ```` (4 backticks) terminé par
118
- `globals()`. Les variables y définies sont injectées dans le MyST via `{{ var }}`.
119
-
120
- ⚠️ La syntaxe `{{ var }}` est exécutée par un runtime Python maison — PAS du
121
- Jinja. RÈGLE ABSOLUE : `{{ … }}` contient UNIQUEMENT un nom de variable nu,
122
- en camelCase, SANS underscore, généralement suffixé `Aff` pour les affichages
123
- (ex: `{{coefAAff}}`). JAMAIS d'appel de fonction, de calcul, de filtre ni de
124
- logique dans `{{ }}` — tout est pré-calculé dans le bloc Python.
125
-
126
- Deux règles d'or :
127
- 1. Tout ce qui s'affiche est PRÉ-CALCULÉ dans une variable puis injecté tel quel.
128
- 2. On ne code JAMAIS en dur une réponse vraie seulement pour les valeurs de la
129
- source : si un paramètre est randomisé, la réponse affichée est RECALCULÉE.
130
-
131
- Priorité aux helpers du catalogue PyxiScience (pxsl_latex_coefficient/lc,
132
- pxsl_res_num, pxsl_format_number, pxsl_matrix, pxs_Interval, pxs_config…) —
133
- appelés DANS le bloc Python, résultat stocké dans une variable `…Aff`.
134
- Ne jamais réimplémenter un helper existant.
135
-
136
- Exercices applicatifs : contexte ÉCONOMIE/GESTION (finance, comptabilité,
137
- marketing, microéconomie — registre école de commerce), jamais physique/chimie.
138
- """
139
-
140
-
141
- # ─────────────────────────────────────────────────────────────────────────────
142
- # STEP 2+ — Génération par paires
143
- # v1 → app/knowledge/prompts_v1_archive.md §STEP_PAIR_PROMPT
144
- # ─────────────────────────────────────────────────────────────────────────────
145
- STEP_PAIR_PROMPT = """\
146
- Tu pythonises un exercice PyxiScience MyST, niveau {niveau}.
147
- Transformer l'exercice statique (valeurs fixes) en version randomisée
148
- (paramètres tirés en Python + injectés dans le MyST), EN respectant à la
149
- lettre les conventions de la plateforme ci-dessous.
150
-
151
- ═══════════════════════════════════════════════════════════════════════════
152
- CONVENTIONS PLATEFORME (vérifiées sur les exercices livrés — NON NÉGOCIABLES)
153
- ═══════════════════════════════════════════════════════════════════════════
154
-
155
- STRUCTURE :
156
- • Enveloppe exercice : 5 backticks `````{{exercise}} … ````` (déjà gérée).
157
- • UN bloc Python principal : 4 backticks ````{{python}} … ```` terminé par
158
- `globals()`. Imports en tête (`import random as rd`, sympy ciblé,
159
- `from pyxiscience.Mes_fctions_generalistes_bis import pxs_config,
160
- pxsl_latex_coefficient as lc` …) puis `config_standard = pxs_config()`.
161
- • Chaque question : `:::::{{question}}` (5 deux-points) avec
162
- `:questionType:`, `:questionId:`, `:questionIndex:` (contigus depuis 0) ;
163
- sous-blocs `::::{{questionStatement}}`, `::::{{questionHint}}`,
164
- `::::{{detailedSolution}}`, `::::{{weightDistribution}}` (4 deux-points).
165
- • `:weightDistribution:` : recopier les poids de la source VERBATIM
166
- (somme = 100 par question).
167
-
168
- INJECTIONS `{{{{ }}}}` — LA règle qui fait tout casser si violée :
169
- • UNIQUEMENT un nom de variable NU : `{{{{coefAAff}}}}`, `{{{{resQ1Aff}}}}`.
170
- camelCase, SANS underscore, suffixe `Aff` pour tout affichage.
171
- • INTERDIT dans `{{{{ }}}}` : appel de fonction (`latex(...)`, `lc(...)`,
172
- `pxsl_…(...)`, `obj.print()`), calcul (`a*b`), `round(...)`, `**kwargs`.
173
- → TOUT se pré-calcule dans le bloc Python :
174
- `eqAff = latex(a*x**2 + b*x + c, **config_standard)` puis `{{{{eqAff}}}}`.
175
- • `**config_standard` est réservé à `sympy.latex(...)` DANS le bloc Python.
176
- `pxsl_format_number()` n'accepte AUCUN kwarg. `pxsl_res_num(x, dec=…,
177
- egal=False)` s'appelle dans le bloc Python, résultat dans une variable Aff.
178
- • Espaces contre la triple-accolade : `x^{{ {{{{expAff}}}} }}` ✅,
179
- jamais `x^{{{{{{expAff}}}}}}` ❌.
180
-
181
- RÈGLE DU `$` COLLÉ À UN CHIFFRE (casse silencieuse) :
182
- Un `$` immédiatement suivi d'un chiffre est lu comme un MONTANT en devise et
183
- désynchronise tout le `$…$`. Préfixer par un groupe vide : `${{}}3 \\times 2$`,
184
- et SURTOUT `${{}}{{{{nAff}}}}$` pour toute injection inline qui rend un nombre.
185
- Pour un vrai pourcentage affiché : `\\%` partout (jamais `%` nu dans le texte) ;
186
- ne JAMAIS échapper les `%` à l'intérieur du bloc Python (chaînes "%.2f").
187
-
188
- TIRAGES — exclure les cas dégénérés (boucle de rejet ou énumération) :
189
- • `for _ in range(300): … ; break` ou liste de candidats + `rd.choice(...)`
190
- (contraintes croisées → TOUJOURS énumération en compréhension, jamais
191
- une boucle qui peut sortir invalide).
192
- • Exclure : exposant 0 ou 1 affiché (`^{{0}}`, `^{{1}}`), dénominateur 1
193
- (`\\frac{{…}}{{1}}`), `\\sqrt[1]`, `\\sqrt[2]` (→ `\\sqrt`), double signe
194
- (`+ -`), division par zéro, Δ de signe inattendu, intervalle vide.
195
- • Exactitude : `sympy.Rational(1, 2)`, JAMAIS `1/2` flottant ni `round()`
196
- pour une valeur exacte. Le module `fractions.Fraction` PLANTE les
197
- formateurs plateforme — ne jamais le passer à un helper pxsl_*.
198
- • Coefficients signés : ne JAMAIS concaténer un signe à une valeur ;
199
- utiliser `lc(coef, sign=True, ones=True)` (pxsl_latex_coefficient) dans
200
- le bloc Python → variable Aff.
201
-
202
- DÉCIMALES SELON LA LANGUE : FR = virgule (`0{{,}}18`, `4,12`), milliers `\\,` ;
203
- EN = point (`0.18`), milliers virgule. {lang_directive}
204
-
205
- BILINGUE (si l'exercice l'est) : rôles INLINE uniquement :
206
- {{fr}}`Calculer …`{{en}}`Compute …`
207
- Jamais de bloc :::{{fr}}/:::{{en}}. Les injections `{{{{var}}}}` se placent
208
- HORS des rôles : {{fr}}`Il y a `{{en}}`There are `{{{{nAff}}}}.
209
- Symétrie totale FR/EN (même détail, mêmes placeholders). Si un nombre
210
- décimal s'affiche, prévoir des variables séparées par langue
211
- (`prixAffFr` virgule / `prixAffEn` point) et injecter la bonne dans chaque rôle.
212
-
213
- FIDÉLITÉ À LA SOURCE (directive Chabane — INTOUCHABLE) :
214
- • Même énoncé, même méthode, même structure de solution, mêmes poids.
215
- • Ne JAMAIS AJOUTER de phrase d'énoncé, de transition ou de rappel de règle
216
- absent de la source (même « pour aider ») — on pythonise, on n'enrichit pas.
217
- • Conserver les commentaires utiles du bloc Python source s'il en a.
218
- • Si la source contient des `detailedSolution` validées : INTERDICTION de
219
- reformuler la prose — seules les valeurs littérales deviennent `{{{{var}}}}`.
220
- • Conserver tels quels : `\\ds`, `\\dfrac`, `\\inftys` (macro maison, ne
221
- JAMAIS la « corriger »), `\\begin{{equation*}}` avec `&=` direct,
222
- `\\phantom{{-}}\\\\`. Pas de `$$…$$`, pas de `\\[…\\]`, pas de `\\begin{{align}}`.
223
- • Géométrie pure : ne pas pythoniser (valeurs statiques conservées).
224
-
225
- FIGURES matplotlib (si l'exo en a) : construites dans LE bloc Python unique,
226
- variables du tirage réellement utilisées dans le tracé, labels DANS la fenêtre,
227
- pas de mélange Rational+numpy (passer par float()), UN SEUL `plt.show()` final —
228
- jamais `savefig`, jamais `matplotlib.use(...)`.
229
-
230
- ═══════════════════════════════════════════════════════════════════════════
231
- CATALOGUE PyxiScience (helpers à utiliser DANS le bloc Python)
232
- ═══════════════════════════════════════════════════════════════════════════
233
- RÈGLE D'OR DU CATALOGUE : avant d'écrire le moindre LaTeX à la main, CHERCHE
234
- ici un helper qui fait le travail et UTILISE-LE (résultat dans une variable
235
- `…Aff` injectée nue). Coder à la main ce qu'un helper sait faire = REJET.
236
- • Matrices → `pxsl_matrix` (jamais `\\begin{{pmatrix}}` à la main) ; sommes/
237
- produits/scalaires détaillés → `pxsl_sum_matrix`/`pxsl_prod_matrix`/
238
- `pxsl_prod_scalar_matrix` ; système `Ax=B` → `pxsl_system_lin` ; résolution
239
- pas à pas / inversion → `pxsl_resol_system` / `pxs_steps_invert_matrix` ;
240
- échelon/RREF → `pxs_compute_ech`/`pxs_compute_ech_reduite`.
241
- • Proba (v.a. finie) → `pxs_finiterv`, tableau de loi `pxsl_law`, moment
242
- `pxsl_moment`, transformation `pxs_fct_finiterv`.
243
- • Intégration par parties → `pxs_explain_IBP` (rédaction complète, injectée
244
- via `{{{{ipp}}}}`).
245
- • Coefficients signés → `pxsl_latex_coefficient`/`lc` ; puissances →
246
- `pxsl_pow` ; résultat numérique → `pxsl_res_num` ; inéquation rédigée →
247
- `pxsl_solve_general_inequality`.
248
- • Voie par défaut pour une expression : `latex(expr, **config_standard)`.
249
- `**config_standard` est réservé à `latex()` — JAMAIS sur un helper `pxsl_*`.
250
- • N'appelle PAS un helper marqué « runtime à vérifier » (indi_l_r_symb,
251
- pxs_round, Poly_with_random_coef) sans certitude qu'il est chargé.
252
-
253
- {functions}
254
-
255
- ═══════════════════════════════════════════════════════════════════════════
256
- EXEMPLE CANONIQUE DU MÊME TYPE (extrait d'un exercice livré et validé —
257
- imite sa structure, ses conventions d'affichage et son niveau de détail)
258
- ═══════════════════════════════════════════════════════════════════════════
259
- {fewshot}
260
-
261
- ═══════════════════════════════════════════════════════════════════════════
262
- CONTEXTE
263
- ═══════════════════════════════════════════════════════════════════════════
264
-
265
- EN-TÊTE déjà finalisé (NE PAS reproduire) :
266
- {content}
267
-
268
- VARIABLES DÉTECTÉES :
269
- {analysis}
270
-
271
- BLOCS PRÉCÉDENTS (ne pas redéfinir leurs variables, ne pas les répéter) :
272
- {previous_blocks}
273
-
274
- SECTION À PYTHONISER ({range_label} / {nb_total}) :
275
- {current_segment}
276
-
277
- ═══════════════════════════════════════════════════════════════════════════
278
- RÈGLES D'ASSEMBLAGE PAR PAIRE
279
- ═══════════════════════════════════════════════════════════════════════════
280
-
281
- ⚠️ TU PRODUIS UNIQUEMENT LE CONTENU DE CETTE PAIRE — jamais celui des paires
282
- précédentes (concaténées mécaniquement avant ta sortie).
283
-
284
- ⚠️ EXACTEMENT {nb_current} bloc(s) `:::::{{question}}` — pas plus, pas moins.
285
- `questionId`/`questionIndex` CONTINUS depuis la paire précédente.
286
-
287
- ⚠️ PAIRE 1 UNIQUEMENT : tu produis le bloc ````{{python}}```` principal
288
- (imports + tirages + calculs + variables Aff + `globals()`) puis l'énoncé
289
- général réécrit avec injections, AVANT la première question.
290
-
291
- ⚠️ PAIRES SUIVANTES : ni énoncé, ni questions précédentes, ni ré-imports.
292
- Si de NOUVELLES variables sont nécessaires (ex. figure d'une partie C), un
293
- PETIT bloc ````{{python}}```` additionnel SANS imports, terminé par `globals()`.
294
-
295
- Format paire 1 :
296
-
297
- ````{{python}}
298
- <imports + tirages (cas dégénérés exclus) + calculs sympy exacts
299
- + TOUTES les variables d'affichage …Aff ; AUCUN texte pédagogique>
300
- globals()
301
- ````
302
-
303
- <énoncé général avec valeurs → {{{{varAff}}}}>
304
-
305
- :::::{{question}}
306
- :questionType: STQ
307
- :questionId: N
308
- :questionIndex: N
309
-
310
- ::::{{questionStatement}} … ::::
311
- ::::{{questionHint}} … ::::
312
- ::::{{detailedSolution}} … ::::
313
- ::::{{weightDistribution}}
314
- :logic: …
315
- :abstraction: …
316
- :reasoning: …
317
- :calculation: …
318
- ::::
319
- :::::
320
-
321
- ═══════════════════════════════════════════════════════════════════════════
322
- RÈGLES CIBLÉES POUR CET EXERCICE (depuis la base de règles)
323
- ═══════════════════════════════════════════════════════════════════════════
324
- {targeted_rules}
325
-
326
- INVARIANTS MATHÉMATIQUES à préserver lors des tirages :
327
- {property_constraints}
328
-
329
- ═══════════════════════════════════════════════════════════════════════════
330
- CHECKLIST FINALE (vérifie chaque point avant de répondre)
331
- ═══════════════════════════════════════════════════════════════════════════
332
- □ Bloc ````{{python}}```` (4 backticks) terminé par `globals()`
333
- □ CHAQUE `{{{{ }}}}` = nom de variable NU camelCase sans underscore
334
- □ Aucun appel/calcul/`**kwargs` dans `{{{{ }}}}` — tout pré-calculé en `…Aff`
335
- □ Aucun `$` collé à un chiffre — `${{}}` devant toute injection inline numérique
336
- □ Tirages sans cas dégénéré (^{{1}}, ^{{0}}, frac{{}}{{1}}, sqrt[2], double signe)
337
- □ Coefficients signés via lc(...) pré-calculé ; décimales localisées
338
- □ weightDistribution = poids source verbatim (somme 100) ; IDs contigus
339
- □ Solutions validées : prose INTACTE, valeurs → {{{{var}}}}
340
- □ {{fr}}`…`{{en}}`…` symétriques si bilingue ; `\\%` pour les pourcentages
341
- """
342
-
343
-
344
- # ─────────────────────────────────────────────────────────────────────────────
345
- # AUDIT — vérification ciblée + patches textuels
346
- # v1 → app/knowledge/prompts_v1_archive.md §STEP_AUDIT_PROMPT
347
- # (v2 : ajout des contrôles injections nues / $+chiffre / fences 4 backticks ;
348
- # les patches sont désormais appliqués à TOUTES les occurrences identiques)
349
- # ─────────────────────────────────────────────────────────────────────────────
350
- STEP_AUDIT_PROMPT = """\
351
- Tu es l'auditeur PyxiScience. Tu reçois l'exercice pythonisé final et tu vérifies
352
- UNIQUEMENT les règles listées ci-dessous (chacune avec son cas FAUTIF/CORRECT),
353
- plus les 4 invariants plateforme :
354
- (a) toute injection `{{{{ }}}}` est un nom de variable NU camelCase sans underscore ;
355
- (b) aucun `$` non échappé collé à un chiffre (préfixe `${{}}` requis) ;
356
- (c) bloc {{python}} à 4 backticks terminé par `globals()` ;
357
- (d) questionId/questionIndex contigus depuis 0, weightDistribution somme 100.
358
-
359
- RÈGLES À VÉRIFIER :
360
-
361
- {audit_rules}
362
-
363
- EXERCICE À AUDITER :
364
- {exercise}
365
-
366
- ═══════════════════════════════════════════════════════════════════════════
367
- MISSION
368
- ═══════════════════════════════════════════════════════════════════════════
369
-
370
- Pour chaque règle violée, renvoie une "issue" :
371
- • rule : ID de la règle (ex: "6.1") ou "(a)"…"(d)"
372
- • location : snippet EXACT (1 ligne, ≤ 200 caractères), copié VERBATIM —
373
- utilisé tel quel par str.replace() côté Python.
374
- Si la même violation apparaît à N endroits IDENTIQUES,
375
- une seule issue suffit (toutes les occurrences seront
376
- remplacées).
377
- • fix : remplacement EXACT.
378
- • python_insert : (OPTIONNEL) ligne(s) Python à insérer avant le `globals()`
379
- du bloc principal (ex: "eqAff = latex(eq, **config_standard)").
380
- • can_patch : true si la correction est sûre, false sinon (warning seul).
381
- • message : phrase explicative en français.
382
-
383
- Ne liste PAS les règles respectées ni celles hors liste. Ne « corrige » JAMAIS
384
- `\\inftys`, `\\ds`, `\\dfrac`, ni la prose d'une solution validée.
385
- Un exercice MONOLINGUE (tout FR ou tout EN, sans rôles {{fr}}`…`{{en}}`…`) est
386
- LÉGITIME — ne le signale pas ; n'exige le bilingue que s'il est déjà partiel.
387
- Les espaces internes `{{{{ var }}}}` sont tolérés (le moteur trim) — ne les
388
- signale pas.
389
-
390
- Réponds UNIQUEMENT en JSON valide, sans markdown :
391
- {{
392
- "verdict": "OK" ou "PATCH_REQUIRED",
393
- "issues": [
394
- {{
395
- "rule": "6.1",
396
- "location": "{{{{latex(fDev, **config_standard)}}}}",
397
- "fix": "{{{{fDevTex}}}}",
398
- "python_insert": "fDevTex = latex(fDev, **config_standard)",
399
- "can_patch": true,
400
- "message": "Appel avec **kwargs dans {{{{…}}}} — variable pré-calculée."
401
- }}
402
- ]
403
- }}
404
- """
405
-
406
-
407
- # ─────────────────────────────────────────────────────────────────────────────
408
- # Traduction de contraintes FR → assertions Python (règle 4.3)
409
- # (inchangé v1 — déplacé depuis routes/pythonise_routes_v2.py)
410
- # ─────────────────────────────────────────────────────────────────────────────
411
- TRANSLATE_CONSTRAINTS_PROMPT = """\
412
- Tu reçois un bloc de code Python qui tire des variables aléatoires et calcule
413
- des grandeurs dérivées, et une liste de contraintes mathématiques en français
414
- à vérifier sur les variables produites.
415
-
416
- CODE PYTHON :
417
- ```python
418
- {code}
419
- ```
420
-
421
- CONTRAINTES (français) :
422
- {constraints}
423
-
424
- MISSION :
425
- Pour chaque contrainte, écris une expression Python booléenne qui, évaluée
426
- dans le namespace résultant de l'exécution du code, retourne True si la
427
- contrainte est respectée.
428
-
429
- Règles :
430
- • Contrainte universelle ("pour tout n") → échantillonner n = 0..10 max et
431
- combiner avec `all(...)` (5 à 10 valeurs, pas plus).
432
- • Notations math (≤, ≥, ≠) → `<=`, `>=`, `!=`.
433
- • Contrainte intestable (variable absente du code) → `"assertion": null`.
434
- • AUCUN import supplémentaire — seulement les variables du namespace + builtins.
435
-
436
- Réponds UNIQUEMENT en JSON valide :
437
- [
438
- {{"description": "<contrainte originale>", "assertion": "<expression Python>" }},
439
- {{"description": "<autre contrainte>", "assertion": null }}
440
- ]
441
- """
442
-
443
-
444
- # ─────────────────────────────────────────────────────────────────────────────
445
- # Substitution des solutions validées (règle 8.1)
446
- # (inchangé v1 — déplacé depuis routes/pythonise_routes_v2.py)
447
- # ─────────────────────────────────────────────────────────────────────────────
448
- SOLUTION_SUBSTITUTION_PROMPT = """\
449
- Tu reçois UNE solution mathématique rédigée avec des VALEURS LITTÉRALES (nombres,
450
- fractions, expressions concrètes), et une liste de VARIABLES PYTHON disponibles
451
- dans le bloc `{{python}}` de l'exercice pythonisé.
452
-
453
- SOLUTION ORIGINALE (source MyST, à préserver mot à mot) :
454
- ─────────────────────────────────
455
- {original_solution}
456
- ─────────────────────────────────
457
-
458
- VARIABLES PYTHON DISPONIBLES (chaque var a une valeur d'exemple ; substitue
459
- chaque occurrence numérique par le placeholder MyST {{{{var}}}}) :
460
- {variables_table}
461
-
462
- ═══════════════════════════════════════════════════════════════════════════
463
- MISSION
464
- ═══════════════════════════════════════════════════════════════════════════
465
-
466
- Remplace CHAQUE valeur littérale (nombre entier, fraction, expression numérique)
467
- qui correspond à une variable Python par son placeholder MyST `{{{{nomVar}}}}`.
468
-
469
- ⚠️ STRICTES INTERDICTIONS :
470
- • NE PAS ajouter de mots (Initialisation, Hérédité, Conclusion, Soit, Donc, etc.)
471
- • NE PAS ajouter de **gras** ou *italique* si pas dans la source
472
- • NE PAS reformuler la moindre phrase ; NE PAS modifier la ponctuation
473
- • NE PAS ajouter de paragraphes ou de blocs equation*
474
- • Si la source utilise déjà des `{{{{var}}}}`, les PRÉSERVER tels quels.
475
-
476
- EXEMPLE :
477
- Source : `On a $f(0) = 3$ et $f(2) = -1$.`
478
- Variables : x0=0, x2=2, fx0=3, fx2=-1
479
- Sortie : `On a $f({{{{x0}}}}) = {{{{fx0}}}}$ et $f({{{{x2}}}}) = {{{{fx2}}}}$.`
480
-
481
- Réponds UNIQUEMENT avec le texte modifié, SANS préambule, SANS wrapper markdown,
482
- SANS guillemets ajoutés. Si tu ne peux pas substituer, recopie la source telle quelle.
483
- """
484
-
485
-
486
- # ─────────────────────────────────────────────────────────────────────────────
487
- # Traduction / bilinguisation (NOUVEAU — chantier langue cible)
488
- # ─────────────────────────────────────────────────────────────────────────────
489
- TRANSLATE_PROMPT = """\
490
- Tu traduis la PROSE d'un exercice PyxiScience MyST, de {source_label} vers {target_label}.
491
-
492
- TEXTE (les blocs Python ont été remplacés par des sentinelles <<<PYBLOCK_n>>> — à RECOPIER TELLES QUELLES) :
493
- ─────────────────────────────────
494
- {body}
495
- ─────────────────────────────────
496
-
497
- RÈGLES ABSOLUES :
498
- • Traduire UNIQUEMENT la prose (énoncés, indications, solutions, titres).
499
- • PRÉSERVER À L'IDENTIQUE : toutes les sentinelles <<<PYBLOCK_n>>>, tous les
500
- placeholders `{{{{var}}}}` (mêmes noms, mêmes positions logiques), tout le
501
- LaTeX/maths ($…$, \\begin{{equation*}}…), la structure des fences MyST
502
- (`````, :::::, ::::), toutes les options `:clé: valeur` (dont
503
- :questionId:, :weightDistribution: et leurs valeurs), `\\ds`, `\\dfrac`,
504
- `\\inftys`, `\\%`.
505
- • {format_directive}
506
- • Décimales : virgule en FR (`0{{,}}5`), point en EN (`0.5`) — adapte les
507
- décimales LITTÉRALES de la prose à la langue de chaque segment ; ne touche
508
- pas aux `{{{{var}}}}`.
509
- • Terminologie mathématique scolaire exacte ; même niveau de détail.
510
-
511
- Réponds UNIQUEMENT avec le texte transformé, sans préambule ni wrapper.
512
- """
513
-
514
- TRANSLATE_FORMAT_MONO = (
515
- "Sortie MONOLINGUE en {target_label} : remplace chaque texte source par sa "
516
- "traduction, sans rôles {{fr}}/{{en}}."
517
- )
518
- TRANSLATE_FORMAT_BOTH = (
519
- "Sortie BILINGUE : chaque segment de prose devient une paire de rôles inline "
520
- "{fr}`texte français`{en}`english text` (JAMAIS de bloc :::{fr}/:::{en}). "
521
- "Les injections {{var}} et le LaTeX restent HORS des rôles, partagés par les "
522
- "deux langues : {fr}`Il y a `{en}`There are `{{nAff}}."
523
- )
524
-
525
-
526
- # ─────────────────────────────────────────────────────────────────────────────
527
- # DÉCLINAISONS QCM / QAT (NOUVEAU 2026-07 — mode `declinaisons`)
528
- # Spécification normative fournie par l'équipe (PROMPT_declinaisons_QCM_QAT §4-§6),
529
- # calibrée sur les 33 exemples validés de knowledge/fewshots/declinaisons/.
530
- # Divergences wx ↔ conventions strictes (corpus 222) tranchées : format
531
- # plateforme des wx (mcqAnswer/:solution:/{input}) + conventions strictes de
532
- # l'app (fences 4, globals(), injections nues camelCase Aff, IDs contigus).
533
- # v1 → v2 (2026-07-02) : MCQ_SPEC + section « DISTRACTEURS EN MIROIR » (règle
534
- # enseignante : grille symétrique, bonne réponse jamais devinable par la forme).
535
- # v2 → v3 (2026-07-06) : intégration du prompt système QCM affiné —
536
- # distracteurs cohérents (erreur réelle, near-miss même famille, flip jamais sur
537
- # entrée nulle, swap sûr en symbolique), formulation directe, matrices d'option
538
- # en \small (anti-scrollbar), éclatement avec solution partitionnée. Écart
539
- # assumé vs le prompt fourni : questionHint reste REMPLI (35/35 dans les 33
540
- # exemples validés qui font foi), pas vide.
541
- # ─────────────────────────────────────────────────────────────────────────────
542
-
543
- MCQ_SPEC = """\
544
- FORMAT QCM (questionType MCQ) — NORMATIF :
545
-
546
- :::::{question}
547
- :questionType: MCQ
548
- :questionId: N
549
- :questionIndex: N
550
-
551
- ::::{questionStatement}
552
- <énoncé auto-suffisant ; maths et {{ }} HORS des rôles bilingues>
553
- ::::
554
-
555
- ::::{questionHint}
556
- <indice de DÉMARRAGE qui ne révèle pas la réponse ; VERBATIM si repris de la source>
557
- ::::
558
-
559
- ::::{mcqAnswer}
560
- :isRightAnswer: true
561
- <BONNE réponse — TOUJOURS en slot 1>
562
- ::::
563
-
564
- ::::{mcqAnswer}
565
- :isRightAnswer: false
566
- <distracteur 1 — erreur type>
567
- ::::
568
-
569
- ::::{mcqAnswer}
570
- :isRightAnswer: false
571
- <distracteur 2 — erreur type>
572
- ::::
573
-
574
- ::::{mcqAnswer}
575
- :isRightAnswer: false
576
- <distracteur 3 — erreur type>
577
- ::::
578
-
579
- ::::{mcqAnswer}
580
- :isRightAnswer: false
581
- {fr}`Aucune de ces réponses n'est correcte`{en}`None of these answers are correct`
582
- ::::
583
-
584
- ::::{detailedSolution}
585
- <solution détaillée ; VERBATIM de la source si elle existe, sinon rédigée>
586
- ::::
587
-
588
- ::::{weightDistribution}
589
- :logic: 25
590
- :abstraction: 25
591
- :reasoning: 25
592
- :calculation: 25
593
- ::::
594
- :::::
595
-
596
- RÈGLES MCQ DURES :
597
- • EXACTEMENT UNE option `:isRightAnswer: true`, EN SLOT 1 (l'affichage est
598
- mélangé côté plateforme — ne PAS randomiser l'ordre dans le fichier).
599
- • 5 options par défaut (1 correcte + 3 distracteurs + « None » EN DERNIER).
600
- Ensembles fermés (vrai/faux, intervalles exhaustifs) : « None » omis
601
- autorisé (⇒ 4 options ; jamais moins de 3).
602
- • PAS de `:solution:`, PAS de `{input}`, PAS de displayedSolution en MCQ.
603
- • Ordre des blocs : questionStatement → questionHint → mcqAnswer×N →
604
- detailedSolution → weightDistribution.
605
- • L'option « None » et toute option textuelle sont bilingues si la cible l'est.
606
-
607
- DISTRACTEURS — ERREURS TYPES UNIQUEMENT (aucune valeur au hasard) :
608
- • Algèbre : erreur de signe ; coefficient inversé ; terme oublié ; distribution partielle.
609
- • Dérivées : oubli du facteur de la règle de chaîne ; exposant non décrémenté ;
610
- primitive au lieu de dérivée ; quotient sans v² ; produit sans la 2e moitié.
611
- • Intégrales : oubli du +1 sur l'exposant ; oubli du 1/a ; dérivée au lieu de
612
- primitive ; oubli de |x| dans ln.
613
- • Matrices : transposée ; colonnes/lignes échangées ; entrée non dérivée ;
614
- matrice opposée ; oubli du terme +b.
615
- • Compositions affines : oubli de b_f ; ordre inversé (g∘f vs f∘g) ; signe opposé.
616
- • Limites : mauvaise forme indéterminée ; mauvais signe d'infini.
617
-
618
- DISTRACTEURS COHÉRENTS & INDEVINABLES (règle enseignante, 2026-07-02) —
619
- un distracteur = une ERREUR RÉELLE que l'élève peut commettre, jamais une
620
- variante trivialement éliminable :
621
-
622
- INTERDITS (trop faciles à deviner) :
623
- • Garder une partie IDENTIQUE dans toutes les options alors qu'un seul bloc
624
- change (ex. matrice A identique partout, seul b varie) → l'élève ignore A
625
- et devine sur b. Fais VARIER la partie qui teste vraiment (ici A).
626
- • ÉTIQUETER la classification qui trahit la réponse (« f is linear, b=0 »
627
- quand des constantes sont visibles → écarté sans réfléchir). Ne nomme PAS
628
- linear/affine dans les options : demande directement « quels A et b ? » et
629
- montre les paires.
630
- • Distracteurs ARTIFICIELS (−A tout entier, 2b…) sans erreur pédagogique
631
- derrière.
632
- • Variations « une seule chose à la fois » autour de la bonne réponse
633
- (2x+3 / 2x−3 / 2x−4 / −2x+3) → le motif commun « 2x+3 » se devine.
634
-
635
- À PRIVILÉGIER (erreurs pédagogiques réelles, en grille refermée) :
636
- • Signe mal lu sur UNE entrée précise de A ou de b (ex. −4x lu +4x).
637
- • Transposée de A (colonnes ↔ lignes) — erreur de lecture classique.
638
- • Constante oubliée (b = 0, NON étiquetée « linear »).
639
- • Oubli/inversion d'un terme (+b oublié, b soustrait au lieu d'ajouté).
640
- • Encadrement/bornes : décalage ±1 (off-by-one).
641
- • Combinaisons SYMÉTRIQUES qui referment la grille (signe × signe,
642
- ordre × signe) plutôt que des erreurs indépendantes autour d'un gabarit :
643
- ✓ 2x+3 / 2x−3 / −2x+3 / −2x−3 (grille de signes : l'intuition ne trie
644
- plus, il faut CALCULER).
645
- • La bonne réponse n'est JAMAIS structurellement unique : ni la seule avec
646
- radical/fraction/facteur, ni la plus longue/courte, ni la seule simplifiée
647
- — TOUTES au même niveau de simplification (jamais de fraction réductible).
648
- • Test avant de rendre : masque mentalement la bonne réponse — si sa place
649
- se retrouve par la seule FORME (motif majoritaire, symétrie incomplète,
650
- singularité, bloc figé), reconstruis les distracteurs.
651
-
652
- DISTINCTION GARANTIE SUR TOUTES LES DÉCLINAISONS :
653
- • Construis chaque distracteur par une modification à DELTA NON NUL GARANTI,
654
- chacun sur une ENTRÉE/POSITION DIFFÉRENTE. Techniques sûres :
655
- – Flip de signe d'une entrée : SEULEMENT sur une entrée GARANTIE ≠ 0
656
- (flipper un 0 redonne l'original → collision). Tire ces entrées dans un
657
- domaine excluant 0, ou choisis une position structurellement non nulle.
658
- – Interversion de colonnes/variables (∂x↔∂y) : SÛRE sur entrées
659
- SYMBOLIQUES (une colonne dépend de x, l'autre de y → jamais égales) ;
660
- DANGEREUSE sur entrées entières (deux colonnes peuvent coïncider) → dans
661
- ce cas, préférer 3 flips de signe sur des entrées distinctes non nulles.
662
- – Décalage ±1 (bornes entières) ; multiple/omission (b oublié, −b, 2b).
663
- • Vérifie en Python : sur ≥300 tirages, `assert` que les options sont deux à
664
- deux distinctes (chaînes rendues). Aucune collision tolérée.
665
-
666
- ANTI-COLLISION (le piège n°1 des QCM randomisés — un distracteur peut devenir
667
- ÉGAL à la bonne réponse sur certaines graines) — dans CET ordre :
668
- 1. DISTINCT PAR CONSTRUCTION (préféré) : distracteurs de type différent,
669
- tirages qui garantissent la non-nullité/non-égalité (coefficients >= 2,
670
- exposants >= 2, entrées non nulles…). Documente-le en commentaire Python.
671
- 2. TIRAGE AVEC REJET dans le bloc Python : reboucler tant que les chaînes
672
- RENDUES (latex) de toutes les options ne sont pas toutes distinctes.
673
- 3. Le harnais vérifie l'unicité sur 100 graines — un doublon = REJET.
674
- ⚠️ La collision est aussi SÉMANTIQUE : deux options formulées différemment
675
- mais mathématiquement ÉQUIVALENTES (« divise x par 2 » ≡ « multiplie x par
676
- 1/2 » ; « T_{1/a,1/b} » ≡ « division par a et b ») comptent comme un doublon
677
- pour l'élève. Vérifie l'équivalence MATHÉMATIQUE de chaque paire d'options
678
- sur TOUT l'espace des tirages (ex. b == 1/a possible ? → l'exclure au tirage).
679
-
680
- UN SEUL bloc {python} au total : JAMAIS de re-tirage (`rd.`/`random`) hors du
681
- bloc principal — un second tirage rendrait les valeurs incohérentes entre les
682
- questions. Les variables des paires suivantes s'ajoutent SANS aléa nouveau.
683
-
684
- FORMATAGE : STRICTEMENT IDENTIQUE entre bonne réponse et distracteurs (même
685
- style LaTeX, mêmes helpers, même nombre de décimales, même notation
686
- matricielle, MÊME LONGUEUR) — sinon la bonne réponse se devine.
687
- • JAMAIS d'exemple/parenthèse explicative sur la SEULE bonne réponse
688
- (« … (e.g. A<B donne x) ») : soit tout le monde a l'ajout, soit personne.
689
- • JAMAIS d'annotation d'erreur visible dans une option (« (signe oublié) »,
690
- « (forgot +b) ») — ça vit UNIQUEMENT dans la solution détaillée.
691
-
692
- FORMULATION DIRECTE (QCM) : on demande le RÉSULTAT à cocher. Retire les consignes
693
- de méthode/rédaction qui n'ont de sens qu'en réponse libre — « calcule de deux
694
- façons », « vos deux réponses doivent coïncider », « montre que… », « trace /
695
- esquisse… », « justifie… ». Ex. : « Compute (f∘g)(x) in two ways… should
696
- agree! » → « Compute (f∘g)(x). »
697
-
698
- RENDU DES MATRICES DANS LES OPTIONS (anti-scrollbar) : une `bmatrix` pleine dans
699
- une case d'option fait apparaître un ascenseur. Si une OPTION contient une
700
- matrice, réduis-la : `{\\small \\begin{bmatrix}…\\end{bmatrix}}` (échelle si
701
- besoin : smallmatrix < \\scriptsize < \\footnotesize < \\small ; défaut \\small).
702
- Garde les `bmatrix` PLEINES dans la detailedSolution (fidélité + place).
703
-
704
- ÉCLATEMENT (autorisé) : si UNE question porte sur N cas indépendants (par
705
- ellipse, par sous-fonction…), tu peux l'éclater en N questions — le contexte
706
- commun monte dans l'énoncé global, et la detailedSolution source est PARTITIONNÉE
707
- par cas (**(i)**, **(ii)**…) puis rattachée à chaque question. Contrainte dure :
708
- la CONCATÉNATION des morceaux doit être IDENTIQUE à la solution source. Par
709
- défaut (pas de cas multiples), garde le MÊME nombre de questions que la source.
710
-
711
- questionHint : indice de DÉMARRAGE qui ne révèle jamais la réponse (cf. exemples
712
- validés — tous en fournissent un) ; VERBATIM si la source en a un ; vide
713
- seulement si aucun indice pertinent.
714
-
715
- L'ÉNONCÉ NE DONNE JAMAIS LA RÉPONSE (l'énoncé définit, la question interroge).
716
- """ # noqa: E501 — texte normatif verbatim (valeur injectée telle quelle, accolades SIMPLES)
717
-
718
- FGQ_SPEC = """\
719
- FORMAT QAT (questionType FGQ — question à champ(s) libre(s) ordonné(s)) — NORMATIF :
720
-
721
- :::::{question}
722
- :questionType: FGQ
723
- :questionId: N
724
- :questionIndex: N
725
- :solution: [["ord","${{v1Aff}}$","${{v2Aff}}$"],["0","0"]]
726
-
727
- ::::{questionStatement}
728
- <énoncé auto-suffisant>
729
-
730
- <LIGNE VIDE obligatoire avant le premier {input}>
731
- $x_1 =$ {input}`||110` $\\qquad x_2 =$ {input}`||110`
732
- ::::
733
-
734
- ::::{questionHint}
735
- <indice — vide ou verbatim source>
736
- ::::
737
-
738
- ::::{displayedSolution}
739
- $x_1 = {{v1Aff}}$, $\\quad x_2 = {{v2Aff}}$
740
- ::::
741
-
742
- ::::{detailedSolution}
743
- <solution détaillée>
744
- ::::
745
-
746
- ::::{weightDistribution}
747
- :logic: 15
748
- :abstraction: 20
749
- :reasoning: 20
750
- :calculation: 45
751
- ::::
752
- :::::
753
-
754
- RÈGLES FGQ DURES :
755
- • `:solution:` DIRECTEMENT dans le champ (jamais construite dans une variable
756
- Python), juste après `:questionIndex:`. Format [["ord","<v1>",…],["0",…]].
757
- • ARITÉ STRICTE : nb de {input} == nb de valeurs dans "ord" == nb de
758
- tolérances. Tolérance TOUJOURS "0" (exacte).
759
- • Un `{{varAff}}` par valeur dynamique dans `:solution:` (variable NUE).
760
- • ORDRE : les valeurs de "ord" suivent l'ordre d'apparition des {input}
761
- dans l'énoncé. Plusieurs solutions (racines…) → ÉNONCER l'ordre (« de la
762
- plus petite à la plus grande ») et le respecter dans "ord" ET dans
763
- displayedSolution.
764
- • Chaque {input} est INTRODUIT PAR UN LABEL ($x =$ {input}`||110`),
765
- jamais nu, jamais collé à la prose ; LIGNE VIDE avant le premier {input}.
766
- • Ordre des blocs : questionStatement → questionHint → displayedSolution →
767
- detailedSolution → weightDistribution.
768
- • Valeurs EXACTES (fractions, \\ln, \\sqrt, +\\infty…) — JAMAIS de décimales
769
- approchées dans `:solution:`.
770
- • REPLI MCQ : si une question n'est PAS auto-corrigeable en champ libre
771
- (réponse avec fonction abstraite, vrai/faux, matrice à dimension VARIABLE),
772
- produis cette question en MCQ (format ci-contre) — une sortie QAT peut être
773
- mixte FGQ + MCQ. Ne force JAMAIS un champ libre ingérable.
774
-
775
- MATRICES EN QAT (PIÈGE plateforme) :
776
- • `pxsl_matrix` est INTERDIT dans un champ `:solution:` (le rendu
777
- \\left[\\begin{array}… ne matche pas le widget). Si champ unique matrice :
778
- variable calculée avec latex(M, mat_delim='', mat_str='pmatrix') (FR)
779
- ou mat_str='bmatrix' (EN), injectée nue.
780
- • Dimension variable ⇒ repli MCQ pour cette question.
781
-
782
- L'ÉNONCÉ NE DONNE JAMAIS LA RÉPONSE (l'énoncé définit, la question interroge).
783
- """ # noqa: E501 — texte normatif verbatim (valeur injectée telle quelle, accolades SIMPLES)
784
-
785
- # Prompt de génération d'une déclinaison (QCM ou QAT) — même mécanique par
786
- # paires que la pythonisation ; le champ {decl_spec} reçoit MCQ_SPEC ou FGQ_SPEC.
787
- STEP_DECLINAISON_PROMPT = """\
788
- Tu déclines un exercice PyxiScience MyST en version {decl_label}, niveau {niveau}.
789
- L'exercice source (statique OU déjà pythonisé) est fourni ; tu produis la
790
- déclinaison RANDOMISÉE (bloc Python terminé par `globals()` + injections),
791
- au format plateforme EXACT ci-dessous.
792
-
793
- ═══════════════════════════════════════════════════════════════════════════
794
- CONVENTIONS PLATEFORME (identiques à la pythonisation — NON NÉGOCIABLES)
795
- ═══════════════════════════════════════════════════════════════════════════
796
- • Bloc Python : 4 backticks ````{{python}} … ```` terminé par `globals()`.
797
- • Injections `{{{{ }}}}` : UNIQUEMENT des noms de variables NUS camelCase
798
- suffixe `Aff` — JAMAIS d'appel de fonction, de calcul, d'underscore, ni
799
- d'accès dict. Tout se pré-calcule dans le bloc Python.
800
- • `{{{{ }}}}` et les maths TOUJOURS HORS des rôles {{fr}}`…`{{en}}`…` (un
801
- placeholder dans un rôle ne s'évalue PAS — texte cassé).
802
- • Règle du `$` collé à un chiffre : préfixe `${{}}`. Décimales localisées
803
- (virgule FR / point EN). `latex(expr, **config_standard)`.
804
- • `:questionId:`/`:questionIndex:` contigus dès 0.
805
- • Interdits : \\py{{}}, \\qcm, \\qat, \\qcl, \\right/\\wrong (légacy),
806
- \\begin{{align*}}, \\displaystyle, \\[ \\], $$.
807
-
808
- FIDÉLITÉ À LA SOURCE :
809
- • 1 question source → 1 question déclinée. NE JAMAIS inventer de
810
- sous-questions ni enrichir l'énoncé.
811
- • L'énoncé ne doit JAMAIS donner la réponse (l'énoncé définit, la question
812
- interroge).
813
- • detailedSolution : VERBATIM de la source si elle existe (seules les valeurs
814
- littérales deviennent des {{{{varAff}}}}), sinon rédigée sobrement.
815
- • Source DÉJÀ PYTHONISÉE : recopie son bloc Python À L'IDENTIQUE (octet pour
816
- octet), puis ajoute `# === Ajouts déclinaison {decl_label} ===` suivi des
817
- NOUVELLES variables (distracteurs/solutions), AVANT le `globals()` final.
818
- • weightDistribution : repris de la question source si présent, sinon le
819
- barème par défaut du format ci-dessous (somme = 100 TOUJOURS).
820
-
821
- ═══════════════════════════════════════════════════════════════════════════
822
- SPÉCIFICATION DU FORMAT {decl_label}
823
- ═══════════════════════════════════════════════════════════════════════════
824
- {decl_spec}
825
-
826
- ═══════════════════════════════════════════════════════════════════════════
827
- EXEMPLE CANONIQUE COMPLET (structure et conventions à imiter)
828
- ═══════════════════════════════════════════════════════════════════════════
829
- {fewshot}
830
-
831
- ═══════════════════════════════════════════════════════════════════════════
832
- CATALOGUE PyxiScience (helpers à utiliser DANS le bloc Python)
833
- ═══════════════════════════════════════════════════════════════════════════
834
- {functions}
835
-
836
- ═══════════════════════════════════════════════════════════════════════════
837
- CONTEXTE
838
- ═══════════════════════════════════════════════════════════════════════════
839
-
840
- EN-TÊTE déjà finalisé (NE PAS reproduire) :
841
- {content}
842
-
843
- VARIABLES DÉTECTÉES :
844
- {analysis}
845
-
846
- BLOCS PRÉCÉDENTS (ne pas redéfinir leurs variables, ne pas les répéter) :
847
- {previous_blocks}
848
-
849
- SECTION À DÉCLINER ({range_label} / {nb_total}) :
850
- {current_segment}
851
-
852
- {lang_directive}
853
-
854
- ═══════════════════════════════════════════════════════════════════════════
855
- RÈGLES D'ASSEMBLAGE PAR PAIRE
856
- ═══════════════════════════════════════════════════════════════════════════
857
- ⚠️ TU PRODUIS UNIQUEMENT LE CONTENU DE CETTE PAIRE (les paires précédentes
858
- sont concaténées mécaniquement avant ta sortie).
859
- ⚠️ EXACTEMENT {nb_current} bloc(s) `:::::{{question}}` — questionId/questionIndex
860
- CONTINUS depuis la paire précédente.
861
- ⚠️ PAIRE 1 UNIQUEMENT : le bloc ````{{python}}```` (source recopiée si déjà
862
- pythonisée + ajouts déclinaison + `globals()`) puis l'énoncé général VERBATIM,
863
- AVANT la première question.
864
- ⚠️ PAIRES SUIVANTES : ni énoncé, ni ré-imports ; petit bloc ````{{python}}````
865
- additionnel possible pour les nouvelles variables seulement.
866
-
867
- ═══════════════════════════════════════════════════════════════════════════
868
- CHECKLIST FINALE
869
- ═══════════════════════════════════════════════════════════════════════════
870
- □ Bloc ````{{python}}```` (4 backticks) terminé par `globals()`
871
- □ CHAQUE `{{{{ }}}}` = variable NUE camelCase (Aff) — aucun appel/underscore
872
- □ Aucun `{{{{ }}}}` NI maths à l'intérieur d'un rôle {{fr}}`…`/{{en}}`…`
873
- □ MCQ : 1 seule bonne réponse (slot 1), « None » en dernier, options toutes
874
- distinctes SUR TOUTES LES GRAINES, formatage identique
875
- □ FGQ : arité #input == #valeurs == #tolérances ("0"), :solution: littérale,
876
- ordre énoncé/"ord"/displayedSolution cohérents, labels devant chaque {{input}}
877
- □ 1 question source → 1 question ; solutions source VERBATIM ; poids repris
878
- □ IDs contigus dès 0 ; aucun motif interdit ; `\\%` pour les pourcentages
879
- """
880
-
881
-
882
- # ─────────────────────────────────────────────────────────────────────────────
883
- # Réparation post-harnais (NOUVEAU — 1 itération max)
884
- # ─────────────────────────────────────────────────────────────────────────────
885
- REPAIR_PROMPT = """\
886
- Le harnais de validation PyxiScience a REJETÉ l'exercice pythonisé ci-dessous.
887
- Corrige-le en changeant LE MINIMUM (ne réécris pas l'exercice, ne reformule
888
- aucune prose, ne change pas la structure ni les poids).
889
-
890
- ÉCHECS DU HARNAIS :
891
- {failures}
892
-
893
- EXERCICE ACTUEL :
894
- {exercise}
895
-
896
- RÈGLES DE CORRECTION :
897
- • Exception à l'exécution → corrige le bloc Python (tirage dégénéré, import
898
- manquant, division par zéro…) par la modification la plus locale possible.
899
- • Variable non résolue `{{{{x}}}}` → définis-la dans le bloc Python (avant
900
- `globals()`) ou corrige le nom injecté.
901
- • Motif interdit `$`+chiffre → préfixe `${{}}`.
902
- • **Double signe `+ -`** (cas le plus fréquent) : un `+` littéral du texte
903
- est suivi d'une injection qui rend une valeur NÉGATIVE (ex.
904
- `… + {{{{dfAff}}}}` avec dfAff = "- 4 \\sin(4x)"). Correctif : supprimer le
905
- `+` littéral ET pré-calculer la chaîne SIGNÉE dans le bloc Python
906
- (`dfSignAff = latex(df, **config_standard)` rend déjà le signe ; ou
907
- construire `"+ …"`/`"- …"` selon le signe) puis injecter
908
- `… {{{{dfSignAff}}}}` sans opérateur devant.
909
- • **`\\frac{{…}}{{1}}` / `^{{1}}` / `^{{0}}` / `\\sqrt[2]`** : presque toujours un
910
- TIRAGE DÉGÉNÉRÉ — exclure la valeur fautive à la source
911
- (ex. `b = rd.randint(2, 5)` au lieu de `randint(1, 5)`, ou boucle de rejet
912
- `if b == 1: continue`). Ne PAS rafistoler le texte : corriger le tirage.
913
- **EXCEPTION — texte pédagogique FIXE** qui enseigne précisément la règle de
914
- l'exposant (`$b^{{0}} = 1$`, `$b^{{1}} = b$`) : réécrire SANS accolades
915
- (`$b^0 = 1$`, `$b^1 = b$`) — rendu LaTeX identique pour un exposant à un
916
- seul caractère, et conforme au corpus validé.
917
- • Injection non nue → pré-calculer en variable camelCase `…Aff`.
918
- • Le bloc {{python}} reste à 4 backticks et se termine par `globals()`.
919
- • Ne touche NI à `\\inftys`/`\\ds`/`\\dfrac`, NI à la prose des solutions.
920
- • **QCM — collision d'options** (deux options rendues identiques sur une
921
- graine) : contraindre le TIRAGE (rejet `while` sur les chaînes rendues) ou
922
- changer la CONSTRUCTION du distracteur — jamais rafistoler le texte.
923
- • **QCM — plusieurs/zéro `:isRightAnswer: true`** : exactement une, en slot 1.
924
- • **FGQ — arité** : le nb de {{{{input}}}} doit égaler le nb de valeurs de
925
- `"ord"` et le nb de tolérances ("0") dans `:solution:`.
926
- • **`{{{{ }}}}` dans un rôle {{fr}}`…`/{{en}}`…`** : sortir l'injection du
927
- rôle (elle ne s'évalue pas dedans) — découper le rôle autour.
928
-
929
- Réponds UNIQUEMENT avec l'exercice complet corrigé (de `````{{exercise}} à `````),
930
- sans préambule ni wrapper markdown.
931
- """
932
-
933
-
934
- # ─────────────────────────────────────────────────────────────────────────────
935
- # AUDIT PÉDAGOGIQUE (2026-07-06) — juge la QUALITÉ (au-delà du harnais mécanique).
936
- # Le harnais prouve la conformité structurelle ; ce juge évalue la finesse
937
- # pédagogique et le RESPECT DES CONSIGNES (distracteurs cohérents, indevinabilité,
938
- # énoncé qui ne donne pas la réponse…). Peut déclencher une réparation ciblée et,
939
- # en mode auto, une escalade de modèle.
940
- # ─────────────────────────────────────────────────────────────────────────────
941
-
942
- PEDAGOGICAL_AUDIT_PROMPT = """\
943
- Tu es un RELECTEUR PÉDAGOGIQUE senior de PyxiScience. On te donne une déclinaison
944
- {decl_label} DÉJÀ VALIDE au harnais (structure/exécution correctes). Juge
945
- UNIQUEMENT sa QUALITÉ PÉDAGOGIQUE et le respect des consignes — jamais la syntaxe.
946
-
947
- CRITÈRES QCM (les plus importants) :
948
- 1. Distracteurs = ERREURS RÉELLES plausibles d'un élève (signe mal lu sur une
949
- entrée, transposée, terme oublié, off-by-one…), JAMAIS des valeurs
950
- artificielles ou au hasard, JAMAIS des variantes « une seule chose à la
951
- fois » autour de la bonne réponse.
952
- 2. INDEVINABILITÉ — la bonne réponse ne doit PAS se repérer par la FORME :
953
- • pas un bloc identique figé pendant qu'un seul varie (ex. matrice A figée,
954
- seul b change) ; • pas d'étiquette qui trahit (« linear », « b=0 ») ;
955
- • pas la seule simplifiée / avec radical / la plus longue ou courte ;
956
- • pas de parenthèse ou exemple explicatif sur la SEULE bonne réponse ;
957
- • distracteurs en grille symétrique (signe×signe, ordre×signe).
958
- 3. L'ÉNONCÉ ne donne JAMAIS la réponse ; formulation DIRECTE (retirer « montre
959
- que », « calcule de deux façons », « trace », « justifie »).
960
- 4. questionHint = amorce de méthode qui NE révèle PAS la réponse.
961
- 5. Format IDENTIQUE entre toutes les options (longueur, style LaTeX, notation).
962
- 6. Fidélité à la source : mêmes notions testées, solution cohérente.
963
-
964
- CRITÈRES QAT/FGQ : champs {{input}} pertinents et bien placés ; displayedSolution
965
- lisible ; énoncé qui ne donne pas la réponse ; consignes de saisie claires.
966
-
967
- EXERCICE À JUGER :
968
- {exercise}
969
-
970
- Réponds UNIQUEMENT en JSON (aucune prose autour, aucun bloc markdown) :
971
- {{"verdict": "OK ou A_REVOIR", "score": 0-100, "issues": [{{"gravite": "haute|moyenne|basse", "ou": "question/option concernée", "probleme": "ce qui cloche", "correction": "quoi faire concrètement"}}]}}
972
- Règles de verdict : « OK » seulement si AUCUNE issue de gravité haute. Sois
973
- EXIGEANT mais JUSTE — signale un vrai défaut d'apprentissage, pas une préférence
974
- de style. Si l'exercice est bon, renvoie verdict « OK » et issues [].
975
- """ # noqa: E501
976
-
977
- PEDAGOGICAL_REPAIR_PROMPT = """\
978
- Un relecteur pédagogique a listé des défauts de QUALITÉ sur cette déclinaison
979
- {decl_label}. La STRUCTURE est déjà correcte (harnais VERT) — NE LA CASSE PAS.
980
- Corrige UNIQUEMENT les défauts listés, avec le minimum de changements.
981
-
982
- DÉFAUTS À CORRIGER :
983
- {issues}
984
-
985
- CONTRAINTES DURES (ne rien casser) :
986
- • Ne touche PAS à la structure MyST, aux IDs, aux poids, au format des blocs.
987
- • UN SEUL bloc {{python}} (4 backticks) terminé par `globals()` ; les
988
- distracteurs restent construits DANS ce bloc (variables camelCase `…Aff`),
989
- à DELTA NON NUL garanti (jamais de flip de signe sur une entrée nulle).
990
- • Exactement UNE `:isRightAnswer: true`, en slot 1 ; « None » en dernier.
991
- • L'énoncé ne révèle JAMAIS la réponse ; garde la solution fidèle à la source.
992
- • Injections `{{{{ }}}}` = noms de variables nus camelCase `Aff` uniquement.
993
-
994
- EXERCICE ACTUEL :
995
- {exercise}
996
-
997
- Réponds UNIQUEMENT avec l'exercice complet corrigé (de `````{{exercise}} à `````),
998
- sans préambule ni wrapper markdown.
999
- """ # noqa: E501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
smoke.py DELETED
@@ -1,581 +0,0 @@
1
- """
2
- Smoke tests minimaux — AUCUN appel réseau (LLM mocké), exécution :
3
-
4
- .venv/bin/python tests/smoke.py
5
-
6
- Couvre : import/create_app, /health, 1 run de pipeline complet (mock LLM)
7
- avec porte harnais VERTE, et les filets déterministes clés.
8
- """
9
-
10
- import json
11
- import sys
12
- from pathlib import Path
13
-
14
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
-
16
- PASS = []
17
-
18
-
19
- def check(name, cond):
20
- PASS.append((name, bool(cond)))
21
- print(("✓" if cond else "✗"), name)
22
-
23
-
24
- # ── 1. Import + /health ──────────────────────────────────────────────────────
25
- from app import create_app # noqa: E402
26
-
27
- flask_app = create_app()
28
- client = flask_app.test_client()
29
- health = client.get("/health").get_json()
30
- check("create_app + /health", health and health["status"] == "ok")
31
- models = client.get("/api/models").get_json()
32
- check("/api/models expose le roster", len(models["models"]) >= 5)
33
-
34
- # ── 2. Filets déterministes ──────────────────────────────────────────────────
35
- from app.pipeline import postprocess as pp # noqa: E402
36
-
37
- t, _ = pp.fix_dollar_digit("Prix : $3$ et ${{nAff}}$.")
38
- check("fix_dollar_digit", "${}3$" in t and "${}{{nAff}}$" in t)
39
- check("detect_languages both", pp.detect_languages("{fr}`Calculer`{en}`Compute` $x$") == "both")
40
- check("strip_language fr", pp.strip_language("{fr}`Bonjour `{en}`Hello `", "fr").strip() == "Bonjour")
41
-
42
- # ── 3. Pipeline complet avec LLM mocké ───────────────────────────────────────
43
- SMOKE_SOURCE = """`````{exercise}
44
- :title: Somme de deux entiers
45
- :level: Elementary
46
-
47
- On additionne deux entiers.
48
-
49
- :::::{question}
50
- :questionType: STQ
51
- :questionId: 0
52
- :questionIndex: 0
53
-
54
- ::::{questionStatement}
55
- Calculer $3 + 4$.
56
- ::::
57
-
58
- ::::{questionHint}
59
- Poser l'addition.
60
- ::::
61
-
62
- ::::{detailedSolution}
63
- On trouve $7$.
64
- ::::
65
-
66
- ::::{weightDistribution}
67
- :logic: 25
68
- :abstraction: 25
69
- :reasoning: 25
70
- :calculation: 25
71
- ::::
72
- :::::
73
- `````"""
74
-
75
- MOCK_ANALYSIS = json.dumps({
76
- "exercise_type": "équation linéaire",
77
- "exercise_title": "Somme de deux entiers",
78
- "nb_questions": 1,
79
- "variables": [{"nom": "a", "type_python": "int", "description": "1er terme",
80
- "contraintes": "2..9", "plage_python": "rd.randint(2, 9)",
81
- "location": "énoncé", "valeur_exemple": "3"}],
82
- "needs_fraction": False, "needs_sympy": False, "needs_numpy": False,
83
- "needs_matplolib": False, # typo v1 volontaire : doit être normalisée
84
- "target_rules": [], "property_constraints": [],
85
- "has_validated_solution_in_input": False,
86
- })
87
-
88
- MOCK_PAIR = """````{python}
89
- import random as rd
90
- a = rd.randint(2, 9)
91
- b = rd.randint(2, 9)
92
- sumAff = str(a + b)
93
- globals()
94
- ````
95
-
96
- On additionne deux entiers.
97
-
98
- :::::{question}
99
- :questionType: STQ
100
- :questionId: 0
101
- :questionIndex: 0
102
-
103
- ::::{questionStatement}
104
- Calculer ${}{{a}} + {{b}}$.
105
- ::::
106
-
107
- ::::{questionHint}
108
- Poser l'addition.
109
- ::::
110
-
111
- ::::{detailedSolution}
112
- On trouve ${}{{a}} + {{b}} = {{sumAff}}$.
113
- ::::
114
-
115
- ::::{weightDistribution}
116
- :logic: 25
117
- :abstraction: 25
118
- :reasoning: 25
119
- :calculation: 25
120
- ::::
121
- :::::"""
122
-
123
-
124
- MOCK_MCQ_PAIR = """````{python}
125
- import random as rd
126
- a = rd.randint(2, 9)
127
- b = rd.randint(2, 9)
128
- sumAff = str(a + b)
129
- d1Aff = str(a + b + 1) # erreur type : +1
130
- d2Aff = str(a + b - 1) # erreur type : -1
131
- d3Aff = str(a * b + 100) # produit décalé — toujours > somme+1 (distinct)
132
- globals()
133
- ````
134
-
135
- On additionne deux entiers.
136
-
137
- :::::{question}
138
- :questionType: MCQ
139
- :questionId: 0
140
- :questionIndex: 0
141
-
142
- ::::{questionStatement}
143
- Combien vaut ${}{{a}} + {{b}}$ ?
144
- ::::
145
-
146
- ::::{questionHint}
147
- Poser l'addition.
148
- ::::
149
-
150
- ::::{mcqAnswer}
151
- :isRightAnswer: true
152
- ${}{{sumAff}}$
153
- ::::
154
-
155
- ::::{mcqAnswer}
156
- :isRightAnswer: false
157
- ${}{{d1Aff}}$
158
- ::::
159
-
160
- ::::{mcqAnswer}
161
- :isRightAnswer: false
162
- ${}{{d2Aff}}$
163
- ::::
164
-
165
- ::::{mcqAnswer}
166
- :isRightAnswer: false
167
- ${}{{d3Aff}}$
168
- ::::
169
-
170
- ::::{mcqAnswer}
171
- :isRightAnswer: false
172
- {fr}`Aucune de ces réponses n'est correcte`{en}`None of these answers are correct`
173
- ::::
174
-
175
- ::::{detailedSolution}
176
- On trouve ${}{{a}} + {{b}} = {{sumAff}}$.
177
- ::::
178
-
179
- ::::{weightDistribution}
180
- :logic: 25
181
- :abstraction: 25
182
- :reasoning: 25
183
- :calculation: 25
184
- ::::
185
- :::::"""
186
-
187
- MOCK_FGQ_PAIR = """````{python}
188
- import random as rd
189
- a = rd.randint(2, 9)
190
- b = rd.randint(2, 9)
191
- sumAff = str(a + b)
192
- globals()
193
- ````
194
-
195
- On additionne deux entiers.
196
-
197
- :::::{question}
198
- :questionType: FGQ
199
- :questionId: 0
200
- :questionIndex: 0
201
- :solution: [["ord","${{sumAff}}$"],["0"]]
202
-
203
- ::::{questionStatement}
204
- Calculer ${}{{a}} + {{b}}$.
205
-
206
- $s =$ {input}`||110`
207
- ::::
208
-
209
- ::::{questionHint}
210
- Poser l'addition.
211
- ::::
212
-
213
- ::::{displayedSolution}
214
- $s = {{sumAff}}$
215
- ::::
216
-
217
- ::::{detailedSolution}
218
- On trouve ${}{{a}} + {{b}} = {{sumAff}}$.
219
- ::::
220
-
221
- ::::{weightDistribution}
222
- :logic: 15
223
- :abstraction: 20
224
- :reasoning: 20
225
- :calculation: 45
226
- ::::
227
- :::::"""
228
-
229
- ANALYSIS_CALLS = {"n": 0}
230
-
231
-
232
- GEN_MODELS_SEEN = [] # IDs de modèle vus par les appels de génération
233
-
234
-
235
- def mock_llm(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
236
- image_b64=None, system_prompt="", reasoning=False, model=None):
237
- if "expert en analyse d'exercices" in prompt:
238
- ANALYSIS_CALLS["n"] += 1
239
- return MOCK_ANALYSIS
240
- if "auditeur PyxiScience" in prompt:
241
- return json.dumps({"verdict": "OK", "issues": []})
242
- if "RELECTEUR PÉDAGOGIQUE" in prompt: # audit pédagogique
243
- PEDAGO_CALLS["n"] += 1
244
- return json.dumps({"verdict": "OK", "score": 95, "issues": []})
245
- if "Tu déclines un exercice" in prompt:
246
- GEN_MODELS_SEEN.append(model)
247
- return MOCK_MCQ_PAIR if "QCM (MCQ)" in prompt else MOCK_FGQ_PAIR
248
- if "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
249
- GEN_MODELS_SEEN.append(model)
250
- return MOCK_PAIR
251
- return "{}"
252
-
253
-
254
- PEDAGO_CALLS = {"n": 0}
255
-
256
-
257
- import app.pipeline.analyze as analyze # noqa: E402
258
- import app.pipeline.audit as audit # noqa: E402
259
- import app.pipeline.generate as generate # noqa: E402
260
- import app.pipeline.orchestrator as orchestrator # noqa: E402
261
-
262
- analyze.process_with_openrouter = mock_llm
263
- audit.process_with_openrouter = mock_llm
264
- generate.process_with_openrouter = mock_llm
265
- orchestrator.process_with_openrouter = mock_llm
266
- analyze.enrich_exercise_with_notions = lambda *a, **k: ("(notions mockées)", "NOTION_TEST")
267
- analyze.retrieve_functions_context = lambda **k: {"catalogue": "(catalogue mocké)"}
268
-
269
- result = orchestrator.run_exercise(
270
- content=SMOKE_SOURCE, filename="smoke.md", level="", model_idx=0, lang="fr")
271
-
272
- check("pipeline : exercice produit", bool(result["exercise"].strip()))
273
- check("pipeline : fence 4 backticks", "````{python}" in result["exercise"])
274
- check("pipeline : se termine par `````", result["exercise"].rstrip().endswith("`````"))
275
- check("pipeline : globals() présent", "globals()" in result["exercise"])
276
- check("pipeline : typo needs_matplolib normalisée",
277
- "needs_matplolib" not in result["analysis"] and result["analysis"]["needs_matplotlib"] is False)
278
- check("pipeline : harnais VERT", result["harness"]["ok"])
279
- check("pipeline : coût exposé", "usd" in result["cost"])
280
- check("pipeline : langue exposée", result["lang"]["target"] == "fr")
281
-
282
- # En-tête {exercise} complet et bien formé
283
- ex = result["exercise"]
284
- check("header : enveloppe `````{exercise} en tête", ex.lstrip().startswith("`````{exercise}"))
285
- for field in (":id:", ":title:", ":modules:", ":recommendedExecutionTime:",
286
- ":level:", ":chap:", ":involvedConcepts:", ":originalSource:", ":visibility:"):
287
- check(f"header : champ {field} présent", field in ex.split("````{python}")[0])
288
- check("header : level mappé (level='' → Elementary)", ":level: Elementary" in ex)
289
- check("header : visibility All", ":visibility: All" in ex)
290
- check("header : concepts = notions", "NOTION_TEST" in ex)
291
- check("header : une seule enveloppe {exercise}", ex.count("`````{exercise}") == 1)
292
-
293
- # ── 3bis. Mode déclinaisons (QCM + QAT, LLM mocké, analyse partagée) ─────────
294
- ANALYSIS_CALLS["n"] = 0
295
- PEDAGO_CALLS["n"] = 0
296
- decl_results = orchestrator.run_declinaisons(
297
- content=SMOKE_SOURCE, filename="smoke.md", level="", model_idx=0,
298
- lang="fr", types=["qcm", "qat"])
299
- check("déclinaisons : 2 sorties (QCM + QAT)", len(decl_results) == 2)
300
- check("déclinaisons : analyse partagée (1 seul appel)", ANALYSIS_CALLS["n"] == 1)
301
- check("audit pédagogique : appelé (QCM + QAT)", PEDAGO_CALLS["n"] >= 2)
302
-
303
- qcm = dict(decl_results)["qcm"]
304
- qat = dict(decl_results)["qat"]
305
- check("QCM : harnais VERT", qcm["harness"]["ok"])
306
- check("QCM : 5 options, 1 seule bonne", qcm["exercise"].count("{mcqAnswer}") == 5
307
- and qcm["exercise"].count(":isRightAnswer: true") == 1)
308
- check("QCM : « None » en dernière option",
309
- "Aucune de ces réponses" in qcm["exercise"].split(":isRightAnswer: false")[-1])
310
- check("QCM : titre suffixé - MCQ", " - MCQ" in qcm["exercise"].split("````{python}")[0])
311
- check("QAT : harnais VERT", qat["harness"]["ok"])
312
- check("QAT : :solution: + {input} présents",
313
- ':solution: [["ord"' in qat["exercise"] and "{input}`" in qat["exercise"])
314
- check("QAT : displayedSolution présent", "{displayedSolution}" in qat["exercise"])
315
- check("déclinaisons : decl_type exposé", qcm["decl_type"] == "qcm" and qat["decl_type"] == "qat")
316
- check("audit pédagogique : verdict exposé (QCM)",
317
- isinstance(qcm.get("pedagogical"), dict) and qcm["pedagogical"]["verdict"] == "OK")
318
-
319
- # Audit pédagogique ROUGE → escalade en mode auto (harnais VERT mais qualité A_REVOIR).
320
- _ped_calls = {"n": 0}
321
-
322
-
323
- def mock_llm_pedago_escalade(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
324
- image_b64=None, system_prompt="", reasoning=False, model=None):
325
- if "expert en analyse d'exercices" in prompt:
326
- return MOCK_ANALYSIS
327
- if "auditeur PyxiScience" in prompt:
328
- return json.dumps({"verdict": "OK", "issues": []})
329
- if "RELECTEUR PÉDAGOGIQUE" in prompt:
330
- _ped_calls["n"] += 1
331
- # Échelon 1 : audit + post-réparation restent A_REVOIR (2 appels) → la
332
- # réparation n'améliore pas, donc ESCALADE. Échelon 2+ : qualité OK.
333
- if _ped_calls["n"] <= 2:
334
- return json.dumps({"verdict": "A_REVOIR", "score": 40, "issues": [
335
- {"gravite": "haute", "ou": "Q0", "probleme": "distracteur devinable",
336
- "correction": "grille miroir"}]})
337
- return json.dumps({"verdict": "OK", "score": 92, "issues": []})
338
- if "défauts de QUALITÉ" in prompt: # réparation pédagogique → sortie VERTE mais non améliorée
339
- return MOCK_MCQ_PAIR
340
- if "Tu déclines un exercice" in prompt or "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
341
- return MOCK_MCQ_PAIR
342
- return "{}"
343
-
344
-
345
- for _m in (analyze, audit, generate, orchestrator):
346
- _m.process_with_openrouter = mock_llm_pedago_escalade
347
- _sols_p = __import__("app.pipeline.solutions", fromlist=["x"])
348
- _tr_p = __import__("app.pipeline.translate", fromlist=["x"])
349
- _sols_p.process_with_openrouter = mock_llm_pedago_escalade
350
- _tr_p.process_with_openrouter = mock_llm_pedago_escalade
351
-
352
- res_ped = orchestrator.run_with_policy(
353
- content=SMOKE_SOURCE, filename="ped.md", lang="fr", policy="auto", decl_type="qcm")
354
- tel_ped = res_ped["policy_telemetry"]
355
- check("escalade pédagogique : ≥2 échelons tentés", len(tel_ped["tried"]) >= 2)
356
- check("escalade pédagogique : 1er échelon qualité A_REVOIR",
357
- tel_ped["tried"][0]["pedago"] == "A_REVOIR")
358
- check("escalade pédagogique : gagnant qualité OK",
359
- tel_ped["pedago_verdict"] == "OK" and not tel_ped["needs_review"])
360
-
361
- for _m in (analyze, audit, generate, orchestrator):
362
- _m.process_with_openrouter = mock_llm
363
- _sols_p.process_with_openrouter = mock_llm
364
- _tr_p.process_with_openrouter = mock_llm
365
-
366
- # Harnais étendu : un MCQ avec collision d'options doit être ROUGE.
367
- from app.validation import harness as _harness # noqa: E402
368
-
369
- _collision = qcm["exercise"].replace("{{d1Aff}}", "{{sumAff}}") # distracteur == bonne réponse
370
- _rep = _harness.validate_text(_collision, seeds=10)
371
- check("harnais étendu : collision d'options MCQ détectée (ROUGE)",
372
- not _rep["ok"] and _rep["n_mcq_collisions"] > 0)
373
- _two_true = qcm["exercise"].replace(":isRightAnswer: false", ":isRightAnswer: true", 1)
374
- _rep2 = _harness.validate_text(_two_true, seeds=5)
375
- check("harnais étendu : 2 bonnes réponses détectées (statique)",
376
- not _rep2["ok"] and any("isRightAnswer" in e for e in _rep2["static_errors"]))
377
- _bad_arity = qat["exercise"].replace('[["ord","${{sumAff}}$"],["0"]]',
378
- '[["ord","${{sumAff}}$","$2$"],["0","0"]]')
379
- _rep3 = _harness.validate_text(_bad_arity, seeds=5)
380
- check("harnais étendu : arité FGQ incohérente détectée",
381
- not _rep3["ok"] and any("arité" in e for e in _rep3["static_errors"]))
382
-
383
- # Validation API du mode (sans lancer de job).
384
- r_bad_mode = client.post("/api/jobs", json={"content": "x", "mode": "zzz"})
385
- check("API : mode invalide → 400", r_bad_mode.status_code == 400)
386
- r_no_types = client.post("/api/jobs", json={"content": "x", "mode": "declinaisons", "types": {}})
387
- check("API : declinaisons sans type → 400", r_no_types.status_code == 400)
388
-
389
- # ── 3ter. Politiques de modèle + escalade + retrait de Fable ────────────────
390
- from app.models.catalog import CATALOG, CANDIDATES # noqa: E402
391
- from app.models import policy as _mp # noqa: E402
392
-
393
- check("Fable absent du catalogue",
394
- not any("fable" in k.lower() for k in CATALOG)
395
- and not any("fable" in v["openrouter_id"].lower() for v in CATALOG.values()))
396
- from app.config import AVAILABLE_MODELS as _AM # noqa: E402
397
- check("Fable absent d'AVAILABLE_MODELS",
398
- not any("fable" in v.lower() for v in _AM.values()))
399
- check("Fable absent du fallback policy",
400
- not any("fable" in str(_mp.DEFAULT_RECOMMENDED).lower() for _ in [0]))
401
-
402
- # best / cheap / manual suivent recommended.json (source VIVANTE : le banc la
403
- # réécrit — on vérifie la cohérence de la résolution, pas des noms figés).
404
- _rec_gen = _mp.load_recommended()["generate"]
405
- check("policy best suit recommended.json",
406
- _mp.resolve("generate", "best") == _rec_gen["best"])
407
- check("policy cheap suit recommended.json",
408
- _mp.resolve("generate", "cheap") == _rec_gen["cheap"])
409
- check("policy manual respecté",
410
- _mp.resolve("generate", "manual", {"generate": "deepseek-v4-pro"}) == "deepseek-v4-pro")
411
- check("difficulté : matrices → difficile",
412
- _mp.classify_difficulty("Matrix systeme " * 100 + ":::::{question}" * 6) == "difficile")
413
-
414
- # Escalade : 1er échelon forcé ROUGE (options en collision) → échelon 2 VERT.
415
- MOCK_MCQ_RED = MOCK_MCQ_PAIR.replace("{{d1Aff}}", "{{sumAff}}")
416
- _calls = {"n": 0}
417
-
418
-
419
- def mock_llm_escalade(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
420
- image_b64=None, system_prompt="", reasoning=False, model=None):
421
- if "expert en analyse d'exercices" in prompt:
422
- return MOCK_ANALYSIS
423
- if "auditeur PyxiScience" in prompt:
424
- return json.dumps({"verdict": "OK", "issues": []})
425
- if "RELECTEUR PÉDAGOGIQUE" in prompt: # qualité OK → escalade pilotée par le harnais seul
426
- return json.dumps({"verdict": "OK", "score": 95, "issues": []})
427
- if "harnais" in prompt and "REJETÉ" in prompt:
428
- return MOCK_MCQ_RED # la réparation échoue aussi sur l'échelon 1
429
- if "Tu déclines un exercice" in prompt or "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
430
- _calls["n"] += 1
431
- # 1re GÉNÉRATION (échelon 1) rouge ; la suivante (échelon 2) verte.
432
- return MOCK_MCQ_RED if _calls["n"] <= 1 else MOCK_MCQ_PAIR
433
- return "{}"
434
-
435
-
436
- for _m in (analyze, audit, generate, orchestrator):
437
- _m.process_with_openrouter = mock_llm_escalade
438
- import app.pipeline.solutions as _sols # noqa: E402
439
- import app.pipeline.translate as _tr # noqa: E402
440
- _sols.process_with_openrouter = mock_llm_escalade
441
- _tr.process_with_openrouter = mock_llm_escalade
442
-
443
- res_esc = orchestrator.run_with_policy(
444
- content=SMOKE_SOURCE, filename="esc.md", lang="fr",
445
- policy="auto", decl_type="qcm")
446
- tel = res_esc["policy_telemetry"]
447
- check("escalade : ≥2 échelons tentés", len(tel["tried"]) >= 2)
448
- check("escalade : échelon 1 ROUGE puis gagnant VERT",
449
- tel["tried"][0]["ok"] is False and tel["tried"][-1]["ok"] is True)
450
- check("escalade : échelon gagnant journalisé",
451
- tel["winning_model"] == tel["tried"][-1]["model"] and not tel["needs_review"])
452
-
453
- # Restaure les mocks standards pour la suite.
454
- for _m in (analyze, audit, generate, orchestrator):
455
- _m.process_with_openrouter = mock_llm
456
- _sols.process_with_openrouter = mock_llm
457
- _tr.process_with_openrouter = mock_llm
458
-
459
- # API : policy invalide → 400 ; manual avec modèle hors rôle → 400.
460
- check("API : policy invalide → 400",
461
- client.post("/api/jobs", json={"content": "x", "policy": "zzz"}).status_code == 400)
462
- check("API : manual modèle hors rôle → 400",
463
- client.post("/api/jobs", json={"content": "x", "policy": "manual",
464
- "models": {"generate": "glm-4-7-flash"}}).status_code == 400)
465
- check("/api/models expose catalogue par rôle sans Fable",
466
- "fable" not in json.dumps(client.get("/api/models").get_json()).lower())
467
-
468
- # ── 3quater. Aération, originalExerciseId, annulation (bouton Stop) ─────────
469
- from app.pipeline.postprocess import aerate_blocks # noqa: E402
470
-
471
- _compact = (":::::{question}\n:questionType: MCQ\n::::{questionStatement}\n"
472
- "texte\n::::\n::::{mcqAnswer}\n:isRightAnswer: true\nx\n::::")
473
- _aered, _n_aer = aerate_blocks(_compact)
474
- check("aération : lignes vides avant chaque bloc",
475
- _n_aer == 2 and "\n\n::::{questionStatement}" in _aered
476
- and "\n\n::::{mcqAnswer}" in _aered)
477
- check("aération : idempotente", aerate_blocks(_aered)[1] == 0)
478
-
479
- from app.pipeline.generate import build_exercise_metadata # noqa: E402
480
-
481
- check("déclinaison : originalExerciseId = id du QST source",
482
- ":originalExerciseId: abc-123" in build_exercise_metadata(
483
- ":id: abc-123\n:title: T", "", {}, "", decl_type="qcm"))
484
- check("déclinaison : originalExerciseId présent même sans id source",
485
- "\n:originalExerciseId:" in build_exercise_metadata(
486
- ":title: T", "", {}, "", decl_type="qcm"))
487
- check("pythonise : pas d'originalExerciseId",
488
- "originalExerciseId" not in build_exercise_metadata(
489
- ":title: T", "", {}, "", decl_type=None))
490
-
491
- # Annulation : job 3 fichiers avec mock LENT, cancel immédiat → cancelled.
492
- check("annulation : job inconnu → 404",
493
- client.post("/api/jobs/zzz/cancel").status_code == 404)
494
-
495
- import time as _time # noqa: E402
496
-
497
-
498
- def mock_llm_slow(*a, **k):
499
- _time.sleep(0.15)
500
- return mock_llm(*a, **k)
501
-
502
-
503
- for _m in (analyze, audit, generate, orchestrator):
504
- _m.process_with_openrouter = mock_llm_slow
505
- _sols.process_with_openrouter = mock_llm_slow
506
- _tr.process_with_openrouter = mock_llm_slow
507
-
508
- _rc_start = client.post("/api/jobs", json={
509
- "files": [{"filename": f"c{i}.md", "content": SMOKE_SOURCE} for i in range(3)]})
510
- _jid_c = _rc_start.get_json()["job_id"]
511
- check("annulation : cancel accepté (202)",
512
- client.post(f"/api/jobs/{_jid_c}/cancel").status_code == 202)
513
- _st_c = None
514
- for _ in range(400):
515
- _st_c = client.get(f"/api/jobs/{_jid_c}").get_json()
516
- if _st_c["status"] != "running":
517
- break
518
- _time.sleep(0.05)
519
- check("annulation : statut final cancelled", _st_c["status"] == "cancelled")
520
- check("annulation : arrêt anticipé (résultats partiels conservés)",
521
- _st_c["files_done"] < 3 and isinstance(_st_c["results"], list))
522
- check("annulation : re-cancel d'un job terminé → 409",
523
- client.post(f"/api/jobs/{_jid_c}/cancel").status_code == 409)
524
-
525
- for _m in (analyze, audit, generate, orchestrator):
526
- _m.process_with_openrouter = mock_llm
527
- _sols.process_with_openrouter = mock_llm
528
- _tr.process_with_openrouter = mock_llm
529
-
530
- # Banc : --dry-run (plomberie complète hors ligne).
531
- import subprocess # noqa: E402
532
-
533
- bench_proc = subprocess.run(
534
- [sys.executable, "-m", "bench", "run", "--dry-run",
535
- "--roles", "generate", "--models", "claude-sonnet-5,claude-opus-4-8",
536
- "--seeds", "5"],
537
- capture_output=True, text=True, timeout=600,
538
- cwd=str(Path(__file__).resolve().parent.parent),
539
- )
540
- check("bench --dry-run : exit 0", bench_proc.returncode == 0)
541
- check("bench --dry-run : reco produite", "best=" in bench_proc.stdout)
542
- check("bench --dry-run : recommended.json non modifié",
543
- "NON modifié" in bench_proc.stdout)
544
-
545
- # ── 4. Téléchargement ZIP (endpoint, sans LLM) ───────────────────────────────
546
- import io as _io # noqa: E402
547
- import zipfile as _zipfile # noqa: E402
548
-
549
- import app.server as _server # noqa: E402
550
-
551
- _fake = {
552
- "status": "done", "step_label": "Terminé", "current_file": "b.md",
553
- "files_total": 2, "files_done": 2, "error": None, "summary": {},
554
- "results": [
555
- {"filename": "a.md", "status": "done",
556
- "result": {"exercise": result["exercise"], "warnings": [],
557
- "harness": {"ok": True, "seeds": 100}, "cost": {"usd": 0.01}}},
558
- {"filename": "a.md", "status": "done", # collision de nom volontaire
559
- "result": {"exercise": "````{python}\nglobals()\n````\n`````", "warnings": [{}],
560
- "harness": {"ok": False, "seeds": 100}, "cost": {"usd": 0.02}}},
561
- {"filename": "c.md", "status": "error", "error": "boom"},
562
- ],
563
- }
564
- with _server._JOBS_LOCK:
565
- _server._JOBS["smoketest"] = _fake
566
- resp = client.get("/api/jobs/smoketest/download")
567
- check("ZIP : 200 + mimetype zip", resp.status_code == 200 and "zip" in resp.mimetype)
568
- zf = _zipfile.ZipFile(_io.BytesIO(resp.data))
569
- names = zf.namelist()
570
- check("ZIP : 2 .md (collision dédupliquée) + récap",
571
- "a_pythonise.md" in names and "a_pythonise_2.md" in names
572
- and "_recapitulatif.md" in names)
573
- check("ZIP : 404 si job inconnu", client.get("/api/jobs/zzz/download").status_code == 404)
574
-
575
- # ── Bilan ────────────────────────────────────────────────────────────────────
576
- failed = [n for n, ok in PASS if not ok]
577
- print(f"\n{len(PASS) - len(failed)}/{len(PASS)} smoke tests verts")
578
- if failed:
579
- print("ÉCHECS :", failed)
580
- sys.exit(1)
581
- print("✅ SMOKE OK")