Skip to content

Repository files navigation

Veille IA & Cyber — Digest personnel

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


Table des matières

  1. Architecture
  2. Structure du repo
  3. Sources OPML
  4. Setup complet
  5. Paramètres de tuning
  6. Maintenance
  7. Choix techniques
  8. Roadmap

1. Architecture

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.

Vue d'ensemble

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)

Flux de données

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

LLMs utilisés

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

Fournisseurs supportés

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é.

Déterminisme (température)

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.

⚠️ Déterminisme quasi-total, pas garanti à 100 % : les modèles MoE (routage dépendant du batch serveur) et le routage hardware côté OpenRouter peuvent laisser un résiduel sur les articles-limite. Un seed fixe et l'épinglage du provider OpenRouter (provider.order) réduiraient encore ce reliquat.


2. Structure du repo

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

Rôle de chaque 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.


3. Sources OPML

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

Sources sans RSS officiel

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.

Ajouter une source

  1. Trouver l'URL du flux RSS (patterns courants : /feed, /rss.xml, /atom.xml).
  2. Tester l'URL dans un navigateur (doit afficher du XML).
  3. Ajouter dans sources.opml dans la catégorie appropriée :
    <outline type="rss" text="Nom du site"
             xmlUrl="https://exemple.com/feed"
             htmlUrl="https://exemple.com"/>
  4. Commiter et pousser — pris en compte au prochain run automatiquement.

4. Setup complet

Prérequis

  • Compte GitHub gratuit.
  • Compte chez au moins un des fournisseurs LLM supportés :
  • Prévoir quelques crédits sur le fournisseur choisi.

Étape 1 — Cloner ou forker ce repo

git clone https://github.com/julienoh/veille.git
cd veille

Étape 2 — Adapter les sources (optionnel)

Modifier 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.

Étape 3 — Ajuster DIGEST_URL dans digest.py

# digest.py, section Configuration
DIGEST_URL = "https://TON-USER.github.io/NOM-REPO/digest.xml"

Étape 4 — Créer le repo GitHub

  • Repo public (obligatoire pour GitHub Pages gratuit).
  • Pousser tous les fichiers sur la branche main.
  • Vérifier que .github/workflows/digest.yml est bien présent.

Étape 5 — Générer et configurer la clé API

  1. Aller sur le portail du fournisseur LLM choisi.
  2. Settings → API Keys → Create Key.
  3. Copier la clé immédiatement (affichée une seule fois).
  4. 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é.

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.

Étape 6 — Configurer les limites de dépenses

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.

Étape 7 — Premier run manuel

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.

Étape 8 — Activer GitHub Pages

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

Étape 9 — Abonner Reeder (iOS)

Reeder → + → coller l'URL → valider.


5. Paramètres de tuning

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.

Grille de scoring (synthèse)

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.

Tags disponibles

ia_recherche · ia_produit · ia_stratégie · ia_management_equipe · cyber_vuln · cyber_strategie · dev_tooling · business · autre.

Itérer sur les prompts

Trois prompts dans prompt.py. Bonnes pratiques :

  1. Changer une chose à la fois et observer sur 2-3 runs avant de rechanger.
  2. Versionner chaque changement avec un message de commit descriptif (git log sur prompt.py devient ton historique d'expérimentation).
  3. Tester en local : OPENROUTER_API_KEY=sk-... python3 digest.py.
  4. Télécharger l'artefact audit-details-<run_id> du run Actions pour vérifier la distribution des scores (cf. §6).

6. Maintenance

Le digest ne se met pas à jour (alors que le run réussit)

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.

Horaires des runs (cron & DST)

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.

Un feed casse (erreur 404, timeout)

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.

Trop peu d'articles dans le digest

  • Vérifier que last_success.json contient un timestamp valide ; en son absence, le pipeline utilise INITIAL_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.

Trop de bruit dans le digest

  • ACCEPTED_DECISIONS trop large → retirer "skim" (ne garder que score 4-5).
  • Profil dans SCORING_PROMPT trop 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.

Trop de doublons restent

  • 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).

Évaluer la pertinence du scoring dans le digest

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.

Mettre à jour les modèles LLM

Quand le fournisseur publie de nouveaux modèles, mettre à jour dans digest.py :

FILTERING_MODEL = "..."
SYNTHESIS_MODEL = "..."

Surveiller la deprecation des actions GitHub

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.

Détecter un volume anormal

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.

Logs d'audit

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.

logs/audit-summary.md — vue "santé du pipeline"

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).

Artefact audit-details-<run_id> — détail article par article

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.

logs/audit-errors.md — journal des erreurs

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.

Rétention et purge

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.

Cross-link

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.


7. Choix techniques

Pourquoi un pipeline de filtrage en deux phases ?

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.

Pourquoi un projet agnostique du LLM ?

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.

Pourquoi GitHub Actions + Pages ?

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é.

Pourquoi l'OPML comme source de vérité ?

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.

Pourquoi seen.json commité dans git et pas une base de données ?

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.

Pourquoi last_success.json plutôt qu'une fenêtre fixe ?

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.

Pourquoi plafonner avant le scoring ?

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.

Pourquoi deux modèles (filtrage + synthèse) au lieu d'un seul ?

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.

Pourquoi les scores sont-ils rendus par Python ?

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.

Pourquoi un seul item RSS par run ?

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é).

Pourquoi MIT et repo public ?

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.


8. Roadmap

Court terme

  • Retry sur les appels LLM : un blip réseau fait planter un article. Ajouter tenacity ou une boucle try/except avec 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 de SCORING_PROMPT à partir de la distribution réelle des scores (cf. §6).

Moyen terme

  • 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.

Long terme

  • 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@v4 par les actions officielles actions/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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages