Spaces:
Runtime error
Runtime error
wilenPyxi commited on
Commit ·
9346c36
1
Parent(s): 54b6073
Nettoyage : retire 7 fichiers deposes par erreur a la racine du clone (doublons de app/ et tests/)
Browse files- audit.py +0 -331
- config.py +0 -124
- gen_decl_sample.py +0 -36
- index.html +0 -1048
- orchestrator.py +0 -695
- prompts.py +0 -999
- smoke.py +0 -581
audit.py
DELETED
|
@@ -1,331 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,124 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,36 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,1048 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,695 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,999 +0,0 @@
|
|
| 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
DELETED
|
@@ -1,581 +0,0 @@
|
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|