Spaces:
Runtime error
Runtime error
wilenPyxi Claude Fable 5 commited on
Commit ·
54b6073
1
Parent(s): ad519bc
Audit pedagogique des declinaisons (qualite au-dela du harnais mecanique)
Browse filesJuge 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 +14 -1
- app/pipeline/audit.py +64 -1
- app/pipeline/orchestrator.py +109 -23
- app/pipeline/prompts.py +68 -0
- app/web/templates/index.html +15 -1
- audit.py +331 -0
- config.py +124 -0
- gen_decl_sample.py +36 -0
- index.html +1048 -0
- orchestrator.py +695 -0
- prompts.py +999 -0
- smoke.py +581 -0
- tests/gen_decl_sample.py +10 -0
- tests/smoke.py +59 -0
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 —
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
break
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
result["policy_telemetry"] = {
|
| 541 |
"mode": policy,
|
| 542 |
"difficulty": difficulty,
|
| 543 |
"tried": tried,
|
| 544 |
"winning_model": key,
|
| 545 |
-
"
|
|
|
|
|
|
|
| 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
| 1043 |
+
.replace(/"/g, """).replace(/'/g, "'");
|
| 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:
|