Pipeline de curation automatique : lit des flux RSS, filtre en deux phases puis synthétise avec des LLM, expose un flux RSS consommable dans Reeder (iOS).
URL du digest : https://julienoh.github.io/veille/digest.xml Licence : MIT Fréquence : 3 digests/jour (7h, 13h, 19h heure de Paris) Infra : gratuite (GitHub Actions + Pages) ; seule dépense = l'API LLM
- Architecture
- Structure du repo
- Sources OPML
- Setup complet
- Paramètres de tuning
- Maintenance
- Choix techniques
- Roadmap
Le pipeline est agnostique du LLM côté conception (deux rôles : LLM de filtrage
et LLM de synthèse). L'implémentation actuelle utilise DeepSeek via OpenRouter
(deepseek-v4-flash pour le filtrage, deepseek-v4-pro pour la synthèse). Les
modèles sont configurables dans digest.py ; n'importe quel fournisseur supporté
(cf. §1 « Fournisseurs ») peut les remplacer sans toucher au reste du code.
sources.opml
│
▼
digest.py ←─── cron 3x/jour (GitHub Actions)
│
├── 1. Charge les sources depuis l'OPML
├── 2. Calcule la fenêtre depuis le dernier run réussi (last_success.json)
├── 3. Fetch les flux RSS et retire les articles déjà vus (seen.json)
├── 4. Priorise les plus récents puis plafonne avant tout appel LLM
│ ≤ 50 articles/source et ≤ 200 articles/run
│
├── 5. PHASE 1 — Scoring (LLM de filtrage, 1 appel par article)
│ Sortie : {score, decision, tags, raison}
│ Filtre : on garde decision ∈ {read_now, read_later, skim} (≈ score ≥ 3)
│
├── 6. PHASE 2 — Déduplication (LLM de filtrage, 1 appel par catégorie OPML)
│ Identifie les clusters de doublons, garde l'article canonique
│ Les autres : decision=archive, score=2 (rétrogradés, pas supprimés)
│
├── 7. PHASE 3 — Synthèse (LLM de synthèse, 1 appel par catégorie OPML)
│ Markdown éditorial : 1-2 phrases par sujet, regroupement thématique
│
├── 8. Annexe de scoring déterministe (Python, aucun appel LLM)
│ Pour chaque article retenu : score phase 1 (3-5) + raison
│
└── 9. Génère output/digest.xml puis avance last_success.json après succès
│
├──► git commit → main (seen.json + last_success.json + digest.xml)
├──► audit détaillé → artefact Actions, rétention 30 jours
└──► branche gh-pages → GitHub Pages → Reeder (iOS)
OPML (catégories → feeds)
└─► feedparser.parse(url) — par source
└─► filtre temporel : published > dernier run réussi - chevauchement
└─► filtre doublon URL : sha1(url)[:12] pas dans seen.json
│
▼ GARDE-FOUS AVANT LLM
tri : articles datés les plus récents d'abord
plafonds : 50/source puis 200/run
les articles hors budget ne déclenchent aucun appel LLM
│
▼ PHASE 1
LLM de filtrage
input : titre + source + résumé (400 chars max)
output : JSON compact (score 1-5, decision, tags, raison)
└─► filtre : decision ∈ {read_now, read_later, skim}
│
▼ groupement par catégorie OPML
│
▼ PHASE 2
LLM de filtrage
input : liste indexée des articles d'une catégorie
output : clusters de doublons + canonical_id
action : rétrograde les non-canoniques en archive
│
▼ PHASE 3
LLM de synthèse (par catégorie)
input : articles dédupliqués
output : Markdown avec puces et liens
└─► annexe Python score + raison/article
└─► feedgen → digest.xml (RSS 2.0)
└─► 1 item RSS par run
| Rôle | Configuration digest.py |
Justification |
|---|---|---|
| Filtrage (phases 1 et 2) | FILTERING_MODEL |
Tâche simple de classification, JSON court, volume élevé → modèle léger et rapide |
| Synthèse (phase 3) | SYNTHESIS_MODEL |
Nuance, regroupement thématique, ton éditorial → modèle plus capable |
L'abstraction llm_client.py route les appels selon le préfixe du nom de
modèle. Deux fournisseurs supportés en natif :
| Préfixe | Fournisseur | Clé API à fournir | Exemple |
|---|---|---|---|
anthropic/ |
Anthropic (SDK natif) | ANTHROPIC_API_KEY |
anthropic/claude-haiku-4-5-20251001 |
openrouter/ |
OpenRouter (SDK OpenAI, base_url custom) | OPENROUTER_API_KEY |
openrouter/openai/gpt-5, openrouter/google/gemini-2.5-flash |
Les deux phases peuvent utiliser des fournisseurs différents (ex : filtrage via Anthropic direct pour le caching, synthèse via OpenRouter pour tester un autre modèle). Il suffit de définir les variables d'env correspondantes.
Pour ajouter un troisième fournisseur (Mistral direct, Together, etc.),
étendre llm_client.py avec un nouveau préfixe et un client dédié.
Tous les appels passent par complete() avec temperature=0 (constante
TEMPERATURE dans llm_client.py), c.-à-d. décodage glouton. Le pipeline est
un filtre/classifieur : on veut qu'un même article reçoive toujours le même
score/décision, des logs d'audit interprétables (la distribution des scores
reflète le contenu réel, pas du bruit d'échantillonnage), et pouvoir attribuer
toute variation d'output à une modif de prompt plutôt qu'au hasard.
seed fixe et l'épinglage du
provider OpenRouter (provider.order) réduiraient encore ce reliquat.
veille/
├── .github/
│ └── workflows/
│ └── digest.yml # Workflow GitHub Actions (cron + deploy)
├── output/
│ └── digest.xml # Flux RSS généré, commité + servi par Pages
├── logs/
│ ├── audit-summary.md # Synthèse compteurs par run, 30 derniers jours
│ └── audit-errors.md # Erreurs survenues par run, 30 derniers jours
├── tests/
│ ├── test_collection.py # Tests fenêtre de collecte + plafonds de coût
│ └── test_score_details.py # Tests du rendu de scoring visible
├── collection.py # Fenêtre dynamique, priorité temporelle, plafonds
├── digest.py # Pipeline complet (load, fetch, score, dédup, synth, audit)
├── audit.py # Logs Markdown des 3 phases (cf. §6 Logs d'audit)
├── llm_client.py # Mini-abstraction LLM (route anthropic/ vs openrouter/)
├── prompt.py # Prompts LLM isolés (itérables indépendamment du code)
├── score_details.py # Annexe score + raison, rendue sans LLM
├── requirements.txt # feedparser, feedgen, anthropic, openai, httpx
├── seen.json # Hashes SHA1 des articles traités (fenêtre 14 jours)
├── last_success.json # Timestamp du dernier run terminé avec succès
├── sources.opml # Sources organisées par catégorie
├── LICENSE # MIT
└── README.md # Ce fichier
digest.py : pipeline complet. Toute la logique métier : chargement OPML,
fetch RSS, déduplication URL, scoring (phase 1), déduplication sémantique
(phase 2), synthèse (phase 3), génération RSS. La section Configuration
en haut regroupe tous les paramètres ajustables.
collection.py : logique pure exécutée avant les appels LLM. Calcule la
fenêtre depuis le dernier run réussi, trie les candidats par fraîcheur et
applique les plafonds par source puis par run. Ce module est testé sans réseau.
prompt.py : trois prompts LLM isolés (SCORING_PROMPT, DEDUP_PROMPT,
SYNTHESIS_PROMPT). Découplage intentionnel : on itère sur les prompts sans
risquer de casser la logique Python, et l'historique git des changements
de prompts est séparé de celui du code.
score_details.py : produit l'annexe visible d'évaluation du scoring à
partir des données de phase 1. Le rendu est déterministe : aucun modèle ne peut
oublier un score ou l'associer au mauvais article. Seuls les articles retenus
(scores 3 à 5) apparaissent, avec leur raison.
sources.opml : source de vérité des feeds. Le script le lit à chaque run —
modifier l'OPML suffit pour ajouter/retirer une source. Même fichier utilisable
dans Reeder pour abonnement direct.
seen.json : dictionnaire {hash: date_iso} des articles déjà traités.
Commité à chaque run via git commit -m "Update digest [skip ci]".
La fenêtre glissante de SEEN_RETENTION_DAYS évite la croissance infinie.
last_success.json : état minimal de collecte. La prochaine exécution repart
du début du dernier run terminé avec succès, avec une heure de chevauchement.
Le timestamp n'est avancé qu'à la fin d'un run réussi : un cron retardé, sauté
ou en échec ne crée donc plus de période aveugle.
digest.yml : workflow GitHub Actions. Trois déclencheurs : cron 3x/jour,
workflow_dispatch (bouton manuel), push sur main.
audit.py : produit le résumé, les erreurs et le détail article par article.
Le résumé et les erreurs restent versionnés ; le détail est téléversé comme
artefact GitHub Actions pendant 30 jours afin de ne plus gonfler l'historique Git.
Le fichier sources.opml est organisé en catégories (premier niveau d'outline)
qui contiennent chacune des feeds (deuxième niveau). Seuls les outlines avec
un attribut xmlUrl sont fetchés ; les sources documentées sans RSS sont
ignorées silencieusement par le pipeline mais restent visibles dans l'OPML.
Catégories actuelles :
- Sources françaises IA : Actu IA, Usine Digitale, Frenchweb, Next.ink…
- Grandes sources internationales : The Batch, MIT Technology Review, VentureBeat AI, TechCrunch AI, Wired AI, Ars Technica…
- Labos & éditeurs : OpenAI, DeepMind, Anthropic (feed tiers), Hugging Face, NVIDIA…
- Recherche & veille technique : arXiv cs.AI, arXiv cs.LG, HF Daily Papers (feed tiers), EleutherAI, LessWrong AI
- Automatisation & agents : LangChain, LlamaIndex, n8n, Relevance AI
Plusieurs sources de référence n'ont pas de flux RSS direct :
- Anthropic, HF Daily Papers : feeds tiers générés par Olshansk/rss-feeds ou takara.ai. Fiables en pratique mais dépendants d'un mainteneur tiers.
- Mistral, Bloomberg Tech, FT AI, The Information, Superhuman AI : pas de RSS officiel, paywall ou newsletter. Pour les newsletters, utiliser Kill the Newsletter qui convertit n'importe quelle newsletter email en flux RSS.
- Trouver l'URL du flux RSS (patterns courants :
/feed,/rss.xml,/atom.xml). - Tester l'URL dans un navigateur (doit afficher du XML).
- Ajouter dans
sources.opmldans la catégorie appropriée :<outline type="rss" text="Nom du site" xmlUrl="https://exemple.com/feed" htmlUrl="https://exemple.com"/>
- Commiter et pousser — pris en compte au prochain run automatiquement.
- Compte GitHub gratuit.
- Compte chez au moins un des fournisseurs LLM supportés :
- openrouter.ai (OpenRouter — utilisé par défaut, accès multi-modèles dont DeepSeek)
- platform.claude.com (Anthropic — alternative)
- Prévoir quelques crédits sur le fournisseur choisi.
git clone https://github.com/julienoh/veille.git
cd veilleModifier sources.opml pour ajouter tes propres sources ou retirer celles
qui ne t'intéressent pas. Format standard OPML, importable dans n'importe
quel lecteur RSS.
# digest.py, section Configuration
DIGEST_URL = "https://TON-USER.github.io/NOM-REPO/digest.xml"- Repo public (obligatoire pour GitHub Pages gratuit).
- Pousser tous les fichiers sur la branche
main. - Vérifier que
.github/workflows/digest.ymlest bien présent.
- Aller sur le portail du fournisseur LLM choisi.
- Settings → API Keys → Create Key.
- Copier la clé immédiatement (affichée une seule fois).
- Dans le repo GitHub : Settings → Secrets and variables → Actions →
New repository secret.
- Pour OpenRouter (config par défaut) :
OPENROUTER_API_KEY. - Pour Anthropic (alternative) :
ANTHROPIC_API_KEY. - Tu peux ajouter les deux si tu veux mixer les fournisseurs entre filtrage et synthèse.
- Value : coller la clé.
- Pour OpenRouter (config par défaut) :
Ne jamais mettre la clé dans le code ou dans un fichier du repo. GitHub Secrets est le seul endroit approprié.
La clé est lue dans le step env: du workflow .github/workflows/digest.yml,
qui expose déjà OPENROUTER_API_KEY et ANTHROPIC_API_KEY.
Sur le portail du fournisseur LLM, Settings → Billing → Limits :
- Monthly budget limit : 20-30$ (protection contre les bugs de boucle).
- Désactiver l'auto-reload.
Actions → Build digest → Run workflow → branche main → Run workflow.
Attendre ~2 minutes. Vérifier dans les logs :
Sources chargées : XX
Fenêtre de collecte : depuis ...
Articles frais détectés : XX
Articles admis au scoring : XX/200 (... écartés ...)
Phase 1 terminée : XX articles retenus
Phase 2 terminée : XX articles après dédup
✓ Digest écrit dans output/digest.xml
Si Articles frais détectés : 0, augmenter temporairement
INITIAL_LOOKBACK_HOURS pour le premier run, puis remettre la valeur à 14.
Après un premier run réussi (la branche gh-pages est créée automatiquement) :
Settings → Pages → Source = Deploy from a branch →
Branch = gh-pages / / (root) → Save.
Attendre 2 minutes, puis vérifier :
https://TON-USER.github.io/NOM-REPO/digest.xml
Reeder → + → coller l'URL → valider.
Tous dans digest.py, section Configuration en haut du fichier.
| Paramètre | Défaut | Quand changer |
|---|---|---|
INITIAL_LOOKBACK_HOURS |
14 | Fenêtre de secours au premier run ou si last_success.json est absent/invalide. |
FETCH_OVERLAP_MINUTES |
60 | Chevauchement avant le dernier run réussi. seen.json retire les doublons. |
MAX_ARTICLES_PER_SOURCE |
50 | Budget phase 1 par source ; protège notamment contre les pics arXiv. |
MAX_ARTICLES_PER_RUN |
200 | Nombre maximal d'articles envoyés au scoring LLM sur un run. |
ACCEPTED_DECISIONS |
{"read_now", "read_later", "skim"} |
Inclut skim → retient ≈ tout score ≥ 3. Retirer "skim" pour ne garder que les score 4-5 ; retirer aussi "read_later" pour un digest "urgent only". |
MAX_ARTICLES_PER_CATEGORY |
20 | Baisser à 10 si une catégorie déborde. Garde-fou contre les pics de volume (ex: arXiv). |
SEEN_RETENTION_DAYS |
14 | Fenêtre de déduplication URL. 14 jours = un article vu cette semaine ne reviendra pas la semaine prochaine. |
FILTERING_MODEL |
openrouter/deepseek/deepseek-v4-flash |
Modèle utilisé pour les phases 1 et 2. Préférer un petit modèle léger et rapide. Format <provider>/<nom> cf. §1. |
SYNTHESIS_MODEL |
openrouter/deepseek/deepseek-v4-pro |
Modèle utilisé pour la phase 3. Préférer un modèle plus capable pour le ton et le regroupement. Format <provider>/<nom> cf. §1. |
Le détail (critères, exemples) vit dans prompt.py (SCORING_PROMPT).
Synthèse pour avoir le réflexe sans ouvrir le prompt :
Le mapping score→décision est strictement déterministe (SCORING_PROMPT),
sans exception :
| Score | Niveau | Décision |
|---|---|---|
| 5 | Incontournable (CVE exploitée, modèle SOTA, décision ANSSI…) | read_now |
| 4 | Intéressant (vuln importante, étude solide, release majeure) | read_later |
| 3 | Utile si du temps (tutoriel, REX, analyse correcte) | skim |
| 2 | Marginal (annonce mineure, opinion peu argumentée) | archive |
| 1 | Bruit (clickbait, communiqué pur, méta-annonce) | archive |
Le score (1-5) est attribué par bandes d'archétypes (« première règle qui
correspond »), décrites dans SCORING_PROMPT. Pas de système de plafonds
(choisi pour maximiser le rappel — quitte à laisser passer un peu de bruit
marketing). Avec ACCEPTED_DECISIONS incluant skim, la rétention = score ≥ 3.
ia_recherche · ia_produit · ia_stratégie · ia_management_equipe ·
cyber_vuln · cyber_strategie · dev_tooling · business · autre.
Trois prompts dans prompt.py. Bonnes pratiques :
- Changer une chose à la fois et observer sur 2-3 runs avant de rechanger.
- Versionner chaque changement avec un message de commit descriptif
(
git logsurprompt.pydevient ton historique d'expérimentation). - Tester en local :
OPENROUTER_API_KEY=sk-... python3 digest.py. - Télécharger l'artefact
audit-details-<run_id>du run Actions pour vérifier la distribution des scores (cf. §6).
Comportement attendu, pas un bug : output/digest.xml n'est réécrit
(write_rss()) que si au moins un article passe le scoring (read_now/
read_later), survit à la dédup et obtient une synthèse. Si un run ne retient
rien, digest.py sort tôt (Rien de pertinent, on sort.) et ne touche pas le
flux — seen.json, last_success.json et les logs synthétiques sont committés
(le commit Update digest [skip ci] apparaît quand même, d'où la confusion).
Pour savoir pourquoi rien n'est retenu : télécharger l'artefact
audit-details-<run_id> du run Actions ; il montre tous les articles avec
score + tag + raison. Un taux de
rétention durablement à 0% (cf. colonne Retenue% du summary) peut être
légitime (rubric strict + feeds hors-cible) ou signaler un prompt trop sévère.
Le cron GitHub Actions est interprété en UTC, sans gestion du DST. Le
schedule 0 5,11,17 vise 7h/13h/19h Paris en heure d'été (UTC+2) ; en
hiver (UTC+1) les runs tombent 1h plus tôt (6h/12h/18h Paris). Compromis assumé.
Note aussi que GitHub retarde fréquemment les runs schedule (souvent
1-2h) et peut en sauter sous forte charge : un digest qui semble « manquant »
est le plus souvent juste décalé. La collecte ne dépend plus de l'heure théorique
du cron : elle repart de last_success.json, avec 60 minutes de chevauchement.
Dans les logs Actions → dernier run → "Run digest", chercher :
! fetch error NOM_DU_FEED: ...
Corriger l'URL dans sources.opml ou commenter l'entrée le temps de
trouver la nouvelle URL.
- Vérifier que
last_success.jsoncontient un timestamp valide ; en son absence, le pipeline utiliseINITIAL_LOOKBACK_HOURS(14h par défaut). - Scoring trop sévère → assouplir les bandes de score dans
SCORING_PROMPT(skim, score 3, est déjà retenu → rétention = score ≥ 3). - Sources peu actives → vérifier directement dans Reeder.
ACCEPTED_DECISIONStrop large → retirer"skim"(ne garder que score 4-5).- Profil dans
SCORING_PROMPTtrop large → affiner les critères de score. - Réintroduire des plafonds anti-bruit dans
SCORING_PROMPT(levée de fonds, sponsorisé, tweet-seul → max 2) si le marketing passe trop. - Source particulièrement bruyante → retirer de l'OPML ou la déplacer dans une catégorie séparée.
- Vérifier les logs de la phase 2 :
dédup CATEGORIE : N doublon(s) rétrogradé(s). - Si N = 0 alors que tu vois des doublons : affiner
DEDUP_PROMPT(exemples, critères de canonical_id, granularité du "même sujet factuel"). - La phase 2 est intra-catégorie : les doublons cross-catégorie ne sont pas détectés actuellement (cf. roadmap).
Chaque catégorie du bulletin se termine par Évaluation du scoring. Pour chaque article réellement transmis à la synthèse, cette annexe affiche :
5/5 — Titre — Source. Raison : justification du modèle de filtrage
Le score affiché est score_phase1, c'est-à-dire la note initiale avant la
déduplication. Les scores 1 et 2 restent exclus du digest et sont consultables
dans logs/audit-details.md. L'annexe est construite par Python après la
synthèse ; elle ne dépend donc pas du respect d'une consigne par le LLM.
Quand le fournisseur publie de nouveaux modèles, mettre à jour dans digest.py :
FILTERING_MODEL = "..."
SYNTHESIS_MODEL = "..."Les versions majeures des actions GitHub (checkout, setup-python, actions-gh-pages…) sortent régulièrement avec des changements de runtime Node.js. Quand un warning apparaît dans les logs du type :
"Node.js XX actions are deprecated [...] forced to run with Node.js YY"
Mettre à jour les uses: correspondants dans digest.yml en consultant
la dernière version stable de chaque action sur leur repo GitHub respectif.
Surveiller Articles frais détectés et Articles admis au scoring dans le log
Actions. Le second ne peut jamais dépasser 200, ni 50 pour une même source.
Un grand nombre d'articles écartés indique une source exceptionnellement
volumique ; les plus récents restent prioritaires.
Trois fichiers Markdown sont produits à chaque run. audit-summary.md et
audit-errors.md restent sous logs/ avec une rétention glissante de 30 jours.
audit-details.md est temporaire, ignoré par Git et publié comme artefact Actions
avec une rétention de 30 jours.
Une ligne par run. À consulter en passant pour spotter une anomalie globale.
| Colonne | Sens | Valeur typique |
|---|---|---|
Date UTC |
Timestamp du run (UTC, sans secondes) | 2026-06-19 18:01 UTC |
Filtrage |
Modèle LLM utilisé en phases 1 et 2 (slug abrégé) | dsv4-flash, claude-haiku-4-5, gpt-5-mini |
Synthèse |
Modèle LLM utilisé en phase 3 (slug abrégé) | dsv4-pro, claude-sonnet-4-6 |
Trouvés |
Articles admis au scoring après les plafonds (hors seen.json) |
10-200 |
RN |
Articles décidés read_now après dédup |
0-5 |
RL |
Articles décidés read_later après dédup |
0-15 |
Skim |
Articles décidés skim après dédup (retenus depuis 2026-06-22) |
0-15 |
Arch |
Non retenu (decision = archive) |
majorité |
Dédup |
Articles rétrogradés par la phase 2 | 0-5 |
Err |
Erreurs survenues, cliquable → ouvre audit-errors.md |
0 idéalement |
Retenue% |
(RN + RL + Skim) / Trouvés — taux de retenue |
20-40% |
Signaux d'alerte : Retenue% > 50% (prompt trop laxiste ou profil cible trop
large), Retenue% < 5% sur plusieurs runs (sources sèches ou prompt trop
strict), Err > 10% (modèle qui dérive ou bug récent).
Le fichier audit-details.md contenu dans l'artefact commence par une ligne
Distribution : …×s2, …×s1 (récap des scores phase 1), puis un tableau de
tous les articles scorés (y compris les rejetés score 1-2), trié par score
décroissant. On trace tout volontairement : c'est le détail des articles
rejetés qui permet de comprendre un run qui ne retient rien — sans ça, un run
sans rétention produisait _Aucun article score ≥ 3_ et masquait les
scores/raisons (cf. §6 « Le digest ne se met pas à jour »).
| Colonne | Sens |
|---|---|
Sc |
Score initial donné par le LLM en phase 1 (1-5), avant rétrogradation éventuelle par la phase 2 |
Décision |
Décision finale : read_now, read_later, skim, archive. Si la phase 2 a rétrogradé en doublon, vaut archive |
Tag |
Catégorie thématique : ia_recherche, ia_produit, ia_stratégie, ia_management_equipe, cyber_vuln, cyber_strategie, dev_tooling, business, autre |
Titre |
Titre du feed, cliquable vers l'article (` |
Source |
Nom du feed dans l'OPML |
Raison |
Justification narrative du LLM (max 40 mots). Pour un doublon rétrogradé : doublon de [N] sujet… |
Tri intra-run : par score décroissant.
Un tableau plat. Une ligne par erreur survenue, toutes phases confondues. Aucune ligne pour un run = pipeline sain.
| Colonne | Sens |
|---|---|
Date UTC |
Timestamp où l'erreur s'est produite |
Phase |
state (last_success.json), fetch (feed RSS), scoring (phase 1), dedup (phase 2), synthese (phase 3) |
Cible |
Nom du feed (fetch), titre+source de l'article (scoring), nom de catégorie OPML (dedup, synthese) |
Erreur |
Message traduit lisible (ex: content=None côté LLM, Rate limit fournisseur LLM atteint, JSON malformé) |
Pour le debug profond (stack trace complète), consulter les logs du run correspondant sur GitHub Actions.
Les logs versionnés sont purgés en fin de run par audit.log_run(). Les
artefacts détaillés expirent automatiquement après 30 jours côté GitHub Actions.
La cellule Err du summary pointe vers le fichier audit-errors.md
(lien vers le fichier, pas vers une ancre précise). Pour retrouver les
erreurs d'un run particulier : Cmd+F sur la date dans audit-errors.md.
Phase 1 (scoring) note chaque article isolément — efficace mais ne voit pas les doublons. Phase 2 (déduplication) compare uniquement les articles score 3-5 entre eux, ce qui élimine le bruit sémantique (deux médias qui relayent la même CVE) sans retraiter les articles déjà jetés en phase 1. Séparer évite aussi qu'un prompt unique devienne illisible.
Le découplage rôle / modèle (LLM de filtrage vs LLM de synthèse) permet de mixer les fournisseurs, changer un seul modèle sans toucher au reste, et de bénéficier rapidement des nouvelles versions sans réécrire la doc. L'implémentation actuelle utilise DeepSeek via OpenRouter, mais le design ne l'impose pas.
Zéro infrastructure à maintenir, gratuit sur repo public, logs intégrés,
versionné. Alternatives écartées : VPS (charge mentale d'admin),
n8n (UX limitée pour la logique fine), Claude Code en -p (besoin d'une
machine allumée). Tradeoff : on dépend de GitHub côté disponibilité.
Double usage : le même fichier est lisible par le pipeline Python
(ET.parse()) ET importable directement dans Reeder, Feedly, ou n'importe
quel lecteur RSS standard. Ajouter une source = modifier l'OPML, pas le code.
Cela sépare aussi les préoccupations : un éditeur non-développeur pourrait
maintenir l'OPML sans toucher au code.
Zéro infrastructure. Le fichier reste petit (quelques dizaines de KB après
14 jours de rétention), git gère les conflits proprement (le [skip ci]
évite les boucles), et l'historique des articles traités est versionné
gratuitement. SQLite sur le runner Actions serait réinitialisé à chaque run
(éphémère). Redis ou DynamoDB seraient overkill pour ce volume.
Les horaires 7h/13h/19h comportent deux écarts de 6h et un écart nocturne de
12h. Une fenêtre fixe de 8h laissait donc 4h non couvertes chaque nuit. L'état
du dernier run réussi couvre aussi les retards, les crons sautés et les échecs ;
le chevauchement d'une heure est sans coût fonctionnel grâce à seen.json.
Le coût principal est proportionnel au nombre d'appels de phase 1. Les plafonds 50/source et 200/run sont donc appliqués avant le premier appel LLM, après un tri par fraîcheur. Le plafond historique de 20 par catégorie reste un garde-fou de synthèse, mais il n'est plus la première protection budgétaire.
Le scoring et la déduplication sont des tâches simples et répétitives : un modèle léger et rapide suffit, et il encaisse bien le volume. La synthèse finale (regroupement thématique, ton éditorial, Markdown structuré) demande plus de nuance, donc un modèle plus capable. Séparer les deux rôles permet de choisir le bon modèle pour chaque tâche, sans surdimensionner le filtrage.
Le LLM de synthèse peut regrouper plusieurs articles sous une même puce. Lui demander d'insérer les scores créerait un risque d'omission ou de mauvaise association. L'annexe est donc générée depuis les objets d'articles après la phase 2 : chaque titre, lien, score initial et raison restent liés sans appel supplémentaire et peuvent être couverts par des tests unitaires.
Le digest est un bulletin éditorial, pas un agrégateur. Un item par run dans Reeder = "1 nouveau bulletin" 3×/jour, avec tout le contenu dedans. Plus lisible qu'une liste de 20 items atomiques. Inconvénient : si on veut marquer un article spécifique comme "à relire", c'est moins pratique (alternative possible : mode hybride avec un item par article retenu + un item résumé).
Le code n'a rien d'innovant (pipeline classique de curation), aucune logique métier originale à protéger. MIT = friction minimale pour quiconque voudrait s'en inspirer. Repo public = GitHub Pages gratuit + Actions illimité + partage possible. La seule donnée sensible (clé API) est dans GitHub Secrets, jamais dans le repo.
- Retry sur les appels LLM : un blip réseau fait planter un article.
Ajouter
tenacityou une boucletry/exceptavec backoff exponentiel. - Batch scoring : les ~50 appels phase 1 séquentiels prennent ~30s. Les envoyer en parallèle (ou via une API Batch) réduirait la durée du run.
- Alertes sur feeds cassés : log structuré des erreurs de fetch, envoi d'une notification quand un feed échoue X fois de suite.
- Calibration du scoring sur données : les logs d'audit (
logs/) tracent désormais score + tag + raison de chaque article ; itérer sur les bandes deSCORING_PROMPTà partir de la distribution réelle des scores (cf. §6).
- Déduplication cross-catégorie : la phase 2 actuelle travaille par catégorie OPML. Étendre à une dédup globale (ou par paire de catégories proches) pour attraper les doublons entre "Sources FR" et "Sources internationales" sur un même incident.
- Filtre cybersécurité dédié : ajouter CERT-FR, ANSSI alertes, CISA KEV dans une catégorie "Cyber FR" avec un prompt de scoring spécialisé (criticité CVE, périmètre DICP…).
- Pré-filtre arXiv par mots-clés : avant la phase 1, filtrer les titres arXiv par liste de mots-clés pertinents pour réduire le volume entrant.
- Mémoire des sujets : embeddings + index vectoriel pour détecter les thèmes récurrents sur plusieurs semaines et adapter le scoring ("ce sujet a déjà été couvert 3 fois ce mois, baisser le score").
- Feedback loop : mécanisme pour signaler les articles mal scorés et affiner le prompt automatiquement.
- Migration vers GitHub Actions native Pages : remplacer
peaceiris/actions-gh-pages@v4par les actions officiellesactions/upload-pages-artifact+actions/deploy-pages. - Mode multi-fournisseur : abstraire le client LLM derrière une interface commune pour permettre de mixer ou basculer entre fournisseurs.