Spaces:
Runtime error
Runtime error
File size: 6,137 Bytes
81836d8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | """
policy.py — politique de sélection de modèle par rôle (§5).
Modes :
auto (défaut) : échelle ordonnée par coût croissant des modèles qui
tiennent SEUIL_VERT, plafonnée par `best`. Départ choisi
par un pré-classifieur de difficulté ; escalade d'un
échelon à chaque échec harnais après réparations.
best : modèle `best` du rôle (qualité max, coût ignoré).
cheap : modèle `cheap` (le moins cher qui tient le seuil).
manual : IDs explicites par rôle (config ou requête API).
Alimentation : app/models/recommended.json (écrit par le banc). Fallback si
absent : routage par défaut ci-dessous — SANS Fable (retiré volontairement).
"""
from __future__ import annotations
import json
import logging
import re
from app.config import MODEL_AUDIT, MODEL_GENERATE, MODEL_MECANIQUE, RECOMMENDED_PATH
from app.models.catalog import CATALOG, model_info
from app.models.client import is_available
logger = logging.getLogger(__name__)
POLICIES = ("auto", "best", "cheap", "manual")
# Fallback §5.2 (si recommended.json absent) — generate = Sonnet 5 avec
# escalade Opus 4.8 ; audit = Opus 4.8 ; mécanique = Haiku 4.5. Fable exclu.
DEFAULT_RECOMMENDED = {
"generate": {"best": "claude-opus-4-8", "cheap": "claude-sonnet-5",
"ladder": ["claude-sonnet-5", "claude-opus-4-8"]},
"audit": {"best": "claude-opus-4-8", "cheap": "claude-opus-4-8",
"ladder": ["claude-opus-4-8"]},
"mecanique": {"best": "claude-haiku-4-5", "cheap": "claude-haiku-4-5",
"ladder": ["claude-haiku-4-5"]},
}
def load_recommended() -> dict:
"""recommended.json s'il existe (produit par le banc), sinon fallback."""
try:
data = json.loads(RECOMMENDED_PATH.read_text(encoding="utf-8"))
roles = data.get("roles") or {}
except (OSError, json.JSONDecodeError, AttributeError, TypeError):
return {k: dict(v) for k, v in DEFAULT_RECOMMENDED.items()}
try:
merged = {}
for role, fb in DEFAULT_RECOMMENDED.items():
r = roles.get(role) or {}
merged[role] = {
"best": r.get("best") or fb["best"],
"cheap": r.get("cheap") or fb["cheap"],
"ladder": r.get("ladder") or fb["ladder"],
}
return merged
except (OSError, json.JSONDecodeError):
return {k: dict(v) for k, v in DEFAULT_RECOMMENDED.items()}
def _usable(keys: list) -> list:
"""Filtre : au catalogue (sans Fable, par construction) ET clé dispo."""
out = []
for k in keys:
if k not in CATALOG:
logger.warning("Modèle recommandé hors catalogue (ignoré) : %s", k)
continue
if not is_available(k):
logger.warning("Modèle sans clé disponible (ignoré) : %s", k)
continue
out.append(k)
return out
def ladder(role: str) -> list:
"""Échelle `auto` du rôle, coût croissant. Le plafonnement vit dans la
DÉCISION du banc (report.decide) : l'échelle écrite dans recommended.json
fait foi telle quelle — elle peut dépasser `best` quand des modèles de
qualité ÉGALE existent (redondance légitime : si l'échelon `best` échoue
sur UN exercice, un pair de même taux VERT peut réussir)."""
rec = load_recommended()[role]
keys = _usable(list(dict.fromkeys(rec["ladder"] + [rec["best"]])))
if not keys:
# Fallback élargi : tout candidat du rôle avec une clé disponible.
from app.models.catalog import CANDIDATES
keys = _usable(CANDIDATES[role])
if not keys:
raise RuntimeError(
f"Aucun modèle utilisable pour le rôle {role!r} : "
"aucune clé API disponible (OPENROUTER_API_KEY ou clé fournisseur).")
return keys
# ── Pré-classifieur de difficulté (heuristique légère, zéro LLM) ─────────────
_HARD_HINTS = re.compile(
r"Matrix|matrice|bmatrix|pmatrix|jacobien|système|systeme|"
r"int[ée]gra|\bipp\b|r[ée]currence|dimension", re.IGNORECASE)
def classify_difficulty(content: str) -> str:
score = 0
if content.count(":::::{question}") >= 5:
score += 1
if len(content) > 6000:
score += 1
if _HARD_HINTS.search(content):
score += 1
if content.count("````{python}") + content.count("```{python}") > 1:
score += 1
return "difficile" if score >= 2 else "simple"
def start_rung(role: str, difficulty: str) -> int:
"""simple → échelon le moins cher ; difficile → échelon plus haut."""
steps = ladder(role)
if difficulty == "difficile" and len(steps) > 1:
return min(len(steps) - 1, len(steps) // 2 if len(steps) > 2 else 1)
return 0
# ── Résolution par rôle / tentative ──────────────────────────────────────────
def resolve(role: str, policy: str = "auto", manual: dict | None = None,
rung: int = 0) -> str:
"""Clé catalogue du modèle à utiliser pour `role` (échelon `rung` en auto)."""
rec = load_recommended()[role]
if policy == "manual":
manual = manual or {}
key = (manual.get(role)
or {"generate": MODEL_GENERATE, "audit": MODEL_AUDIT,
"mecanique": MODEL_MECANIQUE}[role])
if key in CATALOG and is_available(key):
return key
logger.warning("Choix manuel %r indisponible pour %s — repli cheap.", key, role)
policy = "cheap"
if policy == "best":
keys = _usable([rec["best"]]) or ladder(role)
return keys[-1] if keys else rec["best"]
if policy == "cheap":
keys = _usable([rec["cheap"]]) or ladder(role)
return keys[0] if keys else rec["cheap"]
# auto
steps = ladder(role)
if not steps:
return rec["best"]
return steps[min(rung, len(steps) - 1)]
def openrouter_id(model_key: str) -> str:
"""Pont vers le client pipeline (route OpenRouter)."""
return model_info(model_key)["openrouter_id"]
|