Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
47 changes: 9 additions & 38 deletions backend/app/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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)


Expand Down
48 changes: 38 additions & 10 deletions backend/app/services/article_summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """\
Expand Down Expand Up @@ -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
6 changes: 2 additions & 4 deletions collector/analyze_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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(
Expand Down
18 changes: 4 additions & 14 deletions collector/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading