Spaces:
Runtime error
Politique de modeles multi-fournisseurs + retrait de Fable + durcissements
Browse files- app/models/: catalogue 16 modeles (IDs OpenRouter verifies 2026-07-02), client
route directe-ou-OpenRouter, policy auto/best/cheap/manual (pre-classifieur de
difficulte + escalade sur echec harnais, plafond MAX_ESCALADES), prix, et
recommended.json issu du banc reel (51 cellules role x modele x exo) :
generate/audit best=deepseek-v4-pro, mecanique best=mistral-small.
- claude-fable-5 retire volontairement de tout le code (catalog/config/UI/docs).
- UI : selecteur de politique + dropdowns par role (mode manuel), telemetrie
de routage (modele gagnant, echelons, difficulte, revue humaine).
- Durcissements : reponse LLM vide (content:null) -> retry puis erreur propre ;
harnais time-boxe par graine (10s, exec+rendu+scans, abandon apres 3
timeouts) ; escalade BaseException contre les try/except avaleurs ; cout
expose honnete (analyse partagee + echelons perdants inclus).
- Non-regression : pythonise (Exercice_1) et QAT verts a 300 graines.
- Smoke 61/61.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- README_APP.md +39 -2
- app/config.py +36 -14
- app/llm/client.py +21 -8
- app/models/__init__.py +0 -0
- app/models/catalog.py +96 -0
- app/models/client.py +135 -0
- app/models/policy.py +155 -0
- app/models/prices.json +22 -0
- app/models/recommended.json +44 -0
- app/pipeline/analyze.py +5 -1
- app/pipeline/audit.py +2 -0
- app/pipeline/generate.py +3 -0
- app/pipeline/orchestrator.py +118 -13
- app/pipeline/solutions.py +4 -1
- app/pipeline/translate.py +2 -0
- app/server.py +42 -8
- app/validation/harness.py +34 -2
- app/validation/sandbox.py +36 -15
- app/web/templates/index.html +64 -15
- tests/nonreg_pythonise.py +45 -0
- tests/nonreg_qat.py +38 -0
- tests/smoke.py +98 -1
|
@@ -94,6 +94,42 @@ Résultat par fichier : `exercise`, `analysis`, `notions`, `audit_patches`,
|
|
| 94 |
écraser la source) ; en **batch**, bouton **« Tout (.zip) »** → ZIP de toutes
|
| 95 |
les sorties + un `_recapitulatif.md` (verdict harnais / warnings / coût par fichier).
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
## Sécurité
|
| 98 |
|
| 99 |
🚨 **Les clés API dans `.env` étaient en clair dans le zip d'origine** —
|
|
@@ -108,8 +144,9 @@ modèle de menace est l'accident LLM, pas un adversaire).
|
|
| 108 |
## Notes
|
| 109 |
|
| 110 |
- Premier lancement : reconstruit le cache FAISS si absent (≈ 30 s).
|
| 111 |
-
- Modèles (IDs vérifiés sur OpenRouter 2026-
|
| 112 |
-
(défaut)**,
|
|
|
|
| 113 |
- L'étape d'analyse suit le modèle choisi par l'utilisateur
|
| 114 |
(`ANALYSIS_MODEL_IDX=None` dans config.py pour ce comportement).
|
| 115 |
- Mode batch : fichiers traités séquentiellement ; un échec n'arrête pas les
|
|
|
|
| 94 |
écraser la source) ; en **batch**, bouton **« Tout (.zip) »** → ZIP de toutes
|
| 95 |
les sorties + un `_recapitulatif.md` (verdict harnais / warnings / coût par fichier).
|
| 96 |
|
| 97 |
+
## Politique de modèles & banc d'essai (2026-07)
|
| 98 |
+
|
| 99 |
+
**4 politiques** (`policy` dans `POST /api/jobs`, sélecteur dans l'UI) :
|
| 100 |
+
`auto` (défaut — pré-classifieur de difficulté, départ sur l'échelle par coût
|
| 101 |
+
croissant, escalade d'un échelon à chaque échec harnais, plafonnée `best`) ·
|
| 102 |
+
`best` (qualité max) · `cheap` (le moins cher qui tient `SEUIL_VERT=0.90`) ·
|
| 103 |
+
`manual` (IDs explicites par rôle : `models {generate, audit, mecanique}`).
|
| 104 |
+
Rôles : `generate` (+réparation), `audit`, `mecanique` (analyse, traduction,
|
| 105 |
+
substitutions). Télémétrie de l'échelon gagnant dans `result.policy_telemetry`.
|
| 106 |
+
**`claude-fable-5` est retiré de toutes les listes (choix volontaire).**
|
| 107 |
+
|
| 108 |
+
**Clés API** : tout fonctionne avec la seule `OPENROUTER_API_KEY` (route de
|
| 109 |
+
repli universelle). Clés directes optionnelles par fournisseur :
|
| 110 |
+
`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`,
|
| 111 |
+
`DEEPSEEK_API_KEY`, `MOONSHOT_API_KEY`, `ZAI_API_KEY`, `MINIMAX_API_KEY`,
|
| 112 |
+
`MISTRAL_API_KEY` — un modèle sans aucune clé est « non testé », jamais bloquant.
|
| 113 |
+
|
| 114 |
+
**Banc d'essai** :
|
| 115 |
+
```bash
|
| 116 |
+
python -m bench run # rôles/modèles dispo, corpus échantillonné
|
| 117 |
+
python -m bench run --roles generate --models claude-sonnet-5,deepseek-v4-pro
|
| 118 |
+
python -m bench run --exos all --types both --seuil 0.92
|
| 119 |
+
python -m bench run --dry-run # plomberie mockée (CI, aucune clé)
|
| 120 |
+
python -m bench consolidate # fusionne les runs partiels/parallèles
|
| 121 |
+
```
|
| 122 |
+
Sorties : `bench/results/<ts>.json|.csv|_rapport.md` + mise à jour de
|
| 123 |
+
`app/models/recommended.json` (protégé par `manual_override: true`).
|
| 124 |
+
Un run filtré `--models` n'écrit jamais recommended.json ; `consolidate`
|
| 125 |
+
fusionne tous les résultats (la cellule la plus récente gagne, restreinte
|
| 126 |
+
à l'échantillon courant du rôle) puis réécrit la recommandation globale.
|
| 127 |
+
Plusieurs bancs peuvent tourner en parallèle (un processus = un compteur
|
| 128 |
+
de coûts isolé) ; ne pas lancer d'autre job LLM dans le même processus.
|
| 129 |
+
Corpus : `bench/corpus/*.md` (ajouter un exercice = y déposer un `.md`).
|
| 130 |
+
Prix : `app/models/prices.json` — **à re-vérifier avant prod, ça change
|
| 131 |
+
chaque semaine**.
|
| 132 |
+
|
| 133 |
## Sécurité
|
| 134 |
|
| 135 |
🚨 **Les clés API dans `.env` étaient en clair dans le zip d'origine** —
|
|
|
|
| 144 |
## Notes
|
| 145 |
|
| 146 |
- Premier lancement : reconstruit le cache FAISS si absent (≈ 30 s).
|
| 147 |
+
- Modèles (IDs vérifiés sur OpenRouter 2026-07-02) : Opus 4.8, **Sonnet 5
|
| 148 |
+
(défaut)**, Haiku 4.5, Gemini 2.5 Pro, GPT-5.4 — catalogue complet (16
|
| 149 |
+
modèles) dans `app/models/catalog.py`. Fable 5 retiré volontairement.
|
| 150 |
- L'étape d'analyse suit le modèle choisi par l'utilisateur
|
| 151 |
(`ANALYSIS_MODEL_IDX=None` dans config.py pour ce comportement).
|
| 152 |
- Mode batch : fichiers traités séquentiellement ; un échec n'arrête pas les
|
|
@@ -25,16 +25,16 @@ TEMPLATES_DIR = PACKAGE_DIR / "web" / "templates"
|
|
| 25 |
PYTHON_FENCE_BACKTICKS = 4
|
| 26 |
EXERCISE_FENCE_BACKTICKS = 5
|
| 27 |
|
| 28 |
-
# ── Modèles LLM (IDs vérifiés sur l'API OpenRouter le 2026-
|
|
|
|
| 29 |
AVAILABLE_MODELS = {
|
| 30 |
0: "anthropic/claude-opus-4.8",
|
| 31 |
-
1: "anthropic/claude-sonnet-
|
| 32 |
-
2: "anthropic/claude-
|
| 33 |
-
3: "
|
| 34 |
-
4: "
|
| 35 |
-
5: "openai/gpt-5.2",
|
| 36 |
}
|
| 37 |
-
DEFAULT_MODEL_IDX = 1 # claude-sonnet-
|
| 38 |
|
| 39 |
# Modèle de l'étape d'analyse : None = suivre le modèle choisi par l'utilisateur
|
| 40 |
# (corrige le model_idx=2 codé en dur de l'ancienne version) ; un int force un
|
|
@@ -47,16 +47,38 @@ ANALYSIS_MODEL_IDX: int | None = None
|
|
| 47 |
NOTIONS_MODEL = "openai/gpt-5-mini"
|
| 48 |
|
| 49 |
# Prix $/M tokens (fallback si l'API generation ne renvoie pas le coût réel).
|
| 50 |
-
# Relevés sur openrouter.ai le 2026-
|
|
|
|
| 51 |
MODEL_PRICING = {
|
| 52 |
-
"anthropic/claude-opus-4.8": {"input": 5.0,
|
| 53 |
-
"anthropic/claude-sonnet-
|
| 54 |
-
"anthropic/claude-
|
| 55 |
-
"
|
| 56 |
-
"google/gemini-2.5-
|
| 57 |
-
"openai/gpt-5.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
# ── Pipeline ─────────────────────────────────────────────────────────────────
|
| 61 |
RAG_TOP_K = 10 # catalogue RAG (était 3 — trop étroit)
|
| 62 |
RAG_EMBEDDING_MODEL = "openai-3-small"
|
|
|
|
| 25 |
PYTHON_FENCE_BACKTICKS = 4
|
| 26 |
EXERCISE_FENCE_BACKTICKS = 5
|
| 27 |
|
| 28 |
+
# ── Modèles LLM (IDs vérifiés sur l'API OpenRouter le 2026-07-02) ────────────
|
| 29 |
+
# NOTE : claude-fable-5 retiré volontairement (§7 du prompt banc multi-modèles).
|
| 30 |
AVAILABLE_MODELS = {
|
| 31 |
0: "anthropic/claude-opus-4.8",
|
| 32 |
+
1: "anthropic/claude-sonnet-5",
|
| 33 |
+
2: "anthropic/claude-haiku-4.5",
|
| 34 |
+
3: "google/gemini-2.5-pro",
|
| 35 |
+
4: "openai/gpt-5.4",
|
|
|
|
| 36 |
}
|
| 37 |
+
DEFAULT_MODEL_IDX = 1 # claude-sonnet-5
|
| 38 |
|
| 39 |
# Modèle de l'étape d'analyse : None = suivre le modèle choisi par l'utilisateur
|
| 40 |
# (corrige le model_idx=2 codé en dur de l'ancienne version) ; un int force un
|
|
|
|
| 47 |
NOTIONS_MODEL = "openai/gpt-5-mini"
|
| 48 |
|
| 49 |
# Prix $/M tokens (fallback si l'API generation ne renvoie pas le coût réel).
|
| 50 |
+
# Relevés sur openrouter.ai le 2026-07-02. Source détaillée (cache/batch) :
|
| 51 |
+
# app/models/prices.json — à re-vérifier avant prod, ça bouge chaque semaine.
|
| 52 |
MODEL_PRICING = {
|
| 53 |
+
"anthropic/claude-opus-4.8": {"input": 5.0, "output": 25.0},
|
| 54 |
+
"anthropic/claude-sonnet-5": {"input": 2.0, "output": 10.0},
|
| 55 |
+
"anthropic/claude-haiku-4.5": {"input": 1.0, "output": 5.0},
|
| 56 |
+
"google/gemini-2.5-pro": {"input": 1.25, "output": 10.0},
|
| 57 |
+
"google/gemini-2.5-flash": {"input": 0.3, "output": 2.5},
|
| 58 |
+
"openai/gpt-5.4": {"input": 2.5, "output": 15.0},
|
| 59 |
+
"openai/gpt-5.4-nano": {"input": 0.2, "output": 1.25},
|
| 60 |
+
"x-ai/grok-4.3": {"input": 1.25, "output": 2.5},
|
| 61 |
+
"moonshotai/kimi-k2.6": {"input": 0.55, "output": 3.2},
|
| 62 |
+
"z-ai/glm-5.2": {"input": 0.93, "output": 3.0},
|
| 63 |
+
"z-ai/glm-4.7-flash": {"input": 0.06, "output": 0.4},
|
| 64 |
+
"deepseek/deepseek-v4-pro": {"input": 0.435, "output": 0.87},
|
| 65 |
+
"deepseek/deepseek-v4-flash": {"input": 0.089, "output": 0.18},
|
| 66 |
+
"mistralai/mistral-large-2512": {"input": 0.5, "output": 1.5},
|
| 67 |
+
"mistralai/mistral-small-3.2-24b-instruct": {"input": 0.075, "output": 0.2},
|
| 68 |
+
"minimax/minimax-m3": {"input": 0.3, "output": 1.2},
|
| 69 |
}
|
| 70 |
|
| 71 |
+
# ── Politique de sélection de modèle (banc multi-modèles, §5) ────────────────
|
| 72 |
+
DEFAULT_POLICY = "auto" # auto | best | cheap | manual
|
| 73 |
+
SEUIL_VERT = 0.90 # taux VERT minimal pour qu'un modèle « tienne »
|
| 74 |
+
MAX_ESCALADES = 3 # plafond d'échelons gravis en mode auto
|
| 75 |
+
PRICES_PATH = PACKAGE_DIR / "models" / "prices.json"
|
| 76 |
+
RECOMMENDED_PATH = PACKAGE_DIR / "models" / "recommended.json"
|
| 77 |
+
# Choix explicites du mode `manual` (clés du catalogue app/models/catalog.py).
|
| 78 |
+
MODEL_GENERATE = "claude-sonnet-5"
|
| 79 |
+
MODEL_AUDIT = "claude-opus-4-8"
|
| 80 |
+
MODEL_MECANIQUE = "claude-haiku-4-5"
|
| 81 |
+
|
| 82 |
# ── Pipeline ─────────────────────────────────────────────────────────────────
|
| 83 |
RAG_TOP_K = 10 # catalogue RAG (était 3 — trop étroit)
|
| 84 |
RAG_EMBEDDING_MODEL = "openai-3-small"
|
|
@@ -47,11 +47,13 @@ class LLMClient:
|
|
| 47 |
self._cost_tracker = get_cost_tracker()
|
| 48 |
return self._cost_tracker
|
| 49 |
|
| 50 |
-
def call_llm(self, prompt: str, model_idx: int, temperature: float = 0.0,
|
| 51 |
max_tokens: int = 4096, system_prompt: str = SYSTEM_MSG,
|
| 52 |
-
reasoning: bool = False) -> str:
|
| 53 |
-
"""Appel LLM standard avec gestion des erreurs et retry.
|
| 54 |
-
model
|
|
|
|
|
|
|
| 55 |
if model is None:
|
| 56 |
raise ValueError(f"model_idx {model_idx} inexistant dans AVAILABLE_MODELS")
|
| 57 |
|
|
@@ -132,6 +134,14 @@ class LLMClient:
|
|
| 132 |
raise RuntimeError(f"API error : {data['error']}")
|
| 133 |
|
| 134 |
content = data["choices"][0]["message"]["content"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
generation_id = data.get("id")
|
| 136 |
return content, generation_id
|
| 137 |
|
|
@@ -155,12 +165,15 @@ def get_llm_client() -> LLMClient:
|
|
| 155 |
return _llm_client
|
| 156 |
|
| 157 |
|
| 158 |
-
def process_with_openrouter(prompt: str, model_idx: int, temperature: float = 0.0,
|
| 159 |
max_tokens: int = 4096, image_b64: str = None,
|
| 160 |
-
system_prompt: str = SYSTEM_MSG, reasoning: bool = False
|
| 161 |
-
|
|
|
|
|
|
|
| 162 |
if image_b64:
|
| 163 |
return get_llm_client().call_llm_multimodal(
|
| 164 |
image_b64, prompt, model_idx, temperature, max_tokens, system_prompt)
|
| 165 |
return get_llm_client().call_llm(
|
| 166 |
-
prompt, model_idx, temperature, max_tokens, system_prompt,
|
|
|
|
|
|
| 47 |
self._cost_tracker = get_cost_tracker()
|
| 48 |
return self._cost_tracker
|
| 49 |
|
| 50 |
+
def call_llm(self, prompt: str, model_idx: int = 0, temperature: float = 0.0,
|
| 51 |
max_tokens: int = 4096, system_prompt: str = SYSTEM_MSG,
|
| 52 |
+
reasoning: bool = False, model: str | None = None) -> str:
|
| 53 |
+
"""Appel LLM standard avec gestion des erreurs et retry.
|
| 54 |
+
`model` (ID OpenRouter en chaîne, ex. "anthropic/claude-sonnet-5")
|
| 55 |
+
court-circuite `model_idx` — c'est la voie de la policy par rôle."""
|
| 56 |
+
model = model or AVAILABLE_MODELS.get(model_idx)
|
| 57 |
if model is None:
|
| 58 |
raise ValueError(f"model_idx {model_idx} inexistant dans AVAILABLE_MODELS")
|
| 59 |
|
|
|
|
| 134 |
raise RuntimeError(f"API error : {data['error']}")
|
| 135 |
|
| 136 |
content = data["choices"][0]["message"]["content"]
|
| 137 |
+
# Certains fournisseurs renvoient content:null (réponse vide,
|
| 138 |
+
# reasoning seul…) : jamais exploitable en aval → retry, puis
|
| 139 |
+
# RuntimeError propre (gérée par les appelants) au lieu d'un
|
| 140 |
+
# AttributeError sur .strip().
|
| 141 |
+
if not isinstance(content, str) or not content.strip():
|
| 142 |
+
raise RuntimeError(
|
| 143 |
+
f"Réponse vide du modèle {payload['model']} "
|
| 144 |
+
f"(content={content!r})")
|
| 145 |
generation_id = data.get("id")
|
| 146 |
return content, generation_id
|
| 147 |
|
|
|
|
| 165 |
return _llm_client
|
| 166 |
|
| 167 |
|
| 168 |
+
def process_with_openrouter(prompt: str, model_idx: int = 0, temperature: float = 0.0,
|
| 169 |
max_tokens: int = 4096, image_b64: str = None,
|
| 170 |
+
system_prompt: str = SYSTEM_MSG, reasoning: bool = False,
|
| 171 |
+
model: str | None = None) -> str:
|
| 172 |
+
"""Point d'entrée unique du pipeline pour tous les appels LLM.
|
| 173 |
+
`model` (chaîne) prime sur `model_idx` (legacy)."""
|
| 174 |
if image_b64:
|
| 175 |
return get_llm_client().call_llm_multimodal(
|
| 176 |
image_b64, prompt, model_idx, temperature, max_tokens, system_prompt)
|
| 177 |
return get_llm_client().call_llm(
|
| 178 |
+
prompt, model_idx, temperature, max_tokens, system_prompt,
|
| 179 |
+
reasoning=reasoning, model=model)
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
catalog.py — catalogue des modèles candidats par rôle. SANS Fable
|
| 3 |
+
(claude-fable-5 retiré volontairement de toute liste utilisable — §7).
|
| 4 |
+
|
| 5 |
+
Chaque entrée décrit sa route DIRECTE fournisseur (api_format anthropic|openai,
|
| 6 |
+
base_url, api_key_env) ET sa route de repli OpenRouter (openrouter_id). La
|
| 7 |
+
résolution effective (app/models/client.py) prend la clé directe si présente,
|
| 8 |
+
sinon OPENROUTER_API_KEY — un modèle sans aucune clé est « non testé ».
|
| 9 |
+
|
| 10 |
+
⚠️ IDs vérifiés sur l'API OpenRouter le 2026-07-02. Écarts vs la spec §3.2 :
|
| 11 |
+
• gemini-3-1-pro / gemini-3-flash absents → mappés sur gemini-2.5-pro/flash ;
|
| 12 |
+
• magistral-medium indisponible sur OpenRouter → retiré des candidats audit ;
|
| 13 |
+
• grok-4-1-fast inexistant → retiré des candidats mécanique.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
ROLES = ("generate", "audit", "mecanique")
|
| 17 |
+
|
| 18 |
+
_ANTHROPIC = dict(provider="anthropic", api_format="anthropic",
|
| 19 |
+
base_url="https://api.anthropic.com/v1",
|
| 20 |
+
api_key_env="ANTHROPIC_API_KEY")
|
| 21 |
+
_OPENAI = dict(provider="openai", api_format="openai",
|
| 22 |
+
base_url="https://api.openai.com/v1",
|
| 23 |
+
api_key_env="OPENAI_API_KEY")
|
| 24 |
+
_GEMINI = dict(provider="google", api_format="openai",
|
| 25 |
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
| 26 |
+
api_key_env="GEMINI_API_KEY")
|
| 27 |
+
_XAI = dict(provider="x-ai", api_format="openai",
|
| 28 |
+
base_url="https://api.x.ai/v1", api_key_env="XAI_API_KEY")
|
| 29 |
+
_DEEPSEEK = dict(provider="deepseek", api_format="openai",
|
| 30 |
+
base_url="https://api.deepseek.com/v1",
|
| 31 |
+
api_key_env="DEEPSEEK_API_KEY")
|
| 32 |
+
_MOONSHOT = dict(provider="moonshot", api_format="anthropic",
|
| 33 |
+
base_url="https://api.moonshot.ai/anthropic",
|
| 34 |
+
api_key_env="MOONSHOT_API_KEY")
|
| 35 |
+
_ZAI = dict(provider="z-ai", api_format="openai",
|
| 36 |
+
base_url="https://api.z.ai/api/paas/v4",
|
| 37 |
+
api_key_env="ZAI_API_KEY")
|
| 38 |
+
_MISTRAL = dict(provider="mistral", api_format="openai",
|
| 39 |
+
base_url="https://api.mistral.ai/v1",
|
| 40 |
+
api_key_env="MISTRAL_API_KEY")
|
| 41 |
+
_MINIMAX = dict(provider="minimax", api_format="openai",
|
| 42 |
+
base_url="https://api.minimax.io/v1",
|
| 43 |
+
api_key_env="MINIMAX_API_KEY")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _m(key, base, direct_id, openrouter_id, roles, cache=True, batch=True):
|
| 47 |
+
return key, {**base, "model_id": direct_id, "openrouter_id": openrouter_id,
|
| 48 |
+
"roles": tuple(roles), "supports_cache": cache, "supports_batch": batch}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
CATALOG: dict = dict([
|
| 52 |
+
# ── GENERATE (frontière + quasi-frontière) ───────────────────────────────
|
| 53 |
+
_m("claude-sonnet-5", _ANTHROPIC, "claude-sonnet-5",
|
| 54 |
+
"anthropic/claude-sonnet-5", ("generate",)),
|
| 55 |
+
_m("claude-opus-4-8", _ANTHROPIC, "claude-opus-4-8",
|
| 56 |
+
"anthropic/claude-opus-4.8", ("generate", "audit")),
|
| 57 |
+
_m("gpt-5-4", _OPENAI, "gpt-5.4", "openai/gpt-5.4", ("generate",)),
|
| 58 |
+
_m("gemini-3-1-pro", _GEMINI, "gemini-2.5-pro", # mappé (3.1 absent)
|
| 59 |
+
"google/gemini-2.5-pro", ("generate", "audit")),
|
| 60 |
+
_m("grok-4-3", _XAI, "grok-4.3", "x-ai/grok-4.3", ("generate", "audit")),
|
| 61 |
+
_m("kimi-k2-6", _MOONSHOT, "kimi-k2.6",
|
| 62 |
+
"moonshotai/kimi-k2.6", ("generate", "audit")),
|
| 63 |
+
_m("glm-5-2", _ZAI, "glm-5.2", "z-ai/glm-5.2", ("generate",)),
|
| 64 |
+
_m("deepseek-v4-pro", _DEEPSEEK, "deepseek-v4-pro",
|
| 65 |
+
"deepseek/deepseek-v4-pro", ("generate", "audit")),
|
| 66 |
+
_m("mistral-large-3", _MISTRAL, "mistral-large-2512",
|
| 67 |
+
"mistralai/mistral-large-2512", ("generate",)),
|
| 68 |
+
# ── MECANIQUE (rapide / bon marché) ──────────────────────────────────────
|
| 69 |
+
_m("claude-haiku-4-5", _ANTHROPIC, "claude-haiku-4-5",
|
| 70 |
+
"anthropic/claude-haiku-4.5", ("mecanique",)),
|
| 71 |
+
_m("mistral-small", _MISTRAL, "mistral-small-3.2",
|
| 72 |
+
"mistralai/mistral-small-3.2-24b-instruct", ("mecanique",), cache=False),
|
| 73 |
+
_m("deepseek-v4-flash", _DEEPSEEK, "deepseek-v4-flash",
|
| 74 |
+
"deepseek/deepseek-v4-flash", ("mecanique",)),
|
| 75 |
+
_m("glm-4-7-flash", _ZAI, "glm-4.7-flash",
|
| 76 |
+
"z-ai/glm-4.7-flash", ("mecanique",)),
|
| 77 |
+
_m("gemini-3-flash", _GEMINI, "gemini-2.5-flash", # mappé (3-flash absent)
|
| 78 |
+
"google/gemini-2.5-flash", ("mecanique",)),
|
| 79 |
+
_m("gpt-5-4-nano", _OPENAI, "gpt-5.4-nano",
|
| 80 |
+
"openai/gpt-5.4-nano", ("mecanique",)),
|
| 81 |
+
# minimax-m3 : catalogue §3.3, candidat d'appoint générique.
|
| 82 |
+
_m("minimax-m3", _MINIMAX, "minimax-m3",
|
| 83 |
+
"minimax/minimax-m3", ("mecanique",), cache=False),
|
| 84 |
+
])
|
| 85 |
+
|
| 86 |
+
# Candidats par rôle (ordre indicatif ; le banc trie par ses mesures).
|
| 87 |
+
CANDIDATES = {
|
| 88 |
+
role: [k for k, v in CATALOG.items() if role in v["roles"]]
|
| 89 |
+
for role in ROLES
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def model_info(key: str) -> dict:
|
| 94 |
+
if key not in CATALOG:
|
| 95 |
+
raise KeyError(f"Modèle inconnu au catalogue : {key!r}")
|
| 96 |
+
return CATALOG[key]
|
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
client.py — adaptateur fournisseur unifié (§3.1).
|
| 3 |
+
|
| 4 |
+
Une seule interface :
|
| 5 |
+
ModelClient().complete(system, messages, model_key, **opts)
|
| 6 |
+
-> {"text", "usage_in", "usage_out", "latency_ms", "route"}
|
| 7 |
+
|
| 8 |
+
Dispatch par entrée de catalogue : {api_format ∈ {anthropic, openai}, base_url,
|
| 9 |
+
api_key_env, model_id, openrouter_id}. Résolution de route :
|
| 10 |
+
1. clé DIRECTE du fournisseur présente (api_key_env) → endpoint natif ;
|
| 11 |
+
2. sinon OPENROUTER_API_KEY présente → route OpenRouter (format openai) ;
|
| 12 |
+
3. sinon MissingKeyError — le banc marque « non testé (clé absente) »,
|
| 13 |
+
jamais une erreur bloquante.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
import random
|
| 21 |
+
import time
|
| 22 |
+
|
| 23 |
+
import requests
|
| 24 |
+
|
| 25 |
+
from app.models.catalog import model_info
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class MissingKeyError(RuntimeError):
|
| 33 |
+
"""Aucune clé disponible pour ce modèle (direct + OpenRouter absents)."""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def resolve_route(model_key: str) -> dict:
|
| 37 |
+
"""Route effective pour un modèle : direct si sa clé est là, sinon
|
| 38 |
+
OpenRouter. Lève MissingKeyError si aucune clé."""
|
| 39 |
+
info = model_info(model_key)
|
| 40 |
+
direct_key = os.getenv(info["api_key_env"], "")
|
| 41 |
+
if direct_key:
|
| 42 |
+
return {"kind": "direct", "api_format": info["api_format"],
|
| 43 |
+
"base_url": info["base_url"], "model": info["model_id"],
|
| 44 |
+
"key": direct_key}
|
| 45 |
+
or_key = os.getenv("OPENROUTER_API_KEY", "")
|
| 46 |
+
if or_key:
|
| 47 |
+
return {"kind": "openrouter", "api_format": "openai",
|
| 48 |
+
"base_url": OPENROUTER_URL, "model": info["openrouter_id"],
|
| 49 |
+
"key": or_key}
|
| 50 |
+
raise MissingKeyError(
|
| 51 |
+
f"{model_key}: ni {info['api_key_env']} ni OPENROUTER_API_KEY présente")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def is_available(model_key: str) -> bool:
|
| 55 |
+
try:
|
| 56 |
+
resolve_route(model_key)
|
| 57 |
+
return True
|
| 58 |
+
except (MissingKeyError, KeyError):
|
| 59 |
+
return False
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ModelClient:
|
| 63 |
+
"""Client fin multi-fournisseurs avec retries (backoff + jitter, 429)."""
|
| 64 |
+
|
| 65 |
+
def __init__(self, max_retries: int = 3, timeout: int = 180):
|
| 66 |
+
self.max_retries = max_retries
|
| 67 |
+
self.timeout = timeout
|
| 68 |
+
|
| 69 |
+
def complete(self, system: str, messages: list, model_key: str,
|
| 70 |
+
temperature: float = 0.0, max_tokens: int = 4096) -> dict:
|
| 71 |
+
route = resolve_route(model_key)
|
| 72 |
+
t0 = time.time()
|
| 73 |
+
if route["api_format"] == "anthropic" and route["kind"] == "direct":
|
| 74 |
+
out = self._anthropic(route, system, messages, temperature, max_tokens)
|
| 75 |
+
else:
|
| 76 |
+
out = self._openai(route, system, messages, temperature, max_tokens)
|
| 77 |
+
out["latency_ms"] = int((time.time() - t0) * 1000)
|
| 78 |
+
out["route"] = route["kind"]
|
| 79 |
+
return out
|
| 80 |
+
|
| 81 |
+
# ── formats ──────────────────────────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
def _anthropic(self, route, system, messages, temperature, max_tokens) -> dict:
|
| 84 |
+
url = route["base_url"].rstrip("/") + "/messages"
|
| 85 |
+
headers = {"x-api-key": route["key"],
|
| 86 |
+
"anthropic-version": "2023-06-01",
|
| 87 |
+
"content-type": "application/json"}
|
| 88 |
+
payload = {"model": route["model"], "system": system,
|
| 89 |
+
"messages": messages, "temperature": temperature,
|
| 90 |
+
"max_tokens": max_tokens}
|
| 91 |
+
data = self._post(url, headers, payload)
|
| 92 |
+
text = "".join(b.get("text", "") for b in data.get("content", [])
|
| 93 |
+
if b.get("type") == "text")
|
| 94 |
+
usage = data.get("usage", {})
|
| 95 |
+
return {"text": text,
|
| 96 |
+
"usage_in": usage.get("input_tokens", 0),
|
| 97 |
+
"usage_out": usage.get("output_tokens", 0)}
|
| 98 |
+
|
| 99 |
+
def _openai(self, route, system, messages, temperature, max_tokens) -> dict:
|
| 100 |
+
url = (route["base_url"] if route["kind"] == "openrouter"
|
| 101 |
+
else route["base_url"].rstrip("/") + "/chat/completions")
|
| 102 |
+
headers = {"Authorization": f"Bearer {route['key']}",
|
| 103 |
+
"Content-Type": "application/json"}
|
| 104 |
+
payload = {"model": route["model"],
|
| 105 |
+
"messages": [{"role": "system", "content": system}] + messages,
|
| 106 |
+
"temperature": temperature, "max_tokens": max_tokens}
|
| 107 |
+
data = self._post(url, headers, payload)
|
| 108 |
+
text = data["choices"][0]["message"]["content"]
|
| 109 |
+
usage = data.get("usage", {})
|
| 110 |
+
return {"text": text,
|
| 111 |
+
"usage_in": usage.get("prompt_tokens", 0),
|
| 112 |
+
"usage_out": usage.get("completion_tokens", 0)}
|
| 113 |
+
|
| 114 |
+
def _post(self, url, headers, payload) -> dict:
|
| 115 |
+
for attempt in range(self.max_retries):
|
| 116 |
+
try:
|
| 117 |
+
resp = requests.post(url, json=payload, headers=headers,
|
| 118 |
+
timeout=self.timeout)
|
| 119 |
+
if resp.status_code == 429:
|
| 120 |
+
wait = 2 * (2 ** attempt) + random.uniform(0, 4)
|
| 121 |
+
logger.warning("429 sur %s — retry dans %.1fs", url, wait)
|
| 122 |
+
time.sleep(wait)
|
| 123 |
+
continue
|
| 124 |
+
resp.raise_for_status()
|
| 125 |
+
data = resp.json()
|
| 126 |
+
if "error" in data:
|
| 127 |
+
raise RuntimeError(f"API error: {data['error']}")
|
| 128 |
+
return data
|
| 129 |
+
except (requests.RequestException, RuntimeError) as e:
|
| 130 |
+
if attempt == self.max_retries - 1:
|
| 131 |
+
raise
|
| 132 |
+
wait = 2 * (2 ** attempt) + random.uniform(0, 4)
|
| 133 |
+
logger.warning("Erreur %s — retry dans %.1fs", e, wait)
|
| 134 |
+
time.sleep(wait)
|
| 135 |
+
raise RuntimeError("Échec après retries")
|
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
policy.py — politique de sélection de modèle par rôle (§5).
|
| 3 |
+
|
| 4 |
+
Modes :
|
| 5 |
+
auto (défaut) : échelle ordonnée par coût croissant des modèles qui
|
| 6 |
+
tiennent SEUIL_VERT, plafonnée par `best`. Départ choisi
|
| 7 |
+
par un pré-classifieur de difficulté ; escalade d'un
|
| 8 |
+
échelon à chaque échec harnais après réparations.
|
| 9 |
+
best : modèle `best` du rôle (qualité max, coût ignoré).
|
| 10 |
+
cheap : modèle `cheap` (le moins cher qui tient le seuil).
|
| 11 |
+
manual : IDs explicites par rôle (config ou requête API).
|
| 12 |
+
|
| 13 |
+
Alimentation : app/models/recommended.json (écrit par le banc). Fallback si
|
| 14 |
+
absent : routage par défaut ci-dessous — SANS Fable (retiré volontairement).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import re
|
| 22 |
+
|
| 23 |
+
from app.config import MODEL_AUDIT, MODEL_GENERATE, MODEL_MECANIQUE, RECOMMENDED_PATH
|
| 24 |
+
from app.models.catalog import CATALOG, model_info
|
| 25 |
+
from app.models.client import is_available
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
POLICIES = ("auto", "best", "cheap", "manual")
|
| 30 |
+
|
| 31 |
+
# Fallback §5.2 (si recommended.json absent) — generate = Sonnet 5 avec
|
| 32 |
+
# escalade Opus 4.8 ; audit = Opus 4.8 ; mécanique = Haiku 4.5. Fable exclu.
|
| 33 |
+
DEFAULT_RECOMMENDED = {
|
| 34 |
+
"generate": {"best": "claude-opus-4-8", "cheap": "claude-sonnet-5",
|
| 35 |
+
"ladder": ["claude-sonnet-5", "claude-opus-4-8"]},
|
| 36 |
+
"audit": {"best": "claude-opus-4-8", "cheap": "claude-opus-4-8",
|
| 37 |
+
"ladder": ["claude-opus-4-8"]},
|
| 38 |
+
"mecanique": {"best": "claude-haiku-4-5", "cheap": "claude-haiku-4-5",
|
| 39 |
+
"ladder": ["claude-haiku-4-5"]},
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_recommended() -> dict:
|
| 44 |
+
"""recommended.json s'il existe (produit par le banc), sinon fallback."""
|
| 45 |
+
try:
|
| 46 |
+
data = json.loads(RECOMMENDED_PATH.read_text(encoding="utf-8"))
|
| 47 |
+
roles = data.get("roles") or {}
|
| 48 |
+
except (OSError, json.JSONDecodeError, AttributeError, TypeError):
|
| 49 |
+
return {k: dict(v) for k, v in DEFAULT_RECOMMENDED.items()}
|
| 50 |
+
try:
|
| 51 |
+
merged = {}
|
| 52 |
+
for role, fb in DEFAULT_RECOMMENDED.items():
|
| 53 |
+
r = roles.get(role) or {}
|
| 54 |
+
merged[role] = {
|
| 55 |
+
"best": r.get("best") or fb["best"],
|
| 56 |
+
"cheap": r.get("cheap") or fb["cheap"],
|
| 57 |
+
"ladder": r.get("ladder") or fb["ladder"],
|
| 58 |
+
}
|
| 59 |
+
return merged
|
| 60 |
+
except (OSError, json.JSONDecodeError):
|
| 61 |
+
return {k: dict(v) for k, v in DEFAULT_RECOMMENDED.items()}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _usable(keys: list) -> list:
|
| 65 |
+
"""Filtre : au catalogue (sans Fable, par construction) ET clé dispo."""
|
| 66 |
+
out = []
|
| 67 |
+
for k in keys:
|
| 68 |
+
if k not in CATALOG:
|
| 69 |
+
logger.warning("Modèle recommandé hors catalogue (ignoré) : %s", k)
|
| 70 |
+
continue
|
| 71 |
+
if not is_available(k):
|
| 72 |
+
logger.warning("Modèle sans clé disponible (ignoré) : %s", k)
|
| 73 |
+
continue
|
| 74 |
+
out.append(k)
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def ladder(role: str) -> list:
|
| 79 |
+
"""Échelle `auto` du rôle, coût croissant. Le plafonnement vit dans la
|
| 80 |
+
DÉCISION du banc (report.decide) : l'échelle écrite dans recommended.json
|
| 81 |
+
fait foi telle quelle — elle peut dépasser `best` quand des modèles de
|
| 82 |
+
qualité ÉGALE existent (redondance légitime : si l'échelon `best` échoue
|
| 83 |
+
sur UN exercice, un pair de même taux VERT peut réussir)."""
|
| 84 |
+
rec = load_recommended()[role]
|
| 85 |
+
keys = _usable(list(dict.fromkeys(rec["ladder"] + [rec["best"]])))
|
| 86 |
+
if not keys:
|
| 87 |
+
# Fallback élargi : tout candidat du rôle avec une clé disponible.
|
| 88 |
+
from app.models.catalog import CANDIDATES
|
| 89 |
+
keys = _usable(CANDIDATES[role])
|
| 90 |
+
if not keys:
|
| 91 |
+
raise RuntimeError(
|
| 92 |
+
f"Aucun modèle utilisable pour le rôle {role!r} : "
|
| 93 |
+
"aucune clé API disponible (OPENROUTER_API_KEY ou clé fournisseur).")
|
| 94 |
+
return keys
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ── Pré-classifieur de difficulté (heuristique légère, zéro LLM) ─────────────
|
| 98 |
+
|
| 99 |
+
_HARD_HINTS = re.compile(
|
| 100 |
+
r"Matrix|matrice|bmatrix|pmatrix|jacobien|système|systeme|"
|
| 101 |
+
r"int[ée]gra|\bipp\b|r[ée]currence|dimension", re.IGNORECASE)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def classify_difficulty(content: str) -> str:
|
| 105 |
+
score = 0
|
| 106 |
+
if content.count(":::::{question}") >= 5:
|
| 107 |
+
score += 1
|
| 108 |
+
if len(content) > 6000:
|
| 109 |
+
score += 1
|
| 110 |
+
if _HARD_HINTS.search(content):
|
| 111 |
+
score += 1
|
| 112 |
+
if content.count("````{python}") + content.count("```{python}") > 1:
|
| 113 |
+
score += 1
|
| 114 |
+
return "difficile" if score >= 2 else "simple"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def start_rung(role: str, difficulty: str) -> int:
|
| 118 |
+
"""simple → échelon le moins cher ; difficile → échelon plus haut."""
|
| 119 |
+
steps = ladder(role)
|
| 120 |
+
if difficulty == "difficile" and len(steps) > 1:
|
| 121 |
+
return min(len(steps) - 1, len(steps) // 2 if len(steps) > 2 else 1)
|
| 122 |
+
return 0
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ── Résolution par rôle / tentative ──────────────────────────────────────────
|
| 126 |
+
|
| 127 |
+
def resolve(role: str, policy: str = "auto", manual: dict | None = None,
|
| 128 |
+
rung: int = 0) -> str:
|
| 129 |
+
"""Clé catalogue du modèle à utiliser pour `role` (échelon `rung` en auto)."""
|
| 130 |
+
rec = load_recommended()[role]
|
| 131 |
+
if policy == "manual":
|
| 132 |
+
manual = manual or {}
|
| 133 |
+
key = (manual.get(role)
|
| 134 |
+
or {"generate": MODEL_GENERATE, "audit": MODEL_AUDIT,
|
| 135 |
+
"mecanique": MODEL_MECANIQUE}[role])
|
| 136 |
+
if key in CATALOG and is_available(key):
|
| 137 |
+
return key
|
| 138 |
+
logger.warning("Choix manuel %r indisponible pour %s — repli cheap.", key, role)
|
| 139 |
+
policy = "cheap"
|
| 140 |
+
if policy == "best":
|
| 141 |
+
keys = _usable([rec["best"]]) or ladder(role)
|
| 142 |
+
return keys[-1] if keys else rec["best"]
|
| 143 |
+
if policy == "cheap":
|
| 144 |
+
keys = _usable([rec["cheap"]]) or ladder(role)
|
| 145 |
+
return keys[0] if keys else rec["cheap"]
|
| 146 |
+
# auto
|
| 147 |
+
steps = ladder(role)
|
| 148 |
+
if not steps:
|
| 149 |
+
return rec["best"]
|
| 150 |
+
return steps[min(rung, len(steps) - 1)]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def openrouter_id(model_key: str) -> str:
|
| 154 |
+
"""Pont vers le client pipeline (route OpenRouter)."""
|
| 155 |
+
return model_info(model_key)["openrouter_id"]
|
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Tarifs USD / 1M tokens (in / out / cache-hit lecture). À RE-VÉRIFIER AVANT PROD — ils changent chaque semaine. Relevés sur l'API OpenRouter le 2026-07-02. batch_discount s'applique aux variantes batch (−50 %). claude-sonnet-5 : tarif d'intro (standard 3/15 annoncé après le 2026-08-31).",
|
| 3 |
+
"batch_discount": 0.5,
|
| 4 |
+
"models": {
|
| 5 |
+
"claude-opus-4-8": {"in": 5.0, "out": 25.0, "cache_in": 0.5},
|
| 6 |
+
"claude-sonnet-5": {"in": 2.0, "out": 10.0, "cache_in": 0.2},
|
| 7 |
+
"claude-haiku-4-5": {"in": 1.0, "out": 5.0, "cache_in": 0.1},
|
| 8 |
+
"gpt-5-4": {"in": 2.5, "out": 15.0, "cache_in": 0.25},
|
| 9 |
+
"gpt-5-4-nano": {"in": 0.2, "out": 1.25, "cache_in": 0.02},
|
| 10 |
+
"gemini-3-1-pro": {"in": 1.25, "out": 10.0, "cache_in": 0.125},
|
| 11 |
+
"gemini-3-flash": {"in": 0.3, "out": 2.5, "cache_in": 0.03},
|
| 12 |
+
"grok-4-3": {"in": 1.25, "out": 2.5, "cache_in": 0.2},
|
| 13 |
+
"kimi-k2-6": {"in": 0.55, "out": 3.2, "cache_in": 0.11},
|
| 14 |
+
"glm-5-2": {"in": 0.93, "out": 3.0, "cache_in": 0.18},
|
| 15 |
+
"glm-4-7-flash": {"in": 0.06, "out": 0.4, "cache_in": 0.01},
|
| 16 |
+
"deepseek-v4-pro": {"in": 0.435, "out": 0.87, "cache_in": 0.00363},
|
| 17 |
+
"deepseek-v4-flash": {"in": 0.089, "out": 0.18, "cache_in": 0.018},
|
| 18 |
+
"mistral-large-3": {"in": 0.5, "out": 1.5, "cache_in": 0.05},
|
| 19 |
+
"mistral-small": {"in": 0.075, "out": 0.2, "cache_in": 0.0},
|
| 20 |
+
"minimax-m3": {"in": 0.3, "out": 1.2, "cache_in": 0.06}
|
| 21 |
+
}
|
| 22 |
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Écrit par `python -m bench run`. manual_override=true pour protéger un choix manuel.",
|
| 3 |
+
"_bench_timestamp": "20260702-165614_consolidated_370220",
|
| 4 |
+
"manual_override": false,
|
| 5 |
+
"roles": {
|
| 6 |
+
"generate": {
|
| 7 |
+
"best": "deepseek-v4-pro",
|
| 8 |
+
"cheap": "deepseek-v4-pro",
|
| 9 |
+
"ladder": [
|
| 10 |
+
"deepseek-v4-pro",
|
| 11 |
+
"kimi-k2-6",
|
| 12 |
+
"glm-5-2",
|
| 13 |
+
"grok-4-3",
|
| 14 |
+
"gemini-3-1-pro",
|
| 15 |
+
"claude-sonnet-5",
|
| 16 |
+
"claude-opus-4-8"
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
"mecanique": {
|
| 20 |
+
"best": "mistral-small",
|
| 21 |
+
"cheap": "mistral-small",
|
| 22 |
+
"ladder": [
|
| 23 |
+
"mistral-small",
|
| 24 |
+
"deepseek-v4-flash",
|
| 25 |
+
"glm-4-7-flash",
|
| 26 |
+
"gpt-5-4-nano",
|
| 27 |
+
"minimax-m3",
|
| 28 |
+
"gemini-3-flash",
|
| 29 |
+
"claude-haiku-4-5"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
"audit": {
|
| 33 |
+
"best": "deepseek-v4-pro",
|
| 34 |
+
"cheap": "deepseek-v4-pro",
|
| 35 |
+
"ladder": [
|
| 36 |
+
"deepseek-v4-pro",
|
| 37 |
+
"kimi-k2-6",
|
| 38 |
+
"grok-4-3",
|
| 39 |
+
"gemini-3-1-pro",
|
| 40 |
+
"claude-opus-4-8"
|
| 41 |
+
]
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
|
@@ -68,13 +68,16 @@ def _parse_analysis(raw: str, content: str) -> dict:
|
|
| 68 |
return analysis
|
| 69 |
|
| 70 |
|
| 71 |
-
def run_analysis_phase(content: str, model_idx: int
|
|
|
|
| 72 |
"""
|
| 73 |
Lance EN PARALLÈLE : analyse LLM, notions, RAG fonctions.
|
| 74 |
|
| 75 |
Retourne (analysis, notions_ctx, lists_of_notions, functions_ctx).
|
| 76 |
Une erreur sur notions/RAG est dégradée en contexte vide (warning loggé) ;
|
| 77 |
une erreur sur l'analyse LLM est propagée (le pipeline n'a pas de sens sans).
|
|
|
|
|
|
|
| 78 |
"""
|
| 79 |
analysis_model = ANALYSIS_MODEL_IDX if ANALYSIS_MODEL_IDX is not None else model_idx
|
| 80 |
|
|
@@ -83,6 +86,7 @@ def run_analysis_phase(content: str, model_idx: int) -> tuple[dict, str, str, st
|
|
| 83 |
process_with_openrouter,
|
| 84 |
prompt=STEP1_PROMPT.format(content=content, available_rules_menu=_rules_menu()),
|
| 85 |
model_idx=analysis_model,
|
|
|
|
| 86 |
max_tokens=6096,
|
| 87 |
)
|
| 88 |
f_notions = pool.submit(enrich_exercise_with_notions, content, xlsx_path=NOTIONS_XLSX)
|
|
|
|
| 68 |
return analysis
|
| 69 |
|
| 70 |
|
| 71 |
+
def run_analysis_phase(content: str, model_idx: int,
|
| 72 |
+
model: str | None = None) -> tuple[dict, str, str, str]:
|
| 73 |
"""
|
| 74 |
Lance EN PARALLÈLE : analyse LLM, notions, RAG fonctions.
|
| 75 |
|
| 76 |
Retourne (analysis, notions_ctx, lists_of_notions, functions_ctx).
|
| 77 |
Une erreur sur notions/RAG est dégradée en contexte vide (warning loggé) ;
|
| 78 |
une erreur sur l'analyse LLM est propagée (le pipeline n'a pas de sens sans).
|
| 79 |
+
`model` (ID chaîne) prime sur model_idx — sous policy, l'analyse relève du
|
| 80 |
+
rôle `mecanique` (classification, §2 du prompt banc).
|
| 81 |
"""
|
| 82 |
analysis_model = ANALYSIS_MODEL_IDX if ANALYSIS_MODEL_IDX is not None else model_idx
|
| 83 |
|
|
|
|
| 86 |
process_with_openrouter,
|
| 87 |
prompt=STEP1_PROMPT.format(content=content, available_rules_menu=_rules_menu()),
|
| 88 |
model_idx=analysis_model,
|
| 89 |
+
model=model,
|
| 90 |
max_tokens=6096,
|
| 91 |
)
|
| 92 |
f_notions = pool.submit(enrich_exercise_with_notions, content, xlsx_path=NOTIONS_XLSX)
|
|
@@ -172,6 +172,7 @@ def run_audit(
|
|
| 172 |
step1_targets: list[str],
|
| 173 |
model_idx: int,
|
| 174 |
set_step: Optional[Callable[[str], None]] = None,
|
|
|
|
| 175 |
) -> tuple[str, list[dict], list[dict]]:
|
| 176 |
"""Audit LLM en boucle (≤ MAX_AUDIT_ITERATIONS). Retourne
|
| 177 |
(exercice patché, patches appliqués, warnings)."""
|
|
@@ -191,6 +192,7 @@ def run_audit(
|
|
| 191 |
exercise=myst_exercise,
|
| 192 |
),
|
| 193 |
model_idx=model_idx,
|
|
|
|
| 194 |
temperature=0.0,
|
| 195 |
max_tokens=8192,
|
| 196 |
system_prompt=SYSTEM_PROMPT,
|
|
|
|
| 172 |
step1_targets: list[str],
|
| 173 |
model_idx: int,
|
| 174 |
set_step: Optional[Callable[[str], None]] = None,
|
| 175 |
+
model: Optional[str] = None,
|
| 176 |
) -> tuple[str, list[dict], list[dict]]:
|
| 177 |
"""Audit LLM en boucle (≤ MAX_AUDIT_ITERATIONS). Retourne
|
| 178 |
(exercice patché, patches appliqués, warnings)."""
|
|
|
|
| 192 |
exercise=myst_exercise,
|
| 193 |
),
|
| 194 |
model_idx=model_idx,
|
| 195 |
+
model=model,
|
| 196 |
temperature=0.0,
|
| 197 |
max_tokens=8192,
|
| 198 |
system_prompt=SYSTEM_PROMPT,
|
|
@@ -244,6 +244,7 @@ def generate_pair_blocks(
|
|
| 244 |
lang: str = "auto",
|
| 245 |
set_step: Optional[Callable[[str], None]] = None,
|
| 246 |
decl_type: Optional[str] = None,
|
|
|
|
| 247 |
) -> list[str]:
|
| 248 |
"""Boucle de génération par paires (séquentielle). Retourne les blocs.
|
| 249 |
`decl_type` (qcm|qat) bascule sur le prompt Déclinaisons — même mécanique
|
|
@@ -291,6 +292,7 @@ def generate_pair_blocks(
|
|
| 291 |
**common,
|
| 292 |
),
|
| 293 |
model_idx=model_idx,
|
|
|
|
| 294 |
max_tokens=30000,
|
| 295 |
system_prompt=SYSTEM_PROMPT,
|
| 296 |
reasoning=USE_REASONING,
|
|
@@ -320,6 +322,7 @@ def generate_pair_blocks(
|
|
| 320 |
**common,
|
| 321 |
),
|
| 322 |
model_idx=model_idx,
|
|
|
|
| 323 |
temperature=0.4,
|
| 324 |
max_tokens=16384,
|
| 325 |
system_prompt=SYSTEM_PROMPT,
|
|
|
|
| 244 |
lang: str = "auto",
|
| 245 |
set_step: Optional[Callable[[str], None]] = None,
|
| 246 |
decl_type: Optional[str] = None,
|
| 247 |
+
model: Optional[str] = None,
|
| 248 |
) -> list[str]:
|
| 249 |
"""Boucle de génération par paires (séquentielle). Retourne les blocs.
|
| 250 |
`decl_type` (qcm|qat) bascule sur le prompt Déclinaisons — même mécanique
|
|
|
|
| 292 |
**common,
|
| 293 |
),
|
| 294 |
model_idx=model_idx,
|
| 295 |
+
model=model,
|
| 296 |
max_tokens=30000,
|
| 297 |
system_prompt=SYSTEM_PROMPT,
|
| 298 |
reasoning=USE_REASONING,
|
|
|
|
| 322 |
**common,
|
| 323 |
),
|
| 324 |
model_idx=model_idx,
|
| 325 |
+
model=model,
|
| 326 |
temperature=0.4,
|
| 327 |
max_tokens=16384,
|
| 328 |
system_prompt=SYSTEM_PROMPT,
|
|
@@ -30,6 +30,7 @@ from typing import Callable, Optional
|
|
| 30 |
from app.config import (
|
| 31 |
HARNESS_GATE_SEEDS,
|
| 32 |
HARNESS_REPAIR_MAX,
|
|
|
|
| 33 |
MULTI_SEED_NUM,
|
| 34 |
)
|
| 35 |
from app.knowledge.rules_digest import build_rules_digest
|
|
@@ -65,7 +66,8 @@ TRUNK_RULES = ["2.1", "3.1", "3.2", "6.1", "6.3", "8.1"]
|
|
| 65 |
|
| 66 |
|
| 67 |
def _translate_constraints_to_assertions(code: str, constraints: list[str],
|
| 68 |
-
model_idx: int
|
|
|
|
| 69 |
"""Mini appel LLM : contrainte FR → expression booléenne Python."""
|
| 70 |
if not constraints or not code.strip():
|
| 71 |
return []
|
|
@@ -77,6 +79,7 @@ def _translate_constraints_to_assertions(code: str, constraints: list[str],
|
|
| 77 |
if isinstance(c, str) and c.strip()),
|
| 78 |
),
|
| 79 |
model_idx=model_idx,
|
|
|
|
| 80 |
temperature=0.0,
|
| 81 |
max_tokens=2048,
|
| 82 |
system_prompt=SYSTEM_PROMPT,
|
|
@@ -107,6 +110,7 @@ def run_exercise(
|
|
| 107 |
set_step: Optional[Callable[[str], None]] = None,
|
| 108 |
decl_type: Optional[str] = None,
|
| 109 |
shared_phase: Optional[tuple] = None,
|
|
|
|
| 110 |
) -> dict:
|
| 111 |
"""
|
| 112 |
Traite UN exercice. `decl_type=None` = pythonisation (flux historique) ;
|
|
@@ -122,6 +126,12 @@ def run_exercise(
|
|
| 122 |
t0 = time.time()
|
| 123 |
cost_before = cost_snapshot()
|
| 124 |
_step = set_step or (lambda label: None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
# ── 1. Analyse + notions + RAG (parallèle ; partagée en mode QCM+QAT) ────
|
| 127 |
if shared_phase is not None:
|
|
@@ -173,6 +183,7 @@ def run_exercise(
|
|
| 173 |
lang=lang,
|
| 174 |
set_step=_step,
|
| 175 |
decl_type=decl_type,
|
|
|
|
| 176 |
)
|
| 177 |
|
| 178 |
# ── 3. Post-traitements déterministes ────────────────────────────────────
|
|
@@ -220,12 +231,12 @@ def run_exercise(
|
|
| 220 |
if analysis.get("has_validated_solution_in_input"):
|
| 221 |
_step("Substitution déterministe des solutions validées…")
|
| 222 |
myst_exercise, sol_patches = replace_gen_solutions_with_source(
|
| 223 |
-
myst_exercise, content, analysis, model_idx)
|
| 224 |
audit_patches.extend(sol_patches)
|
| 225 |
|
| 226 |
# ── 5. Audit LLM ─────────────────────────────────────────────────────────
|
| 227 |
myst_exercise, llm_patches, llm_warnings = run_audit(
|
| 228 |
-
myst_exercise, step1_targets, model_idx, set_step=_step)
|
| 229 |
audit_patches.extend(llm_patches)
|
| 230 |
audit_warnings.extend(llm_warnings)
|
| 231 |
|
|
@@ -315,7 +326,7 @@ def run_exercise(
|
|
| 315 |
main_code = extract_main_python_block(myst_exercise)
|
| 316 |
if constraints and main_code:
|
| 317 |
_step("Validation multi-seed des invariants…")
|
| 318 |
-
assertions = _translate_constraints_to_assertions(main_code, constraints, model_idx)
|
| 319 |
if assertions:
|
| 320 |
seed_report = multi_seed_validate(
|
| 321 |
main_code, assertions, num_seeds=MULTI_SEED_NUM, timeout_per_seed=3.0)
|
|
@@ -363,7 +374,7 @@ def run_exercise(
|
|
| 363 |
|
| 364 |
# ── 7. Langue cible ──────────────────────────────────────────────────────
|
| 365 |
_step("Langue cible…")
|
| 366 |
-
myst_exercise, lang_warnings, lang_info = ensure_language(myst_exercise, lang, model_idx)
|
| 367 |
audit_warnings.extend(lang_warnings)
|
| 368 |
effective_lang = lang if lang_info["action"] != "aucune" else lang_info["source"]
|
| 369 |
audit_warnings.extend(pp.check_decimals_for_lang(myst_exercise, effective_lang))
|
|
@@ -391,6 +402,7 @@ def run_exercise(
|
|
| 391 |
exercise=myst_exercise,
|
| 392 |
),
|
| 393 |
model_idx=model_idx,
|
|
|
|
| 394 |
temperature=0.0,
|
| 395 |
max_tokens=30000,
|
| 396 |
system_prompt=SYSTEM_PROMPT,
|
|
@@ -455,11 +467,87 @@ def run_exercise(
|
|
| 455 |
},
|
| 456 |
"lang": lang_info,
|
| 457 |
"decl_type": decl_type,
|
|
|
|
| 458 |
"cost": cost_delta(cost_before),
|
| 459 |
"duration_s": round(time.time() - t0, 1),
|
| 460 |
}
|
| 461 |
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
def run_declinaisons(
|
| 464 |
content: str,
|
| 465 |
filename: str = "exercise.md",
|
|
@@ -468,18 +556,25 @@ def run_declinaisons(
|
|
| 468 |
lang: str = "fr",
|
| 469 |
types: Optional[list] = None,
|
| 470 |
set_step: Optional[Callable[[str], None]] = None,
|
|
|
|
|
|
|
| 471 |
) -> list[tuple[str, dict]]:
|
| 472 |
"""
|
| 473 |
-
Mode `declinaisons` : produit une déclinaison par type coché (qcm/qat)
|
| 474 |
-
L'analyse + notions + RAG sont
|
| 475 |
-
|
| 476 |
-
|
| 477 |
"""
|
|
|
|
|
|
|
| 478 |
_step = set_step or (lambda label: None)
|
| 479 |
types = [t for t in (types or []) if t in ("qcm", "qat")] or ["qcm"]
|
| 480 |
|
|
|
|
| 481 |
_step("Analyse + notions + catalogue RAG (partagés QCM/QAT)…")
|
| 482 |
-
|
|
|
|
|
|
|
| 483 |
|
| 484 |
out: list[tuple[str, dict]] = []
|
| 485 |
for decl_type in types:
|
|
@@ -488,15 +583,25 @@ def run_declinaisons(
|
|
| 488 |
def step_with_type(msg: str, _label=label):
|
| 489 |
_step(f"[{_label}] {msg}")
|
| 490 |
|
| 491 |
-
result =
|
| 492 |
content=content,
|
| 493 |
filename=filename,
|
| 494 |
level=level,
|
| 495 |
-
model_idx=model_idx,
|
| 496 |
lang=lang,
|
| 497 |
-
|
|
|
|
| 498 |
decl_type=decl_type,
|
| 499 |
shared_phase=shared,
|
|
|
|
| 500 |
)
|
| 501 |
out.append((decl_type, result))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
return out
|
|
|
|
| 30 |
from app.config import (
|
| 31 |
HARNESS_GATE_SEEDS,
|
| 32 |
HARNESS_REPAIR_MAX,
|
| 33 |
+
MAX_ESCALADES,
|
| 34 |
MULTI_SEED_NUM,
|
| 35 |
)
|
| 36 |
from app.knowledge.rules_digest import build_rules_digest
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
def _translate_constraints_to_assertions(code: str, constraints: list[str],
|
| 69 |
+
model_idx: int,
|
| 70 |
+
model: str | None = None) -> list[dict]:
|
| 71 |
"""Mini appel LLM : contrainte FR → expression booléenne Python."""
|
| 72 |
if not constraints or not code.strip():
|
| 73 |
return []
|
|
|
|
| 79 |
if isinstance(c, str) and c.strip()),
|
| 80 |
),
|
| 81 |
model_idx=model_idx,
|
| 82 |
+
model=model,
|
| 83 |
temperature=0.0,
|
| 84 |
max_tokens=2048,
|
| 85 |
system_prompt=SYSTEM_PROMPT,
|
|
|
|
| 110 |
set_step: Optional[Callable[[str], None]] = None,
|
| 111 |
decl_type: Optional[str] = None,
|
| 112 |
shared_phase: Optional[tuple] = None,
|
| 113 |
+
forced_models: Optional[dict] = None,
|
| 114 |
) -> dict:
|
| 115 |
"""
|
| 116 |
Traite UN exercice. `decl_type=None` = pythonisation (flux historique) ;
|
|
|
|
| 126 |
t0 = time.time()
|
| 127 |
cost_before = cost_snapshot()
|
| 128 |
_step = set_step or (lambda label: None)
|
| 129 |
+
# Modèles par rôle (IDs OpenRouter en chaîne), résolus par la policy ;
|
| 130 |
+
# None → comportement legacy (model_idx partout).
|
| 131 |
+
fm = forced_models or {}
|
| 132 |
+
m_gen = fm.get("generate")
|
| 133 |
+
m_audit = fm.get("audit")
|
| 134 |
+
m_meca = fm.get("mecanique")
|
| 135 |
|
| 136 |
# ── 1. Analyse + notions + RAG (parallèle ; partagée en mode QCM+QAT) ────
|
| 137 |
if shared_phase is not None:
|
|
|
|
| 183 |
lang=lang,
|
| 184 |
set_step=_step,
|
| 185 |
decl_type=decl_type,
|
| 186 |
+
model=m_gen,
|
| 187 |
)
|
| 188 |
|
| 189 |
# ── 3. Post-traitements déterministes ────────────────────────────────────
|
|
|
|
| 231 |
if analysis.get("has_validated_solution_in_input"):
|
| 232 |
_step("Substitution déterministe des solutions validées…")
|
| 233 |
myst_exercise, sol_patches = replace_gen_solutions_with_source(
|
| 234 |
+
myst_exercise, content, analysis, model_idx, model=m_meca)
|
| 235 |
audit_patches.extend(sol_patches)
|
| 236 |
|
| 237 |
# ── 5. Audit LLM ─────────────────────────────────────────────────────────
|
| 238 |
myst_exercise, llm_patches, llm_warnings = run_audit(
|
| 239 |
+
myst_exercise, step1_targets, model_idx, set_step=_step, model=m_audit)
|
| 240 |
audit_patches.extend(llm_patches)
|
| 241 |
audit_warnings.extend(llm_warnings)
|
| 242 |
|
|
|
|
| 326 |
main_code = extract_main_python_block(myst_exercise)
|
| 327 |
if constraints and main_code:
|
| 328 |
_step("Validation multi-seed des invariants…")
|
| 329 |
+
assertions = _translate_constraints_to_assertions(main_code, constraints, model_idx, model=m_meca)
|
| 330 |
if assertions:
|
| 331 |
seed_report = multi_seed_validate(
|
| 332 |
main_code, assertions, num_seeds=MULTI_SEED_NUM, timeout_per_seed=3.0)
|
|
|
|
| 374 |
|
| 375 |
# ── 7. Langue cible ──────────────────────────────────────────────────────
|
| 376 |
_step("Langue cible…")
|
| 377 |
+
myst_exercise, lang_warnings, lang_info = ensure_language(myst_exercise, lang, model_idx, model=m_meca)
|
| 378 |
audit_warnings.extend(lang_warnings)
|
| 379 |
effective_lang = lang if lang_info["action"] != "aucune" else lang_info["source"]
|
| 380 |
audit_warnings.extend(pp.check_decimals_for_lang(myst_exercise, effective_lang))
|
|
|
|
| 402 |
exercise=myst_exercise,
|
| 403 |
),
|
| 404 |
model_idx=model_idx,
|
| 405 |
+
model=m_gen,
|
| 406 |
temperature=0.0,
|
| 407 |
max_tokens=30000,
|
| 408 |
system_prompt=SYSTEM_PROMPT,
|
|
|
|
| 467 |
},
|
| 468 |
"lang": lang_info,
|
| 469 |
"decl_type": decl_type,
|
| 470 |
+
"model_used": m_gen,
|
| 471 |
"cost": cost_delta(cost_before),
|
| 472 |
"duration_s": round(time.time() - t0, 1),
|
| 473 |
}
|
| 474 |
|
| 475 |
|
| 476 |
+
def run_with_policy(
|
| 477 |
+
content: str,
|
| 478 |
+
filename: str = "exercise.md",
|
| 479 |
+
level: str = "",
|
| 480 |
+
lang: str = "fr",
|
| 481 |
+
policy: str = "auto",
|
| 482 |
+
manual_models: Optional[dict] = None,
|
| 483 |
+
decl_type: Optional[str] = None,
|
| 484 |
+
shared_phase: Optional[tuple] = None,
|
| 485 |
+
set_step: Optional[Callable[[str], None]] = None,
|
| 486 |
+
) -> dict:
|
| 487 |
+
"""
|
| 488 |
+
Traite UN exercice sous POLITIQUE de sélection de modèle (§5) :
|
| 489 |
+
auto : pré-classifieur de difficulté → départ sur l'échelle `auto` ;
|
| 490 |
+
génération → harnais → ≤HARNESS_REPAIR_MAX réparations (même
|
| 491 |
+
modèle) → si toujours ROUGE, ESCALADE d'un échelon et retente
|
| 492 |
+
(analyse/RAG PARTAGÉS entre tentatives) → si `best` échoue,
|
| 493 |
+
marque l'exo pour revue humaine.
|
| 494 |
+
best / cheap / manual : un seul échelon (le modèle du preset).
|
| 495 |
+
Télémétrie dans result["policy_telemetry"].
|
| 496 |
+
"""
|
| 497 |
+
from app.models import policy as mp
|
| 498 |
+
|
| 499 |
+
_step = set_step or (lambda label: None)
|
| 500 |
+
manual = manual_models or {}
|
| 501 |
+
m_audit = mp.openrouter_id(mp.resolve("audit", policy, manual))
|
| 502 |
+
m_meca = mp.openrouter_id(mp.resolve("mecanique", policy, manual))
|
| 503 |
+
|
| 504 |
+
cost_before_all = cost_snapshot() # coût HONNÊTE : analyse + tous échelons
|
| 505 |
+
difficulty = mp.classify_difficulty(content)
|
| 506 |
+
if policy == "auto":
|
| 507 |
+
steps = mp.ladder("generate")
|
| 508 |
+
start = mp.start_rung("generate", difficulty)
|
| 509 |
+
rungs = steps[start:start + MAX_ESCALADES + 1] or steps[-1:]
|
| 510 |
+
else:
|
| 511 |
+
rungs = [mp.resolve("generate", policy, manual)]
|
| 512 |
+
if not rungs:
|
| 513 |
+
raise RuntimeError("Aucun modèle utilisable pour le rôle generate "
|
| 514 |
+
"(clés API absentes) — vérifier OPENROUTER_API_KEY.")
|
| 515 |
+
|
| 516 |
+
shared = shared_phase
|
| 517 |
+
if shared is None:
|
| 518 |
+
_step("Analyse + notions + catalogue RAG (en parallèle)…")
|
| 519 |
+
shared = run_analysis_phase(content, 0, model=m_meca)
|
| 520 |
+
|
| 521 |
+
tried: list[dict] = []
|
| 522 |
+
result: dict = {}
|
| 523 |
+
key = rungs[0]
|
| 524 |
+
for i, key in enumerate(rungs):
|
| 525 |
+
_step(f"Échelon {i + 1}/{len(rungs)} — {key}…")
|
| 526 |
+
result = run_exercise(
|
| 527 |
+
content=content, filename=filename, level=level,
|
| 528 |
+
lang=lang, set_step=_step, decl_type=decl_type,
|
| 529 |
+
shared_phase=shared,
|
| 530 |
+
forced_models={"generate": mp.openrouter_id(key),
|
| 531 |
+
"audit": m_audit, "mecanique": m_meca},
|
| 532 |
+
)
|
| 533 |
+
tried.append({"rung": i, "model": key, "ok": result["harness"]["ok"]})
|
| 534 |
+
if result["harness"]["ok"]:
|
| 535 |
+
break
|
| 536 |
+
logger.info("Échelon %s ROUGE sur %s — escalade.", key, filename)
|
| 537 |
+
|
| 538 |
+
result["policy_telemetry"] = {
|
| 539 |
+
"mode": policy,
|
| 540 |
+
"difficulty": difficulty,
|
| 541 |
+
"tried": tried,
|
| 542 |
+
"winning_model": key,
|
| 543 |
+
"needs_review": not result["harness"]["ok"],
|
| 544 |
+
}
|
| 545 |
+
# Coût honnête : inclut l'analyse partagée (si calculée ici) ET les
|
| 546 |
+
# échelons perdants — pas seulement la tentative gagnante.
|
| 547 |
+
result["cost"] = cost_delta(cost_before_all)
|
| 548 |
+
return result
|
| 549 |
+
|
| 550 |
+
|
| 551 |
def run_declinaisons(
|
| 552 |
content: str,
|
| 553 |
filename: str = "exercise.md",
|
|
|
|
| 556 |
lang: str = "fr",
|
| 557 |
types: Optional[list] = None,
|
| 558 |
set_step: Optional[Callable[[str], None]] = None,
|
| 559 |
+
policy: str = "auto",
|
| 560 |
+
manual_models: Optional[dict] = None,
|
| 561 |
) -> list[tuple[str, dict]]:
|
| 562 |
"""
|
| 563 |
+
Mode `declinaisons` : produit une déclinaison par type coché (qcm/qat),
|
| 564 |
+
sous politique de sélection de modèle. L'analyse + notions + RAG sont
|
| 565 |
+
calculées UNE SEULE fois et partagées entre les types ET les échelons
|
| 566 |
+
(aucun appel LLM redondant). Retourne [(decl_type, result), …].
|
| 567 |
"""
|
| 568 |
+
from app.models import policy as mp
|
| 569 |
+
|
| 570 |
_step = set_step or (lambda label: None)
|
| 571 |
types = [t for t in (types or []) if t in ("qcm", "qat")] or ["qcm"]
|
| 572 |
|
| 573 |
+
m_meca = mp.openrouter_id(mp.resolve("mecanique", policy, manual_models))
|
| 574 |
_step("Analyse + notions + catalogue RAG (partagés QCM/QAT)…")
|
| 575 |
+
cost_before_analysis = cost_snapshot()
|
| 576 |
+
shared = run_analysis_phase(content, model_idx, model=m_meca)
|
| 577 |
+
analysis_cost = cost_delta(cost_before_analysis)
|
| 578 |
|
| 579 |
out: list[tuple[str, dict]] = []
|
| 580 |
for decl_type in types:
|
|
|
|
| 583 |
def step_with_type(msg: str, _label=label):
|
| 584 |
_step(f"[{_label}] {msg}")
|
| 585 |
|
| 586 |
+
result = run_with_policy(
|
| 587 |
content=content,
|
| 588 |
filename=filename,
|
| 589 |
level=level,
|
|
|
|
| 590 |
lang=lang,
|
| 591 |
+
policy=policy,
|
| 592 |
+
manual_models=manual_models,
|
| 593 |
decl_type=decl_type,
|
| 594 |
shared_phase=shared,
|
| 595 |
+
set_step=step_with_type,
|
| 596 |
)
|
| 597 |
out.append((decl_type, result))
|
| 598 |
+
# L'analyse partagée tombe HORS des fenêtres de coût de run_with_policy :
|
| 599 |
+
# on l'impute au premier type pour que le total du job reste honnête.
|
| 600 |
+
if out and analysis_cost["requests"]:
|
| 601 |
+
c = out[0][1].get("cost") or {"usd": 0.0, "eur": 0.0, "requests": 0}
|
| 602 |
+
out[0][1]["cost"] = {
|
| 603 |
+
"usd": round(c["usd"] + analysis_cost["usd"], 6),
|
| 604 |
+
"eur": round(c["eur"] + analysis_cost["eur"], 6),
|
| 605 |
+
"requests": c["requests"] + analysis_cost["requests"],
|
| 606 |
+
}
|
| 607 |
return out
|
|
@@ -30,6 +30,7 @@ def _substitute_solution_via_llm(
|
|
| 30 |
source_text: str,
|
| 31 |
analysis: dict,
|
| 32 |
model_idx: int,
|
|
|
|
| 33 |
) -> Optional[str]:
|
| 34 |
"""Appel LLM ciblé : valeurs littérales de la solution source → {{var}}.
|
| 35 |
None en cas d'échec (l'appelant garde alors la solution générée)."""
|
|
@@ -53,6 +54,7 @@ def _substitute_solution_via_llm(
|
|
| 53 |
variables_table=variables_table,
|
| 54 |
),
|
| 55 |
model_idx=model_idx,
|
|
|
|
| 56 |
temperature=0.0,
|
| 57 |
max_tokens=4096,
|
| 58 |
system_prompt=SYSTEM_PROMPT,
|
|
@@ -70,6 +72,7 @@ def replace_gen_solutions_with_source(
|
|
| 70 |
source_content: str,
|
| 71 |
analysis: dict,
|
| 72 |
model_idx: int,
|
|
|
|
| 73 |
) -> tuple[str, list[dict]]:
|
| 74 |
"""Remplace chaque detailedSolution générée par la version source
|
| 75 |
substituée (correspondance positionnelle). Retourne (exercice, patches)."""
|
|
@@ -88,7 +91,7 @@ def replace_gen_solutions_with_source(
|
|
| 88 |
src = source_solutions[i]
|
| 89 |
if not src.strip():
|
| 90 |
return match.group(0)
|
| 91 |
-
substituted = _substitute_solution_via_llm(src, analysis, model_idx)
|
| 92 |
if not substituted:
|
| 93 |
return match.group(0)
|
| 94 |
|
|
|
|
| 30 |
source_text: str,
|
| 31 |
analysis: dict,
|
| 32 |
model_idx: int,
|
| 33 |
+
model: Optional[str] = None,
|
| 34 |
) -> Optional[str]:
|
| 35 |
"""Appel LLM ciblé : valeurs littérales de la solution source → {{var}}.
|
| 36 |
None en cas d'échec (l'appelant garde alors la solution générée)."""
|
|
|
|
| 54 |
variables_table=variables_table,
|
| 55 |
),
|
| 56 |
model_idx=model_idx,
|
| 57 |
+
model=model,
|
| 58 |
temperature=0.0,
|
| 59 |
max_tokens=4096,
|
| 60 |
system_prompt=SYSTEM_PROMPT,
|
|
|
|
| 72 |
source_content: str,
|
| 73 |
analysis: dict,
|
| 74 |
model_idx: int,
|
| 75 |
+
model: Optional[str] = None,
|
| 76 |
) -> tuple[str, list[dict]]:
|
| 77 |
"""Remplace chaque detailedSolution générée par la version source
|
| 78 |
substituée (correspondance positionnelle). Retourne (exercice, patches)."""
|
|
|
|
| 91 |
src = source_solutions[i]
|
| 92 |
if not src.strip():
|
| 93 |
return match.group(0)
|
| 94 |
+
substituted = _substitute_solution_via_llm(src, analysis, model_idx, model=model)
|
| 95 |
if not substituted:
|
| 96 |
return match.group(0)
|
| 97 |
|
|
@@ -48,6 +48,7 @@ def ensure_language(
|
|
| 48 |
exercise: str,
|
| 49 |
target: str,
|
| 50 |
model_idx: int,
|
|
|
|
| 51 |
) -> tuple[str, list[dict], dict]:
|
| 52 |
"""
|
| 53 |
Amène l'exercice dans la langue cible. Retourne
|
|
@@ -83,6 +84,7 @@ def ensure_language(
|
|
| 83 |
format_directive=fmt,
|
| 84 |
),
|
| 85 |
model_idx=model_idx,
|
|
|
|
| 86 |
temperature=0.0,
|
| 87 |
max_tokens=24000,
|
| 88 |
)
|
|
|
|
| 48 |
exercise: str,
|
| 49 |
target: str,
|
| 50 |
model_idx: int,
|
| 51 |
+
model: str | None = None,
|
| 52 |
) -> tuple[str, list[dict], dict]:
|
| 53 |
"""
|
| 54 |
Amène l'exercice dans la langue cible. Retourne
|
|
|
|
| 84 |
format_directive=fmt,
|
| 85 |
),
|
| 86 |
model_idx=model_idx,
|
| 87 |
+
model=model,
|
| 88 |
temperature=0.0,
|
| 89 |
max_tokens=24000,
|
| 90 |
)
|
|
@@ -32,9 +32,10 @@ from app.config import (
|
|
| 32 |
DEFAULT_LANG,
|
| 33 |
DEFAULT_MODE,
|
| 34 |
DEFAULT_MODEL_IDX,
|
|
|
|
| 35 |
JOB_TTL,
|
| 36 |
)
|
| 37 |
-
from app.pipeline.orchestrator import run_declinaisons,
|
| 38 |
|
| 39 |
logger = logging.getLogger(__name__)
|
| 40 |
|
|
@@ -89,10 +90,11 @@ def _decl_output_name(source_name: str, decl_type: str) -> str:
|
|
| 89 |
|
| 90 |
|
| 91 |
def _run_job(job_id: str, files: list[dict], level: str, model_idx: int,
|
| 92 |
-
lang: str, mode: str = "pythonise", decl_types: list | None = None
|
|
|
|
| 93 |
"""Worker de job : boucle séquentielle sur les fichiers, robuste.
|
| 94 |
En mode `declinaisons`, chaque source produit 1 résultat PAR type coché
|
| 95 |
-
(analyse partagée entre types — aucun appel LLM redondant)."""
|
| 96 |
results: list[dict] = []
|
| 97 |
for i, f in enumerate(files):
|
| 98 |
name = f.get("filename") or f"fichier_{i + 1}.md"
|
|
@@ -112,25 +114,30 @@ def _run_job(job_id: str, files: list[dict], level: str, model_idx: int,
|
|
| 112 |
lang=lang,
|
| 113 |
types=decl_types,
|
| 114 |
set_step=set_step,
|
|
|
|
|
|
|
| 115 |
):
|
| 116 |
out_name = _decl_output_name(name, decl_type)
|
| 117 |
results.append({"filename": out_name, "status": "done", "result": result})
|
| 118 |
-
logger.info("Déclinaison %s : harnais %s, %d warnings, %.1fs, %.4f$",
|
| 119 |
out_name, "VERT" if result["harness"]["ok"] else "ROUGE",
|
|
|
|
| 120 |
len(result["warnings"]), result["duration_s"],
|
| 121 |
result["cost"]["usd"])
|
| 122 |
else:
|
| 123 |
-
result =
|
| 124 |
content=f["content"],
|
| 125 |
filename=name,
|
| 126 |
level=level,
|
| 127 |
-
model_idx=model_idx,
|
| 128 |
lang=lang,
|
|
|
|
|
|
|
| 129 |
set_step=set_step,
|
| 130 |
)
|
| 131 |
results.append({"filename": name, "status": "done", "result": result})
|
| 132 |
-
logger.info("Fichier %s : harnais %s, %d warnings, %.1fs, %.4f$",
|
| 133 |
name, "VERT" if result["harness"]["ok"] else "ROUGE",
|
|
|
|
| 134 |
len(result["warnings"]), result["duration_s"],
|
| 135 |
result["cost"]["usd"])
|
| 136 |
except Exception as exc:
|
|
@@ -198,10 +205,16 @@ def register_routes(app):
|
|
| 198 |
|
| 199 |
@app.route("/api/models", methods=["GET"])
|
| 200 |
def models():
|
|
|
|
|
|
|
| 201 |
return jsonify({
|
| 202 |
"models": {str(k): v for k, v in AVAILABLE_MODELS.items()},
|
| 203 |
"default_idx": DEFAULT_MODEL_IDX,
|
| 204 |
"default_lang": DEFAULT_LANG,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
})
|
| 206 |
|
| 207 |
@app.route("/api/jobs", methods=["POST"])
|
|
@@ -254,6 +267,26 @@ def register_routes(app):
|
|
| 254 |
if not decl_types:
|
| 255 |
return jsonify({"error": "En mode 'declinaisons', cocher au moins un type (qcm/qat)."}), 400
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
job_id = uuid.uuid4().hex
|
| 258 |
with _JOBS_LOCK:
|
| 259 |
_JOBS[job_id] = {
|
|
@@ -271,7 +304,8 @@ def register_routes(app):
|
|
| 271 |
|
| 272 |
threading.Thread(
|
| 273 |
target=_run_job,
|
| 274 |
-
args=(job_id, clean_files, level, model_idx, lang, mode, decl_types
|
|
|
|
| 275 |
daemon=True,
|
| 276 |
).start()
|
| 277 |
return jsonify({"job_id": job_id}), 202
|
|
|
|
| 32 |
DEFAULT_LANG,
|
| 33 |
DEFAULT_MODE,
|
| 34 |
DEFAULT_MODEL_IDX,
|
| 35 |
+
DEFAULT_POLICY,
|
| 36 |
JOB_TTL,
|
| 37 |
)
|
| 38 |
+
from app.pipeline.orchestrator import run_declinaisons, run_with_policy
|
| 39 |
|
| 40 |
logger = logging.getLogger(__name__)
|
| 41 |
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
def _run_job(job_id: str, files: list[dict], level: str, model_idx: int,
|
| 93 |
+
lang: str, mode: str = "pythonise", decl_types: list | None = None,
|
| 94 |
+
policy: str = "auto", manual_models: dict | None = None):
|
| 95 |
"""Worker de job : boucle séquentielle sur les fichiers, robuste.
|
| 96 |
En mode `declinaisons`, chaque source produit 1 résultat PAR type coché
|
| 97 |
+
(analyse partagée entre types et échelons — aucun appel LLM redondant)."""
|
| 98 |
results: list[dict] = []
|
| 99 |
for i, f in enumerate(files):
|
| 100 |
name = f.get("filename") or f"fichier_{i + 1}.md"
|
|
|
|
| 114 |
lang=lang,
|
| 115 |
types=decl_types,
|
| 116 |
set_step=set_step,
|
| 117 |
+
policy=policy,
|
| 118 |
+
manual_models=manual_models,
|
| 119 |
):
|
| 120 |
out_name = _decl_output_name(name, decl_type)
|
| 121 |
results.append({"filename": out_name, "status": "done", "result": result})
|
| 122 |
+
logger.info("Déclinaison %s : harnais %s, modèle %s, %d warnings, %.1fs, %.4f$",
|
| 123 |
out_name, "VERT" if result["harness"]["ok"] else "ROUGE",
|
| 124 |
+
result.get("model_used"),
|
| 125 |
len(result["warnings"]), result["duration_s"],
|
| 126 |
result["cost"]["usd"])
|
| 127 |
else:
|
| 128 |
+
result = run_with_policy(
|
| 129 |
content=f["content"],
|
| 130 |
filename=name,
|
| 131 |
level=level,
|
|
|
|
| 132 |
lang=lang,
|
| 133 |
+
policy=policy,
|
| 134 |
+
manual_models=manual_models,
|
| 135 |
set_step=set_step,
|
| 136 |
)
|
| 137 |
results.append({"filename": name, "status": "done", "result": result})
|
| 138 |
+
logger.info("Fichier %s : harnais %s, modèle %s, %d warnings, %.1fs, %.4f$",
|
| 139 |
name, "VERT" if result["harness"]["ok"] else "ROUGE",
|
| 140 |
+
result.get("model_used"),
|
| 141 |
len(result["warnings"]), result["duration_s"],
|
| 142 |
result["cost"]["usd"])
|
| 143 |
except Exception as exc:
|
|
|
|
| 205 |
|
| 206 |
@app.route("/api/models", methods=["GET"])
|
| 207 |
def models():
|
| 208 |
+
from app.models.catalog import CANDIDATES
|
| 209 |
+
from app.models.policy import POLICIES, load_recommended
|
| 210 |
return jsonify({
|
| 211 |
"models": {str(k): v for k, v in AVAILABLE_MODELS.items()},
|
| 212 |
"default_idx": DEFAULT_MODEL_IDX,
|
| 213 |
"default_lang": DEFAULT_LANG,
|
| 214 |
+
"policies": list(POLICIES),
|
| 215 |
+
"default_policy": DEFAULT_POLICY,
|
| 216 |
+
"catalog": CANDIDATES, # candidats par rôle (sans Fable)
|
| 217 |
+
"recommended": load_recommended(),
|
| 218 |
})
|
| 219 |
|
| 220 |
@app.route("/api/jobs", methods=["POST"])
|
|
|
|
| 267 |
if not decl_types:
|
| 268 |
return jsonify({"error": "En mode 'declinaisons', cocher au moins un type (qcm/qat)."}), 400
|
| 269 |
|
| 270 |
+
# Politique de sélection de modèle (§5) — Fable absent du catalogue.
|
| 271 |
+
from app.models.catalog import CATALOG, ROLES
|
| 272 |
+
from app.models.policy import POLICIES
|
| 273 |
+
policy = (data.get("policy") or DEFAULT_POLICY).strip()
|
| 274 |
+
if policy not in POLICIES:
|
| 275 |
+
return jsonify({"error": f"'policy' invalide : {policy!r} (auto|best|cheap|manual)."}), 400
|
| 276 |
+
manual_models: dict = {}
|
| 277 |
+
if policy == "manual":
|
| 278 |
+
models_obj = data.get("models") or {}
|
| 279 |
+
if not isinstance(models_obj, dict):
|
| 280 |
+
return jsonify({"error": "'models' doit être un objet {generate, audit, mecanique}."}), 400
|
| 281 |
+
for role in ROLES:
|
| 282 |
+
key = models_obj.get(role)
|
| 283 |
+
if not key: # absent OU "" (select vide) → défaut config
|
| 284 |
+
continue
|
| 285 |
+
if key not in CATALOG or role not in CATALOG[key]["roles"]:
|
| 286 |
+
return jsonify({"error": f"Modèle {key!r} invalide pour le rôle {role} "
|
| 287 |
+
f"(catalogue : {[k for k, v in CATALOG.items() if role in v['roles']]})."}), 400
|
| 288 |
+
manual_models[role] = key
|
| 289 |
+
|
| 290 |
job_id = uuid.uuid4().hex
|
| 291 |
with _JOBS_LOCK:
|
| 292 |
_JOBS[job_id] = {
|
|
|
|
| 304 |
|
| 305 |
threading.Thread(
|
| 306 |
target=_run_job,
|
| 307 |
+
args=(job_id, clean_files, level, model_idx, lang, mode, decl_types,
|
| 308 |
+
policy, manual_models),
|
| 309 |
daemon=True,
|
| 310 |
).start()
|
| 311 |
return jsonify({"job_id": job_id}), 202
|
|
@@ -30,6 +30,10 @@ from contextlib import redirect_stdout
|
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
try:
|
| 34 |
import matplotlib
|
| 35 |
matplotlib.use("Agg")
|
|
@@ -374,14 +378,42 @@ def validate_text(text: str, seeds: int = 100) -> dict:
|
|
| 374 |
body_tmpl = strip_python_blocks(text)
|
| 375 |
install_pyxiscience_stubs()
|
| 376 |
failures: list[str] = []
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
for s in range(seeds):
|
| 379 |
random.seed(s)
|
| 380 |
env: dict = {"rd": random, "random": random}
|
| 381 |
-
|
|
|
|
|
|
|
| 382 |
with redirect_stdout(io.StringIO()), _warnings.catch_warnings():
|
| 383 |
_warnings.simplefilter("ignore") # plt.show() sous Agg, etc.
|
| 384 |
exec(code, env) # noqa: S102 — sandbox stubs + contenu maison
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
except Exception:
|
| 386 |
report["n_exec_errors"] += 1
|
| 387 |
if len(failures) < 5:
|
|
@@ -393,7 +425,7 @@ def validate_text(text: str, seeds: int = 100) -> dict:
|
|
| 393 |
if _HAS_MPL:
|
| 394 |
_plt.close("all")
|
| 395 |
|
| 396 |
-
rendered, unresolved =
|
| 397 |
if unresolved:
|
| 398 |
report["n_unresolved"] += 1
|
| 399 |
if len(failures) < 8:
|
|
|
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
| 33 |
+
# Budget par graine (exec + rendu + scans). Un bloc légitime tient en < 1 s ;
|
| 34 |
+
# au-delà c'est un code généré pathologique → seed en échec, pas de blocage.
|
| 35 |
+
SEED_TIMEOUT_S = 10.0
|
| 36 |
+
|
| 37 |
try:
|
| 38 |
import matplotlib
|
| 39 |
matplotlib.use("Agg")
|
|
|
|
| 378 |
body_tmpl = strip_python_blocks(text)
|
| 379 |
install_pyxiscience_stubs()
|
| 380 |
failures: list[str] = []
|
| 381 |
+
n_timeouts = 0
|
| 382 |
+
|
| 383 |
+
from app.validation.sandbox import ExecTimeout, run_with_timeout
|
| 384 |
|
| 385 |
for s in range(seeds):
|
| 386 |
random.seed(s)
|
| 387 |
env: dict = {"rd": random, "random": random}
|
| 388 |
+
out: dict = {}
|
| 389 |
+
|
| 390 |
+
def _seed_pass(env=env, out=out):
|
| 391 |
with redirect_stdout(io.StringIO()), _warnings.catch_warnings():
|
| 392 |
_warnings.simplefilter("ignore") # plt.show() sous Agg, etc.
|
| 393 |
exec(code, env) # noqa: S102 — sandbox stubs + contenu maison
|
| 394 |
+
# Le rendu et le scan MCQ font des str() sur les valeurs du
|
| 395 |
+
# namespace : time-boxés AVEC l'exec (un str() sympy sur une
|
| 396 |
+
# expression géante peut mouliner des heures — banc 2026-07-02).
|
| 397 |
+
out["rendered"], out["unresolved"] = render_body(body_tmpl, env)
|
| 398 |
+
if declinaison:
|
| 399 |
+
out["mcq"] = check_mcq_collisions(text, env)
|
| 400 |
+
|
| 401 |
+
try:
|
| 402 |
+
run_with_timeout(_seed_pass, SEED_TIMEOUT_S)
|
| 403 |
+
except ExecTimeout:
|
| 404 |
+
report["n_exec_errors"] += 1
|
| 405 |
+
n_timeouts += 1
|
| 406 |
+
if len(failures) < 5:
|
| 407 |
+
failures.append(
|
| 408 |
+
f"seed {s} : timeout ({SEED_TIMEOUT_S:g}s) — exécution ou "
|
| 409 |
+
"rendu pathologiquement lents (expression sympy géante ?)")
|
| 410 |
+
if _HAS_MPL:
|
| 411 |
+
_plt.close("all")
|
| 412 |
+
if n_timeouts >= 3:
|
| 413 |
+
failures.append("≥ 3 timeouts — graines restantes abandonnées "
|
| 414 |
+
"(verdict déjà ROUGE)")
|
| 415 |
+
break
|
| 416 |
+
continue
|
| 417 |
except Exception:
|
| 418 |
report["n_exec_errors"] += 1
|
| 419 |
if len(failures) < 5:
|
|
|
|
| 425 |
if _HAS_MPL:
|
| 426 |
_plt.close("all")
|
| 427 |
|
| 428 |
+
rendered, unresolved = out["rendered"], out["unresolved"]
|
| 429 |
if unresolved:
|
| 430 |
report["n_unresolved"] += 1
|
| 431 |
if len(failures) < 8:
|
|
@@ -214,40 +214,55 @@ class ExecTimeout(Exception):
|
|
| 214 |
"""Raised when exec exceeds its time budget."""
|
| 215 |
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
|
|
|
| 219 |
|
| 220 |
|
| 221 |
-
def
|
| 222 |
"""
|
| 223 |
-
Run `
|
|
|
|
|
|
|
| 224 |
|
| 225 |
Two strategies:
|
| 226 |
-
• Main thread →
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
| 228 |
The daemon thread can't actually be killed in Python; it survives the
|
| 229 |
timeout but doesn't block subsequent execs since each call spawns a
|
| 230 |
fresh daemon. Acceptable for short math-only workloads.
|
| 231 |
"""
|
| 232 |
-
compiled = compile(code, "<sandbox>", "exec")
|
| 233 |
-
|
| 234 |
if threading.current_thread() is threading.main_thread():
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
try:
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
finally:
|
| 240 |
signal.setitimer(signal.ITIMER_REAL, 0)
|
| 241 |
signal.signal(signal.SIGALRM, old_handler)
|
| 242 |
-
return
|
| 243 |
|
| 244 |
# Background-thread variant: run in a daemon child thread.
|
| 245 |
-
captured: dict[str,
|
| 246 |
done = threading.Event()
|
| 247 |
|
| 248 |
def _target() -> None:
|
| 249 |
try:
|
| 250 |
-
|
| 251 |
except BaseException as e: # noqa: BLE001 — re-raised below
|
| 252 |
captured["exc"] = e
|
| 253 |
finally:
|
|
@@ -258,7 +273,13 @@ def _exec_with_timeout(code: str, namespace: dict, timeout: float) -> None:
|
|
| 258 |
if not done.wait(timeout):
|
| 259 |
raise ExecTimeout(f"exec exceeded {timeout}s")
|
| 260 |
if captured["exc"] is not None:
|
| 261 |
-
raise captured["exc"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
|
| 264 |
def exec_python_block(
|
|
|
|
| 214 |
"""Raised when exec exceeds its time budget."""
|
| 215 |
|
| 216 |
|
| 217 |
+
class _ExecKill(BaseException):
|
| 218 |
+
"""Escalade du timeout : BaseException pour percer les `except Exception`
|
| 219 |
+
avaleurs du code généré (seul un `except:` nu peut encore l'attraper)."""
|
| 220 |
|
| 221 |
|
| 222 |
+
def run_with_timeout(fn, timeout: float):
|
| 223 |
"""
|
| 224 |
+
Run `fn()` under a timeout — utilisé pour l'exec sandboxé ET pour le
|
| 225 |
+
rendu/scan par graine du harnais (un `str()` sympy sur une expression
|
| 226 |
+
géante peut mouliner des heures : vu au banc du 2026-07-02).
|
| 227 |
|
| 228 |
Two strategies:
|
| 229 |
+
• Main thread → `signal.SIGALRM` avec re-tir périodique (interval) :
|
| 230 |
+
1er tir = ExecTimeout ; tirs suivants = _ExecKill (BaseException),
|
| 231 |
+
car un `try/except Exception` du code généré avale ExecTimeout mais
|
| 232 |
+
ne peut pas attraper une BaseException. Seul un `except:` nu résiste.
|
| 233 |
+
• Background thread (Flask worker) → daemon thread + `Event.wait`.
|
| 234 |
The daemon thread can't actually be killed in Python; it survives the
|
| 235 |
timeout but doesn't block subsequent execs since each call spawns a
|
| 236 |
fresh daemon. Acceptable for short math-only workloads.
|
| 237 |
"""
|
|
|
|
|
|
|
| 238 |
if threading.current_thread() is threading.main_thread():
|
| 239 |
+
fired = {"n": 0}
|
| 240 |
+
|
| 241 |
+
def _handler(signum, frame):
|
| 242 |
+
fired["n"] += 1
|
| 243 |
+
if fired["n"] == 1:
|
| 244 |
+
raise ExecTimeout("exec exceeded its time budget")
|
| 245 |
+
raise _ExecKill
|
| 246 |
+
|
| 247 |
+
old_handler = signal.signal(signal.SIGALRM, _handler)
|
| 248 |
+
signal.setitimer(signal.ITIMER_REAL, timeout, 0.5)
|
| 249 |
try:
|
| 250 |
+
return fn()
|
| 251 |
+
except _ExecKill:
|
| 252 |
+
raise ExecTimeout(
|
| 253 |
+
f"exec tué après {timeout}s (timeout avalé par le code ?)"
|
| 254 |
+
) from None
|
| 255 |
finally:
|
| 256 |
signal.setitimer(signal.ITIMER_REAL, 0)
|
| 257 |
signal.signal(signal.SIGALRM, old_handler)
|
|
|
|
| 258 |
|
| 259 |
# Background-thread variant: run in a daemon child thread.
|
| 260 |
+
captured: dict[str, object] = {"exc": None, "ret": None}
|
| 261 |
done = threading.Event()
|
| 262 |
|
| 263 |
def _target() -> None:
|
| 264 |
try:
|
| 265 |
+
captured["ret"] = fn()
|
| 266 |
except BaseException as e: # noqa: BLE001 — re-raised below
|
| 267 |
captured["exc"] = e
|
| 268 |
finally:
|
|
|
|
| 273 |
if not done.wait(timeout):
|
| 274 |
raise ExecTimeout(f"exec exceeded {timeout}s")
|
| 275 |
if captured["exc"] is not None:
|
| 276 |
+
raise captured["exc"] # type: ignore[misc]
|
| 277 |
+
return captured["ret"]
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _exec_with_timeout(code: str, namespace: dict, timeout: float) -> None:
|
| 281 |
+
compiled = compile(code, "<sandbox>", "exec")
|
| 282 |
+
run_with_timeout(lambda: exec(compiled, namespace), timeout)
|
| 283 |
|
| 284 |
|
| 285 |
def exec_python_block(
|
|
@@ -362,8 +362,24 @@
|
|
| 362 |
</div>
|
| 363 |
</div>
|
| 364 |
<div>
|
| 365 |
-
<label class="field-label" for="
|
| 366 |
-
<select id="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
</div>
|
| 368 |
<div>
|
| 369 |
<label class="field-label" for="lang-select">Langue cible</label>
|
|
@@ -474,35 +490,51 @@ async function fetchModels() {
|
|
| 474 |
try {
|
| 475 |
const res = await fetch("/api/models");
|
| 476 |
if (!res.ok) return;
|
| 477 |
-
const {
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
sel.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
}
|
|
|
|
| 487 |
if (default_lang) document.getElementById("lang-select").value = default_lang;
|
|
|
|
| 488 |
} catch (e) { console.warn("/api/models:", e); }
|
| 489 |
}
|
| 490 |
|
| 491 |
function prettyModelName(slug) {
|
| 492 |
-
const
|
| 493 |
return name
|
| 494 |
.replace(/^claude-/, "Claude ").replace(/^gpt-/, "GPT-")
|
| 495 |
.replace(/^gemini-/, "Gemini ").replace(/-/g, " ")
|
| 496 |
.replace(/\b\w/g, c => c.toUpperCase()).replace(/Gpt/g, "GPT");
|
| 497 |
}
|
| 498 |
|
| 499 |
-
/* ─── Mode (pythonisation / déclinaisons) ─── */
|
| 500 |
function onModeChange() {
|
| 501 |
const decl = document.getElementById("mode-select").value === "declinaisons";
|
| 502 |
document.getElementById("decl-types").hidden = !decl;
|
| 503 |
updateRunLabel();
|
| 504 |
}
|
| 505 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
function updateRunLabel() {
|
| 507 |
const btn = document.getElementById("run-btn");
|
| 508 |
if (document.getElementById("mode-select").value !== "declinaisons") {
|
|
@@ -723,7 +755,16 @@ function setMetaResult(entry) {
|
|
| 723 |
decl += `<span class="pill pill--info">repli MCQ partiel</span>`;
|
| 724 |
}
|
| 725 |
}
|
| 726 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
}
|
| 728 |
|
| 729 |
/* Sélecteur de fichier (batch) */
|
|
@@ -767,13 +808,21 @@ async function run() {
|
|
| 767 |
if (!files.length) { setStatus("error", "Saisissez un énoncé ou chargez des fichiers .md."); return; }
|
| 768 |
|
| 769 |
const mode = document.getElementById("mode-select").value;
|
|
|
|
| 770 |
const payload = {
|
| 771 |
files,
|
| 772 |
level: document.getElementById("level").value,
|
| 773 |
lang: document.getElementById("lang-select").value,
|
| 774 |
-
model_idx: Number(document.getElementById("model-select").value || 0),
|
| 775 |
mode,
|
|
|
|
| 776 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
if (mode === "declinaisons") {
|
| 778 |
payload.types = {
|
| 779 |
qcm: document.getElementById("type-qcm").checked,
|
|
|
|
| 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>
|
|
|
|
| 490 |
try {
|
| 491 |
const res = await fetch("/api/models");
|
| 492 |
if (!res.ok) return;
|
| 493 |
+
const { default_lang, default_policy, catalog, recommended } = await res.json();
|
| 494 |
+
// Dropdowns par rôle (mode Manuel), alimentés par le catalogue (sans Fable).
|
| 495 |
+
const roleDefaults = {
|
| 496 |
+
generate: recommended?.generate?.cheap,
|
| 497 |
+
audit: recommended?.audit?.best,
|
| 498 |
+
mecanique: recommended?.mecanique?.cheap,
|
| 499 |
+
};
|
| 500 |
+
for (const role of ["generate", "audit", "mecanique"]) {
|
| 501 |
+
const sel = document.getElementById(`model-${role}`);
|
| 502 |
+
if (!sel || !catalog?.[role]) continue;
|
| 503 |
+
sel.innerHTML = "";
|
| 504 |
+
for (const key of catalog[role]) {
|
| 505 |
+
const opt = document.createElement("option");
|
| 506 |
+
opt.value = key;
|
| 507 |
+
opt.textContent = prettyModelName(key);
|
| 508 |
+
if (key === roleDefaults[role]) opt.selected = true;
|
| 509 |
+
sel.appendChild(opt);
|
| 510 |
+
}
|
| 511 |
}
|
| 512 |
+
if (default_policy) document.getElementById("policy-select").value = default_policy;
|
| 513 |
if (default_lang) document.getElementById("lang-select").value = default_lang;
|
| 514 |
+
onPolicyChange();
|
| 515 |
} catch (e) { console.warn("/api/models:", e); }
|
| 516 |
}
|
| 517 |
|
| 518 |
function prettyModelName(slug) {
|
| 519 |
+
const name = slug.includes("/") ? slug.split("/")[1] : slug;
|
| 520 |
return name
|
| 521 |
.replace(/^claude-/, "Claude ").replace(/^gpt-/, "GPT-")
|
| 522 |
.replace(/^gemini-/, "Gemini ").replace(/-/g, " ")
|
| 523 |
.replace(/\b\w/g, c => c.toUpperCase()).replace(/Gpt/g, "GPT");
|
| 524 |
}
|
| 525 |
|
| 526 |
+
/* ─── Mode (pythonisation / déclinaisons) + politique de modèle ─── */
|
| 527 |
function onModeChange() {
|
| 528 |
const decl = document.getElementById("mode-select").value === "declinaisons";
|
| 529 |
document.getElementById("decl-types").hidden = !decl;
|
| 530 |
updateRunLabel();
|
| 531 |
}
|
| 532 |
|
| 533 |
+
function onPolicyChange() {
|
| 534 |
+
const manual = document.getElementById("policy-select").value === "manual";
|
| 535 |
+
document.getElementById("manual-models").hidden = !manual;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
function updateRunLabel() {
|
| 539 |
const btn = document.getElementById("run-btn");
|
| 540 |
if (document.getElementById("mode-select").value !== "declinaisons") {
|
|
|
|
| 755 |
decl += `<span class="pill pill--info">repli MCQ partiel</span>`;
|
| 756 |
}
|
| 757 |
}
|
| 758 |
+
let pol = "";
|
| 759 |
+
const t = r.policy_telemetry;
|
| 760 |
+
if (t) {
|
| 761 |
+
const esc = (t.tried || []).length > 1 ? ` (${t.tried.length} échelons)` : "";
|
| 762 |
+
pol = `<span class="pill pill--info">modèle : ${escapeHtml(prettyModelName(t.winning_model || r.model_used || "?"))}${esc} · ${escapeHtml(t.mode)}/${escapeHtml(t.difficulty)}</span>`;
|
| 763 |
+
if (t.needs_review) pol += `<span class="pill pill--ko">revue humaine requise</span>`;
|
| 764 |
+
} else if (r.model_used) {
|
| 765 |
+
pol = `<span class="pill pill--info">modèle : ${escapeHtml(prettyModelName(r.model_used))}</span>`;
|
| 766 |
+
}
|
| 767 |
+
el.innerHTML = verdict + decl + pol + cost + lang + dur;
|
| 768 |
}
|
| 769 |
|
| 770 |
/* Sélecteur de fichier (batch) */
|
|
|
|
| 808 |
if (!files.length) { setStatus("error", "Saisissez un énoncé ou chargez des fichiers .md."); return; }
|
| 809 |
|
| 810 |
const mode = document.getElementById("mode-select").value;
|
| 811 |
+
const policyMode = document.getElementById("policy-select").value;
|
| 812 |
const payload = {
|
| 813 |
files,
|
| 814 |
level: document.getElementById("level").value,
|
| 815 |
lang: document.getElementById("lang-select").value,
|
|
|
|
| 816 |
mode,
|
| 817 |
+
policy: policyMode,
|
| 818 |
};
|
| 819 |
+
if (policyMode === "manual") {
|
| 820 |
+
payload.models = {
|
| 821 |
+
generate: document.getElementById("model-generate").value,
|
| 822 |
+
audit: document.getElementById("model-audit").value,
|
| 823 |
+
mecanique: document.getElementById("model-mecanique").value,
|
| 824 |
+
};
|
| 825 |
+
}
|
| 826 |
if (mode === "declinaisons") {
|
| 827 |
payload.types = {
|
| 828 |
qcm: document.getElementById("type-qcm").checked,
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Non-régression RÉELLE du mode pythonise sous politique `auto`.
|
| 2 |
+
|
| 3 |
+
Lance le pipeline complet (LLM réels) sur l'énoncé de référence Exercice_1,
|
| 4 |
+
puis valide la sortie au harnais 300 graines. À lancer ponctuellement après
|
| 5 |
+
un chantier (le smoke hors-ligne reste la vérif de routine).
|
| 6 |
+
|
| 7 |
+
PYTHONPATH=. .venv/bin/python tests/nonreg_pythonise.py [chemin_exo]
|
| 8 |
+
"""
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 13 |
+
|
| 14 |
+
from app import _load_env, _setup_logging
|
| 15 |
+
|
| 16 |
+
_setup_logging()
|
| 17 |
+
_load_env()
|
| 18 |
+
|
| 19 |
+
from app.pipeline.orchestrator import run_with_policy
|
| 20 |
+
from app.validation import harness
|
| 21 |
+
|
| 22 |
+
SRC = Path(sys.argv[1]) if len(sys.argv) > 1 else \
|
| 23 |
+
Path(__file__).resolve().parent.parent.parent / "Exemples d'exercices" / "Exercice_1.md"
|
| 24 |
+
|
| 25 |
+
content = SRC.read_text(encoding="utf-8")
|
| 26 |
+
res = run_with_policy(content, filename=SRC.name, lang="fr", policy="auto",
|
| 27 |
+
set_step=lambda s: print(f" · {s}", flush=True))
|
| 28 |
+
|
| 29 |
+
out = Path("/tmp/nonreg_pythonise_out.md")
|
| 30 |
+
out.write_text(res["exercise"], encoding="utf-8")
|
| 31 |
+
|
| 32 |
+
tele = res["policy_telemetry"]
|
| 33 |
+
print(f"\nharnais porte : {'VERT' if res['harness']['ok'] else 'ROUGE'}")
|
| 34 |
+
print(f"policy : mode={tele['mode']} difficulté={tele['difficulty']} "
|
| 35 |
+
f"gagnant={tele['winning_model']} échelons={len(tele['tried'])}")
|
| 36 |
+
print(f"coût : {res['cost']}")
|
| 37 |
+
print(f"sortie : {out}")
|
| 38 |
+
|
| 39 |
+
# Contre-validation indépendante, 300 graines (plus dur que la porte à 100).
|
| 40 |
+
rep = harness.validate_text(res["exercise"], seeds=300)
|
| 41 |
+
print(f"harnais 300 : {'VERT' if rep['ok'] else 'ROUGE'}")
|
| 42 |
+
if not rep["ok"]:
|
| 43 |
+
print("échecs:", rep["first_failures"][:5])
|
| 44 |
+
sys.exit(1)
|
| 45 |
+
print("NON-RÉGRESSION OK")
|
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Non-régression RÉELLE du mode déclinaisons QAT (non couvert par le banc,
|
| 2 |
+
qui exerce le chemin QCM). LLM réels + harnais 300 graines.
|
| 3 |
+
|
| 4 |
+
PYTHONPATH=. .venv/bin/python tests/nonreg_qat.py [chemin_exo]
|
| 5 |
+
"""
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 10 |
+
|
| 11 |
+
from app import _load_env, _setup_logging
|
| 12 |
+
|
| 13 |
+
_setup_logging()
|
| 14 |
+
_load_env()
|
| 15 |
+
|
| 16 |
+
from app.pipeline.orchestrator import run_declinaisons
|
| 17 |
+
from app.validation import harness
|
| 18 |
+
|
| 19 |
+
SRC = Path(sys.argv[1]) if len(sys.argv) > 1 else \
|
| 20 |
+
Path(__file__).resolve().parent.parent / "bench" / "corpus" / "trinome_pythonise.md"
|
| 21 |
+
|
| 22 |
+
content = SRC.read_text(encoding="utf-8")
|
| 23 |
+
out = run_declinaisons(content, filename=SRC.name, lang="fr", types=["qat"],
|
| 24 |
+
set_step=lambda s: print(f" · {s}", flush=True))
|
| 25 |
+
|
| 26 |
+
(decl_type, res), = out
|
| 27 |
+
tele = res["policy_telemetry"]
|
| 28 |
+
print(f"\nharnais porte : {'VERT' if res['harness']['ok'] else 'ROUGE'}")
|
| 29 |
+
print(f"policy : gagnant={tele['winning_model']} échelons={len(tele['tried'])}")
|
| 30 |
+
print(f"coût : {res['cost']}")
|
| 31 |
+
|
| 32 |
+
# (les contrôles déclinaison sont auto-détectés par validate_text)
|
| 33 |
+
rep = harness.validate_text(res["exercise"], seeds=300)
|
| 34 |
+
print(f"harnais 300 : {'VERT' if rep['ok'] else 'ROUGE'}")
|
| 35 |
+
if not rep["ok"]:
|
| 36 |
+
print("échecs:", rep["first_failures"][:5])
|
| 37 |
+
sys.exit(1)
|
| 38 |
+
print("NON-RÉGRESSION QAT OK")
|
|
@@ -229,16 +229,21 @@ On trouve ${}{{a}} + {{b}} = {{sumAff}}$.
|
|
| 229 |
ANALYSIS_CALLS = {"n": 0}
|
| 230 |
|
| 231 |
|
|
|
|
|
|
|
|
|
|
| 232 |
def mock_llm(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
|
| 233 |
-
image_b64=None, system_prompt="", reasoning=False):
|
| 234 |
if "expert en analyse d'exercices" in prompt:
|
| 235 |
ANALYSIS_CALLS["n"] += 1
|
| 236 |
return MOCK_ANALYSIS
|
| 237 |
if "auditeur PyxiScience" in prompt:
|
| 238 |
return json.dumps({"verdict": "OK", "issues": []})
|
| 239 |
if "Tu déclines un exercice" in prompt:
|
|
|
|
| 240 |
return MOCK_MCQ_PAIR if "QCM (MCQ)" in prompt else MOCK_FGQ_PAIR
|
| 241 |
if "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
|
|
|
|
| 242 |
return MOCK_PAIR
|
| 243 |
return "{}"
|
| 244 |
|
|
@@ -324,6 +329,98 @@ check("API : mode invalide → 400", r_bad_mode.status_code == 400)
|
|
| 324 |
r_no_types = client.post("/api/jobs", json={"content": "x", "mode": "declinaisons", "types": {}})
|
| 325 |
check("API : declinaisons sans type → 400", r_no_types.status_code == 400)
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
# ── 4. Téléchargement ZIP (endpoint, sans LLM) ───────────────────────────────
|
| 328 |
import io as _io # noqa: E402
|
| 329 |
import zipfile as _zipfile # noqa: E402
|
|
|
|
| 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 "Tu déclines un exercice" in prompt:
|
| 243 |
+
GEN_MODELS_SEEN.append(model)
|
| 244 |
return MOCK_MCQ_PAIR if "QCM (MCQ)" in prompt else MOCK_FGQ_PAIR
|
| 245 |
if "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
|
| 246 |
+
GEN_MODELS_SEEN.append(model)
|
| 247 |
return MOCK_PAIR
|
| 248 |
return "{}"
|
| 249 |
|
|
|
|
| 329 |
r_no_types = client.post("/api/jobs", json={"content": "x", "mode": "declinaisons", "types": {}})
|
| 330 |
check("API : declinaisons sans type → 400", r_no_types.status_code == 400)
|
| 331 |
|
| 332 |
+
# ── 3ter. Politiques de modèle + escalade + retrait de Fable ────────────────
|
| 333 |
+
from app.models.catalog import CATALOG, CANDIDATES # noqa: E402
|
| 334 |
+
from app.models import policy as _mp # noqa: E402
|
| 335 |
+
|
| 336 |
+
check("Fable absent du catalogue",
|
| 337 |
+
not any("fable" in k.lower() for k in CATALOG)
|
| 338 |
+
and not any("fable" in v["openrouter_id"].lower() for v in CATALOG.values()))
|
| 339 |
+
from app.config import AVAILABLE_MODELS as _AM # noqa: E402
|
| 340 |
+
check("Fable absent d'AVAILABLE_MODELS",
|
| 341 |
+
not any("fable" in v.lower() for v in _AM.values()))
|
| 342 |
+
check("Fable absent du fallback policy",
|
| 343 |
+
not any("fable" in str(_mp.DEFAULT_RECOMMENDED).lower() for _ in [0]))
|
| 344 |
+
|
| 345 |
+
# best / cheap / manual suivent recommended.json (source VIVANTE : le banc la
|
| 346 |
+
# réécrit — on vérifie la cohérence de la résolution, pas des noms figés).
|
| 347 |
+
_rec_gen = _mp.load_recommended()["generate"]
|
| 348 |
+
check("policy best suit recommended.json",
|
| 349 |
+
_mp.resolve("generate", "best") == _rec_gen["best"])
|
| 350 |
+
check("policy cheap suit recommended.json",
|
| 351 |
+
_mp.resolve("generate", "cheap") == _rec_gen["cheap"])
|
| 352 |
+
check("policy manual respecté",
|
| 353 |
+
_mp.resolve("generate", "manual", {"generate": "deepseek-v4-pro"}) == "deepseek-v4-pro")
|
| 354 |
+
check("difficulté : matrices → difficile",
|
| 355 |
+
_mp.classify_difficulty("Matrix systeme " * 100 + ":::::{question}" * 6) == "difficile")
|
| 356 |
+
|
| 357 |
+
# Escalade : 1er échelon forcé ROUGE (options en collision) → échelon 2 VERT.
|
| 358 |
+
MOCK_MCQ_RED = MOCK_MCQ_PAIR.replace("{{d1Aff}}", "{{sumAff}}")
|
| 359 |
+
_calls = {"n": 0}
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def mock_llm_escalade(prompt, model_idx=0, temperature=0.0, max_tokens=4096,
|
| 363 |
+
image_b64=None, system_prompt="", reasoning=False, model=None):
|
| 364 |
+
if "expert en analyse d'exercices" in prompt:
|
| 365 |
+
return MOCK_ANALYSIS
|
| 366 |
+
if "auditeur PyxiScience" in prompt:
|
| 367 |
+
return json.dumps({"verdict": "OK", "issues": []})
|
| 368 |
+
if "harnais" in prompt and "REJETÉ" in prompt:
|
| 369 |
+
return MOCK_MCQ_RED # la réparation échoue aussi sur l'échelon 1
|
| 370 |
+
if "Tu déclines un exercice" in prompt or "RÈGLES D'ASSEMBLAGE PAR PAIRE" in prompt:
|
| 371 |
+
_calls["n"] += 1
|
| 372 |
+
# 1re GÉNÉRATION (échelon 1) rouge ; la suivante (échelon 2) verte.
|
| 373 |
+
return MOCK_MCQ_RED if _calls["n"] <= 1 else MOCK_MCQ_PAIR
|
| 374 |
+
return "{}"
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
for _m in (analyze, audit, generate, orchestrator):
|
| 378 |
+
_m.process_with_openrouter = mock_llm_escalade
|
| 379 |
+
import app.pipeline.solutions as _sols # noqa: E402
|
| 380 |
+
import app.pipeline.translate as _tr # noqa: E402
|
| 381 |
+
_sols.process_with_openrouter = mock_llm_escalade
|
| 382 |
+
_tr.process_with_openrouter = mock_llm_escalade
|
| 383 |
+
|
| 384 |
+
res_esc = orchestrator.run_with_policy(
|
| 385 |
+
content=SMOKE_SOURCE, filename="esc.md", lang="fr",
|
| 386 |
+
policy="auto", decl_type="qcm")
|
| 387 |
+
tel = res_esc["policy_telemetry"]
|
| 388 |
+
check("escalade : ≥2 échelons tentés", len(tel["tried"]) >= 2)
|
| 389 |
+
check("escalade : échelon 1 ROUGE puis gagnant VERT",
|
| 390 |
+
tel["tried"][0]["ok"] is False and tel["tried"][-1]["ok"] is True)
|
| 391 |
+
check("escalade : échelon gagnant journalisé",
|
| 392 |
+
tel["winning_model"] == tel["tried"][-1]["model"] and not tel["needs_review"])
|
| 393 |
+
|
| 394 |
+
# Restaure les mocks standards pour la suite.
|
| 395 |
+
for _m in (analyze, audit, generate, orchestrator):
|
| 396 |
+
_m.process_with_openrouter = mock_llm
|
| 397 |
+
_sols.process_with_openrouter = mock_llm
|
| 398 |
+
_tr.process_with_openrouter = mock_llm
|
| 399 |
+
|
| 400 |
+
# API : policy invalide → 400 ; manual avec modèle hors rôle → 400.
|
| 401 |
+
check("API : policy invalide → 400",
|
| 402 |
+
client.post("/api/jobs", json={"content": "x", "policy": "zzz"}).status_code == 400)
|
| 403 |
+
check("API : manual modèle hors rôle → 400",
|
| 404 |
+
client.post("/api/jobs", json={"content": "x", "policy": "manual",
|
| 405 |
+
"models": {"generate": "glm-4-7-flash"}}).status_code == 400)
|
| 406 |
+
check("/api/models expose catalogue par rôle sans Fable",
|
| 407 |
+
"fable" not in json.dumps(client.get("/api/models").get_json()).lower())
|
| 408 |
+
|
| 409 |
+
# Banc : --dry-run (plomberie complète hors ligne).
|
| 410 |
+
import subprocess # noqa: E402
|
| 411 |
+
|
| 412 |
+
bench_proc = subprocess.run(
|
| 413 |
+
[sys.executable, "-m", "bench", "run", "--dry-run",
|
| 414 |
+
"--roles", "generate", "--models", "claude-sonnet-5,claude-opus-4-8",
|
| 415 |
+
"--seeds", "5"],
|
| 416 |
+
capture_output=True, text=True, timeout=600,
|
| 417 |
+
cwd=str(Path(__file__).resolve().parent.parent),
|
| 418 |
+
)
|
| 419 |
+
check("bench --dry-run : exit 0", bench_proc.returncode == 0)
|
| 420 |
+
check("bench --dry-run : reco produite", "best=" in bench_proc.stdout)
|
| 421 |
+
check("bench --dry-run : recommended.json non modifié",
|
| 422 |
+
"NON modifié" in bench_proc.stdout)
|
| 423 |
+
|
| 424 |
# ── 4. Téléchargement ZIP (endpoint, sans LLM) ───────────────────────────────
|
| 425 |
import io as _io # noqa: E402
|
| 426 |
import zipfile as _zipfile # noqa: E402
|