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

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 CHANGED
@@ -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-06-12) : Opus 4.8, **Sonnet 4.6
112
- (défaut)**, Fable 5, Haiku 4.5, Gemini 2.5 Pro, GPT-5.2.
 
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
app/config.py CHANGED
@@ -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-06-12) ────────────
 
29
  AVAILABLE_MODELS = {
30
  0: "anthropic/claude-opus-4.8",
31
- 1: "anthropic/claude-sonnet-4.6",
32
- 2: "anthropic/claude-fable-5",
33
- 3: "anthropic/claude-haiku-4.5",
34
- 4: "google/gemini-2.5-pro",
35
- 5: "openai/gpt-5.2",
36
  }
37
- DEFAULT_MODEL_IDX = 1 # claude-sonnet-4.6
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-06-12.
 
51
  MODEL_PRICING = {
52
- "anthropic/claude-opus-4.8": {"input": 5.0, "output": 25.0},
53
- "anthropic/claude-sonnet-4.6": {"input": 3.0, "output": 15.0},
54
- "anthropic/claude-fable-5": {"input": 10.0, "output": 50.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
- "openai/gpt-5.2": {"input": 1.75, "output": 14.0},
 
 
 
 
 
 
 
 
 
 
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"
app/llm/client.py CHANGED
@@ -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 = AVAILABLE_MODELS.get(model_idx)
 
 
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) -> str:
161
- """Point d'entrée unique du pipeline pour tous les appels LLM."""
 
 
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, reasoning=reasoning)
 
 
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)
app/models/__init__.py ADDED
File without changes
app/models/catalog.py ADDED
@@ -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]
app/models/client.py ADDED
@@ -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")
app/models/policy.py ADDED
@@ -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"]
app/models/prices.json ADDED
@@ -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
+ }
app/models/recommended.json ADDED
@@ -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
+ }
app/pipeline/analyze.py CHANGED
@@ -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) -> tuple[dict, str, str, str]:
 
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)
app/pipeline/audit.py CHANGED
@@ -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,
app/pipeline/generate.py CHANGED
@@ -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,
app/pipeline/orchestrator.py CHANGED
@@ -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) -> list[dict]:
 
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 calculées UNE SEULE fois et partagées entre
475
- les types (aucun appel LLM redondant). Retourne [(decl_type, result), …].
476
- Un échec sur un type n'empêche pas l'autre (l'appelant gère l'erreur).
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
- shared = run_analysis_phase(content, model_idx)
 
 
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 = run_exercise(
492
  content=content,
493
  filename=filename,
494
  level=level,
495
- model_idx=model_idx,
496
  lang=lang,
497
- set_step=step_with_type,
 
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
app/pipeline/solutions.py CHANGED
@@ -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
 
app/pipeline/translate.py CHANGED
@@ -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
  )
app/server.py CHANGED
@@ -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, run_exercise
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 = run_exercise(
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
app/validation/harness.py CHANGED
@@ -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
- try:
 
 
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 = render_body(body_tmpl, env)
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:
app/validation/sandbox.py CHANGED
@@ -214,40 +214,55 @@ class ExecTimeout(Exception):
214
  """Raised when exec exceeds its time budget."""
215
 
216
 
217
- def _timeout_handler(signum, frame):
218
- raise ExecTimeout("exec exceeded its time budget")
 
219
 
220
 
221
- def _exec_with_timeout(code: str, namespace: dict, timeout: float) -> None:
222
  """
223
- Run `exec(code, namespace)` under a timeout.
 
 
224
 
225
  Two strategies:
226
- • Main thread → use `signal.SIGALRM` (cheap, interruptible).
227
- Background thread (Flask worker) use a daemon thread + `Event.wait`.
 
 
 
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
- old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
236
- signal.setitimer(signal.ITIMER_REAL, timeout)
 
 
 
 
 
 
 
 
237
  try:
238
- exec(compiled, namespace)
 
 
 
 
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, Optional[BaseException]] = {"exc": None}
246
  done = threading.Event()
247
 
248
  def _target() -> None:
249
  try:
250
- exec(compiled, namespace)
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(
app/web/templates/index.html CHANGED
@@ -362,8 +362,24 @@
362
  </div>
363
  </div>
364
  <div>
365
- <label class="field-label" for="model-select">Modèle</label>
366
- <select id="model-select" aria-label="Modèle LLM"></select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { models, default_idx, default_lang } = await res.json();
478
- const sel = document.getElementById("model-select");
479
- sel.innerHTML = "";
480
- for (const [idx, slug] of Object.entries(models).sort((a, b) => Number(a[0]) - Number(b[0]))) {
481
- const opt = document.createElement("option");
482
- opt.value = idx;
483
- opt.textContent = prettyModelName(slug);
484
- if (Number(idx) === default_idx) opt.selected = true;
485
- sel.appendChild(opt);
 
 
 
 
 
 
 
 
 
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 [, name = slug] = slug.split("/");
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
- el.innerHTML = verdict + decl + cost + lang + dur;
 
 
 
 
 
 
 
 
 
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,
tests/nonreg_pythonise.py ADDED
@@ -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")
tests/nonreg_qat.py ADDED
@@ -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")
tests/smoke.py CHANGED
@@ -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