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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Code
2026-06-14 09:32:41 +00:00
Ursprung 68f0792440
Commit 46b2acfc36

Datei anzeigen

@@ -495,11 +495,14 @@ async def get_articles_sources_summary(
tenant_id = current_user.get("tenant_id") tenant_id = current_user.get("tenant_id")
await _check_incident_access(db, incident_id, current_user["id"], tenant_id) await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
cursor = await db.execute( cursor = await db.execute(
"""SELECT source, """SELECT a.source,
COUNT(*) AS article_count, COUNT(*) AS article_count,
GROUP_CONCAT(DISTINCT COALESCE(language,'de')) AS languages GROUP_CONCAT(DISTINCT COALESCE(a.language,'de')) AS languages,
FROM articles WHERE incident_id = ? COUNT(DISTINCT m.article_id) AS fimi_match_count
GROUP BY source ORDER BY article_count DESC""", 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,), (incident_id,),
) )
sources = [] sources = []
@@ -507,6 +510,7 @@ async def get_articles_sources_summary(
d = dict(r) d = dict(r)
langs = (d.pop("languages") or "de").split(",") langs = (d.pop("languages") or "de").split(",")
d["languages"] = sorted({(l or "de").strip() for l in langs if l is not None}) 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) # Quellentyp aus dem source-Praefix ableiten (fuer den Typ-Filter der Quellenuebersicht)
src = d.get("source") or "" src = d.get("source") or ""
if src.startswith("X: "): if src.startswith("X: "):
@@ -532,6 +536,114 @@ async def get_articles_sources_summary(
return {"total": total, "sources": sources, "language_counts": lang_counts} 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") @router.get("/{incident_id}/articles/timeline-buckets")
async def get_articles_timeline_buckets( async def get_articles_timeline_buckets(
incident_id: int, incident_id: int,