Spaces:
Sleeping
Sleeping
| """ | |
| 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") | |