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
51 changes: 44 additions & 7 deletions backend/app/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ def purge_articles(_: dict = Depends(require_admin)):


def _trigger_local(source_id: str | None = None, synthesis_only: bool = False,
synthesis_date: str | None = None) -> dict:
synthesis_date: str | None = None,
synthesis_date_end: str | None = None) -> dict:
"""En local : lance le collector en sous-processus avec l'émulateur."""
venv_python = COLLECTOR_DIR / "venv" / "bin" / "python"
python = str(venv_python) if venv_python.exists() else "python3"
Expand All @@ -183,6 +184,8 @@ def _trigger_local(source_id: str | None = None, synthesis_only: bool = False,
env["COLLECTOR_SYNTHESIS_ONLY"] = "1"
if synthesis_date:
env["COLLECTOR_SYNTHESIS_DATE"] = synthesis_date
if synthesis_date_end:
env["COLLECTOR_SYNTHESIS_DATE_END"] = synthesis_date_end

import tempfile, pathlib
log_file = pathlib.Path(tempfile.gettempdir()) / "collector_local.log"
Expand All @@ -198,7 +201,8 @@ def _trigger_local(source_id: str | None = None, synthesis_only: bool = False,


def _trigger_job(source_id: str | None = None, synthesis_only: bool = False,
synthesis_date: str | None = None) -> dict:
synthesis_date: str | None = None,
synthesis_date_end: str | None = None) -> dict:
"""Déclenche le Cloud Run Job, avec filtre source ou mode synthèse seule optionnels."""
token = _get_access_token()
env_vars = []
Expand All @@ -208,6 +212,8 @@ def _trigger_job(source_id: str | None = None, synthesis_only: bool = False,
env_vars.append({"name": "COLLECTOR_SYNTHESIS_ONLY", "value": "1"})
if synthesis_date:
env_vars.append({"name": "COLLECTOR_SYNTHESIS_DATE", "value": synthesis_date})
if synthesis_date_end:
env_vars.append({"name": "COLLECTOR_SYNTHESIS_DATE_END", "value": synthesis_date_end})
body: dict = {}
if env_vars:
body = {"overrides": {"containerOverrides": [{"env": env_vars}]}}
Expand Down Expand Up @@ -250,24 +256,49 @@ def collect_single_source(source_id: str, _: dict = Depends(require_admin)):

@router.post("/synthesis/generate", status_code=202)
def generate_synthesis_now(date_: str | None = Query(None, alias="date"),
end_date_: str | None = Query(None, alias="end_date"),
_: dict = Depends(require_admin)):
"""Déclenche manuellement la génération de la synthèse (mode synthèse seule).

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) : restreint le corpus aux articles
collectés ce jour-là et écrit dans syntheses/{date}.
`end_date` (YYYY-MM-DD, optionnelle) : étend le corpus aux articles
collectés entre `date` et `end_date` incluses (date de départ ≤ date de
fin), document écrit dans syntheses/{date}_{end_date}.
"""
if end_date_ and not date_:
raise HTTPException(status_code=400,
detail="Date de départ requise quand une date de fin est fournie.")
synthesis_date = None
synthesis_date_end = None
if date_:
try:
parsed = date.fromisoformat(date_)
except ValueError:
raise HTTPException(status_code=400, detail="Date invalide — format attendu : YYYY-MM-DD.")
if parsed > date.today():
raise HTTPException(status_code=400, detail="La date ne peut pas être dans le futur.")
parsed_end = None
if end_date_:
try:
parsed_end = date.fromisoformat(end_date_)
except ValueError:
raise HTTPException(status_code=400,
detail="Date de fin invalide — format attendu : YYYY-MM-DD.")
if parsed_end > date.today():
raise HTTPException(status_code=400, detail="La date de fin ne peut pas être dans le futur.")
if parsed > parsed_end:
raise HTTPException(status_code=400,
detail="La date de départ doit être antérieure ou égale à la date de fin.")
if parsed_end == parsed:
parsed_end = None # plage d'un seul jour = comportement jour unique
if parsed != date.today():
synthesis_date = parsed.isoformat() # aujourd'hui = comportement par défaut
if parsed_end:
# end > start ≥ aujourd'hui étant rejeté, une plage implique start < aujourd'hui
synthesis_date_end = parsed_end.isoformat()

db = get_db()
doc = db.collection("settings").document("global").get()
Expand All @@ -277,9 +308,11 @@ def generate_synthesis_now(date_: str | None = Query(None, alias="date"),
detail="Aucun centre d'intérêt renseigné — synthèse désactivée.")
if IS_LOCAL:
_check_emulator_reachable()
return _trigger_local(synthesis_only=True, synthesis_date=synthesis_date)
return _trigger_local(synthesis_only=True, synthesis_date=synthesis_date,
synthesis_date_end=synthesis_date_end)
try:
return _trigger_job(synthesis_only=True, synthesis_date=synthesis_date)
return _trigger_job(synthesis_only=True, synthesis_date=synthesis_date,
synthesis_date_end=synthesis_date_end)
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail=str(e))

Expand Down Expand Up @@ -341,6 +374,7 @@ def get_stats(_: dict = Depends(require_admin)):

@router.get("/syntheses")
def get_syntheses(date_: str | None = Query(None, alias="date"),
end_date_: str | None = Query(None, alias="end_date"),
_: dict = Depends(require_admin)):
"""Retourne les N dernières synthèses existantes avec les articles cités.

Expand All @@ -349,15 +383,18 @@ def get_syntheses(date_: str | None = Query(None, alias="date"),
donne les plus récentes — y compris celles générées a posteriori pour
une date passée.
`date` (YYYY-MM-DD, optionnelle) : retourne uniquement la synthèse de ce
jour-là (consultation d'une date précise).
jour-là. Avec `end_date`, retourne la synthèse de la plage
`{date}_{end_date}` (consultation d'une période générée manuellement).
"""
db = get_db()
if date_:
try:
day_ids = [date.fromisoformat(date_).isoformat()]
start = date.fromisoformat(date_).isoformat()
end = date.fromisoformat(end_date_).isoformat() if end_date_ else None
except ValueError:
raise HTTPException(status_code=400, detail="Date invalide — format attendu : YYYY-MM-DD.")
docs = [db.collection("syntheses").document(d).get() for d in day_ids]
doc_id = f"{start}_{end}" if end and end != start else start
docs = [db.collection("syntheses").document(doc_id).get()]
else:
settings_doc = db.collection("settings").document("global").get()
count = (settings_doc.to_dict() or {}).get("synthesis_display_count", 3) if settings_doc.exists else 3
Expand Down
8 changes: 5 additions & 3 deletions collector/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,16 @@ def _add_articles_to_batch(articles: list[dict]) -> None:

# Synthèse du jour — périmètre admin + contenu intégral (cf. processors/synthesis.py)
# En mode synthèse seule, new_articles=None désactive le skip « rien de
# nouveau » : un déclenchement manuel régénère toujours. La date ciblée
# (COLLECTOR_SYNTHESIS_DATE) n'est honorée qu'en mode manuel.
# nouveau » : un déclenchement manuel régénère toujours. La période ciblée
# (COLLECTOR_SYNTHESIS_DATE / COLLECTOR_SYNTHESIS_DATE_END) n'est honorée
# qu'en mode manuel.
if interest:
try:
target_date = os.environ.get("COLLECTOR_SYNTHESIS_DATE") if synthesis_only else None
target_date_end = os.environ.get("COLLECTOR_SYNTHESIS_DATE_END") if synthesis_only else None
run_synthesis(db, global_settings, model_priority,
new_articles=None if synthesis_only else enriched_articles,
target_date=target_date)
target_date=target_date, target_date_end=target_date_end)
except Exception as e:
logger.error(f"Erreur lors de la génération de la synthèse : {e}", exc_info=True)
elif synthesis_only:
Expand Down
37 changes: 25 additions & 12 deletions collector/processors/synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,20 @@ def _add_usage(total: dict, usage: dict | None) -> dict:

def run_synthesis(db, global_settings: dict, model_priority: list[str],
new_articles: list[dict] | None = None,
target_date: str | None = None) -> None:
target_date: str | None = None,
target_date_end: str | None = None) -> None:
"""Génère la synthèse du jour et l'écrit dans `syntheses/{date}`.

`new_articles` : articles ajoutés par le run en cours. Si la synthèse du
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é).

Le corpus est strictement limité aux articles collectés le jour de la
synthèse (00:00 → 23:59) : le jour courant par défaut (run automatique),
ou `target_date` (YYYY-MM-DD, génération manuelle) — le document est
écrit dans `syntheses/{date}`.
Le corpus est strictement limité aux articles collectés sur la période de
la synthèse (00:00 du premier jour → 23:59 du dernier) : le jour courant
par défaut (run automatique), ou la plage `target_date` → `target_date_end`
(YYYY-MM-DD, génération manuelle ; date_end omise = jour unique). Le
document est écrit dans `syntheses/{date}` pour un jour unique, ou
`syntheses/{date}_{date_end}` pour une plage.
"""
interest = global_settings.get("interest", "").strip()
if not interest:
Expand All @@ -164,7 +167,16 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str],
categories = global_settings.get("synthesis_categories") or []
max_input_chars = int(global_settings.get("synthesis_max_input_chars") or MAX_SYNTHESIS_INPUT)
synthesis_date = target_date or date.today().isoformat()
today_ref = db.collection("syntheses").document(synthesis_date)
date_end = target_date_end or synthesis_date
if date_end < synthesis_date:
logger.error(f"Période de synthèse invalide : la date de départ ({synthesis_date}) "
f"doit être antérieure ou égale à la date de fin ({date_end}) — abandon.")
return
doc_id = synthesis_date if date_end == synthesis_date else f"{synthesis_date}_{date_end}"
# Libellé de période pour les logs et messages (« le J » ou « entre le J1 et le J2 »)
periode = (f"le {synthesis_date}" if date_end == synthesis_date
else f"entre le {synthesis_date} et le {date_end}")
today_ref = db.collection("syntheses").document(doc_id)

# Levier économie n°1 : ne pas régénérer si rien n'a changé depuis la
# synthèse du jour (même centre d'intérêt, même périmètre, aucun nouvel
Expand All @@ -183,16 +195,16 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str],
"aucun appel LLM.")
return

logger.info(f"Génération de la synthèse pour : «{interest}» (date : {synthesis_date})...")
logger.info(f"Génération de la synthèse pour : «{interest}» (articles collectés {periode})...")
logger.info(f"Périmètre — sources : {', '.join(source_ids) if source_ids else 'toutes'} ; "
f"thèmes : {', '.join(categories) if categories else 'tous'} ; "
f"plafond prompt : {max_input_chars} caractères")

# Corpus strictement limité aux articles collectés le jour de la synthèse
# (date du jour pour le run automatique, date choisie en génération manuelle)
# Corpus strictement limité aux articles collectés sur la période de la
# synthèse (jour courant pour le run automatique, plage choisie en manuel)
query = (db.collection("articles")
.where("collected_at", ">=", f"{synthesis_date}T00:00:00")
.where("collected_at", "<=", f"{synthesis_date}T23:59:59.999999"))
.where("collected_at", "<=", f"{date_end}T23:59:59.999999"))
recent = query.order_by(
"collected_at", direction="DESCENDING"
).limit(RECENT_ARTICLES_POOL).stream()
Expand All @@ -204,11 +216,11 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str],
result = None

if not articles:
logger.warning(f"Aucun article collecté le {synthesis_date} dans le périmètre sélectionné "
logger.warning(f"Aucun article collecté {periode} dans le périmètre sélectionné "
"— pas d'appel LLM.")
corpus = []
result = {
"synthesis": f"⚠️ Aucun article collecté le {synthesis_date} dans le périmètre "
"synthesis": f"⚠️ Aucun article collecté {periode} dans le périmètre "
"sélectionné (sources/thèmes) — synthèse non générée.",
"cited_ids": [],
}
Expand Down Expand Up @@ -259,6 +271,7 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str],
today_ref.set({
"interest": interest,
"target_date": synthesis_date,
"target_date_end": date_end,
"source_ids": source_ids,
"categories": categories,
"content": result["synthesis"],
Expand Down
42 changes: 30 additions & 12 deletions frontend/src/app/admin/synthesis/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { ArticleSummary } from "@/lib/types";

const API = process.env.NEXT_PUBLIC_API_URL;

const formatDay = (d: string, opts: Intl.DateTimeFormatOptions =
{ weekday: "long", day: "numeric", month: "long", year: "numeric" }) =>
new Date(d).toLocaleDateString("fr-FR", opts);

export default function SynthesisPage() {
const { data: session, status } = useSession();
const router = useRouter();
Expand All @@ -21,25 +25,34 @@ export default function SynthesisPage() {
if (status === "authenticated" && role !== "admin") router.replace("/");
}, [status, role, router]);

// Filtre optionnel ?date=YYYY-MM-DD (lien « Voir la synthèse » de l'admin)
// Filtre optionnel ?date=YYYY-MM-DD[&end_date=YYYY-MM-DD]
// (lien « Voir la synthèse » de l'admin, jour unique ou période)
const [dateFilter, setDateFilter] = useState<string | null>(null);
const [endDateFilter, setEndDateFilter] = useState<string | null>(null);
const [dateFilterReady, setDateFilterReady] = useState(false);
useEffect(() => {
const d = new URLSearchParams(window.location.search).get("date");
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) setDateFilter(d);
const params = new URLSearchParams(window.location.search);
const d = params.get("date");
const end = params.get("end_date");
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d)) {
setDateFilter(d);
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) setEndDateFilter(end);
}
setDateFilterReady(true);
}, []);

const { data, isLoading, mutate } = useSWR(
token && role === "admin" && dateFilterReady
? `admin-syntheses${dateFilter ? `-${dateFilter}` : ""}`
? `admin-syntheses${dateFilter ? `-${dateFilter}` : ""}${endDateFilter ? `-${endDateFilter}` : ""}`
: null,
() => fetch(`${API}/admin/syntheses${dateFilter ? `?date=${dateFilter}` : ""}`,
() => fetch(`${API}/admin/syntheses${dateFilter
? `?date=${dateFilter}${endDateFilter ? `&end_date=${endDateFilter}` : ""}` : ""}`,
{ headers: { Authorization: `Bearer ${token}` } }).then(r => r.json())
);

const clearDateFilter = () => {
setDateFilter(null);
setEndDateFilter(null);
window.history.replaceState(null, "", "/admin/synthesis");
};

Expand Down Expand Up @@ -173,13 +186,14 @@ export default function SynthesisPage() {
</button>
</div>

{/* Bandeau filtre date (consultation d'une date générée manuellement) */}
{/* Bandeau filtre date (consultation d'une date ou période générée manuellement) */}
{dateFilter && (
<div className="rounded-lg border px-4 py-3 flex items-center justify-between gap-4 flex-wrap"
style={{ backgroundColor: "var(--surface-2)", borderColor: "var(--border)" }}>
<span className="text-sm" style={{ color: "var(--text)" }}>
📅 Synthèse du{" "}
<strong>{new Date(dateFilter).toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</strong>
📅 Synthèse {endDateFilter ? "de la période du" : "du"}{" "}
<strong>{formatDay(dateFilter)}</strong>
{endDateFilter && <> au <strong>{formatDay(endDateFilter)}</strong></>}
</span>
<button onClick={clearDateFilter}
className="text-sm hover:underline" style={{ color: "var(--text-muted)" }}>
Expand All @@ -192,11 +206,13 @@ export default function SynthesisPage() {
<div className="rounded-xl border p-8 text-center"
style={{ backgroundColor: "var(--surface)", borderColor: "var(--border)" }}>
<p className="text-lg mb-2" style={{ color: "var(--text)" }}>
{dateFilter ? "Aucune synthèse pour cette date" : "Aucune synthèse disponible"}
{dateFilter
? `Aucune synthèse pour cette ${endDateFilter ? "période" : "date"}`
: "Aucune synthèse disponible"}
</p>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{dateFilter
? "Générez-la depuis la section « Synthèse du jour » de la console admin en choisissant cette date."
? `Générez-la depuis la section « Synthèse du jour » de la console admin en choisissant cette ${endDateFilter ? "période" : "date"}.`
: "Renseignez un centre d'intérêt dans la section « Synthèse du jour » de la console admin et lancez une collecte."}
</p>
</div>
Expand All @@ -210,12 +226,14 @@ export default function SynthesisPage() {
<div>
<p className="font-semibold" style={{ color: "var(--text)" }}>🎯 {s.interest}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
{new Date(s.date).toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long" })}
{s.target_date_end && s.target_date_end !== (s.target_date ?? s.date)
? `du ${formatDay(s.target_date ?? s.date, { day: "numeric", month: "long" })} au ${formatDay(s.target_date_end, { day: "numeric", month: "long" })}`
: formatDay(s.target_date ?? s.date, { weekday: "long", day: "numeric", month: "long" })}
{" · "}{s.articles_count} articles analysés
{s.perimeter_count != null && s.perimeter_count !== s.articles_count &&
` (sur ${s.perimeter_count} dans le périmètre)`}
{" · "}
{s.generated_at?.slice(0, 10) !== s.date
{s.generated_at?.slice(0, 10) !== (s.target_date_end ?? s.date)
? `générée a posteriori le ${new Date(s.generated_at).toLocaleDateString("fr-FR", { day: "numeric", month: "long" })} à ${new Date(s.generated_at).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`
: new Date(s.generated_at).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}
{s.usage?.total_tokens > 0 &&
Expand Down
Loading
Loading