From 46b2acfc360d2baafbd132bd2142628f83170a05 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 09:32:41 +0000 Subject: [PATCH] feat(fimi): Backend-Endpoints fimi-matches + fimi-summary, Match-Count in sources-summary - GET /{id}/fimi-matches: Treffer gruppiert nach Artikel inkl. Provenienz (Claim, Widerlegung, Case-URL, Zitat) fuer Andockpunkt 1. - GET /{id}/fimi-summary: Aggregat (geprueft, Treffer, Narrative, Quellen) fuer die Lagebild-Qualitaetsachse (Andockpunkt 3). - sources-summary um fimi_match_count erweitert (Andockpunkt 2). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routers/incidents.py | 120 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/src/routers/incidents.py b/src/routers/incidents.py index fb7a6a9..2710433 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -495,11 +495,14 @@ async def get_articles_sources_summary( tenant_id = current_user.get("tenant_id") await _check_incident_access(db, incident_id, current_user["id"], tenant_id) cursor = await db.execute( - """SELECT source, + """SELECT a.source, COUNT(*) AS article_count, - GROUP_CONCAT(DISTINCT COALESCE(language,'de')) AS languages - FROM articles WHERE incident_id = ? - GROUP BY source ORDER BY article_count DESC""", + GROUP_CONCAT(DISTINCT COALESCE(a.language,'de')) AS languages, + COUNT(DISTINCT m.article_id) AS fimi_match_count + FROM articles a + LEFT JOIN article_fimi_matches m ON m.article_id = a.id + WHERE a.incident_id = ? + GROUP BY a.source ORDER BY article_count DESC""", (incident_id,), ) sources = [] @@ -507,6 +510,7 @@ async def get_articles_sources_summary( d = dict(r) langs = (d.pop("languages") or "de").split(",") d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None}) + d["fimi_match_count"] = d.get("fimi_match_count") or 0 # Quellentyp aus dem source-Praefix ableiten (fuer den Typ-Filter der Quellenuebersicht) src = d.get("source") or "" if src.startswith("X: "): @@ -532,6 +536,114 @@ async def get_articles_sources_summary( return {"total": total, "sources": sources, "language_counts": lang_counts} +@router.get("/{incident_id}/fimi-matches") +async def get_fimi_matches( + incident_id: int, + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """FIMI-Treffer einer Lage, gruppiert nach Artikel (fuer Andockpunkt 1). + + Pro Artikel die verlinkten EUvsDisinfo-Falschbehauptungen mit Provenienz: + Claim-Text, Widerlegung, Case-URL, Embedding-Score und das woertliche + Zitat aus dem Artikel. Der Monitor wertet nicht selbst, er verweist. + """ + tenant_id = current_user.get("tenant_id") + await _check_incident_access(db, incident_id, current_user["id"], tenant_id) + cursor = await db.execute( + """SELECT m.article_id, m.fimi_claim_id, m.score, m.role, m.matched_text, + c.text AS claim_text, c.verdict, c.verdict_summary, + c.source_ref, c.case_url + FROM article_fimi_matches m + JOIN articles a ON a.id = m.article_id + JOIN fimi_claims c ON c.id = m.fimi_claim_id + WHERE a.incident_id = ? + ORDER BY m.score DESC""", + (incident_id,), + ) + by_article: dict[str, list] = {} + for r in await cursor.fetchall(): + d = dict(r) + aid = str(d["article_id"]) + by_article.setdefault(aid, []).append({ + "claim_id": d["fimi_claim_id"], + "claim_text": d["claim_text"], + "verdict": d["verdict"], + "verdict_summary": d["verdict_summary"], + "case_url": d["case_url"], + "source_ref": d["source_ref"], + "score": d["score"], + "passage": d["matched_text"], + }) + return {"matches_by_article": by_article, "article_count": len(by_article)} + + +@router.get("/{incident_id}/fimi-summary") +async def get_fimi_summary( + incident_id: int, + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Aggregierte FIMI-Kennzahlen fuer die Lagebild-Qualitaetsachse (Andockpunkt 3). + + Antwortet auch sinnvoll, wenn noch nichts geprueft wurde.""" + tenant_id = current_user.get("tenant_id") + await _check_incident_access(db, incident_id, current_user["id"], tenant_id) + + cur = await db.execute( + """SELECT COUNT(*) AS total, + SUM(CASE WHEN fimi_checked_at IS NOT NULL THEN 1 ELSE 0 END) AS checked + FROM articles WHERE incident_id = ?""", + (incident_id,), + ) + row = await cur.fetchone() + total = row["total"] or 0 + checked = row["checked"] or 0 + + cur = await db.execute( + """SELECT COUNT(DISTINCT m.article_id) AS matched_articles, + COUNT(DISTINCT m.fimi_claim_id) AS distinct_claims + FROM article_fimi_matches m + JOIN articles a ON a.id = m.article_id + WHERE a.incident_id = ?""", + (incident_id,), + ) + row = await cur.fetchone() + matched_articles = row["matched_articles"] or 0 + distinct_claims = row["distinct_claims"] or 0 + + cur = await db.execute( + """SELECT c.id AS claim_id, c.text AS claim_text, c.case_url, + COUNT(DISTINCT m.article_id) AS article_count + FROM article_fimi_matches m + JOIN articles a ON a.id = m.article_id + JOIN fimi_claims c ON c.id = m.fimi_claim_id + WHERE a.incident_id = ? + GROUP BY c.id ORDER BY article_count DESC LIMIT 10""", + (incident_id,), + ) + top_claims = [dict(r) for r in await cur.fetchall()] + + cur = await db.execute( + """SELECT a.source, COUNT(DISTINCT m.article_id) AS match_count + FROM article_fimi_matches m + JOIN articles a ON a.id = m.article_id + WHERE a.incident_id = ? + GROUP BY a.source ORDER BY match_count DESC LIMIT 10""", + (incident_id,), + ) + by_source = [dict(r) for r in await cur.fetchall()] + + return { + "articles_total": total, + "articles_checked": checked, + "articles_with_match": matched_articles, + "distinct_claims": distinct_claims, + "top_claims": top_claims, + "by_source": by_source, + } + + @router.get("/{incident_id}/articles/timeline-buckets") async def get_articles_timeline_buckets( incident_id: int,