Skip to content

Commit 38cfe2f

Browse files
authored
Show article scores in RSS digest (#2)
Co-authored-by: julienoh <7431012+julienoh@users.noreply.github.com>
1 parent 0e54cbe commit 38cfe2f

6 files changed

Lines changed: 171 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ Le projet suit un versionnement informel (pas de tags pour l'instant).
77

88
## [Non publié]
99

10+
### Added
11+
- **2026-08-08** — Ajout dans chaque catégorie du digest RSS d'une annexe
12+
déterministe **Évaluation du scoring**. Chaque article retenu (score 3 à 5)
13+
affiche son score initial de phase 1 et la raison du modèle de filtrage.
14+
Le rendu est produit par Python, sans appel LLM supplémentaire, et couvert
15+
par des tests unitaires.
16+
1017
### Fixed
1118
- **2026-08-07** — Suppression du trou de collecte nocturne : les horaires
1219
7h/13h/19h créent un intervalle de 12h incompatible avec l'ancienne fenêtre

MEMORY.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
> Se lit au début de chaque nouvelle conversation pour reprendre le contexte.
55
> Se met à jour à la fin de chaque session productive.
66
7-
**Dernière mise à jour** : 2026-08-07
7+
**Dernière mise à jour** : 2026-08-08
88

99
---
1010

@@ -15,6 +15,10 @@ le 2026-04-19. Tourne 3x/jour via GitHub Actions, synthétise avec Claude
1515
Haiku (scoring) + Sonnet (synthèse), servi par GitHub Pages, consommé
1616
dans Reeder iOS.
1717

18+
**2026-08-08** — Les articles retenus affichent désormais dans le digest leur
19+
score initial (3 à 5) et la raison du filtrage. L'annexe est rendue par Python
20+
après la synthèse afin de garantir l'association score/article sans appel LLM.
21+
1822
**2026-08-07** — Garde-fous critiques de collecte et de coût : la fenêtre
1923
repart du dernier run réussi au lieu d'un lookback fixe, avec 60 min de
2024
chevauchement. Les candidats sont triés par fraîcheur puis plafonnés à
@@ -78,6 +82,11 @@ documentait 3 déclencheurs mais le workflow n'en avait que 2.
7882
- **2026-08-07** — Plafonds appliqués avant le scoring LLM : 50 articles par
7983
source et 200 par run, avec priorité aux articles datés les plus récents
8084

85+
### Observabilité du scoring
86+
87+
- **2026-08-08** — Score + raison visibles pour chaque article retenu dans le RSS
88+
→ rendu déterministe depuis `score_phase1`, sans confier l'association au LLM
89+
8190
### Licence et ouverture
8291

8392
- **2026-04-21** — Licence MIT (vs Apache, AGPL, pas de licence)

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ digest.py ←─── cron 3x/jour (GitHub Actions)
5656
├── 7. PHASE 3 — Synthèse (LLM de synthèse, 1 appel par catégorie OPML)
5757
│ Markdown éditorial : 1-2 phrases par sujet, regroupement thématique
5858
59-
└── 8. Génère le RSS puis avance last_success.json après succès
59+
├── 8. Annexe de scoring déterministe (Python, aucun appel LLM)
60+
│ Pour chaque article retenu : score phase 1 (3-5) + raison
61+
62+
└── 9. Génère output/digest.xml puis avance last_success.json après succès
6063
6164
├──► git commit → main (seen.json + last_success.json + digest.xml)
6265
├──► audit détaillé → artefact Actions, rétention 30 jours
@@ -94,7 +97,8 @@ OPML (catégories → feeds)
9497
LLM de synthèse (par catégorie)
9598
input : articles dédupliqués
9699
output : Markdown avec puces et liens
97-
└─► feedgen → digest.xml (RSS 2.0)
100+
└─► annexe Python score + raison/article
101+
└─► feedgen → digest.xml (RSS 2.0)
98102
└─► 1 item RSS par run
99103
```
100104

@@ -151,12 +155,14 @@ veille/
151155
│ ├── audit-summary.md # Synthèse compteurs par run, 30 derniers jours
152156
│ └── audit-errors.md # Erreurs survenues par run, 30 derniers jours
153157
├── tests/
154-
│ └── test_collection.py # Tests fenêtre de collecte + plafonds de coût
158+
│ ├── test_collection.py # Tests fenêtre de collecte + plafonds de coût
159+
│ └── test_score_details.py # Tests du rendu de scoring visible
155160
├── collection.py # Fenêtre dynamique, priorité temporelle, plafonds
156161
├── digest.py # Pipeline complet (load, fetch, score, dédup, synth, audit)
157162
├── audit.py # Logs Markdown des 3 phases (cf. §6 Logs d'audit)
158163
├── llm_client.py # Mini-abstraction LLM (route anthropic/ vs openrouter/)
159164
├── prompt.py # Prompts LLM isolés (itérables indépendamment du code)
165+
├── score_details.py # Annexe score + raison, rendue sans LLM
160166
├── requirements.txt # feedparser, feedgen, anthropic, openai, httpx
161167
├── seen.json # Hashes SHA1 des articles traités (fenêtre 14 jours)
162168
├── last_success.json # Timestamp du dernier run terminé avec succès
@@ -181,6 +187,11 @@ applique les plafonds par source puis par run. Ce module est testé sans réseau
181187
risquer de casser la logique Python, et l'historique git des changements
182188
de prompts est séparé de celui du code.
183189

190+
**`score_details.py`** : produit l'annexe visible d'évaluation du scoring à
191+
partir des données de phase 1. Le rendu est déterministe : aucun modèle ne peut
192+
oublier un score ou l'associer au mauvais article. Seuls les articles retenus
193+
(scores 3 à 5) apparaissent, avec leur raison.
194+
184195
**`sources.opml`** : source de vérité des feeds. Le script le lit à chaque run —
185196
modifier l'OPML suffit pour ajouter/retirer une source. Même fichier utilisable
186197
dans Reeder pour abonnement direct.
@@ -455,6 +466,20 @@ trouver la nouvelle URL.
455466
- La phase 2 est intra-catégorie : les doublons cross-catégorie ne sont pas
456467
détectés actuellement (cf. roadmap).
457468

469+
### Évaluer la pertinence du scoring dans le digest
470+
471+
Chaque catégorie du bulletin se termine par **Évaluation du scoring**. Pour
472+
chaque article réellement transmis à la synthèse, cette annexe affiche :
473+
474+
```text
475+
5/5 — Titre — Source. Raison : justification du modèle de filtrage
476+
```
477+
478+
Le score affiché est `score_phase1`, c'est-à-dire la note initiale avant la
479+
déduplication. Les scores 1 et 2 restent exclus du digest et sont consultables
480+
dans `logs/audit-details.md`. L'annexe est construite par Python après la
481+
synthèse ; elle ne dépend donc pas du respect d'une consigne par le LLM.
482+
458483
### Mettre à jour les modèles LLM
459484

460485
Quand le fournisseur publie de nouveaux modèles, mettre à jour dans `digest.py` :
@@ -620,6 +645,14 @@ finale (regroupement thématique, ton éditorial, Markdown structuré) demande
620645
plus de nuance, donc un modèle plus capable. Séparer les deux rôles permet de
621646
choisir le bon modèle pour chaque tâche, sans surdimensionner le filtrage.
622647

648+
### Pourquoi les scores sont-ils rendus par Python ?
649+
650+
Le LLM de synthèse peut regrouper plusieurs articles sous une même puce. Lui
651+
demander d'insérer les scores créerait un risque d'omission ou de mauvaise
652+
association. L'annexe est donc générée depuis les objets d'articles après la
653+
phase 2 : chaque titre, lien, score initial et raison restent liés sans appel
654+
supplémentaire et peuvent être couverts par des tests unitaires.
655+
623656
### Pourquoi un seul item RSS par run ?
624657

625658
Le digest est un **bulletin éditorial**, pas un agrégateur. Un item par run

digest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from collection import compute_collection_cutoff, limit_articles
4545
from llm_client import complete
4646
from prompt import SCORING_PROMPT, DEDUP_PROMPT, SYNTHESIS_PROMPT
47+
from score_details import render_score_details
4748

4849
# ---- Configuration -----------------------------------------------------------
4950
# Tous les paramètres ajustables du pipeline sont regroupés ici.
@@ -610,7 +611,8 @@ def main():
610611
print(f" synthèse {cat} ({len(items)} articles)")
611612
result = synthesize(cat, items)
612613
if result:
613-
sections[cat] = result
614+
score_details = render_score_details(items)
615+
sections[cat] = f"{result.rstrip()}\n\n{score_details}" if score_details else result
614616

615617
if sections:
616618
write_rss(sections)

score_details.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Rendu déterministe des scores visibles dans le bulletin RSS."""
2+
3+
from __future__ import annotations
4+
5+
6+
VISIBLE_SCORES = {3, 4, 5}
7+
8+
9+
def render_score_details(articles: list[dict]) -> str:
10+
"""Retourne une annexe Markdown score + raison pour les articles retenus.
11+
12+
Le rendu est produit par Python, sans nouvel appel LLM, afin de garantir
13+
que chaque score reste associé au bon article. ``score_phase1`` est la
14+
référence : il représente la note initiale avant une éventuelle déduplication.
15+
"""
16+
visible = []
17+
for position, article in enumerate(articles):
18+
score = _article_score(article)
19+
if score in VISIBLE_SCORES:
20+
visible.append((position, score, article))
21+
22+
if not visible:
23+
return ""
24+
25+
visible.sort(key=lambda item: (-item[1], item[0]))
26+
lines = ["**Évaluation du scoring**", ""]
27+
for _, score, article in visible:
28+
title = _escape_markdown(article.get("title", "Sans titre"))
29+
source = _escape_markdown(article.get("source", "Source inconnue"))
30+
reason = _escape_markdown(article.get("raison", ""))
31+
link = str(article.get("link", "")).strip()
32+
title_md = f"[{title}]({link})" if link.startswith(("https://", "http://")) else title
33+
34+
line = f"- **{score}/5** — {title_md}{source}"
35+
if reason:
36+
line += f". Raison : {reason}"
37+
lines.append(line)
38+
39+
return "\n".join(lines)
40+
41+
42+
def _article_score(article: dict) -> int | None:
43+
value = article.get("score_phase1", article.get("score"))
44+
try:
45+
return int(value)
46+
except (TypeError, ValueError):
47+
return None
48+
49+
50+
def _escape_markdown(value: object) -> str:
51+
text = " ".join(str(value or "").split())
52+
for char in ("\\", "*", "_", "[", "]"):
53+
text = text.replace(char, "\\" + char)
54+
return text

tests/test_score_details.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import unittest
2+
3+
from score_details import render_score_details
4+
5+
6+
class ScoreDetailsTests(unittest.TestCase):
7+
def test_renders_only_scores_three_to_five_with_reasons(self):
8+
articles = [
9+
self.article("Score 3", 3, "utile"),
10+
self.article("Score 1", 1, "bruit"),
11+
self.article("Score 5", 5, "critique"),
12+
self.article("Score 4", 4, "important"),
13+
self.article("Score 2", 2, "marginal"),
14+
]
15+
16+
rendered = render_score_details(articles)
17+
18+
self.assertIn("**5/5**", rendered)
19+
self.assertIn("**4/5**", rendered)
20+
self.assertIn("**3/5**", rendered)
21+
self.assertIn("Raison : critique", rendered)
22+
self.assertIn("Raison : important", rendered)
23+
self.assertIn("Raison : utile", rendered)
24+
self.assertNotIn("Score 1", rendered)
25+
self.assertNotIn("Score 2", rendered)
26+
self.assertLess(rendered.index("Score 5"), rendered.index("Score 4"))
27+
self.assertLess(rendered.index("Score 4"), rendered.index("Score 3"))
28+
29+
def test_uses_original_phase_one_score(self):
30+
article = self.article("Article dédupliqué", 2, "raison finale")
31+
article["score_phase1"] = 5
32+
33+
rendered = render_score_details([article])
34+
35+
self.assertIn("**5/5**", rendered)
36+
self.assertNotIn("**2/5**", rendered)
37+
38+
def test_escapes_untrusted_text_and_ignores_non_http_links(self):
39+
article = self.article("Titre [test]", 4, "raison *forte*")
40+
article["link"] = "javascript:alert(1)"
41+
42+
rendered = render_score_details([article])
43+
44+
self.assertIn(r"Titre \[test\]", rendered)
45+
self.assertIn(r"raison \*forte\*", rendered)
46+
self.assertNotIn("javascript:", rendered)
47+
48+
@staticmethod
49+
def article(title, score, reason):
50+
return {
51+
"title": title,
52+
"source": "Source",
53+
"link": "https://example.com/article",
54+
"score": score,
55+
"score_phase1": score,
56+
"raison": reason,
57+
}
58+
59+
60+
if __name__ == "__main__":
61+
unittest.main()

0 commit comments

Comments
 (0)