wilenPxs Claude Fable 5 commited on
Commit
77c855f
·
1 Parent(s): 81836d8

Declinaisons: originalExerciseId systematique + grille miroir + bouton Stop

Browse files

- :originalExerciseId: toujours present dans l en-tete des declinaisons,
valeur = :id: du QST source (vide si la source n en a pas) — convention
des 33 exemples valides, prouve sur generation reelle (harnais VERT).
- MCQ_SPEC v2 : distracteurs en GRILLE MIROIR (variations symetriques,
bonne reponse jamais devinable par la forme — regle enseignante) ; jamais
d option structurellement unique ni de fraction non simplifiee.
- Filet d aeration deterministe (ligne vide avant chaque bloc question/
sous-bloc — mise en page des exemples valides), idempotent, applique
aussi aux candidats de reparation.
- Bouton Stop (tous modes) : POST /api/jobs/<id>/cancel, annulation
cooperative au prochain point d etape, resultats partiels conserves,
statut cancelled dans l UI.
- Smoke 71/71 (aeration, originalExerciseId, flux d annulation complet).

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

README_APP.md CHANGED
@@ -4,6 +4,13 @@ App Flask à deux modes :
4
  - **Pythonisation** d'exercices PyxiScience MyST (valeurs fixes → randomisées) ;
5
  - **Déclinaisons** (2026-07) : génération de versions **QCM** (MCQ) et/ou
6
  **QAT** (FGQ, champs libres ordonnés) d'un exercice, randomisées elles aussi.
 
 
 
 
 
 
 
7
 
8
  Pipeline async : analyse LLM + notions + RAG (parallèles, **partagés entre QCM
9
  et QAT**) → génération par paires → audit LLM → filets déterministes → **porte
 
4
  - **Pythonisation** d'exercices PyxiScience MyST (valeurs fixes → randomisées) ;
5
  - **Déclinaisons** (2026-07) : génération de versions **QCM** (MCQ) et/ou
6
  **QAT** (FGQ, champs libres ordonnés) d'un exercice, randomisées elles aussi.
7
+ `:originalExerciseId:` systématique (= `:id:` du QST source, vide sinon) ;
8
+ distracteurs MCQ en **grille miroir** (variations symétriques — la bonne
9
+ réponse ne se devine jamais par la forme ; règle enseignante 2026-07-02).
10
+
11
+ Un bouton **⏹ Arrêter** (tous modes) annule le job en cours : arrêt coopératif
12
+ au prochain point d'étape (`POST /api/jobs/<id>/cancel`), résultats partiels
13
+ conservés et téléchargeables.
14
 
15
  Pipeline async : analyse LLM + notions + RAG (parallèles, **partagés entre QCM
16
  et QAT**) → génération par paires → audit LLM → filets déterministes → **porte
app/pipeline/generate.py CHANGED
@@ -184,10 +184,11 @@ def build_exercise_metadata(
184
  lines = [f"{fence}{{exercise}}"]
185
  for k in _HEADER_FIELDS:
186
  lines.append(f":{k}: {values[k]}".rstrip())
187
- # Déclinaison d'un exercice existant : tracer l'id source juste après :id:
188
- # (convention des exemples validés : :originalExerciseId:).
189
- if k == "id" and decl_type and src.get("id"):
190
- lines.append(f":originalExerciseId: {src['id']}")
 
191
  return "\n".join(lines)
192
 
193
 
 
184
  lines = [f"{fence}{{exercise}}"]
185
  for k in _HEADER_FIELDS:
186
  lines.append(f":{k}: {values[k]}".rstrip())
187
+ # Déclinaison : tracer l'id de l'exercice SOURCE juste après :id:
188
+ # (convention des 33 exemples validés : :originalExerciseId: TOUJOURS
189
+ # présent vide si la source n'a pas d'id, à compléter côté plateforme).
190
+ if k == "id" and decl_type:
191
+ lines.append(f":originalExerciseId: {src.get('id', '')}".rstrip())
192
  return "\n".join(lines)
193
 
194
 
app/pipeline/orchestrator.py CHANGED
@@ -380,6 +380,7 @@ def run_exercise(
380
  audit_warnings.extend(pp.check_decimals_for_lang(myst_exercise, effective_lang))
381
 
382
  # ── 8. Porte harnais + réparation ────────────────────────────────────────
 
383
  myst_exercise, renum = pp.renumber_question_ids(myst_exercise)
384
  if renum:
385
  audit_patches.append({
@@ -427,6 +428,7 @@ def run_exercise(
427
  candidate, _ = pp.merge_decl_python_blocks(candidate)
428
  if decl_type == "qcm":
429
  candidate, _ = pp.fix_none_option_last(candidate)
 
430
  candidate, _ = pp.renumber_question_ids(candidate)
431
  candidate_report = harness.validate_text(candidate, seeds=HARNESS_GATE_SEEDS)
432
 
 
380
  audit_warnings.extend(pp.check_decimals_for_lang(myst_exercise, effective_lang))
381
 
382
  # ── 8. Porte harnais + réparation ────────────────────────────────────────
383
+ myst_exercise, _aer = pp.aerate_blocks(myst_exercise) # lisibilité (exemples)
384
  myst_exercise, renum = pp.renumber_question_ids(myst_exercise)
385
  if renum:
386
  audit_patches.append({
 
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
 
app/pipeline/postprocess.py CHANGED
@@ -783,3 +783,27 @@ def check_decimals_for_lang(exercise: str, lang: str) -> list[dict]:
783
  else:
784
  _scan(body, lang, "corps")
785
  return warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
  else:
784
  _scan(body, lang, "corps")
785
  return warnings
786
+
787
+
788
+ # ── Aération (lisibilité — mise en page des exemples validés) ────────────────
789
+
790
+ _BLOCK_OPENER_RE = re.compile(r"^:{4,5}\{\w")
791
+
792
+
793
+ def aerate_blocks(exercise: str) -> tuple[str, int]:
794
+ """Garantit une ligne vide avant chaque ouverture de bloc
795
+ `:::::{question}` / `::::{sous-bloc}` — la mise en page des 33 exemples
796
+ validés (déclinaisons) et du corpus 222. Idempotent. Pas de suivi de
797
+ fences : `::::{` n'existe qu'en structure MyST, et une ligne vide serait
798
+ de toute façon inoffensive dans un bloc Python."""
799
+ out: list[str] = []
800
+ added = 0
801
+ for line in exercise.splitlines():
802
+ if _BLOCK_OPENER_RE.match(line) and out and out[-1].strip():
803
+ out.append("")
804
+ added += 1
805
+ out.append(line)
806
+ text = "\n".join(out)
807
+ if exercise.endswith("\n"):
808
+ text += "\n"
809
+ return text, added
app/pipeline/prompts.py CHANGED
@@ -530,6 +530,8 @@ TRANSLATE_FORMAT_BOTH = (
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
  # ─────────────────────────────────────────────────────────────────────────────
534
 
535
  MCQ_SPEC = """\
@@ -607,6 +609,27 @@ DISTRACTEURS — ERREURS TYPES UNIQUEMENT (aucune valeur au hasard) :
607
  • Compositions affines : oubli de b_f ; ordre inversé (g∘f vs f∘g) ; signe opposé.
608
  • Limites : mauvaise forme indéterminée ; mauvais signe d'infini.
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  ANTI-COLLISION (le piège n°1 des QCM randomisés — un distracteur peut devenir
611
  ÉGAL à la bonne réponse sur certaines graines) — dans CET ordre :
612
  1. DISTINCT PAR CONSTRUCTION (préféré) : distracteurs de type différent,
 
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
  # ─────────────────────────────────────────────────────────────────────────────
536
 
537
  MCQ_SPEC = """\
 
609
  • Compositions affines : oubli de b_f ; ordre inversé (g∘f vs f∘g) ; signe opposé.
610
  • Limites : mauvaise forme indéterminée ; mauvais signe d'infini.
611
 
612
+ DISTRACTEURS EN MIROIR — INDEVINABILITÉ (règle enseignante, 2026-07-02) :
613
+ • Les options forment une GRILLE SYMÉTRIQUE dont la bonne réponse est un
614
+ élément comme les autres — JAMAIS des variations « une seule chose à la
615
+ fois » autour de la bonne réponse.
616
+ ✗ MAUVAIS : 2x+3 (vraie) ; 2x−3 ; 2x−4 ; −2x+3
617
+ → le motif commun « 2x+3 » se devine (chaque distracteur n'en dévie
618
+ que d'un seul trait).
619
+ ✓ BON : 2x+3 ; 2x−3 ; −2x+3 ; −2x−3
620
+ → grille complète des combinaisons de signes : l'intuition ne peut
621
+ plus trier, il faut CALCULER.
622
+ • La bonne réponse ne doit JAMAIS être structurellement unique parmi les
623
+ options : ni la seule avec un radical/une fraction/un facteur, ni la plus
624
+ longue ou la plus courte, ni la seule sous forme simplifiée — TOUTES les
625
+ options au même niveau de simplification (jamais de fraction réductible).
626
+ • Concilier avec les erreurs types : choisir les erreurs types qui FERMENT
627
+ la grille (signe × signe, ordre × signe…) plutôt que d'empiler des erreurs
628
+ indépendantes autour du même gabarit.
629
+ • Test avant de rendre : masque mentalement la bonne réponse — si sa place
630
+ se retrouve par simple examen des FORMES (motif majoritaire, symétrie
631
+ incomplète, singularité), reconstruis la grille.
632
+
633
  ANTI-COLLISION (le piège n°1 des QCM randomisés — un distracteur peut devenir
634
  ÉGAL à la bonne réponse sur certaines graines) — dans CET ordre :
635
  1. DISTINCT PAR CONSTRUCTION (préféré) : distracteurs de type différent,
app/server.py CHANGED
@@ -47,6 +47,12 @@ VALID_LEVELS = ("", "Elementary", "Intermediate", "Advanced")
47
  VALID_MODES = ("pythonise", "declinaisons")
48
 
49
 
 
 
 
 
 
 
50
  def _set_job(job_id: str, **kwargs):
51
  with _JOBS_LOCK:
52
  if job_id in _JOBS:
@@ -79,10 +85,29 @@ def _purge_old_jobs():
79
  cutoff = time.time() - JOB_TTL
80
  with _JOBS_LOCK:
81
  for jid in [jid for jid, j in _JOBS.items()
82
- if j.get("status") in ("done", "error") and (j.get("finished_at") or 0) < cutoff]:
 
83
  del _JOBS[jid]
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def _decl_output_name(source_name: str, decl_type: str) -> str:
87
  """foo.md + qcm → foo_QCM.md (nommage de sortie des déclinaisons)."""
88
  base = re.sub(r"\.(md|txt)$", "", source_name or "exercice", flags=re.IGNORECASE)
@@ -96,12 +121,47 @@ def _run_job(job_id: str, files: list[dict], level: str, model_idx: int,
96
  En mode `declinaisons`, chaque source produit 1 résultat PAR type coché
97
  (analyse partagée entre types et échelons — aucun appel LLM redondant)."""
98
  results: list[dict] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  for i, f in enumerate(files):
 
 
100
  name = f.get("filename") or f"fichier_{i + 1}.md"
101
  _set_job(job_id, current_file=name, files_done=i,
102
  step_label=f"[{i + 1}/{len(files)}] {name} — démarrage…")
103
 
104
  def set_step(label: str, _i=i, _name=name):
 
 
 
 
105
  _set_job(job_id, step_label=f"[{_i + 1}/{len(files)}] {_name} — {label}")
106
 
107
  try:
@@ -140,28 +200,13 @@ def _run_job(job_id: str, files: list[dict], level: str, model_idx: int,
140
  result.get("model_used"),
141
  len(result["warnings"]), result["duration_s"],
142
  result["cost"]["usd"])
 
 
143
  except Exception as exc:
144
  logger.exception("Échec du pipeline sur %s", name)
145
  results.append({"filename": name, "status": "error", "error": str(exc)})
146
  _set_job(job_id, results=list(results), files_done=i + 1)
147
 
148
- ok = sum(1 for r in results
149
- if r["status"] == "done" and r["result"]["harness"]["ok"])
150
- warn = sum(1 for r in results
151
- if r["status"] == "done" and not r["result"]["harness"]["ok"])
152
- err = sum(1 for r in results if r["status"] == "error")
153
- total_cost = round(sum(r["result"]["cost"]["usd"] for r in results
154
- if r["status"] == "done"), 4)
155
- _set_job(
156
- job_id,
157
- status="done",
158
- step_label="Terminé",
159
- finished_at=time.time(),
160
- summary={"total": len(files), "verts": ok, "rouges": warn,
161
- "erreurs": err, "cost_usd": total_cost},
162
- )
163
- _purge_old_jobs()
164
-
165
 
166
  def _auth_ok() -> bool:
167
  """Basic Auth si APP_PASSWORD est défini ; ouvert sinon (dev local)."""
@@ -326,6 +371,20 @@ def register_routes(app):
326
  "error": job.get("error"),
327
  })
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  @app.route("/api/jobs/<job_id>/download", methods=["GET"])
330
  def job_download_zip(job_id: str):
331
  """ZIP de toutes les sorties .md prêtes du job + un récapitulatif."""
 
47
  VALID_MODES = ("pythonise", "declinaisons")
48
 
49
 
50
+ class JobCancelled(Exception):
51
+ """Arrêt demandé par l'utilisateur (bouton Stop). L'annulation est
52
+ COOPÉRATIVE : elle prend effet au prochain point d'étape du pipeline
53
+ (un appel LLM en vol n'est pas interrompu, mais rien ne s'enchaîne)."""
54
+
55
+
56
  def _set_job(job_id: str, **kwargs):
57
  with _JOBS_LOCK:
58
  if job_id in _JOBS:
 
85
  cutoff = time.time() - JOB_TTL
86
  with _JOBS_LOCK:
87
  for jid in [jid for jid, j in _JOBS.items()
88
+ if j.get("status") in ("done", "error", "cancelled")
89
+ and (j.get("finished_at") or 0) < cutoff]:
90
  del _JOBS[jid]
91
 
92
 
93
+ def _cancel_requested(job_id: str) -> bool:
94
+ with _JOBS_LOCK:
95
+ j = _JOBS.get(job_id)
96
+ return bool(j and j.get("cancel_requested"))
97
+
98
+
99
+ def _summarize(results: list[dict], total: int) -> dict:
100
+ ok = sum(1 for r in results
101
+ if r["status"] == "done" and r["result"]["harness"]["ok"])
102
+ warn = sum(1 for r in results
103
+ if r["status"] == "done" and not r["result"]["harness"]["ok"])
104
+ err = sum(1 for r in results if r["status"] == "error")
105
+ cost = round(sum(r["result"]["cost"]["usd"] for r in results
106
+ if r["status"] == "done"), 4)
107
+ return {"total": total, "verts": ok, "rouges": warn,
108
+ "erreurs": err, "cost_usd": cost}
109
+
110
+
111
  def _decl_output_name(source_name: str, decl_type: str) -> str:
112
  """foo.md + qcm → foo_QCM.md (nommage de sortie des déclinaisons)."""
113
  base = re.sub(r"\.(md|txt)$", "", source_name or "exercice", flags=re.IGNORECASE)
 
121
  En mode `declinaisons`, chaque source produit 1 résultat PAR type coché
122
  (analyse partagée entre types et échelons — aucun appel LLM redondant)."""
123
  results: list[dict] = []
124
+ try:
125
+ _run_job_files(job_id, files, level, model_idx, lang, mode,
126
+ decl_types, policy, manual_models, results)
127
+ except JobCancelled:
128
+ _set_job(
129
+ job_id,
130
+ status="cancelled",
131
+ step_label="Arrêté par l'utilisateur",
132
+ finished_at=time.time(),
133
+ results=list(results),
134
+ summary=_summarize(results, len(files)),
135
+ )
136
+ logger.info("Job %s annulé par l'utilisateur (%d/%d fichiers traités).",
137
+ job_id, len(results), len(files))
138
+ _purge_old_jobs()
139
+ return
140
+
141
+ _set_job(
142
+ job_id,
143
+ status="done",
144
+ step_label="Terminé",
145
+ finished_at=time.time(),
146
+ summary=_summarize(results, len(files)),
147
+ )
148
+ _purge_old_jobs()
149
+
150
+
151
+ def _run_job_files(job_id, files, level, model_idx, lang, mode, decl_types,
152
+ policy, manual_models, results):
153
  for i, f in enumerate(files):
154
+ if _cancel_requested(job_id):
155
+ raise JobCancelled()
156
  name = f.get("filename") or f"fichier_{i + 1}.md"
157
  _set_job(job_id, current_file=name, files_done=i,
158
  step_label=f"[{i + 1}/{len(files)}] {name} — démarrage…")
159
 
160
  def set_step(label: str, _i=i, _name=name):
161
+ # Point de contrôle d'annulation : chaque étape du pipeline passe
162
+ # ici (analyse, génération, audit, harnais, réparation, échelon).
163
+ if _cancel_requested(job_id):
164
+ raise JobCancelled()
165
  _set_job(job_id, step_label=f"[{_i + 1}/{len(files)}] {_name} — {label}")
166
 
167
  try:
 
200
  result.get("model_used"),
201
  len(result["warnings"]), result["duration_s"],
202
  result["cost"]["usd"])
203
+ except JobCancelled:
204
+ raise # remonte au gestionnaire de _run_job
205
  except Exception as exc:
206
  logger.exception("Échec du pipeline sur %s", name)
207
  results.append({"filename": name, "status": "error", "error": str(exc)})
208
  _set_job(job_id, results=list(results), files_done=i + 1)
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
  def _auth_ok() -> bool:
212
  """Basic Auth si APP_PASSWORD est défini ; ouvert sinon (dev local)."""
 
371
  "error": job.get("error"),
372
  })
373
 
374
+ @app.route("/api/jobs/<job_id>/cancel", methods=["POST"])
375
+ def job_cancel(job_id: str):
376
+ """Arrêt coopératif : prend effet au prochain point d'étape (un appel
377
+ LLM en vol se termine, rien ne s'enchaîne). Résultats partiels gardés."""
378
+ with _JOBS_LOCK:
379
+ job = _JOBS.get(job_id)
380
+ if not job:
381
+ return jsonify({"error": "Job inconnu."}), 404
382
+ if job["status"] != "running":
383
+ return jsonify({"status": job["status"],
384
+ "error": "Job déjà terminé."}), 409
385
+ job["cancel_requested"] = True
386
+ return jsonify({"status": "cancelling"}), 202
387
+
388
  @app.route("/api/jobs/<job_id>/download", methods=["GET"])
389
  def job_download_zip(job_id: str):
390
  """ZIP de toutes les sorties .md prêtes du job + un récapitulatif."""
app/web/templates/index.html CHANGED
@@ -406,6 +406,8 @@
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" type="button" onclick="document.getElementById('file-input').click()">Charger des fichiers .md</button>
410
  <input type="file" id="file-input" accept=".md,.txt,text/markdown,text/plain" multiple hidden onchange="loadFiles(event)">
411
  <button class="btn btn--ghost" type="button" onclick="clearAll()">Effacer</button>
@@ -839,6 +841,9 @@ async function run() {
839
  pollFailures = 0;
840
  jobResults = []; activeResultIdx = 0;
841
  document.getElementById("run-btn").disabled = true;
 
 
 
842
  hideStatus();
843
  setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
844
  setMetaResult(null);
@@ -862,7 +867,35 @@ async function run() {
862
  } catch (e) {
863
  setProgress("error", "Échec", "—");
864
  setStatus("error", "Erreur : " + e.message);
865
- document.getElementById("run-btn").disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
866
  }
867
  }
868
 
@@ -882,7 +915,7 @@ async function poll() {
882
  // Job inconnu : le serveur a probablement redémarré (perte du suivi en mémoire).
883
  setProgress("error", "Suivi perdu", "—");
884
  setStatus("error", "Le serveur a perdu ce job (redémarrage). Le traitement est interrompu — relance.");
885
- document.getElementById("run-btn").disabled = false;
886
  return;
887
  }
888
 
@@ -902,11 +935,19 @@ async function poll() {
902
  activeResultIdx = 0;
903
  renderResultFiles();
904
  showResult(0);
905
- document.getElementById("run-btn").disabled = false;
 
 
 
 
 
 
 
 
906
  } else if (data.status === "error") {
907
  setProgress("error", "Erreur", "—");
908
  setStatus("error", "Erreur : " + (data.error || "inconnue"));
909
- document.getElementById("run-btn").disabled = false;
910
  }
911
  } catch (e) {
912
  // Erreur TRANSITOIRE (proxy HF, worker occupé en calcul, réseau) : on
@@ -918,7 +959,7 @@ async function poll() {
918
  "Suivi interrompu après plusieurs tentatives (" + e.message + "). "
919
  + "Le traitement continue peut-être côté serveur : recharge la page dans ~1 min "
920
  + "pour le retrouver, ou relance avec moins de fichiers à la fois.");
921
- document.getElementById("run-btn").disabled = false;
922
  return;
923
  }
924
  setStatus("warn", `Reconnexion au suivi… (${pollFailures}/${POLL_MAX_FAILURES})`);
@@ -929,6 +970,8 @@ async function poll() {
929
  function clearAll() {
930
  if (pollTimer) clearTimeout(pollTimer);
931
  currentJobId = null;
 
 
932
  loadedFiles = []; jobResults = []; activeResultIdx = 0;
933
  renderFileList();
934
  document.getElementById("input").value = "";
 
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>
 
841
  pollFailures = 0;
842
  jobResults = []; activeResultIdx = 0;
843
  document.getElementById("run-btn").disabled = true;
844
+ const stopBtn = document.getElementById("stop-btn");
845
+ stopBtn.hidden = false; stopBtn.disabled = false;
846
+ stopBtn.textContent = "⏹ Arrêter";
847
  hideStatus();
848
  setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
849
  setMetaResult(null);
 
867
  } catch (e) {
868
  setProgress("error", "Échec", "—");
869
  setStatus("error", "Erreur : " + e.message);
870
+ jobFinished();
871
+ }
872
+ }
873
+
874
+ /* Fin de job (quel que soit l'état terminal) : run réactivé, stop masqué. */
875
+ function jobFinished() {
876
+ document.getElementById("run-btn").disabled = false;
877
+ document.getElementById("stop-btn").hidden = true;
878
+ }
879
+
880
+ /* Arrêt coopératif : le serveur stoppe au prochain point d'étape (l'appel
881
+ LLM en cours se termine, rien ne s'enchaîne) ; résultats partiels gardés. */
882
+ async function stopJob() {
883
+ if (!currentJobId) return;
884
+ const stopBtn = document.getElementById("stop-btn");
885
+ stopBtn.disabled = true;
886
+ stopBtn.textContent = "Arrêt en cours…";
887
+ try {
888
+ const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: "POST" });
889
+ if (res.status === 404 || res.status === 409) {
890
+ // Job déjà terminé/perdu : le prochain poll affichera l'état final.
891
+ stopBtn.hidden = true;
892
+ return;
893
+ }
894
+ setStatus("warn", "Arrêt demandé — fin de l'étape en cours…");
895
+ } catch (e) {
896
+ stopBtn.disabled = false;
897
+ stopBtn.textContent = "⏹ Arrêter";
898
+ setStatus("error", "Impossible de demander l'arrêt : " + e.message);
899
  }
900
  }
901
 
 
915
  // Job inconnu : le serveur a probablement redémarré (perte du suivi en mémoire).
916
  setProgress("error", "Suivi perdu", "—");
917
  setStatus("error", "Le serveur a perdu ce job (redémarrage). Le traitement est interrompu — relance.");
918
+ jobFinished();
919
  return;
920
  }
921
 
 
935
  activeResultIdx = 0;
936
  renderResultFiles();
937
  showResult(0);
938
+ jobFinished();
939
+ } else if (data.status === "cancelled") {
940
+ const s = data.summary || {};
941
+ setProgress("error", "Arrêté", `${data.files_done}/${data.files_total}`);
942
+ setStatus("warn",
943
+ `Génération arrêtée. Résultats partiels conservés : ${s.verts ?? 0} VERT(S), `
944
+ + `${s.rouges ?? 0} ROUGE(S), ${s.erreurs ?? 0} erreur(s) · ${s.cost_usd ?? 0} $`);
945
+ if (jobResults.length) { activeResultIdx = 0; renderResultFiles(); showResult(0); }
946
+ jobFinished();
947
  } else if (data.status === "error") {
948
  setProgress("error", "Erreur", "—");
949
  setStatus("error", "Erreur : " + (data.error || "inconnue"));
950
+ jobFinished();
951
  }
952
  } catch (e) {
953
  // Erreur TRANSITOIRE (proxy HF, worker occupé en calcul, réseau) : on
 
959
  "Suivi interrompu après plusieurs tentatives (" + e.message + "). "
960
  + "Le traitement continue peut-être côté serveur : recharge la page dans ~1 min "
961
  + "pour le retrouver, ou relance avec moins de fichiers à la fois.");
962
+ jobFinished();
963
  return;
964
  }
965
  setStatus("warn", `Reconnexion au suivi… (${pollFailures}/${POLL_MAX_FAILURES})`);
 
970
  function clearAll() {
971
  if (pollTimer) clearTimeout(pollTimer);
972
  currentJobId = null;
973
+ document.getElementById("stop-btn").hidden = true;
974
+ document.getElementById("run-btn").disabled = false;
975
  loadedFiles = []; jobResults = []; activeResultIdx = 0;
976
  renderFileList();
977
  document.getElementById("input").value = "";
tests/gen_decl_sample.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}")
tests/smoke.py CHANGED
@@ -406,6 +406,68 @@ check("API : manual modèle hors rôle → 400",
406
  check("/api/models expose catalogue par rôle sans Fable",
407
  "fable" not in json.dumps(client.get("/api/models").get_json()).lower())
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  # Banc : --dry-run (plomberie complète hors ligne).
410
  import subprocess # noqa: E402
411
 
 
406
  check("/api/models expose catalogue par rôle sans Fable",
407
  "fable" not in json.dumps(client.get("/api/models").get_json()).lower())
408
 
409
+ # ── 3quater. Aération, originalExerciseId, annulation (bouton Stop) ─────────
410
+ from app.pipeline.postprocess import aerate_blocks # noqa: E402
411
+
412
+ _compact = (":::::{question}\n:questionType: MCQ\n::::{questionStatement}\n"
413
+ "texte\n::::\n::::{mcqAnswer}\n:isRightAnswer: true\nx\n::::")
414
+ _aered, _n_aer = aerate_blocks(_compact)
415
+ check("aération : lignes vides avant chaque bloc",
416
+ _n_aer == 2 and "\n\n::::{questionStatement}" in _aered
417
+ and "\n\n::::{mcqAnswer}" in _aered)
418
+ check("aération : idempotente", aerate_blocks(_aered)[1] == 0)
419
+
420
+ from app.pipeline.generate import build_exercise_metadata # noqa: E402
421
+
422
+ check("déclinaison : originalExerciseId = id du QST source",
423
+ ":originalExerciseId: abc-123" in build_exercise_metadata(
424
+ ":id: abc-123\n:title: T", "", {}, "", decl_type="qcm"))
425
+ check("déclinaison : originalExerciseId présent même sans id source",
426
+ "\n:originalExerciseId:" in build_exercise_metadata(
427
+ ":title: T", "", {}, "", decl_type="qcm"))
428
+ check("pythonise : pas d'originalExerciseId",
429
+ "originalExerciseId" not in build_exercise_metadata(
430
+ ":title: T", "", {}, "", decl_type=None))
431
+
432
+ # Annulation : job 3 fichiers avec mock LENT, cancel immédiat → cancelled.
433
+ check("annulation : job inconnu → 404",
434
+ client.post("/api/jobs/zzz/cancel").status_code == 404)
435
+
436
+ import time as _time # noqa: E402
437
+
438
+
439
+ def mock_llm_slow(*a, **k):
440
+ _time.sleep(0.15)
441
+ return mock_llm(*a, **k)
442
+
443
+
444
+ for _m in (analyze, audit, generate, orchestrator):
445
+ _m.process_with_openrouter = mock_llm_slow
446
+ _sols.process_with_openrouter = mock_llm_slow
447
+ _tr.process_with_openrouter = mock_llm_slow
448
+
449
+ _rc_start = client.post("/api/jobs", json={
450
+ "files": [{"filename": f"c{i}.md", "content": SMOKE_SOURCE} for i in range(3)]})
451
+ _jid_c = _rc_start.get_json()["job_id"]
452
+ check("annulation : cancel accepté (202)",
453
+ client.post(f"/api/jobs/{_jid_c}/cancel").status_code == 202)
454
+ _st_c = None
455
+ for _ in range(400):
456
+ _st_c = client.get(f"/api/jobs/{_jid_c}").get_json()
457
+ if _st_c["status"] != "running":
458
+ break
459
+ _time.sleep(0.05)
460
+ check("annulation : statut final cancelled", _st_c["status"] == "cancelled")
461
+ check("annulation : arrêt anticipé (résultats partiels conservés)",
462
+ _st_c["files_done"] < 3 and isinstance(_st_c["results"], list))
463
+ check("annulation : re-cancel d'un job terminé → 409",
464
+ client.post(f"/api/jobs/{_jid_c}/cancel").status_code == 409)
465
+
466
+ for _m in (analyze, audit, generate, orchestrator):
467
+ _m.process_with_openrouter = mock_llm
468
+ _sols.process_with_openrouter = mock_llm
469
+ _tr.process_with_openrouter = mock_llm
470
+
471
  # Banc : --dry-run (plomberie complète hors ligne).
472
  import subprocess # noqa: E402
473