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
4 changes: 2 additions & 2 deletions backend/app/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,8 @@ def generate_synthesis_now(date_: str | None = Query(None, alias="date"),

Le collector est lancé avec COLLECTOR_SYNTHESIS_ONLY=1 : aucune collecte,
régénération forcée (le skip « rien de nouveau » est contourné).
`date` (YYYY-MM-DD, optionnelle) : applique la logique comme si le run
avait eu lieu ce jour-là et écrit dans syntheses/{date}.
`date` (YYYY-MM-DD, optionnelle) : restreint le corpus aux articles
collectés ce jour-là et écrit dans syntheses/{date}.
"""
synthesis_date = None
if date_:
Expand Down
51 changes: 40 additions & 11 deletions collector/processors/synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
1. lit le périmètre saisi dans l'IHM admin (`synthesis_source_ids`,
`synthesis_categories` dans `settings/global`) et filtre les articles
récents en conséquence ;
2. télécharge le contenu intégral de chaque article du corpus et le réduit
à du texte brut (suppression HTML, CSS, scripts, images) ;
2. télécharge le contenu intégral de chaque article du corpus depuis sa
vraie source (les liens de tracking newsletter sont déballés au préalable)
et le réduit à du texte brut (suppression HTML, CSS, scripts, images) ;
3. envoie le tout au LLM avec le prompt de synthèse et le centre d'intérêt,
puis écrit le résultat dans `syntheses/{date}`.
"""
import logging
import re
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, date
from urllib.parse import unquote

import httpx
from bs4 import BeautifulSoup, Comment
Expand All @@ -34,6 +37,23 @@
_STRIPPED_TAGS = ["script", "style", "img", "svg", "picture", "video", "audio",
"iframe", "noscript", "nav", "header", "footer", "form", "aside"]

# Liens de tracking TLDR : l'URL réelle de l'article est encodée dans le chemin
# (https://tracking.tldrnewsletter.com/CL0/<url-encodée>/1/<id>/<hash>)
_TLDR_TRACKING_RE = re.compile(
r"^https?://tracking\.tldrnewsletter\.com/CL0/(?P<encoded>[^/]+)", re.IGNORECASE
)


def resolve_article_url(url: str) -> str:
"""Retourne l'URL réelle de l'article quand l'URL stockée est un lien de
tracking de newsletter — le contenu est ainsi téléchargé directement
depuis la vraie source, sans dépendre du redirecteur."""
m = _TLDR_TRACKING_RE.match(url)
if not m:
return url
real = unquote(m.group("encoded"))
return real if real.startswith(("http://", "https://")) else url


def extract_text(html: str) -> str:
"""Réduit une page HTML à son texte brut : ni balise, ni CSS, ni script, ni image."""
Expand All @@ -47,7 +67,14 @@ def extract_text(html: str) -> str:


def fetch_article_text(url: str) -> str | None:
"""Télécharge un article et retourne son texte nettoyé, ou None si inexploitable."""
"""Télécharge un article et retourne son texte nettoyé, ou None si inexploitable.

Les liens de tracking sont d'abord déballés pour interroger la vraie source.
"""
real_url = resolve_article_url(url)
if real_url != url:
logger.info(f" Lien de tracking déballé → {real_url}")
url = real_url
try:
response = httpx.get(
url,
Expand Down Expand Up @@ -124,10 +151,9 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str],
jour existe déjà pour le même périmètre et qu'aucun nouvel article n'y
entre, la génération est sautée (aucun token consommé).

`target_date` (YYYY-MM-DD, génération manuelle) : applique la logique
comme si le run avait eu lieu ce jour-là — corpus limité aux articles
collectés jusqu'à la fin du jour choisi, document écrit dans
`syntheses/{target_date}`.
`target_date` (YYYY-MM-DD, génération manuelle) : corpus strictement
limité aux articles collectés ce jour-là (00:00 → 23:59), document
écrit dans `syntheses/{target_date}`.
"""
interest = global_settings.get("interest", "").strip()
if not interest:
Expand Down Expand Up @@ -163,8 +189,9 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str],

query = db.collection("articles")
if target_date:
# Corpus tel qu'il était à la fin du jour ciblé
query = query.where("collected_at", "<=", f"{target_date}T23:59:59.999999")
# Corpus strictement limité aux articles collectés le jour ciblé
query = (query.where("collected_at", ">=", f"{target_date}T00:00:00")
.where("collected_at", "<=", f"{target_date}T23:59:59.999999"))
recent = query.order_by(
"collected_at", direction="DESCENDING"
).limit(RECENT_ARTICLES_POOL).stream()
Expand All @@ -176,10 +203,12 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str],
result = None

if not articles:
logger.warning("Aucun article dans le périmètre sélectionné — pas d'appel LLM.")
quand = f"collecté le {target_date} " if target_date else ""
logger.warning(f"Aucun article {quand}dans le périmètre sélectionné — pas d'appel LLM.")
corpus = []
result = {
"synthesis": "⚠️ Aucun article dans le périmètre sélectionné (sources/thèmes) — synthèse non générée.",
"synthesis": f"⚠️ Aucun article {quand}dans le périmètre sélectionné (sources/thèmes) "
"— synthèse non générée.",
"cited_ids": [],
}
else:
Expand Down
46 changes: 44 additions & 2 deletions tests/test_synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,46 @@ def test_extract_text_strips_html_css_scripts_images():
assert interdit not in text


def test_resolve_article_url_unwraps_tldr_tracking():
"""Un lien de tracking TLDR est déballé vers l'URL réelle de l'article."""
from processors.synthesis import resolve_article_url
tracking = ("https://tracking.tldrnewsletter.com/CL0/"
"https:%2F%2Fexample.com%2Fpost%3Futm_source%3Dtldrnewsletter"
"/1/010001/abcdef=123")
assert resolve_article_url(tracking) == "https://example.com/post?utm_source=tldrnewsletter"


def test_resolve_article_url_passthrough():
"""Une URL directe (source web) ou un lien de tracking illisible restent inchangés."""
from processors.synthesis import resolve_article_url
assert resolve_article_url("https://example.com/article") == "https://example.com/article"
assert (resolve_article_url("https://tracking.tldrnewsletter.com/CL0/pas-une-url/1/x")
== "https://tracking.tldrnewsletter.com/CL0/pas-une-url/1/x")


def test_fetch_article_text_uses_real_url(monkeypatch):
"""Le téléchargement du contenu interroge l'URL réelle, pas le redirecteur."""
import processors.synthesis as synthesis

requested = {}

def fake_get(url, **kwargs):
requested["url"] = url
class R:
headers = {"content-type": "text/html"}
text = "<p>" + "Contenu réel de l'article. " * 20 + "</p>"
def raise_for_status(self):
pass
return R()

monkeypatch.setattr(synthesis.httpx, "get", fake_get)
tracking = "https://tracking.tldrnewsletter.com/CL0/https:%2F%2Fexample.com%2Fpost/1/010001/abc=1"
text = synthesis.fetch_article_text(tracking)

assert requested["url"] == "https://example.com/post"
assert text and "Contenu réel de l'article." in text


def test_fetch_article_text_fallback_on_error(monkeypatch):
"""URL injoignable → None (l'article retombera sur son résumé stocké)."""
import processors.synthesis as synthesis
Expand Down Expand Up @@ -377,8 +417,9 @@ def test_run_synthesis_manual_trigger_bypasses_skip(monkeypatch):


def test_run_synthesis_target_date(monkeypatch):
"""Génération pour une date choisie : corpus ancré à la fin de ce jour,
document écrit dans syntheses/{date choisie}."""
"""Génération pour une date choisie : corpus limité aux articles collectés
ce jour-là (bornes début ET fin de journée), document écrit dans
syntheses/{date choisie}."""
import processors.synthesis as synthesis

_stub_synthesis(monkeypatch, synthesis)
Expand All @@ -390,6 +431,7 @@ def test_run_synthesis_target_date(monkeypatch):
assert list(db.written.keys()) == ["2026-07-10"]
doc = db.written["2026-07-10"]
assert doc["target_date"] == "2026-07-10"
assert ("collected_at", ">=", "2026-07-10T00:00:00") in db.where_calls
assert ("collected_at", "<=", "2026-07-10T23:59:59.999999") in db.where_calls


Expand Down
Loading