wilenPyxi
Lot pythonisation : lint de rendu (cause racine faux-VERT) + filets + prompts
59e924b
Raw
History Blame Contribute Delete
24.7 kB
"""
Smoke tests minimaux — AUCUN appel réseau (LLM mocké), exécution :
.venv/bin/python tests/smoke.py
Couvre : import/create_app, /health, 1 run de pipeline complet (mock LLM)
avec porte harnais VERTE, et les filets déterministes clés.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
PASS = []
def check(name, cond):
PASS.append((name, bool(cond)))
print(("✓" if cond else "✗"), name)
# ── 1. Import + /health ──────────────────────────────────────────────────────
from app import create_app # noqa: E402
flask_app = create_app()
client = flask_app.test_client()
health = client.get("/health").get_json()
check("create_app + /health", health and health["status"] == "ok")
models = client.get("/api/models").get_json()
check("/api/models expose le roster", len(models["models"]) >= 5)
# ── 2. Filets déterministes ──────────────────────────────────────────────────
from app.pipeline import postprocess as pp # noqa: E402
t, _ = pp.fix_dollar_digit("Prix : $3$ et ${{nAff}}$.")
check("fix_dollar_digit", "${}3$" in t and "${}{{nAff}}$" in t)
check("detect_languages both", pp.detect_languages("{fr}`Calculer`{en}`Compute` $x$") == "both")
check("strip_language fr", pp.strip_language("{fr}`Bonjour `{en}`Hello `", "fr").strip() == "Bonjour")
# ── 3. Pipeline complet avec LLM mocké ───────────────────────────────────────
SMOKE_SOURCE = """`````{exercise}
:title: Somme de deux entiers
:level: Elementary
On additionne deux entiers.
:::::{question}
:questionType: STQ
:questionId: 0
:questionIndex: 0
::::{questionStatement}
Calculer $3 + 4$.
::::
::::{questionHint}
Poser l'addition.
::::
::::{detailedSolution}
On trouve $7$.
::::
::::{weightDistribution}
:logic: 25
:abstraction: 25
:reasoning: 25
:calculation: 25
::::
:::::
`````"""
MOCK_ANALYSIS = json.dumps({
"exercise_type": "équation linéaire",
"exercise_title": "Somme de deux entiers",
"nb_questions": 1,
"variables": [{"nom": "a", "type_python": "int", "description": "1er terme",
"contraintes": "2..9", "plage_python": "rd.randint(2, 9)",
"location": "énoncé", "valeur_exemple": "3"}],
"needs_fraction": False, "needs_sympy": False, "needs_numpy": False,
"needs_matplolib": False, # typo v1 volontaire : doit être normalisée
"target_rules": [], "property_constraints": [],
"has_validated_solution_in_input": False,
})
MOCK_PAIR = """````{python}
import random as rd
a = rd.randint(2, 9)
b = rd.randint(2, 9)
sumAff = str(a + b)
globals()
````
On additionne deux entiers.
:::::{question}
:questionType: STQ
:questionId: 0
:questionIndex: 0
::::{questionStatement}
Calculer ${}{{a}} + {{b}}$.
::::
::::{questionHint}
Poser l'addition.
::::
::::{detailedSolution}
On trouve ${}{{a}} + {{b}} = {{sumAff}}$.
::::
::::{weightDistribution}
:logic: 25
:abstraction: 25
:reasoning: 25
:calculation: 25
::::
:::::"""
MOCK_MCQ_PAIR = """````{python}
import random as rd
a = rd.randint(2, 9)
b = rd.randint(2, 9)
sumAff = str(a + b)
d1Aff = str(a + b + 1) # erreur type : +1
d2Aff = str(a + b - 1) # erreur type : -1
d3Aff = str(a * b + 100) # produit décalé — toujours > somme+1 (distinct)
globals()
````
On additionne deux entiers.
:::::{question}
:questionType: MCQ
:questionId: 0
:questionIndex: 0
::::{questionStatement}
Combien vaut ${}{{a}} + {{b}}$ ?
::::
::::{questionHint}
Poser l'addition.
::::
::::{mcqAnswer}
:isRightAnswer: true
${}{{sumAff}}$
::::
::::{mcqAnswer}
:isRightAnswer: false
${}{{d1Aff}}$
::::
::::{mcqAnswer}
:isRightAnswer: false
${}{{d2Aff}}$
::::
::::{mcqAnswer}
:isRightAnswer: false
${}{{d3Aff}}$
::::
::::{mcqAnswer}
:isRightAnswer: false
{fr}`Aucune de ces réponses n'est correcte`{en}`None of these answers are correct`
::::
::::{detailedSolution}
On trouve ${}{{a}} + {{b}} = {{sumAff}}$.
::::
::::{weightDistribution}
:logic: 25
:abstraction: 25
:reasoning: 25
:calculation: 25
::::
:::::"""
MOCK_FGQ_PAIR = """````{python}
import random as rd
a = rd.randint(2, 9)
b = rd.randint(2, 9)
sumAff = str(a + b)
globals()
````
On additionne deux entiers.
:::::{question}
:questionType: FGQ
:questionId: 0
:questionIndex: 0
:solution: [["ord","${{sumAff}}$"],["0"]]
::::{questionStatement}
Calculer ${}{{a}} + {{b}}$.
$s =$ {input}`||110`
::::
::::{questionHint}
Poser l'addition.
::::
::::{displayedSolution}
$s = {{sumAff}}$
::::
::::{detailedSolution}
On trouve ${}{{a}} + {{b}} = {{sumAff}}$.
::::
::::{weightDistribution}
:logic: 15
:abstraction: 20
:reasoning: 20
:calculation: 45
::::
:::::"""
ANALYSIS_CALLS = {"n": 0}
GEN_MODELS_SEEN = [] # IDs de modèle vus par les appels de génération
def mock_llm(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
image_b64=None, system_prompt="", reasoning=False, model=None):
if "expert en analyse d'exercices" in prompt:
ANALYSIS_CALLS["n"] += 1
return MOCK_ANALYSIS
if "auditeur PyxiScience" in prompt:
return json.dumps({"verdict": "OK", "issues": []})
if "RELECTEUR PÉDAGOGIQUE" in prompt: # audit pédagogique
PEDAGO_CALLS["n"] += 1
return json.dumps({"verdict": "OK", "score": 95, "issues": []})
if "Tu déclines un exercice" in prompt:
GEN_MODELS_SEEN.append(model)
return MOCK_MCQ_PAIR if "QCM (MCQ)" in prompt else MOCK_FGQ_PAIR
if "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
GEN_MODELS_SEEN.append(model)
return MOCK_PAIR
return "{}"
PEDAGO_CALLS = {"n": 0}
import app.pipeline.analyze as analyze # noqa: E402
import app.pipeline.audit as audit # noqa: E402
import app.pipeline.generate as generate # noqa: E402
import app.pipeline.orchestrator as orchestrator # noqa: E402
analyze.process_with_openrouter = mock_llm
audit.process_with_openrouter = mock_llm
generate.process_with_openrouter = mock_llm
orchestrator.process_with_openrouter = mock_llm
analyze.enrich_exercise_with_notions = lambda *a, **k: ("(notions mockées)", "NOTION_TEST")
analyze.retrieve_functions_context = lambda **k: {"catalogue": "(catalogue mocké)"}
result = orchestrator.run_exercise(
content=SMOKE_SOURCE, filename="smoke.md", level="", model_idx=0, lang="fr")
check("pipeline : exercice produit", bool(result["exercise"].strip()))
check("pipeline : fence 4 backticks", "````{python}" in result["exercise"])
check("pipeline : se termine par `````", result["exercise"].rstrip().endswith("`````"))
check("pipeline : globals() présent", "globals()" in result["exercise"])
check("pipeline : typo needs_matplolib normalisée",
"needs_matplolib" not in result["analysis"] and result["analysis"]["needs_matplotlib"] is False)
check("pipeline : harnais VERT", result["harness"]["ok"])
check("pipeline : coût exposé", "usd" in result["cost"])
check("pipeline : langue exposée", result["lang"]["target"] == "fr")
# En-tête {exercise} complet et bien formé
ex = result["exercise"]
check("header : enveloppe `````{exercise} en tête", ex.lstrip().startswith("`````{exercise}"))
for field in (":id:", ":title:", ":modules:", ":recommendedExecutionTime:",
":level:", ":chap:", ":involvedConcepts:", ":originalSource:", ":visibility:"):
check(f"header : champ {field} présent", field in ex.split("````{python}")[0])
check("header : level mappé (level='' → Elementary)", ":level: Elementary" in ex)
check("header : visibility All", ":visibility: All" in ex)
check("header : concepts = notions", "NOTION_TEST" in ex)
check("header : une seule enveloppe {exercise}", ex.count("`````{exercise}") == 1)
# ── 3bis. Mode déclinaisons (QCM + QAT, LLM mocké, analyse partagée) ─────────
ANALYSIS_CALLS["n"] = 0
PEDAGO_CALLS["n"] = 0
decl_results = orchestrator.run_declinaisons(
content=SMOKE_SOURCE, filename="smoke.md", level="", model_idx=0,
lang="fr", types=["qcm", "qat"])
check("déclinaisons : 2 sorties (QCM + QAT)", len(decl_results) == 2)
check("déclinaisons : analyse partagée (1 seul appel)", ANALYSIS_CALLS["n"] == 1)
check("audit pédagogique : appelé (QCM + QAT)", PEDAGO_CALLS["n"] >= 2)
qcm = dict(decl_results)["qcm"]
qat = dict(decl_results)["qat"]
check("QCM : harnais VERT", qcm["harness"]["ok"])
check("QCM : 5 options, 1 seule bonne", qcm["exercise"].count("{mcqAnswer}") == 5
and qcm["exercise"].count(":isRightAnswer: true") == 1)
check("QCM : « None » en dernière option",
"Aucune de ces réponses" in qcm["exercise"].split(":isRightAnswer: false")[-1])
check("QCM : titre suffixé - MCQ", " - MCQ" in qcm["exercise"].split("````{python}")[0])
check("QAT : harnais VERT", qat["harness"]["ok"])
check("QAT : :solution: + {input} présents",
':solution: [["ord"' in qat["exercise"] and "{input}`" in qat["exercise"])
check("QAT : displayedSolution présent", "{displayedSolution}" in qat["exercise"])
check("déclinaisons : decl_type exposé", qcm["decl_type"] == "qcm" and qat["decl_type"] == "qat")
check("audit pédagogique : verdict exposé (QCM)",
isinstance(qcm.get("pedagogical"), dict) and qcm["pedagogical"]["verdict"] == "OK")
# Audit pédagogique ROUGE → escalade en mode auto (harnais VERT mais qualité A_REVOIR).
_ped_calls = {"n": 0}
def mock_llm_pedago_escalade(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
image_b64=None, system_prompt="", reasoning=False, model=None):
if "expert en analyse d'exercices" in prompt:
return MOCK_ANALYSIS
if "auditeur PyxiScience" in prompt:
return json.dumps({"verdict": "OK", "issues": []})
if "RELECTEUR PÉDAGOGIQUE" in prompt:
_ped_calls["n"] += 1
# Échelon 1 : audit + post-réparation restent A_REVOIR (2 appels) → la
# réparation n'améliore pas, donc ESCALADE. Échelon 2+ : qualité OK.
if _ped_calls["n"] <= 2:
return json.dumps({"verdict": "A_REVOIR", "score": 40, "issues": [
{"gravite": "haute", "ou": "Q0", "probleme": "distracteur devinable",
"correction": "grille miroir"}]})
return json.dumps({"verdict": "OK", "score": 92, "issues": []})
if "défauts de QUALITÉ" in prompt: # réparation pédagogique → sortie VERTE mais non améliorée
return MOCK_MCQ_PAIR
if "Tu déclines un exercice" in prompt or "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
return MOCK_MCQ_PAIR
return "{}"
for _m in (analyze, audit, generate, orchestrator):
_m.process_with_openrouter = mock_llm_pedago_escalade
_sols_p = __import__("app.pipeline.solutions", fromlist=["x"])
_tr_p = __import__("app.pipeline.translate", fromlist=["x"])
_sols_p.process_with_openrouter = mock_llm_pedago_escalade
_tr_p.process_with_openrouter = mock_llm_pedago_escalade
res_ped = orchestrator.run_with_policy(
content=SMOKE_SOURCE, filename="ped.md", lang="fr", policy="auto", decl_type="qcm")
tel_ped = res_ped["policy_telemetry"]
check("escalade pédagogique : ≥2 échelons tentés", len(tel_ped["tried"]) >= 2)
check("escalade pédagogique : 1er échelon qualité A_REVOIR",
tel_ped["tried"][0]["pedago"] == "A_REVOIR")
check("escalade pédagogique : gagnant qualité OK",
tel_ped["pedago_verdict"] == "OK" and not tel_ped["needs_review"])
for _m in (analyze, audit, generate, orchestrator):
_m.process_with_openrouter = mock_llm
_sols_p.process_with_openrouter = mock_llm
_tr_p.process_with_openrouter = mock_llm
# Harnais étendu : un MCQ avec collision d'options doit être ROUGE.
from app.validation import harness as _harness # noqa: E402
_collision = qcm["exercise"].replace("{{d1Aff}}", "{{sumAff}}") # distracteur == bonne réponse
_rep = _harness.validate_text(_collision, seeds=10)
check("harnais étendu : collision d'options MCQ détectée (ROUGE)",
not _rep["ok"] and _rep["n_mcq_collisions"] > 0)
_two_true = qcm["exercise"].replace(":isRightAnswer: false", ":isRightAnswer: true", 1)
_rep2 = _harness.validate_text(_two_true, seeds=5)
check("harnais étendu : 2 bonnes réponses détectées (statique)",
not _rep2["ok"] and any("isRightAnswer" in e for e in _rep2["static_errors"]))
_bad_arity = qat["exercise"].replace('[["ord","${{sumAff}}$"],["0"]]',
'[["ord","${{sumAff}}$","$2$"],["0","0"]]')
_rep3 = _harness.validate_text(_bad_arity, seeds=5)
check("harnais étendu : arité FGQ incohérente détectée",
not _rep3["ok"] and any("arité" in e for e in _rep3["static_errors"]))
# Validation API du mode (sans lancer de job).
r_bad_mode = client.post("/api/jobs", json={"content": "x", "mode": "zzz"})
check("API : mode invalide → 400", r_bad_mode.status_code == 400)
r_no_types = client.post("/api/jobs", json={"content": "x", "mode": "declinaisons", "types": {}})
check("API : declinaisons sans type → 400", r_no_types.status_code == 400)
# ── 3ter. Politiques de modèle + escalade + retrait de Fable ────────────────
from app.models.catalog import CATALOG, CANDIDATES # noqa: E402
from app.models import policy as _mp # noqa: E402
check("Fable absent du catalogue",
not any("fable" in k.lower() for k in CATALOG)
and not any("fable" in v["openrouter_id"].lower() for v in CATALOG.values()))
from app.config import AVAILABLE_MODELS as _AM # noqa: E402
check("Fable absent d'AVAILABLE_MODELS",
not any("fable" in v.lower() for v in _AM.values()))
check("Fable absent du fallback policy",
not any("fable" in str(_mp.DEFAULT_RECOMMENDED).lower() for _ in [0]))
# best / cheap / manual suivent recommended.json (source VIVANTE : le banc la
# réécrit — on vérifie la cohérence de la résolution, pas des noms figés).
_rec_gen = _mp.load_recommended()["generate"]
check("policy best suit recommended.json",
_mp.resolve("generate", "best") == _rec_gen["best"])
check("policy cheap suit recommended.json",
_mp.resolve("generate", "cheap") == _rec_gen["cheap"])
check("policy manual respecté",
_mp.resolve("generate", "manual", {"generate": "deepseek-v4-pro"}) == "deepseek-v4-pro")
check("difficulté : matrices → difficile",
_mp.classify_difficulty("Matrix systeme " * 100 + ":::::{question}" * 6) == "difficile")
# Escalade : 1er échelon forcé ROUGE (options en collision) → échelon 2 VERT.
MOCK_MCQ_RED = MOCK_MCQ_PAIR.replace("{{d1Aff}}", "{{sumAff}}")
_calls = {"n": 0}
def mock_llm_escalade(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
image_b64=None, system_prompt="", reasoning=False, model=None):
if "expert en analyse d'exercices" in prompt:
return MOCK_ANALYSIS
if "auditeur PyxiScience" in prompt:
return json.dumps({"verdict": "OK", "issues": []})
if "RELECTEUR PÉDAGOGIQUE" in prompt: # qualité OK → escalade pilotée par le harnais seul
return json.dumps({"verdict": "OK", "score": 95, "issues": []})
if "harnais" in prompt and "REJETÉ" in prompt:
return MOCK_MCQ_RED # la réparation échoue aussi sur l'échelon 1
if "Tu déclines un exercice" in prompt or "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
_calls["n"] += 1
# 1re GÉNÉRATION (échelon 1) rouge ; la suivante (échelon 2) verte.
return MOCK_MCQ_RED if _calls["n"] <= 1 else MOCK_MCQ_PAIR
return "{}"
for _m in (analyze, audit, generate, orchestrator):
_m.process_with_openrouter = mock_llm_escalade
import app.pipeline.solutions as _sols # noqa: E402
import app.pipeline.translate as _tr # noqa: E402
_sols.process_with_openrouter = mock_llm_escalade
_tr.process_with_openrouter = mock_llm_escalade
res_esc = orchestrator.run_with_policy(
content=SMOKE_SOURCE, filename="esc.md", lang="fr",
policy="auto", decl_type="qcm")
tel = res_esc["policy_telemetry"]
check("escalade : ≥2 échelons tentés", len(tel["tried"]) >= 2)
check("escalade : échelon 1 ROUGE puis gagnant VERT",
tel["tried"][0]["ok"] is False and tel["tried"][-1]["ok"] is True)
check("escalade : échelon gagnant journalisé",
tel["winning_model"] == tel["tried"][-1]["model"] and not tel["needs_review"])
# Restaure les mocks standards pour la suite.
for _m in (analyze, audit, generate, orchestrator):
_m.process_with_openrouter = mock_llm
_sols.process_with_openrouter = mock_llm
_tr.process_with_openrouter = mock_llm
# API : policy invalide → 400 ; manual avec modèle hors rôle → 400.
check("API : policy invalide → 400",
client.post("/api/jobs", json={"content": "x", "policy": "zzz"}).status_code == 400)
check("API : manual modèle hors rôle → 400",
client.post("/api/jobs", json={"content": "x", "policy": "manual",
"models": {"generate": "glm-4-7-flash"}}).status_code == 400)
check("/api/models expose catalogue par rôle sans Fable",
"fable" not in json.dumps(client.get("/api/models").get_json()).lower())
# ── 3quater. Aération, originalExerciseId, annulation (bouton Stop) ─────────
from app.pipeline.postprocess import aerate_blocks # noqa: E402
_compact = (":::::{question}\n:questionType: MCQ\n::::{questionStatement}\n"
"texte\n::::\n::::{mcqAnswer}\n:isRightAnswer: true\nx\n::::")
_aered, _n_aer = aerate_blocks(_compact)
check("aération : lignes vides avant chaque bloc",
_n_aer == 2 and "\n\n::::{questionStatement}" in _aered
and "\n\n::::{mcqAnswer}" in _aered)
check("aération : idempotente", aerate_blocks(_aered)[1] == 0)
from app.pipeline.generate import build_exercise_metadata # noqa: E402
check("déclinaison : originalExerciseId = id du QST source",
":originalExerciseId: abc-123" in build_exercise_metadata(
":id: abc-123\n:title: T", "", {}, "", decl_type="qcm"))
check("déclinaison : originalExerciseId présent même sans id source",
"\n:originalExerciseId:" in build_exercise_metadata(
":title: T", "", {}, "", decl_type="qcm"))
check("pythonise : pas d'originalExerciseId",
"originalExerciseId" not in build_exercise_metadata(
":title: T", "", {}, "", decl_type=None))
# Annulation : job 3 fichiers avec mock LENT, cancel immédiat → cancelled.
check("annulation : job inconnu → 404",
client.post("/api/jobs/zzz/cancel").status_code == 404)
import time as _time # noqa: E402
def mock_llm_slow(*a, **k):
_time.sleep(0.15)
return mock_llm(*a, **k)
for _m in (analyze, audit, generate, orchestrator):
_m.process_with_openrouter = mock_llm_slow
_sols.process_with_openrouter = mock_llm_slow
_tr.process_with_openrouter = mock_llm_slow
_rc_start = client.post("/api/jobs", json={
"files": [{"filename": f"c{i}.md", "content": SMOKE_SOURCE} for i in range(3)]})
_jid_c = _rc_start.get_json()["job_id"]
check("annulation : cancel accepté (202)",
client.post(f"/api/jobs/{_jid_c}/cancel").status_code == 202)
_st_c = None
for _ in range(400):
_st_c = client.get(f"/api/jobs/{_jid_c}").get_json()
if _st_c["status"] != "running":
break
_time.sleep(0.05)
check("annulation : statut final cancelled", _st_c["status"] == "cancelled")
check("annulation : arrêt anticipé (résultats partiels conservés)",
_st_c["files_done"] < 3 and isinstance(_st_c["results"], list))
check("annulation : re-cancel d'un job terminé → 409",
client.post(f"/api/jobs/{_jid_c}/cancel").status_code == 409)
for _m in (analyze, audit, generate, orchestrator):
_m.process_with_openrouter = mock_llm
_sols.process_with_openrouter = mock_llm
_tr.process_with_openrouter = mock_llm
# Banc : --dry-run (plomberie complète hors ligne).
import subprocess # noqa: E402
bench_proc = subprocess.run(
[sys.executable, "-m", "bench", "run", "--dry-run",
"--roles", "generate", "--models", "claude-sonnet-5,claude-opus-4-8",
"--seeds", "5"],
capture_output=True, text=True, timeout=600,
cwd=str(Path(__file__).resolve().parent.parent),
)
check("bench --dry-run : exit 0", bench_proc.returncode == 0)
check("bench --dry-run : reco produite", "best=" in bench_proc.stdout)
check("bench --dry-run : recommended.json non modifié",
"NON modifié" in bench_proc.stdout)
# ── 3quinquies. Lint de rendu (lot pythonisation) + net d'échappement % ──────
from app.validation import harness as _h # noqa: E402
from app.pipeline import postprocess as _pp # noqa: E402
check("lint rendu : $ inline déséquilibré → ROUGE",
any("déséquilibré" in e for e in _h.check_render_static("texte ${{ x }}, suite")))
check("lint rendu : % nu → ROUGE",
any("%" in e for e in _h.check_render_static("Remise de 20% sur le prix.")))
check("lint rendu : 0 faux positif sur $ équilibré + \\%",
_h.check_render_static("Prix ${{ p }}$ avec remise de 20\\%.") == [])
check("lint rendu : % dans une métadonnée épargné",
_h.check_render_static(":originalSource: % Exercice 12") == [])
check("net % : prose échappée, Python épargné",
(lambda o: "20\\% " in o and "5 % 2" in o)(
_pp.escape_percent("````{python}\nx = 5 % 2\n````\n\nRemise 20% ici.")[0]))
check("net % : idempotent",
_pp.escape_percent(_pp.escape_percent("Remise 20% ici.")[0])[0]
== _pp.escape_percent("Remise 20% ici.")[0])
check("check_injection_in_roles détecte {{ }} dans un rôle",
any("rôle" in e for e in _h.check_injection_in_roles("{fr}`Il y a {{n}} cas`{en}`x`")))
# Filet : extrait l'injection HORS du rôle, symétrique, et le résultat n'a plus
# aucune injection dans un rôle (bug n°1 du lot pythonisation).
_role_src = "{fr}`Il y a {{nAff}} cas`{en}`There are {{nAff}} cases`"
_role_out, _role_p = _pp.extract_injections_from_roles(_role_src)
check("net rôles : injection extraite (0 résidu dans un rôle)",
_role_p and _h.check_injection_in_roles(_role_out) == [])
check("net rôles : idempotent",
_pp.extract_injections_from_roles(_role_out)[0] == _role_out)
check("net rôles : bail si unités FR/EN non alignées (laisse intact)",
_pp.extract_injections_from_roles("{fr}`a {{x}}`{en}`b {{y}}`")[0]
== "{fr}`a {{x}}`{en}`b {{y}}`")
check("harnais : injection dans rôle → static_error (ROUGE dur)",
any("rôle" in e for e in _h.validate_text(
"`````{exercise}\n:id:\n\n````{python}\nnAff=1\nglobals()\n````\n\n"
":::::{question}\n::::{questionStatement}\n{fr}`n = {{nAff}}`{en}`n = {{nAff}}`\n::::\n:::::\n`````",
seeds=5)["static_errors"]))
# ── 4. Téléchargement ZIP (endpoint, sans LLM) ───────────────────────────────
import io as _io # noqa: E402
import zipfile as _zipfile # noqa: E402
import app.server as _server # noqa: E402
_fake = {
"status": "done", "step_label": "Terminé", "current_file": "b.md",
"files_total": 2, "files_done": 2, "error": None, "summary": {},
"results": [
{"filename": "a.md", "status": "done",
"result": {"exercise": result["exercise"], "warnings": [],
"harness": {"ok": True, "seeds": 100}, "cost": {"usd": 0.01}}},
{"filename": "a.md", "status": "done", # collision de nom volontaire
"result": {"exercise": "````{python}\nglobals()\n````\n`````", "warnings": [{}],
"harness": {"ok": False, "seeds": 100}, "cost": {"usd": 0.02}}},
{"filename": "c.md", "status": "error", "error": "boom"},
],
}
with _server._JOBS_LOCK:
_server._JOBS["smoketest"] = _fake
resp = client.get("/api/jobs/smoketest/download")
check("ZIP : 200 + mimetype zip", resp.status_code == 200 and "zip" in resp.mimetype)
zf = _zipfile.ZipFile(_io.BytesIO(resp.data))
names = zf.namelist()
check("ZIP : 2 .md (collision dédupliquée) + récap",
"a_pythonise.md" in names and "a_pythonise_2.md" in names
and "_recapitulatif.md" in names)
check("ZIP : 404 si job inconnu", client.get("/api/jobs/zzz/download").status_code == 404)
# ── Bilan ────────────────────────────────────────────────────────────────────
failed = [n for n, ok in PASS if not ok]
print(f"\n{len(PASS) - len(failed)}/{len(PASS)} smoke tests verts")
if failed:
print("ÉCHECS :", failed)
sys.exit(1)
print("✅ SMOKE OK")