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..ec09ace 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-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 = 3 + 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..77ecddd 100644 --- a/backend/app/services/article_summarizer.py +++ b/backend/app/services/article_summarizer.py @@ -10,12 +10,13 @@ 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-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/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 d95d493..14a1965 100644 --- a/collector/processors/gemini_processor.py +++ b/collector/processors/gemini_processor.py @@ -7,16 +7,26 @@ genai.configure(api_key=os.environ["GEMINI_API_KEY"]) +# 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.5-flash", - "gemini-3.1-flash-lite", - "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 = 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 @@ -32,6 +42,24 @@ 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. + + - 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 + # 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é @@ -41,11 +69,57 @@ 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 _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) + 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 +128,59 @@ 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 _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)): + 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: @@ -270,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. - 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. + 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. + + 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 @@ -305,13 +419,29 @@ 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):]): + raise LLMCascadeError(failures, aborted=True) from exc + raise LLMCascadeError(failures) @@ -451,7 +581,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) @@ -462,7 +592,9 @@ 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 return None @@ -491,6 +623,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) @@ -506,12 +639,15 @@ 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 + 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 +702,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) @@ -574,11 +711,14 @@ 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 + 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/docs/user-stories/06-admin-global-settings.md b/docs/user-stories/06-admin-global-settings.md index a989e85..60ba821 100644 --- a/docs/user-stories/06-admin-global-settings.md +++ b/docs/user-stories/06-admin-global-settings.md @@ -64,12 +64,17 @@ 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. 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. -- 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. +- 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 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..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: "Dernière génération — GA" }, - "gemini-3.1-flash-lite": { label: "Gemini 3.1 Flash Lite",note: "Rapide — stable" }, - "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 = {}) { @@ -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; @@ -139,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 4763030..f53645b 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -344,3 +344,272 @@ 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" + + +# ─── 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. + +# 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.index("gemini-3.1-flash-lite") < \ + DEFAULT_MODEL_PRIORITY.index("gemini-3-flash-preview") + + +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, 2) == 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 = [ + "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 + + +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" + + +# ─── 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