diff --git a/CLAUDE.md b/CLAUDE.md index f85a22e..00670e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,7 +137,9 @@ 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, **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. +- `DEFAULT_MODEL_PRIORITY` (modèles Gemini, **le moins cher en tête parmi ceux qui tiennent la qualité** ; les Gemma sont en repli) existe en **deux exemplaires seulement** : `collector/processors/gemini_processor.py` et `backend/app/services/article_summarizer.py` — modifier les deux ensemble, avec `MODEL_PRIORITY_VERSION` (`tests/test_collector.py` vérifie que liste et version ne divergent pas). Tous les autres modules importent : `collector/main.py`, `collector/analyze_logs.py`, `backend/app/routers/admin.py`. +- **Tout lecteur de `model_priority` doit passer par `merge_model_priority()`**, sinon il utilise l'ordre brut de Firestore et diverge de la page admin tant que celle-ci n'a pas été ouverte (c'est elle qui persiste la migration). Trois chemins lisent ce réglage : la page admin, la collecte/synthèse, et le bouton « Régénérer le résumé IA ». +- **L'ordre choisi dans la page admin est appliqué littéralement**, à chaque appel LLM, sans aucune réécriture : pas de tri, pas de purge des modèles inconnus, pas d'insertion automatique des nouveaux. `resolve_model_priority()` porte cette règle et n'a qu'un seul comportement par défaut : amorcer un projet neuf sur `DEFAULT_MODEL_PRIORITY` quand `settings/global.model_priority` est vide ou absent. Modifier la constante n'a donc **aucun effet** sur un projet existant — c'est voulu, l'administrateur est seul maître de l'ordre. Un modèle hors catalogue est signalé dans les logs mais tout de même sollicité. +- Trois chemins lisent ce réglage et doivent tous passer par `resolve_model_priority()` : la page admin (`GET /admin/settings`, en lecture seule — seul le PUT écrit), la collecte/synthèse (`collector/main.py`), et le bouton « Régénérer le résumé IA » (`article_summarizer.get_model_priority`). - 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 ec09ace..65f0e31 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -14,6 +14,12 @@ from app.auth.google_oauth import require_admin from app.db.firestore import get_db from app.config import settings +# Source unique côté backend de la liste de modèles et de sa résolution. +# La liste jumelle du collector est dans collector/processors/gemini_processor.py. +from app.services.article_summarizer import ( + DEFAULT_MODEL_PRIORITY, + resolve_model_priority, +) # Chemin vers le collector (relatif au projet) COLLECTOR_DIR = Path(__file__).resolve().parents[3] / "collector" @@ -44,29 +50,10 @@ 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 = [ - "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 = "" @@ -92,25 +79,9 @@ def get_settings(_: dict = Depends(require_admin)): if not doc.exists: return GlobalSettings() data = doc.to_dict() - 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 - 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, - }) + # L'ordre affiché est exactement celui stocké : le GET ne réécrit rien. + # Seul le PUT (réordonnancement par l'admin) modifie model_priority. + data["model_priority"] = resolve_model_priority(data.get("model_priority")) return GlobalSettings(**data) diff --git a/backend/app/services/article_summarizer.py b/backend/app/services/article_summarizer.py index 77ecddd..7cd73a6 100644 --- a/backend/app/services/article_summarizer.py +++ b/backend/app/services/article_summarizer.py @@ -10,15 +10,41 @@ logger = logging.getLogger(__name__) -# Doit rester aligné sur app/routers/admin.py et collector/processors/gemini_processor.py. +# Source unique côté backend (app/routers/admin.py importe d'ici). +# Doit rester alignée sur collector/processors/gemini_processor.py. DEFAULT_MODEL_PRIORITY = [ - "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 $ + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ — GA, cheval de trait + "gemini-3-flash-preview", # 0,25 $ / 1,50 $ — même prix, preview + "gemini-3.5-flash", # 1,50 $ / 9,00 $ — qualité max + "gemma-4-31b-it", # 0,09 $ / 0,34 $ — repli + "gemma-4-26b-a4b-it", # 0,07 $ / 0,30 $ — dernier recours ] +def resolve_model_priority(stored: list[str] | None) -> list[str]: + """Retourne l'ordre à appliquer — **littéralement celui choisi dans l'admin**. + + Jumelle de la fonction du collector. Aucune réécriture : pas de tri, pas d'insertion de modèle, pas de purge. + L'ordre stocké en Firestore par la page admin fait autorité et est + sollicité tel quel à chaque appel LLM. `DEFAULT_MODEL_PRIORITY` ne sert + qu'à amorcer un projet neuf, quand aucun ordre n'a encore été choisi. + + Un modèle inconnu du catalogue est signalé mais tout de même sollicité : + c'est un choix de l'administrateur, pas au code de le censurer. + """ + if not stored: + logger.info("Aucun ordre de modèles en base — amorçage sur la liste par défaut.") + return list(DEFAULT_MODEL_PRIORITY) + + inconnus = [m for m in stored if m not in DEFAULT_MODEL_PRIORITY] + if inconnus: + logger.warning( + f"Modèle(s) hors catalogue dans l'ordre choisi : {', '.join(inconnus)}. " + "Ils seront sollicités quand même — les retirer depuis la page admin " + "s'ils n'existent plus." + ) + return list(stored) + + PROMPT_VERSION = "linkedin-v3" SUMMARY_PROMPT = """\ @@ -201,13 +227,15 @@ async def generate_summary(text: str, models_to_try: list[str]) -> tuple[str, st def get_model_priority(db) -> list[str]: - """Lit model_priority depuis settings/global, retourne DEFAULT_MODEL_PRIORITY si absent.""" + """Lit model_priority depuis settings/global — même ordre que la page admin. + + Applique l'ordre choisi dans l'admin, exactement comme la collecte. + """ try: doc = db.collection("settings").document("global").get() if doc.exists: - priority = doc.to_dict().get("model_priority") or [] - if priority: - return priority + data = doc.to_dict() + return resolve_model_priority(data.get("model_priority")) except Exception as exc: logger.warning(f"Impossible de lire model_priority : {exc}") return DEFAULT_MODEL_PRIORITY diff --git a/collector/analyze_logs.py b/collector/analyze_logs.py index 1a21b5a..2293d76 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, merge_model_priority +from processors.gemini_processor import DEFAULT_MODEL_PRIORITY, resolve_model_priority from processors.log_analyzer import run_log_analysis @@ -21,9 +21,7 @@ def main(): model_priority = DEFAULT_MODEL_PRIORITY if settings_doc.exists: data = settings_doc.to_dict() - model_priority = merge_model_priority( - data.get("model_priority", []), data.get("model_priority_version", 0) - ) + model_priority = resolve_model_priority(data.get("model_priority")) result = run_log_analysis(db, model_priority=model_priority) logging.info( diff --git a/collector/main.py b/collector/main.py index 1b93d93..8a89077 100644 --- a/collector/main.py +++ b/collector/main.py @@ -15,7 +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, + resolve_model_priority, DEFAULT_MODEL_PRIORITY, TITLE_LOG_MAX_LENGTH, ) from processors.synthesis import run_synthesis @@ -37,23 +37,13 @@ def emit(self, record): db = firestore.Client(project=os.environ.get("FIRESTORE_PROJECT_ID", "tech-news-aggregator-001")) -# DEFAULT_MODEL_PRIORITY et MODEL_PRIORITY_VERSION vivent dans gemini_processor -# (cf. import ci-dessus) — une seule définition côté collector. +# DEFAULT_MODEL_PRIORITY vit dans gemini_processor (cf. import ci-dessus). def get_global_settings() -> dict: doc = db.collection("settings").document("global").get() data = doc.to_dict() if doc.exists else {} - # 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']}" - ) + # L'ordre choisi dans la page admin est appliqué tel quel — aucune réécriture. + data["model_priority"] = resolve_model_priority(data.get("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 14a1965..04e739d 100644 --- a/collector/processors/gemini_processor.py +++ b/collector/processors/gemini_processor.py @@ -7,28 +7,28 @@ 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. +# Cascade : le moins cher d'abord **parmi les modèles qui tiennent la qualité +# attendue**, puis montée d'un cran à chaque refus. Les Gemma sont les moins +# chers du catalogue mais produisent des descriptions trop courtes pour la +# fiche article (4 à 6 phrases attendues) — ils restent en repli de dernier +# recours, quand aucun modèle Gemini n'est disponible. +# Coûts en $ par million de tokens (entrée / sortie). DEFAULT_MODEL_PRIORITY = [ - "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 $ + "gemini-3.1-flash-lite", # 0,25 $ / 1,50 $ — GA, cheval de trait + "gemini-3-flash-preview", # 0,25 $ / 1,50 $ — même prix, preview + "gemini-3.5-flash", # 1,50 $ / 9,00 $ — qualité max + "gemma-4-31b-it", # 0,09 $ / 0,34 $ — repli + "gemma-4-26b-a4b-it", # 0,07 $ / 0,30 $ — dernier recours ] -# À 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 +# 1500 caractères ne suffisaient pas à nourrir une analyse de 4 à 6 phrases : +# le modèle n'avait pas assez de matière et produisait des descriptions courtes. +# À 4000, le surcoût est de l'ordre de 0,006 $ par run sur Flash Lite. +MAX_ARTICLE_CONTENT_FOR_BATCH = 4000 # 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 @@ -43,22 +43,30 @@ 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. +def resolve_model_priority(stored: list[str] | None) -> list[str]: + """Retourne l'ordre à appliquer — **littéralement celui choisi dans l'admin**. + + Aucune réécriture : pas de tri, pas d'insertion de modèle, pas de purge. + L'ordre stocké en Firestore par la page admin fait autorité et est + sollicité tel quel à chaque appel LLM. `DEFAULT_MODEL_PRIORITY` ne sert + qu'à amorcer un projet neuf, quand aucun ordre n'a encore été choisi. - - 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. + Un modèle inconnu du catalogue est signalé mais tout de même sollicité : + c'est un choix de l'administrateur, pas au code de le censurer. """ - if stored_version < MODEL_PRIORITY_VERSION: + if not stored: + logger.info("Aucun ordre de modèles en base — amorçage sur la liste par défaut.") 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 + inconnus = [m for m in stored if m not in DEFAULT_MODEL_PRIORITY] + if inconnus: + logger.warning( + f"Modèle(s) hors catalogue dans l'ordre choisi : {', '.join(inconnus)}. " + "Ils seront sollicités quand même — les retirer depuis la page admin " + "s'ils n'existent plus." + ) + return list(stored) + # Diagnostic des échecs LLM. Sans ça, tous les échecs remontaient comme # « quota épuisé » alors que la cause réelle est le plus souvent ailleurs @@ -78,14 +86,27 @@ def merge_model_priority(stored: list[str], stored_version: int = 0) -> list[str # 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. +# +# ATTENTION : ne jamais chercher le mot « billing » seul. TOUS les 429 de +# Gemini contiennent « check your plan and billing details », y compris un +# simple dépassement de débit — ce marqueur trop large faisait passer une +# limite de débit pour un compte suspendu et interrompait la cascade à tort. _BILLING_MARKERS = ( "prepayment credits", "credits are depleted", - "billing", - "free tier is not available", + "billing account", "consumer_suspended", ) +# Signaux d'un dépassement ordinaire. Ils l'emportent sur les marqueurs +# ci-dessus : un message qui nomme une métrique de quota décrit un débit +# dépassé, pas une facturation suspendue — la cascade doit monter d'un cran. +_RATE_LIMIT_MARKERS = ( + "quota exceeded for metric", + "exceeded your current quota", + "rate limit", +) + 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. " @@ -94,10 +115,16 @@ def merge_model_priority(stored: list[str], stored_version: int = 0) -> list[str 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é.""" + """Vrai si l'échec vient du compte (facturation) et non du modèle appelé. + + En cas de doute, on répond False : mieux vaut monter d'un cran pour rien + que d'interrompre la cascade sur un simple dépassement de débit. + """ if code != 429: return False message = str(exc).lower() + if any(marker in message for marker in _RATE_LIMIT_MARKERS): + return False return any(marker in message for marker in _BILLING_MARKERS) @@ -228,10 +255,14 @@ def _extract_response_text(response, model_name: str) -> str: Pour chaque article, produis exactement ces champs : - "title_fr" (max 12 mots) : titre percutant en français - "title_en" (max 12 mots) : titre journalistique en anglais -- "short_description_fr" (1 phrase ~25 mots) : accroche en français -- "short_description_en" (1 phrase ~25 mots) : accroche en anglais -- "long_description_fr" (4 à 6 phrases) : analyse enrichie en français -- "long_description_en" (4 à 6 phrases) : analyse enrichie en anglais +- "short_description_fr" (1 phrase de 25 mots minimum) : accroche en français +- "short_description_en" (1 phrase de 25 mots minimum) : accroche en anglais +- "long_description_fr" (4 à 6 phrases, 500 caractères minimum) : analyse enrichie en français +- "long_description_en" (4 à 6 phrases, 500 caractères minimum) : analyse enrichie en anglais + +Les longueurs minimales sont impératives : une description trop courte est +inexploitable. Développe le contexte et les enjeux pour les atteindre, sans +jamais inventer de fait absent du contenu fourni. - "category" : une valeur parmi {categories} - "keywords_fr" : liste de 10 à 15 mots simples en français (technologies, entreprises, concepts, acteurs clés). Ex: ["intelligence artificielle", "sécurité", "OpenAI"] - "keywords_en" : liste de 10 à 15 mots simples en anglais (mêmes concepts). Ex: ["artificial intelligence", "security", "OpenAI"] diff --git a/docs/user-stories/06-admin-global-settings.md b/docs/user-stories/06-admin-global-settings.md index 60ba821..791ae8e 100644 --- a/docs/user-stories/06-admin-global-settings.md +++ b/docs/user-stories/06-admin-global-settings.md @@ -64,9 +64,9 @@ 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. 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. +- **L'ordre choisi ici est appliqué littéralement à chaque appel LLM.** Aucune déduction du code : ni tri, ni purge, ni insertion. Modifier `DEFAULT_MODEL_PRIORITY` n'a aucun effet sur un projet existant — cette constante n'amorce qu'un projet neuf, quand aucun ordre n'a encore été choisi. +- La liste d'amorçage `DEFAULT_MODEL_PRIORITY` (2 exemplaires — cf. CLAUDE.md) est ordonnée du moins cher au plus cher parmi les modèles qui tiennent la qualité attendue. Les Gemma sont les moins chers du catalogue mais rendent des descriptions trop courtes pour la fiche article (4 à 6 phrases attendues) : ils y figurent en **repli de dernier recours**. Ce n'est qu'un point de départ — l'admin reste libre de tout réordonner. +- Le GET est en **lecture seule** : il restitue l'ordre stocké sans le réécrire. Seul le PUT (boutons ▲▼) modifie `model_priority`. Un modèle hors catalogue est signalé dans les logs mais reste sollicité. - 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 ; @@ -78,10 +78,12 @@ Liste ordonnée des modèles connus avec boutons ▲▼ pour réordonner. Chaque 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). -5. Si un nouveau modèle est ajouté côté code (DEFAULT_MODEL_PRIORITY), il apparaît automatiquement en tête au prochain GET admin/settings. +5. L'ordre affiché est exactement celui stocké : ouvrir la page ne le réécrit jamais. **Cas limites** -- L'admin supprime tous les modèles (impossible via UI mais possible en raw API) → fallback sur DEFAULT_MODEL_PRIORITY. +- L'admin vide la liste (impossible via l'UI, possible en raw API) → amorçage sur `DEFAULT_MODEL_PRIORITY`. +- Un modèle est retiré du catalogue Google mais reste dans l'ordre choisi → il est sollicité quand même, échoue en 404, et la cascade monte d'un cran. Un avertissement le signale dans les logs ; à l'admin de le retirer. +- Un nouveau modèle est ajouté côté code → il n'apparaît **pas** automatiquement. C'est le prix de la règle « aucune déduction ». --- diff --git a/frontend/src/components/admin/AdminSettings.tsx b/frontend/src/components/admin/AdminSettings.tsx index 9e44e25..9a0abaa 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 = { - "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" }, + "gemma-4-26b-a4b-it": { label: "Gemma 4 26B", note: "0,07 $ / 0,30 $ par Mtok — dernier recours" }, + "gemma-4-31b-it": { label: "Gemma 4 31B", note: "0,09 $ / 0,34 $ par Mtok — repli, descriptions courtes" }, "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" }, @@ -29,9 +29,6 @@ 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; @@ -142,7 +139,7 @@ export default function AdminSettings({ token }: { token: string }) {

Priorité des modèles LLM

{saving && Sauvegarde...} -

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.

+

Cascade triée du moins cher au plus cher parmi les modèles qui tiennent la qualité (les Gemma, moins chers mais trop laconiques, sont en repli). 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_article_summary.py b/tests/test_article_summary.py index e696fc1..19a33da 100644 --- a/tests/test_article_summary.py +++ b/tests/test_article_summary.py @@ -525,22 +525,33 @@ def test_summary_prompt_roundtrip_and_reset(admin_token): # ─── get_model_priority ─────────────────────────────────────────────────────── -def test_get_model_priority_uses_firestore_value(): - """get_model_priority retourne la valeur Firestore si elle existe.""" - from app.services.article_summarizer import get_model_priority - +def _mock_db_with_settings(data: dict): mock_doc = MagicMock() mock_doc.exists = True - mock_doc.to_dict = MagicMock(return_value={"model_priority": ["modele-x", "modele-y"]}) + mock_doc.to_dict = MagicMock(return_value=data) mock_collection = MagicMock() mock_collection.document.return_value.get.return_value = mock_doc mock_db = MagicMock() mock_db.collection.return_value = mock_collection + return mock_db - result = get_model_priority(mock_db) - assert result == ["modele-x", "modele-y"] + +def test_get_model_priority_applique_lordre_de_ladmin_litteralement(): + """Le résumé à la demande utilise exactement l'ordre de la page admin.""" + from app.services.article_summarizer import get_model_priority + + choix_admin = ["gemini-3.5-flash", "gemini-3.1-flash-lite"] + result = get_model_priority(_mock_db_with_settings({"model_priority": choix_admin})) + + assert result == choix_admin + + +def test_get_model_priority_amorce_sur_le_defaut_si_rien_de_choisi(): + from app.services.article_summarizer import get_model_priority, DEFAULT_MODEL_PRIORITY + + assert get_model_priority(_mock_db_with_settings({})) == DEFAULT_MODEL_PRIORITY def test_get_model_priority_uses_default_when_missing(): diff --git a/tests/test_collector.py b/tests/test_collector.py index f53645b..cdf705c 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -466,13 +466,26 @@ def fake_model(model_name, *a, **k): } -def test_la_cascade_est_triee_du_moins_cher_au_plus_cher(): - """Règle de conception : on sollicite toujours le moins cher d'abord.""" +# Les Gemma sont les moins chers du catalogue mais rendent des descriptions +# trop courtes pour la fiche article : ils sont relégués en repli. +MODELES_DE_REPLI = ("gemma-4-31b-it", "gemma-4-26b-a4b-it") + + +def test_le_moins_cher_des_modeles_retenus_est_en_tete(): + """Parmi les modèles qui tiennent la qualité, le moins cher passe 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" + retenus = [m for m in DEFAULT_MODEL_PRIORITY if m not in MODELES_DE_REPLI] + couts = [PRIX_PAR_MTOK[m] for m in retenus] + assert couts == sorted(couts), f"cascade non triée par coût : {retenus}" + assert retenus[0] == "gemini-3.1-flash-lite" + + +def test_les_modeles_de_repli_sont_en_fin_de_cascade(): + """Sollicités seulement si aucun modèle Gemini n'est disponible.""" + from processors.gemini_processor import DEFAULT_MODEL_PRIORITY + + assert DEFAULT_MODEL_PRIORITY[-2:] == list(MODELES_DE_REPLI) def test_a_prix_egal_le_modele_stable_precede_le_preview(): @@ -489,51 +502,53 @@ def test_backend_et_collector_partagent_le_meme_ordre(): 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" + # Une seule copie côté backend depuis que admin.py importe le service. + source = (racine / "backend/app/services/article_summarizer.py").read_text() + bloc = re.search(r"DEFAULT_MODEL_PRIORITY = \[(.*?)\]", source, re.S).group(1) + assert re.findall(r'"([^"]+)"', bloc) == DEFAULT_MODEL_PRIORITY, "le backend a divergé du collector" + + assert "DEFAULT_MODEL_PRIORITY = [" not in (racine / "backend/app/routers/admin.py").read_text(), \ + "admin.py doit importer la liste, pas la redéfinir" -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 +def test_lordre_de_ladmin_est_applique_litteralement(): + """Règle absolue : l'ordre choisi dans la page admin est utilisé tel quel.""" + from processors.gemini_processor import resolve_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", + choix_admin = [ + "gemini-3.5-flash", "gemma-4-26b-a4b-it", "gemini-3.1-flash-lite", + "gemini-3-flash-preview", "gemma-4-31b-it", ] + assert resolve_model_priority(choix_admin) == choix_admin - 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_aucun_modele_nest_insere_ni_retire(): + """Ni tri, ni purge, ni insertion : le code ne déduit rien.""" + from processors.gemini_processor import resolve_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 + # Un seul modèle choisi : on ne complète pas la liste. + assert resolve_model_priority(["gemini-3.5-flash"]) == ["gemini-3.5-flash"] - 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 + # Un modèle hors catalogue est conservé — c'est le choix de l'admin. + exotique = ["modele-maison", "gemini-3.1-flash-lite"] + assert resolve_model_priority(exotique) == exotique -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 +def test_liste_vide_amorce_sur_le_defaut(): + """Seul cas où le code décide : un projet neuf, sans ordre encore choisi.""" + from processors.gemini_processor import resolve_model_priority, DEFAULT_MODEL_PRIORITY + + assert resolve_model_priority([]) == DEFAULT_MODEL_PRIORITY + assert resolve_model_priority(None) == DEFAULT_MODEL_PRIORITY - 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" + +def test_resolve_ne_renvoie_pas_la_constante_elle_meme(): + """L'appelant ne doit pas pouvoir muter DEFAULT_MODEL_PRIORITY par accident.""" + from processors.gemini_processor import resolve_model_priority, DEFAULT_MODEL_PRIORITY + + resultat = resolve_model_priority(None) + resultat.append("intrus") + assert "intrus" not in DEFAULT_MODEL_PRIORITY # ─── Motif de la montée en gamme ────────────────────────────────────────────── @@ -613,3 +628,68 @@ def fake_model(model_name, *a, **k): journal = caplog.text assert "dépassement sur sur-quota" in journal assert "anomalie sur inconnu" in journal + + +# ─── 429 de débit vs 429 de facturation ─────────────────────────────────────── +# Régression : le marqueur « billing » était trop large. TOUS les 429 de Gemini +# contiennent « check your plan and billing details », y compris un simple +# dépassement de débit — la cascade s'interrompait donc à tort au premier modèle. + +MESSAGE_429_DEBIT_REEL = ( + "429 You exceeded your current quota, please check your plan and billing details. " + "For more information on this error, head to: " + "https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, " + "head to: https://ai.dev/rate-limit. * Quota exceeded for metric: " + "generativelanguage.googleapis.com/generate_content_free_tier_requests" +) + + +def test_un_429_de_debit_nest_pas_pris_pour_un_compte_bloque(monkeypatch): + """Message réel de production : doit faire monter d'un cran, pas tout arrêter.""" + 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() + if model_name == "sature": + model.generate_content = MagicMock( + side_effect=gexc.ResourceExhausted(MESSAGE_429_DEBIT_REEL)) + else: + response = MagicMock() + response.text = '{"ok": true}' + model.generate_content = MagicMock(return_value=response) + return model + + monkeypatch.setattr(gemini_processor.genai, "GenerativeModel", fake_model) + + assert gemini_processor._call_llm("prompt", ["sature", "suivant"]) == '{"ok": true}' + assert appels == ["sature", "suivant"], "la cascade a été interrompue à tort" + + +def test_le_message_de_debit_ne_parle_pas_de_facturation(): + """Le libellé ne doit pas envoyer l'utilisateur recharger un compte crédité.""" + from processors import gemini_processor + from google.api_core import exceptions as gexc + + describe = gemini_processor._describe_llm_error( + gexc.ResourceExhausted(MESSAGE_429_DEBIT_REEL)) + + assert "FACTURATION BLOQUÉE" not in describe + assert "DÉBIT OU QUOTA DÉPASSÉ" in describe + + +def test_le_vrai_message_de_facturation_reste_detecte(): + """Le cas d'origine ne doit pas régresser.""" + from processors import gemini_processor + from google.api_core import exceptions as gexc + + exc = gexc.ResourceExhausted( + "429 Your prepayment credits are depleted. Please go to AI Studio at " + "https://ai.studio/projects to manage your project and billing.") + + assert gemini_processor._is_account_level_failure(exc, 429) is True + assert "FACTURATION BLOQUÉE" in gemini_processor._describe_llm_error(exc)