wilenPyxi Claude Fable 5 commited on
Commit
54b6073
·
1 Parent(s): ad519bc

Audit pedagogique des declinaisons (qualite au-dela du harnais mecanique)

Browse files

Juge LLM (PEDAGOGICAL_AUDIT_PROMPT, gpt-5.4 + repli JSON OpenAI) evalue une
declinaison VERTE : distracteurs coherents/indevinables, enonce qui ne donne
pas la reponse, formulation directe, indice-amorce, format identique ->
verdict OK/A_REVOIR + issues. Si A_REVOIR : reparation ciblee (gardee seulement
si le harnais reste VERT ET que la qualite s ameliore), puis en mode auto
ESCALADE de modele (le meilleur modele selon l exo). Expose : pastille UI,
result[pedagogical], policy_telemetry[pedago_verdict]. Config PEDAGO_*.
Filets deterministes factorises (_apply_deterministic_nets). Smoke 76/76 ;
generation reelle : juge OK, escalade fonctionnelle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

app/config.py CHANGED
@@ -23,7 +23,7 @@ TEMPLATES_DIR = PACKAGE_DIR / "web" / "templates"
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 — MCQ_SPEC v3 + bouton Stop + suivi robuste + Render"
27
 
28
  # ── Convention MyST (vérifiée empiriquement : 222/222 exemples plateforme) ───
29
  # Bloc {python} = 4 backticks ; enveloppe {exercise} = 5 backticks.
@@ -94,6 +94,19 @@ MULTI_SEED_NUM = 100 # graines de la validation d'invariants (règle
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
  # ── Langue cible ─────────────────────────────────────────────────────────────
98
  DEFAULT_LANG = "fr" # "fr" | "en" | "both"
99
 
 
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.
 
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
 
app/pipeline/audit.py CHANGED
@@ -20,10 +20,73 @@ 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 STEP_AUDIT_PROMPT, SYSTEM_PROMPT
 
 
 
 
24
 
25
  logger = logging.getLogger(__name__)
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  # ─────────────────────────────────────────────────────────────────────────────
29
  # Filet de sécurité des patches (déplacé tel quel, noms inchangés)
 
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)
app/pipeline/orchestrator.py CHANGED
@@ -32,6 +32,9 @@ from app.config import (
32
  HARNESS_REPAIR_MAX,
33
  MAX_ESCALADES,
34
  MULTI_SEED_NUM,
 
 
 
35
  )
36
  from app.knowledge.rules_digest import build_rules_digest
37
  from app.llm.client import process_with_openrouter
@@ -39,7 +42,12 @@ from app.llm.cost import cost_delta, cost_snapshot
39
  from app.rag.catalogue import catalogue_for
40
  from app.pipeline import postprocess as pp
41
  from app.pipeline.analyze import run_analysis_phase
42
- from app.pipeline.audit import run_audit
 
 
 
 
 
43
  from app.pipeline.fewshots import fewshot_for, fewshot_for_declinaison
44
  from app.pipeline.generate import (
45
  assemble_exercise,
@@ -47,7 +55,12 @@ from app.pipeline.generate import (
47
  generate_pair_blocks,
48
  split_original_questions,
49
  )
50
- from app.pipeline.prompts import REPAIR_PROMPT, SYSTEM_PROMPT, TRANSLATE_CONSTRAINTS_PROMPT
 
 
 
 
 
51
  from app.pipeline.solutions import replace_gen_solutions_with_source
52
  from app.pipeline.translate import ensure_language
53
  from app.validation import harness
@@ -101,6 +114,28 @@ def _translate_constraints_to_assertions(code: str, constraints: list[str],
101
  ]
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def run_exercise(
105
  content: str,
106
  filename: str = "exercise.md",
@@ -412,24 +447,8 @@ def run_exercise(
412
  audit_warnings.append({"rule": "harnais",
413
  "message": f"Réparation LLM en échec : {e}."})
414
  break
415
- candidate = pp.strip_fences(repaired)
416
  # Re-passe des filets déterministes sur le candidat réparé.
417
- candidate, _ = pp.fix_orphan_python_openers(candidate)
418
- candidate = pp.normalize_python_fences(candidate)
419
- candidate, _ = pp.drop_empty_python_blocks(candidate)
420
- candidate, _ = pp.fix_triple_braces(candidate)
421
- candidate, _ = pp.fix_superscript_double_brace(candidate)
422
- candidate, _ = pp.unwrap_latex_injections(candidate)
423
- candidate, _ = pp.auto_lift_injections(candidate)
424
- candidate, _ = pp.rename_underscore_injections(candidate)
425
- candidate, _ = pp.fix_dollar_digit(candidate)
426
- if decl_type:
427
- candidate, _ = pp.fix_mcq_answer_aliases(candidate)
428
- candidate, _ = pp.merge_decl_python_blocks(candidate)
429
- if decl_type == "qcm":
430
- candidate, _ = pp.fix_none_option_last(candidate)
431
- candidate, _ = pp.aerate_blocks(candidate)
432
- candidate, _ = pp.renumber_question_ids(candidate)
433
  candidate_report = harness.validate_text(candidate, seeds=HARNESS_GATE_SEEDS)
434
 
435
  def _badness(r: dict) -> int:
@@ -453,6 +472,55 @@ def run_exercise(
453
  + harness.format_report(report)[:600]),
454
  })
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  # ── Résultat ─────────────────────────────────────────────────────────────
457
  return {
458
  "exercise": myst_exercise,
@@ -467,6 +535,7 @@ def run_exercise(
467
  "seeds": report["seeds"],
468
  "summary": harness.format_report(report),
469
  },
 
470
  "lang": lang_info,
471
  "decl_type": decl_type,
472
  "model_used": m_gen,
@@ -532,17 +601,34 @@ def run_with_policy(
532
  forced_models={"generate": mp.openrouter_id(key),
533
  "audit": m_audit, "mecanique": m_meca},
534
  )
535
- tried.append({"rung": i, "model": key, "ok": result["harness"]["ok"]})
536
- if result["harness"]["ok"]:
 
 
 
 
 
 
 
 
 
 
 
537
  break
538
- logger.info("Échelon %s ROUGE sur %s — escalade.", key, filename)
 
 
 
 
539
 
540
  result["policy_telemetry"] = {
541
  "mode": policy,
542
  "difficulty": difficulty,
543
  "tried": tried,
544
  "winning_model": key,
545
- "needs_review": not result["harness"]["ok"],
 
 
546
  }
547
  # Coût honnête : inclut l'analyse partagée (si calculée ici) ET les
548
  # échelons perdants — pas seulement la tentative gagnante.
 
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
 
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,
 
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
 
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",
 
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:
 
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,
 
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,
 
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.
app/pipeline/prompts.py CHANGED
@@ -929,3 +929,71 @@ RÈGLES DE CORRECTION :
929
  Réponds UNIQUEMENT avec l'exercice complet corrigé (de `````{{exercise}} à `````),
930
  sans préambule ni wrapper markdown.
931
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
app/web/templates/index.html CHANGED
@@ -762,6 +762,20 @@ function setMetaResult(entry) {
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
  const cost = r.cost ? `<span class="pill pill--info">${r.cost.usd.toFixed(4)} $ · ${r.cost.requests} appels</span>` : "";
766
  const lang = r.lang ? `<span class="pill pill--info">langue : ${escapeHtml(r.lang.source)} → ${escapeHtml(r.lang.target)} (${escapeHtml(r.lang.action)})</span>` : "";
767
  const dur = r.duration_s ? `<span class="pill pill--info">${r.duration_s}s</span>` : "";
@@ -782,7 +796,7 @@ function setMetaResult(entry) {
782
  } else if (r.model_used) {
783
  pol = `<span class="pill pill--info">modèle : ${escapeHtml(prettyModelName(r.model_used))}</span>`;
784
  }
785
- el.innerHTML = verdict + decl + pol + cost + lang + dur;
786
  }
787
 
788
  /* Sélecteur de fichier (batch) */
 
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>` : "";
 
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) */
audit.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,1048 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,999 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,581 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
tests/gen_decl_sample.py CHANGED
@@ -24,3 +24,13 @@ content = SRC.read_text(encoding="utf-8")
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}")
 
 
 
 
 
 
 
 
 
 
 
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')}")
tests/smoke.py CHANGED
@@ -239,6 +239,9 @@ def mock_llm(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
239
  return MOCK_ANALYSIS
240
  if "auditeur PyxiScience" in prompt:
241
  return json.dumps({"verdict": "OK", "issues": []})
 
 
 
242
  if "Tu déclines un exercice" in prompt:
243
  GEN_MODELS_SEEN.append(model)
244
  return MOCK_MCQ_PAIR if "QCM (MCQ)" in prompt else MOCK_FGQ_PAIR
@@ -248,6 +251,9 @@ def mock_llm(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
248
  return "{}"
249
 
250
 
 
 
 
251
  import app.pipeline.analyze as analyze # noqa: E402
252
  import app.pipeline.audit as audit # noqa: E402
253
  import app.pipeline.generate as generate # noqa: E402
@@ -286,11 +292,13 @@ check("header : une seule enveloppe {exercise}", ex.count("`````{exercise}") ==
286
 
287
  # ── 3bis. Mode déclinaisons (QCM + QAT, LLM mocké, analyse partagée) ─────────
288
  ANALYSIS_CALLS["n"] = 0
 
289
  decl_results = orchestrator.run_declinaisons(
290
  content=SMOKE_SOURCE, filename="smoke.md", level="", model_idx=0,
291
  lang="fr", types=["qcm", "qat"])
292
  check("déclinaisons : 2 sorties (QCM + QAT)", len(decl_results) == 2)
293
  check("déclinaisons : analyse partagée (1 seul appel)", ANALYSIS_CALLS["n"] == 1)
 
294
 
295
  qcm = dict(decl_results)["qcm"]
296
  qat = dict(decl_results)["qat"]
@@ -305,6 +313,55 @@ check("QAT : :solution: + {input} présents",
305
  ':solution: [["ord"' in qat["exercise"] and "{input}`" in qat["exercise"])
306
  check("QAT : displayedSolution présent", "{displayedSolution}" in qat["exercise"])
307
  check("déclinaisons : decl_type exposé", qcm["decl_type"] == "qcm" and qat["decl_type"] == "qat")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
  # Harnais étendu : un MCQ avec collision d'options doit être ROUGE.
310
  from app.validation import harness as _harness # noqa: E402
@@ -365,6 +422,8 @@ def mock_llm_escalade(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
365
  return MOCK_ANALYSIS
366
  if "auditeur PyxiScience" in prompt:
367
  return json.dumps({"verdict": "OK", "issues": []})
 
 
368
  if "harnais" in prompt and "REJETÉ" in prompt:
369
  return MOCK_MCQ_RED # la réparation échoue aussi sur l'échelon 1
370
  if "Tu déclines un exercice" in prompt or "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
 
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
 
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
 
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"]
 
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
 
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: