From 442e2d1f9f54c8991ab9200a948521a81c62c934 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 06:07:08 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(llm):=20distingue=20un=20blocage=20de?= =?UTF-8?q?=20facturation=20d'un=20d=C3=A9passement=20de=20quota?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google renvoie un HTTP 429 aussi bien pour un débit dépassé que pour un compte bloqué (« Your prepayment credits are depleted »). Les deux étaient présentés comme « QUOTA OU DÉBIT DÉPASSÉ », ce qui envoie l'utilisateur consulter des compteurs de quota restés au vert — le solde prépayé n'y figure pas. - Un 429 portant un marqueur de facturation est désormais libellé « FACTURATION BLOQUÉE », avec l'action corrective (recharger le projet). - La cascade s'arrête au premier échec de ce type : le blocage est au niveau du compte, les modèles suivants échoueront à l'identique. Économise 4 appels inutiles par étape LLM, sur les quatre cascades (batch, sélection, synthèse, rapport). - Les messages de repli n'annoncent plus « tous les modèles ont échoué » quand un seul a été essayé. Constaté en production : les 5 modèles renvoyaient le même 429 de facturation, ce qu'aucun problème de modèle ou de SDK ne produirait. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014s387wqjrmhJ34u8js1otm --- collector/processors/gemini_processor.py | 101 ++++++++++++++++++--- tests/test_collector.py | 106 +++++++++++++++++++++++ 2 files changed, 194 insertions(+), 13 deletions(-) diff --git a/collector/processors/gemini_processor.py b/collector/processors/gemini_processor.py index d95d493..580c4ca 100644 --- a/collector/processors/gemini_processor.py +++ b/collector/processors/gemini_processor.py @@ -41,11 +41,46 @@ 401: "clé API absente ou invalide", 403: "accès refusé — API non activée sur le projet, ou clé restreinte", 404: "modèle introuvable sur la version d'API appelée", - 429: "QUOTA OU DÉBIT DÉPASSÉ (429)", + 429: "DÉBIT OU QUOTA DÉPASSÉ (429) — réessayer plus tard", 500: "erreur interne Google", 503: "modèle temporairement surchargé", } +# Google renvoie aussi un 429 quand le compte lui-même est bloqué (solde +# prépayé épuisé, facturation suspendue). Ce n'est PAS un dépassement de +# quota : les compteurs de la console restent au vert, et aucun modèle de la +# cascade ne peut aboutir. Inutile d'essayer les suivants. +_BILLING_MARKERS = ( + "prepayment credits", + "credits are depleted", + "billing", + "free tier is not available", + "consumer_suspended", +) + +BILLING_DIAGNOSTIC = ( + "FACTURATION BLOQUÉE (429) — ce n'est pas un dépassement de quota : " + "le solde prépayé du projet est épuisé ou la facturation est suspendue. " + "Recharger le projet sur https://ai.studio/projects" +) + + +def _is_account_level_failure(exc: Exception, code: int | None) -> bool: + """Vrai si l'échec vient du compte (facturation) et non du modèle appelé.""" + if code != 429: + return False + message = str(exc).lower() + return any(marker in message for marker in _BILLING_MARKERS) + + +def _http_code(exc: Exception) -> int | None: + """Code HTTP porté par une exception google-api-core, s'il y en a un.""" + code = getattr(exc, "code", None) + if isinstance(code, int): + return code + code = getattr(getattr(exc, "response", None), "status_code", None) + return code if isinstance(code, int) else None + def _describe_llm_error(exc: Exception) -> str: """Rend l'échec d'un appel Gemini lisible : code HTTP, diagnostic, message brut. @@ -54,26 +89,53 @@ def _describe_llm_error(exc: Exception) -> str: entre un vrai dépassement de quota et une erreur de configuration. """ message = str(exc).strip().replace("\n", " ")[:MAX_ERROR_DETAIL] - code = getattr(exc, "code", None) - if not isinstance(code, int): - response = getattr(exc, "response", None) - code = getattr(response, "status_code", None) + code = _http_code(exc) + + if _is_account_level_failure(exc, code): + diagnostic = BILLING_DIAGNOSTIC + else: + diagnostic = _HTTP_DIAGNOSTIC.get(code) if code is not None else None - diagnostic = _HTTP_DIAGNOSTIC.get(code) if isinstance(code, int) else None prefix = f"HTTP {code} — {diagnostic}" if diagnostic else exc.__class__.__name__ return f"{prefix} : {message}" +def _entete_echec(errors: list[str], interrompu: bool) -> str: + """En-tête honnête d'un message de repli : ne pas dire « tous les modèles » + quand la cascade a été coupée au premier.""" + if interrompu: + return "le compte est bloqué, cascade interrompue au premier modèle" + return f"les {len(errors)} modèle(s) de la cascade ont échoué" + + +def _stop_cascade(exc: Exception, etape: str, restants: list[str]) -> bool: + """Vrai si l'échec est au niveau du compte : les modèles suivants échoueront pareil.""" + if not _is_account_level_failure(exc, _http_code(exc)): + return False + if restants: + logger.error( + f"{etape} : échec au niveau du compte — cascade interrompue, " + f"{len(restants)} modèle(s) non essayé(s) ({', '.join(restants)}). " + "Aucun modèle ne peut aboutir tant que la facturation est bloquée." + ) + return True + + class LLMCascadeError(RuntimeError): """Tous les modèles de la cascade ont échoué — porte le détail par modèle.""" - def __init__(self, failures: list[tuple[str, str]]): + def __init__(self, failures: list[tuple[str, str]], aborted: bool = False): self.failures = failures + self.aborted = aborted + if not failures: + super().__init__("aucun modèle LLM configuré dans model_priority") + return detail = " | ".join(f"{model} → {reason}" for model, reason in failures) - super().__init__( - f"les {len(failures)} modèle(s) de la cascade ont échoué : {detail}" - if failures else "aucun modèle LLM configuré dans model_priority" + entete = ( + "cascade interrompue — le blocage vient du compte, pas du modèle" + if aborted else f"les {len(failures)} modèle(s) de la cascade ont échoué" ) + super().__init__(f"{entete} : {detail}") def _extract_response_text(response, model_name: str) -> str: @@ -312,6 +374,9 @@ def _call_llm(prompt: str, models_to_try: list[str], thinking: bool = True) -> s logger.warning(f"Modèle {model_name} en échec — {reason}") failures.append((model_name, reason)) + if _stop_cascade(exc, "Cascade LLM", models_to_try[len(failures):]): + raise LLMCascadeError(failures, aborted=True) from exc + raise LLMCascadeError(failures) @@ -451,7 +516,7 @@ def select_relevant_articles(articles: list[dict], interest: str, model_priority ) models_to_try = model_priority or DEFAULT_MODEL_PRIORITY - for model_name in models_to_try: + for rang, model_name in enumerate(models_to_try): try: m = genai.GenerativeModel(model_name, generation_config=config) response = m.generate_content(prompt) @@ -463,6 +528,8 @@ def select_relevant_articles(articles: list[dict], interest: str, model_priority return {"selected_ids": ids, "usage": usage} except Exception as e: logger.warning(f"Sélection : {model_name} en échec — {_describe_llm_error(e)}") + if _stop_cascade(e, "Sélection", models_to_try[rang + 1:]): + break return None @@ -491,6 +558,7 @@ def generate_synthesis(articles: list[dict], interest: str, model_priority: list models_to_try = model_priority or DEFAULT_MODEL_PRIORITY errors = [] + interrompu = False for model_name in models_to_try: try: m = genai.GenerativeModel(model_name, generation_config=config) @@ -508,10 +576,13 @@ def generate_synthesis(articles: list[dict], interest: str, model_priority: list reason = _describe_llm_error(e) logger.warning(f"Synthèse : {model_name} en échec — {reason}") errors.append(f"- **{model_name}** : {reason}") + if _stop_cascade(e, "Synthèse", models_to_try[len(errors):]): + interrompu = True + break details = "\n".join(errors) return { - "synthesis": f"⚠️ Synthèse indisponible — tous les modèles LLM ont échoué :\n{details}", + "synthesis": f"⚠️ Synthèse indisponible — {_entete_echec(errors, interrompu)} :\n{details}", "cited_ids": [], "usage": _no_usage, } @@ -566,6 +637,7 @@ def generate_run_report(logs: str, model_priority: list[str] | None = None) -> s models_to_try = model_priority or DEFAULT_MODEL_PRIORITY errors = [] + interrompu = False for model_name in models_to_try: try: m = genai.GenerativeModel(model_name) @@ -576,9 +648,12 @@ def generate_run_report(logs: str, model_priority: list[str] | None = None) -> s reason = _describe_llm_error(e) logger.warning(f"Rapport : modèle {model_name} en échec — {reason}") errors.append(f"- **{model_name}** : {reason}") + if _stop_cascade(e, "Rapport", models_to_try[len(errors):]): + interrompu = True + break details = "\n".join(errors) or "aucun modèle configuré dans model_priority." - return f"⚠️ Rapport indisponible — tous les modèles LLM ont échoué :\n{details}" + return f"⚠️ Rapport indisponible — {_entete_echec(errors, interrompu)} :\n{details}" def save_raw_articles(raw_articles: list[dict]) -> list[dict]: diff --git a/tests/test_collector.py b/tests/test_collector.py index 4763030..544b567 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -344,3 +344,109 @@ def test_generate_run_report_rapporte_la_cause_reelle(monkeypatch): assert "hors quota" not in rapport assert "modele-x" in rapport assert "404" in rapport + + +# ─── Blocage au niveau du compte (facturation) ──────────────────────────────── +# Google renvoie 429 aussi bien pour un débit dépassé que pour un solde prépayé +# épuisé. Les deux ne se traitent pas pareil : le second bloque tous les modèles. + +_ERREUR_FACTURATION = ( + "429 Your prepayment credits are depleted. Please go to AI Studio at " + "https://ai.studio/projects to manage your project and billing." +) + + +def test_429_de_facturation_nest_pas_presente_comme_un_quota(monkeypatch): + """Solde prépayé épuisé : le message doit désigner la facturation, pas le quota.""" + from processors import gemini_processor + from google.api_core import exceptions as gexc + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", + _fail_with(gexc.ResourceExhausted(_ERREUR_FACTURATION))) + + with pytest.raises(gemini_processor.LLMCascadeError) as excinfo: + gemini_processor._call_llm("prompt", ["modele-a"]) + + message = str(excinfo.value) + assert "FACTURATION BLOQUÉE" in message + assert "DÉBIT OU QUOTA DÉPASSÉ" not in message, "à ne pas confondre avec un débit dépassé" + assert "ai.studio/projects" in message, "l'action corrective doit rester lisible" + + +def test_429_de_debit_reste_un_quota(monkeypatch): + """Un vrai dépassement de débit garde son libellé et n'interrompt pas la cascade.""" + from processors import gemini_processor + from google.api_core import exceptions as gexc + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", + _fail_with(gexc.ResourceExhausted("429 Quota exceeded for quota metric 'requests'"))) + + with pytest.raises(gemini_processor.LLMCascadeError) as excinfo: + gemini_processor._call_llm("prompt", ["modele-a", "modele-b"]) + + assert "DÉBIT OU QUOTA DÉPASSÉ" in str(excinfo.value) + assert excinfo.value.aborted is False + assert len(excinfo.value.failures) == 2, "tous les modèles doivent être essayés" + + +def test_facturation_bloquee_interrompt_la_cascade(monkeypatch): + """Inutile de marteler les modèles suivants : le blocage est au niveau du compte.""" + from processors import gemini_processor + from google.api_core import exceptions as gexc + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", + _fail_with(gexc.ResourceExhausted(_ERREUR_FACTURATION))) + + with pytest.raises(gemini_processor.LLMCascadeError) as excinfo: + gemini_processor._call_llm("prompt", ["modele-a", "modele-b", "modele-c"]) + + assert excinfo.value.aborted is True + assert [m for m, _ in excinfo.value.failures] == ["modele-a"], "un seul modèle essayé" + + +def test_generate_run_report_interrompt_aussi_la_cascade(monkeypatch): + """Le rapport ne doit pas non plus rejouer 5 fois le même échec de facturation.""" + from unittest.mock import MagicMock + from processors import gemini_processor + from google.api_core import exceptions as gexc + + appels = [] + + def fake_model(model_name, *a, **k): + appels.append(model_name) + model = MagicMock() + model.generate_content = MagicMock(side_effect=gexc.ResourceExhausted(_ERREUR_FACTURATION)) + return model + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", fake_model) + + rapport = gemini_processor.generate_run_report("des logs", ["modele-a", "modele-b", "modele-c"]) + + assert appels == ["modele-a"], "cascade non interrompue" + assert "FACTURATION BLOQUÉE" in rapport + + +def test_select_relevant_articles_interrompt_aussi_la_cascade(monkeypatch): + """Même court-circuit sur l'étape de sélection de la synthèse.""" + from unittest.mock import MagicMock + from processors import gemini_processor + from google.api_core import exceptions as gexc + + appels = [] + + def fake_model(model_name, *a, **k): + appels.append(model_name) + model = MagicMock() + model.generate_content = MagicMock(side_effect=gexc.ResourceExhausted(_ERREUR_FACTURATION)) + return model + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", fake_model) + + resultat = gemini_processor.select_relevant_articles( + [{"id": "a1", "title": "Test", "long_description": "Test."}], + "kubernetes", + ["modele-a", "modele-b", "modele-c"], + ) + + assert resultat is None + assert appels == ["modele-a"], "cascade non interrompue" From 9f0308697bac0b7759020c481bb1b388fb3407f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 06:37:53 +0000 Subject: [PATCH 2/3] =?UTF-8?q?perf(llm):=20place=20le=20mod=C3=A8le=20le?= =?UTF-8?q?=20moins=20cher=20en=20t=C3=AAte=20de=20la=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemini-3.1-flash-lite (0,25 $/1,50 $ par Mtok) passe devant gemini-3.5-flash (1,50 $/9,00 $), soit 6x moins cher en entrée comme en sortie. L'essentiel du travail — reformulation de dépêches, extraction de mots-clés, rapport d'exécution — ne justifie pas le modèle haut de gamme, qui reste en second comme repli qualité. Réordonner la constante ne suffisait pas : l'ordre stocké en Firestore l'emportait sans condition, si bien que la mise à jour de cascade de juillet n'a jamais pris effet en production (l'ordre observé dans les rapports diffère de DEFAULT_MODEL_PRIORITY). Ajout de MODEL_PRIORITY_VERSION : quand la version stockée est périmée, l'ordre par défaut s'applique une seule fois, puis le choix fait dans l'admin redevient prioritaire. - `merge_model_priority()` centralise la règle côté collector ; main.py et analyze_logs.py l'utilisent au lieu de leur propre copie. - `GlobalSettings.model_priority_version` a pour défaut la version courante : un PUT qui omettrait le champ ne doit pas rejouer la migration. - Quatrième copie de la liste alignée (article_summarizer.py), qui n'était pas documentée ; un test vérifie désormais que les copies ne divergent pas. - Étiquettes de l'admin annotées du coût par million de tokens. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014s387wqjrmhJ34u8js1otm --- CLAUDE.md | 3 +- backend/app/routers/admin.py | 41 +++++++++--- backend/app/services/article_summarizer.py | 5 +- collector/analyze_logs.py | 9 +-- collector/main.py | 21 ++++-- collector/processors/gemini_processor.py | 31 ++++++++- docs/user-stories/06-admin-global-settings.md | 7 +- .../src/components/admin/AdminSettings.tsx | 7 +- tests/test_collector.py | 66 +++++++++++++++++++ 9 files changed, 159 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 897b26d..f85a22e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,7 @@ Trois workflows GitHub Actions s'enchaînent quand tu pousses un `.feature` Gher - Pas de tests automatisés côté frontend — la CI ne fait que `tsc --noEmit`. - Pas de linter Python configuré — pas de `ruff`/`flake8`/`black` à invoquer. - Catégories d'articles canoniques : `["IA", "DevOps", "Cloud", "Sécurité", "Dev", "IT", "Autre"]`. Définies en doublon dans `backend/app/routers/articles.py`, `backend/app/models/article.py`, et `collector/processors/gemini_processor.py` — modifier les trois ensemble. -- `DEFAULT_MODEL_PRIORITY` (liste de modèles Gemini) est dupliquée dans `collector/main.py`, `collector/processors/gemini_processor.py`, et `backend/app/routers/admin.py`. Quand un nouveau modèle est ajouté en tête de cette liste, le backend nettoie automatiquement les modèles inconnus stockés en Firestore et insère les nouveaux. +- `DEFAULT_MODEL_PRIORITY` (liste de modèles Gemini, **le moins cher en tête**) est définie dans `collector/processors/gemini_processor.py` et dupliquée dans `backend/app/routers/admin.py` et `backend/app/services/article_summarizer.py` — modifier les trois ensemble (`tests/test_collector.py` vérifie qu'elles ne divergent pas). `collector/main.py` et `collector/analyze_logs.py` l'importent depuis le collector. +- **L'ordre stocké en Firestore fait autorité sur la constante** : il est réglable dans l'admin. Pour qu'un changement de l'ordre par défaut s'applique à un projet existant, il faut **incrémenter `MODEL_PRIORITY_VERSION`** (dupliqué dans `gemini_processor.py` et `admin.py`) — sinon la modification reste sans effet en production. La nouvelle liste écrase alors l'ordre stocké une seule fois, puis le choix de l'admin redevient prioritaire. `merge_model_priority()` porte cette règle, purge les modèles inconnus et insère les nouveaux en tête. - Secrets en prod via Secret Manager (montés en env vars par `--set-secrets` dans `.github/workflows/ci-cd.yml`). En local : `backend/.env` et `collector/.env`, jamais commités (`.gitignore` les bloque). - `gmail_token.json` est généré une seule fois via `collector/auth_gmail.py` et monté en env var `GMAIL_TOKEN` en prod. diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2fa2eb0..3cf32d5 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -44,18 +44,29 @@ def _check_emulator_reachable(): ) +# Le modèle le moins cher passe en premier (cf. collector/processors/gemini_processor.py, +# où cette liste et sa version sont dupliquées — modifier les deux ensemble). DEFAULT_MODEL_PRIORITY = [ - "gemini-3.5-flash", - "gemini-3.1-flash-lite", + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ par Mtok + "gemini-3.5-flash", # 1,50 $ / 9,00 $ par Mtok "gemini-3-flash-preview", "gemma-4-31b-it", "gemma-4-26b-a4b-it", ] +# À incrémenter à CHAQUE changement de l'ordre par défaut : sans ce marqueur, +# l'ordre stocké en Firestore l'emporte pour toujours et la constante ci-dessus +# reste sans effet sur un projet existant. +MODEL_PRIORITY_VERSION = 2 + class GlobalSettings(BaseModel): llm_enabled: bool = True thinking_enabled: bool = True model_priority: list[str] = DEFAULT_MODEL_PRIORITY + # Défaut = version courante : un PUT qui omettrait le champ ne doit pas + # rejouer la migration et écraser l'ordre choisi dans l'admin. La détection + # d'un document périmé se fait sur le Firestore brut, pas sur ce défaut. + model_priority_version: int = MODEL_PRIORITY_VERSION gmail_lookback_days: int = 1 retention_days: int = 0 interest: str = "" @@ -81,15 +92,25 @@ def get_settings(_: dict = Depends(require_admin)): if not doc.exists: return GlobalSettings() data = doc.to_dict() - stored = data.get("model_priority", []) - # Garde uniquement les modèles connus, ajoute les nouveaux en tête - stored = [m for m in stored if m in DEFAULT_MODEL_PRIORITY] - for model in reversed(DEFAULT_MODEL_PRIORITY): - if model not in stored: - stored.insert(0, model) + stored_version = data.get("model_priority_version", 0) + + if stored_version < MODEL_PRIORITY_VERSION: + # L'ordre par défaut du code vient de changer : il s'applique une fois, + # puis l'ordre choisi dans l'admin redevient prioritaire. + stored = list(DEFAULT_MODEL_PRIORITY) + else: + stored = [m for m in data.get("model_priority", []) if m in DEFAULT_MODEL_PRIORITY] + for model in reversed(DEFAULT_MODEL_PRIORITY): + if model not in stored: + stored.insert(0, model) + data["model_priority"] = stored - # Persiste la liste nettoyée - db.collection("settings").document("global").update({"model_priority": stored}) + data["model_priority_version"] = MODEL_PRIORITY_VERSION + # Persiste la liste nettoyée et la version appliquée + db.collection("settings").document("global").update({ + "model_priority": stored, + "model_priority_version": MODEL_PRIORITY_VERSION, + }) return GlobalSettings(**data) diff --git a/backend/app/services/article_summarizer.py b/backend/app/services/article_summarizer.py index 73fd4e6..1c6620a 100644 --- a/backend/app/services/article_summarizer.py +++ b/backend/app/services/article_summarizer.py @@ -10,9 +10,10 @@ logger = logging.getLogger(__name__) +# Doit rester aligné sur app/routers/admin.py et collector/processors/gemini_processor.py. DEFAULT_MODEL_PRIORITY = [ - "gemini-3.5-flash", - "gemini-3.1-flash-lite", + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ par Mtok + "gemini-3.5-flash", # 1,50 $ / 9,00 $ par Mtok "gemini-3-flash-preview", "gemma-4-31b-it", "gemma-4-26b-a4b-it", diff --git a/collector/analyze_logs.py b/collector/analyze_logs.py index cf64089..1a21b5a 100644 --- a/collector/analyze_logs.py +++ b/collector/analyze_logs.py @@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") from google.cloud import firestore -from processors.gemini_processor import DEFAULT_MODEL_PRIORITY +from processors.gemini_processor import DEFAULT_MODEL_PRIORITY, merge_model_priority from processors.log_analyzer import run_log_analysis @@ -20,9 +20,10 @@ def main(): settings_doc = db.collection("settings").document("global").get() model_priority = DEFAULT_MODEL_PRIORITY if settings_doc.exists: - stored = settings_doc.to_dict().get("model_priority", []) - if stored: - model_priority = stored + data = settings_doc.to_dict() + model_priority = merge_model_priority( + data.get("model_priority", []), data.get("model_priority_version", 0) + ) result = run_log_analysis(db, model_priority=model_priority) logging.info( diff --git a/collector/main.py b/collector/main.py index 5bfaa98..1b93d93 100644 --- a/collector/main.py +++ b/collector/main.py @@ -15,6 +15,7 @@ from scrapers.gmail_reader import read_gmail_source from processors.gemini_processor import ( enrich_articles_batch, save_raw_articles, generate_run_report, + merge_model_priority, DEFAULT_MODEL_PRIORITY, MODEL_PRIORITY_VERSION, TITLE_LOG_MAX_LENGTH, ) from processors.synthesis import run_synthesis @@ -36,17 +37,23 @@ def emit(self, record): db = firestore.Client(project=os.environ.get("FIRESTORE_PROJECT_ID", "tech-news-aggregator-001")) -DEFAULT_MODEL_PRIORITY = ["gemini-3.5-flash", "gemini-3.1-flash-lite", "gemini-3-flash-preview", "gemma-4-31b-it", "gemma-4-26b-a4b-it"] +# DEFAULT_MODEL_PRIORITY et MODEL_PRIORITY_VERSION vivent dans gemini_processor +# (cf. import ci-dessus) — une seule définition côté collector. def get_global_settings() -> dict: doc = db.collection("settings").document("global").get() data = doc.to_dict() if doc.exists else {} - # Filtre les modèles inconnus et ajoute les nouveaux - stored = [m for m in data.get("model_priority", []) if m in DEFAULT_MODEL_PRIORITY] - for model in reversed(DEFAULT_MODEL_PRIORITY): - if model not in stored: - stored.insert(0, model) - data["model_priority"] = stored + # Purge les modèles inconnus, insère les nouveaux, et réapplique l'ordre + # par défaut si la version stockée est périmée. + stored_version = data.get("model_priority_version", 0) + data["model_priority"] = merge_model_priority( + data.get("model_priority", []), stored_version + ) + if stored_version < MODEL_PRIORITY_VERSION: + logger.info( + f"Ordre des modèles réinitialisé sur la valeur par défaut du code " + f"(version {stored_version} → {MODEL_PRIORITY_VERSION}) : {data['model_priority']}" + ) data.setdefault("llm_enabled", True) data.setdefault("translation_enabled", True) return data diff --git a/collector/processors/gemini_processor.py b/collector/processors/gemini_processor.py index 580c4ca..ee882c8 100644 --- a/collector/processors/gemini_processor.py +++ b/collector/processors/gemini_processor.py @@ -7,16 +7,43 @@ genai.configure(api_key=os.environ["GEMINI_API_KEY"]) +# Le modèle le moins cher passe en premier : l'essentiel du travail (reformulation +# de dépêches, extraction de mots-clés, rapport d'exécution) ne justifie pas un +# modèle 6× plus cher au token. Gemini 3.5 Flash reste en second comme repli qualité. DEFAULT_MODEL_PRIORITY = [ - "gemini-3.5-flash", - "gemini-3.1-flash-lite", + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ par Mtok + "gemini-3.5-flash", # 1,50 $ / 9,00 $ par Mtok "gemini-3-flash-preview", "gemma-4-31b-it", "gemma-4-26b-a4b-it", ] +# À incrémenter à CHAQUE changement de l'ordre par défaut ci-dessus. +# Sans ce marqueur, l'ordre stocké en Firestore l'emporte pour toujours et +# modifier DEFAULT_MODEL_PRIORITY reste sans effet sur un projet existant +# (c'est ce qui est arrivé à la mise à jour de juillet 2026). +MODEL_PRIORITY_VERSION = 2 + CATEGORIES = ["IA", "DevOps", "Cloud", "Sécurité", "Dev", "IT", "Autre"] + +def merge_model_priority(stored: list[str], stored_version: int = 0) -> list[str]: + """Concilie l'ordre choisi dans l'admin et l'ordre par défaut du code. + + - version stockée périmée → l'ordre par défaut s'applique (une seule fois), + sinon un changement de cascade côté code resterait sans effet en prod ; + - sinon on respecte l'ordre de l'admin, en purgeant les modèles inconnus + et en insérant les nouveaux en tête. + """ + if stored_version < MODEL_PRIORITY_VERSION: + return list(DEFAULT_MODEL_PRIORITY) + + connus = [m for m in stored if m in DEFAULT_MODEL_PRIORITY] + for model in reversed(DEFAULT_MODEL_PRIORITY): + if model not in connus: + connus.insert(0, model) + return connus + # Limites de contenu pour les prompts LLM MAX_ARTICLE_CONTENT_FOR_BATCH = 1500 # chars max par article dans le prompt batch MAX_GMAIL_CONTENT_FOR_PROMPT = 50_000 # chars max pour l'extraction Gmail diff --git a/docs/user-stories/06-admin-global-settings.md b/docs/user-stories/06-admin-global-settings.md index a989e85..a85037f 100644 --- a/docs/user-stories/06-admin-global-settings.md +++ b/docs/user-stories/06-admin-global-settings.md @@ -64,12 +64,13 @@ Switch indépendant. Configure `thinking_config = {"thinking_budget": -1}` (auto Liste ordonnée des modèles connus avec boutons ▲▼ pour réordonner. Chaque modèle a une étiquette explicative (ex. "Gemini 3 Flash — Dernière génération", "Gemini 2.0 Flash Lite — Dernier recours"). La liste persiste dans `model_priority[]`. **Règles métier** -- Liste canonique côté backend (`DEFAULT_MODEL_PRIORITY`, dupliquée 3x — cf. CLAUDE.md). +- Liste canonique `DEFAULT_MODEL_PRIORITY`, dupliquée 3x — cf. CLAUDE.md. Le modèle **le moins cher est en tête** : l'essentiel du travail (reformulation, mots-clés, rapport) ne justifie pas un modèle 6x plus cher au token. - Au GET, les modèles inconnus stockés sont nettoyés, les nouveaux sont insérés **en tête** automatiquement. -- Le collector essaie les modèles dans l'ordre ; passe au suivant en cas d'échec (quota, indisponibilité). +- L'ordre stocké fait autorité sur la constante. Changer l'ordre par défaut n'a d'effet sur un projet existant que si `MODEL_PRIORITY_VERSION` est incrémentée : la nouvelle liste s'applique alors **une seule fois**, puis le choix de l'admin redevient prioritaire. +- Le collector essaie les modèles dans l'ordre ; passe au suivant en cas d'échec (quota, indisponibilité). Un échec au niveau du compte (facturation bloquée) interrompt la cascade — aucun modèle ne peut aboutir. **Critères d'acceptation** -1. La liste affiche les 8 modèles dans l'ordre actuel. +1. La liste affiche les 5 modèles dans l'ordre actuel. 2. Cliquer ▲ ou ▼ déplace le modèle d'une position. 3. La sauvegarde est immédiate (PUT /admin/settings). 4. Le collector utilise réellement l'ordre au prochain run (vérifiable dans les logs). diff --git a/frontend/src/components/admin/AdminSettings.tsx b/frontend/src/components/admin/AdminSettings.tsx index 21c58e2..b9578b6 100644 --- a/frontend/src/components/admin/AdminSettings.tsx +++ b/frontend/src/components/admin/AdminSettings.tsx @@ -5,8 +5,8 @@ import useSWR, { mutate } from "swr"; const API = process.env.NEXT_PUBLIC_API_URL; const MODEL_LABELS: Record = { - "gemini-3.5-flash": { label: "Gemini 3.5 Flash", note: "Dernière génération — GA" }, - "gemini-3.1-flash-lite": { label: "Gemini 3.1 Flash Lite",note: "Rapide — stable" }, + "gemini-3.5-flash": { label: "Gemini 3.5 Flash", note: "Qualité max — 1,50 $/9,00 $ par Mtok" }, + "gemini-3.1-flash-lite": { label: "Gemini 3.1 Flash Lite",note: "Économique — 0,25 $/1,50 $ par Mtok" }, "gemini-3-flash-preview": { label: "Gemini 3 Flash", note: "Preview — fallback" }, "gemma-4-31b-it": { label: "Gemma 4 31B", note: "Open source — 31B" }, "gemma-4-26b-a4b-it": { label: "Gemma 4 26B", note: "Dernier recours" }, @@ -29,6 +29,9 @@ interface Settings { llm_enabled: boolean; thinking_enabled: boolean; model_priority: string[]; + // Renvoyé tel quel au PUT : sans lui, l'ordre par défaut du code serait + // réappliqué à la sauvegarde suivante et écraserait le choix de l'admin. + model_priority_version: number; gmail_lookback_days: number; retention_days: number; interest: string; diff --git a/tests/test_collector.py b/tests/test_collector.py index 544b567..9d1d9f4 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -450,3 +450,69 @@ def fake_model(model_name, *a, **k): assert resultat is None assert appels == ["modele-a"], "cascade non interrompue" + + +# ─── Ordre de la cascade de modèles ─────────────────────────────────────────── +# Régression : l'ordre stocké en Firestore l'emportait sans condition, si bien +# qu'une modification de DEFAULT_MODEL_PRIORITY restait sans effet en prod. + +def test_le_modele_le_moins_cher_est_en_tete(): + """Flash Lite (0,25 $/Mtok) doit précéder Flash (1,50 $/Mtok).""" + from processors.gemini_processor import DEFAULT_MODEL_PRIORITY + + assert DEFAULT_MODEL_PRIORITY[0] == "gemini-3.1-flash-lite" + assert DEFAULT_MODEL_PRIORITY.index("gemini-3.1-flash-lite") < \ + DEFAULT_MODEL_PRIORITY.index("gemini-3.5-flash") + + +def test_backend_et_collector_partagent_le_meme_ordre(): + """Les copies dupliquées de la liste ne doivent pas diverger.""" + import re + from pathlib import Path + from processors.gemini_processor import DEFAULT_MODEL_PRIORITY + + racine = Path(__file__).resolve().parents[1] + for fichier in ("backend/app/routers/admin.py", + "backend/app/services/article_summarizer.py"): + source = (racine / fichier).read_text() + bloc = re.search(r"DEFAULT_MODEL_PRIORITY = \[(.*?)\]", source, re.S).group(1) + modeles = re.findall(r'"([^"]+)"', bloc) + assert modeles == DEFAULT_MODEL_PRIORITY, f"{fichier} a divergé du collector" + + +def test_version_perimee_reapplique_lordre_par_defaut(): + """Le cœur du correctif : un projet existant doit recevoir le nouvel ordre.""" + from processors.gemini_processor import merge_model_priority, DEFAULT_MODEL_PRIORITY + + ordre_stocke_en_juillet = [ + "gemini-3.5-flash", "gemini-3-flash-preview", "gemini-3.1-flash-lite", + "gemma-4-31b-it", "gemma-4-26b-a4b-it", + ] + assert merge_model_priority(ordre_stocke_en_juillet, 0) == DEFAULT_MODEL_PRIORITY + assert merge_model_priority(ordre_stocke_en_juillet, 1) == DEFAULT_MODEL_PRIORITY + + +def test_version_a_jour_respecte_lordre_choisi_dans_ladmin(): + """Une fois la migration passée, le choix de l'utilisateur redevient roi.""" + from processors.gemini_processor import merge_model_priority, MODEL_PRIORITY_VERSION + + choix_admin = [ + "gemma-4-31b-it", "gemini-3.1-flash-lite", "gemini-3.5-flash", + "gemini-3-flash-preview", "gemma-4-26b-a4b-it", + ] + assert merge_model_priority(choix_admin, MODEL_PRIORITY_VERSION) == choix_admin + + +def test_purge_les_modeles_inconnus_et_insere_les_nouveaux(): + """Comportement historique conservé pour une version à jour.""" + from processors.gemini_processor import merge_model_priority, MODEL_PRIORITY_VERSION + + resultat = merge_model_priority( + ["modele-retire-du-catalogue", "gemini-3.5-flash"], MODEL_PRIORITY_VERSION + ) + assert "modele-retire-du-catalogue" not in resultat + assert set(resultat) == { + "gemini-3.1-flash-lite", "gemini-3.5-flash", "gemini-3-flash-preview", + "gemma-4-31b-it", "gemma-4-26b-a4b-it", + } + assert resultat[-1] == "gemini-3.5-flash", "les modèles absents s'insèrent en tête" From 62b4690c1821afd00ad7bb34ce3f6c61b237a609 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:19:01 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(llm):=20cascade=20tri=C3=A9e=20par=20c?= =?UTF-8?q?o=C3=BBt,=20mont=C3=A9e=20d'un=20cran=20sur=20d=C3=A9passement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applique la règle demandée : les modèles sont sollicités du moins cher au plus cher, et on ne monte en gamme que lorsque le modèle courant refuse. Ordre établi sur les tarifs réels ($ par million de tokens, entrée/sortie) : gemma-4-26b (0,07/0,30) → gemma-4-31b (0,09/0,34) → gemini-3.1-flash-lite (0,25/1,50, GA) → gemini-3-flash-preview (0,25/1,50) → gemini-3.5-flash (1,50/9,00). À prix égal, le modèle GA précède le preview. MODEL_PRIORITY_VERSION passe à 3 pour que l'ordre s'applique en production. Correction d'un trou qui rendait cet ordre risqué : le JSON était analysé en dehors de la cascade, si bien qu'une réponse malformée ne déclenchait aucune montée d'un cran et faisait basculer tout le run vers les articles bruts. La validité du JSON est désormais vérifiée dans `_call_llm` et compte comme un refus du modèle — un petit modèle qui répond du texte libre cède donc sa place au suivant. Les motifs de montée sont journalisés distinctement : « dépassement » pour un 429 de quota ou de débit (cas nominal), « anomalie » pour un modèle inconnu, une requête invalide ou une réponse illisible (défaut à corriger). Le blocage de facturation continue d'arrêter la cascade. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_014s387wqjrmhJ34u8js1otm --- backend/app/routers/admin.py | 12 +- backend/app/services/article_summarizer.py | 10 +- collector/processors/gemini_processor.py | 102 +++++++++++----- docs/user-stories/06-admin-global-settings.md | 8 +- .../src/components/admin/AdminSettings.tsx | 12 +- tests/test_collector.py | 109 +++++++++++++++++- 6 files changed, 196 insertions(+), 57 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 3cf32d5..ec09ace 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -47,17 +47,17 @@ def _check_emulator_reachable(): # Le modèle le moins cher passe en premier (cf. collector/processors/gemini_processor.py, # où cette liste et sa version sont dupliquées — modifier les deux ensemble). DEFAULT_MODEL_PRIORITY = [ - "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ par Mtok - "gemini-3.5-flash", # 1,50 $ / 9,00 $ par Mtok - "gemini-3-flash-preview", - "gemma-4-31b-it", - "gemma-4-26b-a4b-it", + "gemma-4-26b-a4b-it", # 0,07 $ / 0,30 $ + "gemma-4-31b-it", # 0,09 $ / 0,34 $ + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ — GA + "gemini-3-flash-preview", # 0,25 $ / 1,50 $ — preview + "gemini-3.5-flash", # 1,50 $ / 9,00 $ ] # À incrémenter à CHAQUE changement de l'ordre par défaut : sans ce marqueur, # l'ordre stocké en Firestore l'emporte pour toujours et la constante ci-dessus # reste sans effet sur un projet existant. -MODEL_PRIORITY_VERSION = 2 +MODEL_PRIORITY_VERSION = 3 class GlobalSettings(BaseModel): llm_enabled: bool = True diff --git a/backend/app/services/article_summarizer.py b/backend/app/services/article_summarizer.py index 1c6620a..77ecddd 100644 --- a/backend/app/services/article_summarizer.py +++ b/backend/app/services/article_summarizer.py @@ -12,11 +12,11 @@ # Doit rester aligné sur app/routers/admin.py et collector/processors/gemini_processor.py. DEFAULT_MODEL_PRIORITY = [ - "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ par Mtok - "gemini-3.5-flash", # 1,50 $ / 9,00 $ par Mtok - "gemini-3-flash-preview", - "gemma-4-31b-it", - "gemma-4-26b-a4b-it", + "gemma-4-26b-a4b-it", # 0,07 $ / 0,30 $ + "gemma-4-31b-it", # 0,09 $ / 0,34 $ + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ — GA + "gemini-3-flash-preview", # 0,25 $ / 1,50 $ — preview + "gemini-3.5-flash", # 1,50 $ / 9,00 $ ] PROMPT_VERSION = "linkedin-v3" diff --git a/collector/processors/gemini_processor.py b/collector/processors/gemini_processor.py index ee882c8..14a1965 100644 --- a/collector/processors/gemini_processor.py +++ b/collector/processors/gemini_processor.py @@ -7,26 +7,42 @@ genai.configure(api_key=os.environ["GEMINI_API_KEY"]) -# Le modèle le moins cher passe en premier : l'essentiel du travail (reformulation -# de dépêches, extraction de mots-clés, rapport d'exécution) ne justifie pas un -# modèle 6× plus cher au token. Gemini 3.5 Flash reste en second comme repli qualité. +# Cascade triée par coût croissant (entrée / sortie en $ par million de tokens). +# On sollicite le moins cher d'abord ; en cas de dépassement, on monte d'un cran. +# À prix égal, le modèle stable passe devant le preview. DEFAULT_MODEL_PRIORITY = [ - "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ par Mtok - "gemini-3.5-flash", # 1,50 $ / 9,00 $ par Mtok - "gemini-3-flash-preview", - "gemma-4-31b-it", - "gemma-4-26b-a4b-it", + "gemma-4-26b-a4b-it", # 0,07 $ / 0,30 $ + "gemma-4-31b-it", # 0,09 $ / 0,34 $ + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ — GA + "gemini-3-flash-preview", # 0,25 $ / 1,50 $ — preview + "gemini-3.5-flash", # 1,50 $ / 9,00 $ ] # À incrémenter à CHAQUE changement de l'ordre par défaut ci-dessus. # Sans ce marqueur, l'ordre stocké en Firestore l'emporte pour toujours et # modifier DEFAULT_MODEL_PRIORITY reste sans effet sur un projet existant # (c'est ce qui est arrivé à la mise à jour de juillet 2026). -MODEL_PRIORITY_VERSION = 2 +MODEL_PRIORITY_VERSION = 3 CATEGORIES = ["IA", "DevOps", "Cloud", "Sécurité", "Dev", "IT", "Autre"] +# Limites de contenu pour les prompts LLM +MAX_ARTICLE_CONTENT_FOR_BATCH = 1500 # chars max par article dans le prompt batch +MAX_GMAIL_CONTENT_FOR_PROMPT = 50_000 # chars max pour l'extraction Gmail +MAX_SYNTHESIS_INPUT = 180_000 # chars max pour le prompt de synthèse +MAX_REPORT_LOGS = 8_000 # chars max des logs pour le rapport +FALLBACK_SHORT_DESC_LENGTH = 200 # chars max short_description (fallback brut) +FALLBACK_LONG_DESC_LENGTH = 1_000 # chars max long_description (fallback brut) +LLM_TEMPERATURE = 0.4 # Température génération LLM +LLM_MAX_TOKENS_BATCH = 60_000 # Max tokens pour batch d'articles +LLM_MAX_TOKENS_SYNTHESIS = 8_000 # Max tokens pour la synthèse +TITLE_LOG_MAX_LENGTH = 60 # Longueur max des titres dans les logs +MAX_ERROR_DETAIL = 300 # chars max du message d'erreur conservé dans les logs + +logger = logging.getLogger(__name__) + + def merge_model_priority(stored: list[str], stored_version: int = 0) -> list[str]: """Concilie l'ordre choisi dans l'admin et l'ordre par défaut du code. @@ -44,21 +60,6 @@ def merge_model_priority(stored: list[str], stored_version: int = 0) -> list[str connus.insert(0, model) return connus -# Limites de contenu pour les prompts LLM -MAX_ARTICLE_CONTENT_FOR_BATCH = 1500 # chars max par article dans le prompt batch -MAX_GMAIL_CONTENT_FOR_PROMPT = 50_000 # chars max pour l'extraction Gmail -MAX_SYNTHESIS_INPUT = 180_000 # chars max pour le prompt de synthèse -MAX_REPORT_LOGS = 8_000 # chars max des logs pour le rapport -FALLBACK_SHORT_DESC_LENGTH = 200 # chars max short_description (fallback brut) -FALLBACK_LONG_DESC_LENGTH = 1_000 # chars max long_description (fallback brut) -LLM_TEMPERATURE = 0.4 # Température génération LLM -LLM_MAX_TOKENS_BATCH = 60_000 # Max tokens pour batch d'articles -LLM_MAX_TOKENS_SYNTHESIS = 8_000 # Max tokens pour la synthèse -TITLE_LOG_MAX_LENGTH = 60 # Longueur max des titres dans les logs -MAX_ERROR_DETAIL = 300 # chars max du message d'erreur conservé dans les logs - -logger = logging.getLogger(__name__) - # Diagnostic des échecs LLM. Sans ça, tous les échecs remontaient comme # « quota épuisé » alors que la cause réelle est le plus souvent ailleurs # (modèle inconnu de la version d'API appelée, paramètre non supporté, clé @@ -100,6 +101,17 @@ def _is_account_level_failure(exc: Exception, code: int | None) -> bool: return any(marker in message for marker in _BILLING_MARKERS) +def _is_quota_failure(exc: Exception, code: int | None) -> bool: + """Vrai si le modèle a refusé pour dépassement de quota ou de débit. + + C'est le cas nominal de la cascade : on monte d'un cran en gamme, donc en + prix. À distinguer d'une anomalie (modèle inconnu, requête invalide, + réponse illisible), qui fait aussi monter d'un cran mais signale un défaut + à corriger — et du blocage de compte, où aucun modèle ne peut aboutir. + """ + return code == 429 and not _is_account_level_failure(exc, code) + + def _http_code(exc: Exception) -> int | None: """Code HTTP porté par une exception google-api-core, s'il y en a un.""" code = getattr(exc, "code", None) @@ -135,6 +147,12 @@ def _entete_echec(errors: list[str], interrompu: bool) -> str: return f"les {len(errors)} modèle(s) de la cascade ont échoué" +def _log_montee(etape: str, model_name: str, exc: Exception, reason: str) -> None: + """Journalise la montée d'un cran en distinguant le cas nominal du défaut.""" + motif = "dépassement" if _is_quota_failure(exc, _http_code(exc)) else "anomalie" + logger.warning(f"{etape} : {motif} sur {model_name} — montée d'un cran : {reason}") + + def _stop_cascade(exc: Exception, etape: str, restants: list[str]) -> bool: """Vrai si l'échec est au niveau du compte : les modèles suivants échoueront pareil.""" if not _is_account_level_failure(exc, _http_code(exc)): @@ -359,10 +377,17 @@ def _generate(model_name: str, prompt: str, thinking: bool | None): def _call_llm(prompt: str, models_to_try: list[str], thinking: bool = True) -> str: - """Appelle le LLM en cascade jusqu'au premier modèle disponible. + """Appelle les modèles dans l'ordre reçu — trié du moins cher au plus cher. + + On monte d'un cran (donc en prix) dès qu'un modèle refuse. Le motif de la + montée est journalisé distinctement : + - dépassement de quota ou de débit → cas nominal, c'est le rôle de la cascade ; + - anomalie (modèle inconnu, requête invalide, réponse illisible ou non-JSON) + → on monte aussi, mais c'est un défaut à corriger ; + - blocage du compte → arrêt immédiat, aucun modèle ne peut aboutir. - Chaque échec est journalisé avec son code HTTP et son message brut : c'est - la seule façon de distinguer un vrai 429 d'une erreur de configuration. + La validité du JSON est vérifiée **ici** : une réponse malformée doit faire + monter d'un cran, pas faire échouer tout le run en aval. """ global _thinking_unsupported_logged @@ -394,11 +419,24 @@ def _call_llm(prompt: str, models_to_try: list[str], thinking: bool = True) -> s text = text.split("```")[1] if text.startswith("json"): text = text[4:] + text = text.strip() + + # Un JSON illisible est un échec du modèle, pas du run : on doit + # pouvoir monter d'un cran. Sans ça, un petit modèle bavard fait + # tomber toute la collecte en articles bruts. + try: + json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError( + f"réponse non conforme au JSON demandé ({exc.msg} " + f"ligne {exc.lineno}, colonne {exc.colno})" + ) from exc + logger.info(f"Modèle utilisé avec succès : {model_name}") - return text.strip() + return text except Exception as exc: reason = _describe_llm_error(exc) - logger.warning(f"Modèle {model_name} en échec — {reason}") + _log_montee("Cascade LLM", model_name, exc, reason) failures.append((model_name, reason)) if _stop_cascade(exc, "Cascade LLM", models_to_try[len(failures):]): @@ -554,7 +592,7 @@ def select_relevant_articles(articles: list[dict], interest: str, model_priority f"{usage['total_tokens']} tokens") return {"selected_ids": ids, "usage": usage} except Exception as e: - logger.warning(f"Sélection : {model_name} en échec — {_describe_llm_error(e)}") + _log_montee("Sélection", model_name, e, _describe_llm_error(e)) if _stop_cascade(e, "Sélection", models_to_try[rang + 1:]): break @@ -601,7 +639,7 @@ def generate_synthesis(articles: list[dict], interest: str, model_priority: list } except Exception as e: reason = _describe_llm_error(e) - logger.warning(f"Synthèse : {model_name} en échec — {reason}") + _log_montee("Synthèse", model_name, e, reason) errors.append(f"- **{model_name}** : {reason}") if _stop_cascade(e, "Synthèse", models_to_try[len(errors):]): interrompu = True @@ -673,7 +711,7 @@ def generate_run_report(logs: str, model_priority: list[str] | None = None) -> s return _extract_response_text(response, model_name) except Exception as e: reason = _describe_llm_error(e) - logger.warning(f"Rapport : modèle {model_name} en échec — {reason}") + _log_montee("Rapport", model_name, e, reason) errors.append(f"- **{model_name}** : {reason}") if _stop_cascade(e, "Rapport", models_to_try[len(errors):]): interrompu = True diff --git a/docs/user-stories/06-admin-global-settings.md b/docs/user-stories/06-admin-global-settings.md index a85037f..60ba821 100644 --- a/docs/user-stories/06-admin-global-settings.md +++ b/docs/user-stories/06-admin-global-settings.md @@ -64,10 +64,14 @@ Switch indépendant. Configure `thinking_config = {"thinking_budget": -1}` (auto Liste ordonnée des modèles connus avec boutons ▲▼ pour réordonner. Chaque modèle a une étiquette explicative (ex. "Gemini 3 Flash — Dernière génération", "Gemini 2.0 Flash Lite — Dernier recours"). La liste persiste dans `model_priority[]`. **Règles métier** -- Liste canonique `DEFAULT_MODEL_PRIORITY`, dupliquée 3x — cf. CLAUDE.md. Le modèle **le moins cher est en tête** : l'essentiel du travail (reformulation, mots-clés, rapport) ne justifie pas un modèle 6x plus cher au token. +- Liste canonique `DEFAULT_MODEL_PRIORITY`, dupliquée 3x — cf. CLAUDE.md. Elle est **triée par coût croissant** ($ par million de tokens, entrée puis sortie) : on sollicite toujours le moins cher d'abord et on ne monte en gamme — donc en prix — que si le modèle courant refuse. À prix égal, le modèle GA passe devant le preview. - Au GET, les modèles inconnus stockés sont nettoyés, les nouveaux sont insérés **en tête** automatiquement. - L'ordre stocké fait autorité sur la constante. Changer l'ordre par défaut n'a d'effet sur un projet existant que si `MODEL_PRIORITY_VERSION` est incrémentée : la nouvelle liste s'applique alors **une seule fois**, puis le choix de l'admin redevient prioritaire. -- Le collector essaie les modèles dans l'ordre ; passe au suivant en cas d'échec (quota, indisponibilité). Un échec au niveau du compte (facturation bloquée) interrompt la cascade — aucun modèle ne peut aboutir. +- Motifs de montée d'un cran, journalisés distinctement dans le rapport d'exécution : + - **dépassement** (429 de quota ou de débit) → cas nominal, c'est la raison d'être de la cascade ; + - **anomalie** (404 modèle inconnu, 400 requête invalide, réponse vide ou non conforme au JSON demandé) → on monte aussi, mais c'est un défaut à corriger ; + - **blocage du compte** (429 de facturation) → arrêt immédiat, aucun modèle ne peut aboutir. +- La validité du JSON est vérifiée **dans** la cascade (`_call_llm`) : une réponse malformée fait monter d'un cran au lieu de faire basculer tout le run vers `save_raw_articles`. **Critères d'acceptation** 1. La liste affiche les 5 modèles dans l'ordre actuel. diff --git a/frontend/src/components/admin/AdminSettings.tsx b/frontend/src/components/admin/AdminSettings.tsx index b9578b6..9e44e25 100644 --- a/frontend/src/components/admin/AdminSettings.tsx +++ b/frontend/src/components/admin/AdminSettings.tsx @@ -5,11 +5,11 @@ import useSWR, { mutate } from "swr"; const API = process.env.NEXT_PUBLIC_API_URL; const MODEL_LABELS: Record = { - "gemini-3.5-flash": { label: "Gemini 3.5 Flash", note: "Qualité max — 1,50 $/9,00 $ par Mtok" }, - "gemini-3.1-flash-lite": { label: "Gemini 3.1 Flash Lite",note: "Économique — 0,25 $/1,50 $ par Mtok" }, - "gemini-3-flash-preview": { label: "Gemini 3 Flash", note: "Preview — fallback" }, - "gemma-4-31b-it": { label: "Gemma 4 31B", note: "Open source — 31B" }, - "gemma-4-26b-a4b-it": { label: "Gemma 4 26B", note: "Dernier recours" }, + "gemma-4-26b-a4b-it": { label: "Gemma 4 26B", note: "0,07 $ / 0,30 $ par Mtok — le moins cher" }, + "gemma-4-31b-it": { label: "Gemma 4 31B", note: "0,09 $ / 0,34 $ par Mtok" }, + "gemini-3.1-flash-lite": { label: "Gemini 3.1 Flash Lite",note: "0,25 $ / 1,50 $ par Mtok — GA" }, + "gemini-3-flash-preview": { label: "Gemini 3 Flash", note: "0,25 $ / 1,50 $ par Mtok — preview" }, + "gemini-3.5-flash": { label: "Gemini 3.5 Flash", note: "1,50 $ / 9,00 $ par Mtok — qualité max" }, }; async function apiFetch(path: string, token: string, options: RequestInit = {}) { @@ -142,7 +142,7 @@ export default function AdminSettings({ token }: { token: string }) {

Priorité des modèles LLM

{saving && Sauvegarde...} -

Le collector essaie les modèles dans l'ordre. Si le premier échoue (quota, modèle indisponible, erreur d'API), il passe au suivant. La cause exacte de chaque échec figure dans le rapport d'exécution.

+

Cascade triée du moins cher au plus cher. Le collector sollicite le premier ; en cas de dépassement de quota, il monte d'un cran. Un blocage de facturation arrête la cascade — aucun modèle ne peut aboutir. La cause exacte de chaque échec figure dans le rapport d'exécution.

{(settings.model_priority ?? []).map((modelId, i) => { const info = MODEL_LABELS[modelId] ?? { label: modelId }; diff --git a/tests/test_collector.py b/tests/test_collector.py index 9d1d9f4..f53645b 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -456,13 +456,30 @@ def fake_model(model_name, *a, **k): # Régression : l'ordre stocké en Firestore l'emportait sans condition, si bien # qu'une modification de DEFAULT_MODEL_PRIORITY restait sans effet en prod. -def test_le_modele_le_moins_cher_est_en_tete(): - """Flash Lite (0,25 $/Mtok) doit précéder Flash (1,50 $/Mtok).""" +# Coût entrée/sortie en $ par million de tokens, au 3 août 2026. +PRIX_PAR_MTOK = { + "gemma-4-26b-a4b-it": (0.070, 0.300), + "gemma-4-31b-it": (0.090, 0.340), + "gemini-3.1-flash-lite": (0.250, 1.500), + "gemini-3-flash-preview": (0.250, 1.500), + "gemini-3.5-flash": (1.500, 9.000), +} + + +def test_la_cascade_est_triee_du_moins_cher_au_plus_cher(): + """Règle de conception : on sollicite toujours le moins cher d'abord.""" + from processors.gemini_processor import DEFAULT_MODEL_PRIORITY + + couts = [PRIX_PAR_MTOK[m] for m in DEFAULT_MODEL_PRIORITY] + assert couts == sorted(couts), f"cascade non triée par coût : {DEFAULT_MODEL_PRIORITY}" + assert DEFAULT_MODEL_PRIORITY[-1] == "gemini-3.5-flash", "le plus cher doit être en dernier" + + +def test_a_prix_egal_le_modele_stable_precede_le_preview(): from processors.gemini_processor import DEFAULT_MODEL_PRIORITY - assert DEFAULT_MODEL_PRIORITY[0] == "gemini-3.1-flash-lite" assert DEFAULT_MODEL_PRIORITY.index("gemini-3.1-flash-lite") < \ - DEFAULT_MODEL_PRIORITY.index("gemini-3.5-flash") + DEFAULT_MODEL_PRIORITY.index("gemini-3-flash-preview") def test_backend_et_collector_partagent_le_meme_ordre(): @@ -488,8 +505,9 @@ def test_version_perimee_reapplique_lordre_par_defaut(): "gemini-3.5-flash", "gemini-3-flash-preview", "gemini-3.1-flash-lite", "gemma-4-31b-it", "gemma-4-26b-a4b-it", ] + assert merge_model_priority(ordre_stocke_en_juillet, 0) == DEFAULT_MODEL_PRIORITY - assert merge_model_priority(ordre_stocke_en_juillet, 1) == DEFAULT_MODEL_PRIORITY + assert merge_model_priority(ordre_stocke_en_juillet, 2) == DEFAULT_MODEL_PRIORITY def test_version_a_jour_respecte_lordre_choisi_dans_ladmin(): @@ -497,7 +515,7 @@ def test_version_a_jour_respecte_lordre_choisi_dans_ladmin(): from processors.gemini_processor import merge_model_priority, MODEL_PRIORITY_VERSION choix_admin = [ - "gemma-4-31b-it", "gemini-3.1-flash-lite", "gemini-3.5-flash", + "gemini-3.5-flash", "gemma-4-31b-it", "gemini-3.1-flash-lite", "gemini-3-flash-preview", "gemma-4-26b-a4b-it", ] assert merge_model_priority(choix_admin, MODEL_PRIORITY_VERSION) == choix_admin @@ -516,3 +534,82 @@ def test_purge_les_modeles_inconnus_et_insere_les_nouveaux(): "gemma-4-31b-it", "gemma-4-26b-a4b-it", } assert resultat[-1] == "gemini-3.5-flash", "les modèles absents s'insèrent en tête" + + +# ─── Motif de la montée en gamme ────────────────────────────────────────────── +# La cascade est triée du moins cher au plus cher : on ne monte d'un cran que +# lorsque le modèle courant refuse. Un JSON illisible doit compter comme un +# refus — sinon un petit modèle bavard fait tomber tout le run. + +def test_json_malforme_fait_monter_dun_cran(monkeypatch): + """Le modèle bon marché répond du texte libre : on doit passer au suivant.""" + from unittest.mock import MagicMock + from processors import gemini_processor + + appels = [] + + def fake_model(model_name, *a, **k): + appels.append(model_name) + model = MagicMock() + response = MagicMock() + response.text = ("Bien sûr ! Voici les articles :" if model_name == "pas-cher" + else '{"ok": true}') + model.generate_content = MagicMock(return_value=response) + return model + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", fake_model) + + resultat = gemini_processor._call_llm("prompt", ["pas-cher", "plus-cher"]) + + assert resultat == '{"ok": true}' + assert appels == ["pas-cher", "plus-cher"], "la cascade n'est pas montée d'un cran" + + +def test_json_malforme_partout_leve_une_erreur_de_cascade(monkeypatch): + """Si aucun modèle ne produit du JSON, l'échec doit être explicite.""" + from unittest.mock import MagicMock + from processors import gemini_processor + + model = MagicMock() + response = MagicMock() + response.text = "désolé, je ne peux pas" + model.generate_content = MagicMock(return_value=response) + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", lambda *a, **k: model) + + with pytest.raises(gemini_processor.LLMCascadeError) as excinfo: + gemini_processor._call_llm("prompt", ["a", "b"]) + + assert "JSON" in str(excinfo.value) + assert len(excinfo.value.failures) == 2 + + +def test_depassement_et_anomalie_sont_journalises_differemment(monkeypatch, caplog): + """Le rapport doit distinguer une montée par conception d'un défaut à corriger.""" + import logging + from unittest.mock import MagicMock + from processors import gemini_processor + from google.api_core import exceptions as gexc + + erreurs = { + "sur-quota": gexc.ResourceExhausted("429 Quota exceeded for quota metric 'requests'"), + "inconnu": gexc.NotFound("404 model not found"), + } + + def fake_model(model_name, *a, **k): + model = MagicMock() + if model_name in erreurs: + model.generate_content = MagicMock(side_effect=erreurs[model_name]) + else: + response = MagicMock() + response.text = '{"ok": true}' + model.generate_content = MagicMock(return_value=response) + return model + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", fake_model) + + with caplog.at_level(logging.WARNING): + gemini_processor._call_llm("prompt", ["sur-quota", "inconnu", "bon"]) + + journal = caplog.text + assert "dépassement sur sur-quota" in journal + assert "anomalie sur inconnu" in journal