From acac40103464301d10f7d5e08b4e4672a9ec01b6 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 14 Jun 2026 09:43:11 +0000 Subject: [PATCH] feat(fimi): Frontend Andockpunkte 1-3 + Verifizierer-Robustheit - Andockpunkt 1: dezenter Inline-Hinweis am Artikel (Quellen-Detailliste) mit Provenienz (EUvsDisinfo) + Case-Link, nur bei bestaetigtem Treffer. - Andockpunkt 2: Track-Record-Badge pro Quelle in der Quellenuebersicht. - Andockpunkt 3: Qualitaetsleiste ueber dem Lagebild (geprueft/Treffer/ Narrative), aufklappbare Top-Narrative mit Belegen. - fimi_matcher: URLs aus dem Artikeltext entfernen + Prompt-Praeambel gegen Tool-Nutzung, sonst scheiterte die Haiku-Verifikation an WebFetch-Versuchen (error_max_turns). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/fimi_matcher.py | 13 ++++- src/static/css/style.css | 107 +++++++++++++++++++++++++++++++++++ src/static/dashboard.html | 1 + src/static/js/api.js | 9 +++ src/static/js/app.js | 49 +++++++++++++++- src/static/js/components.js | 69 +++++++++++++++++++++- 6 files changed, 244 insertions(+), 4 deletions(-) diff --git a/src/services/fimi_matcher.py b/src/services/fimi_matcher.py index d7b3f33..ab833bf 100644 --- a/src/services/fimi_matcher.py +++ b/src/services/fimi_matcher.py @@ -26,11 +26,17 @@ import asyncio import json import logging import os +import re import threading import aiosqlite import numpy as np +# URLs aus dem Artikeltext entfernen: sonst versucht das Verifizierer-Modell, +# den Link per WebFetch zu oeffnen, was bei --allowedTools "" als +# error_max_turns scheitert. +_URL_RE = re.compile(r"https?://\S+") + from services.embeddings import encode_batch from agents.claude_client import call_claude, ClaudeCliError from config import CLAUDE_MODEL_FAST @@ -145,7 +151,9 @@ async def match_query_texts( # Stufe 2: LLM-Verifikation # ────────────────────────────────────────────────────────────────── -_VERIFY_PROMPT = """Du pruefst, ob ein Nachrichtenartikel bekannte Falschbehauptungen VERBREITET. +_VERIFY_PROMPT = """Bewerte ausschliesslich den unten stehenden Artikeltext. Du hast KEINEN Internetzugang und darfst KEINE Werkzeuge benutzen (kein WebFetch, keine Suche, kein Oeffnen von Links). Falls der Text gekuerzt ist, bewerte nur das Vorhandene. Antworte sofort mit JSON. + +Du pruefst, ob ein Nachrichtenartikel bekannte Falschbehauptungen VERBREITET. Unterscheide streng: - VERBREITET (spreads=true): Der Artikel stellt die Behauptung als Tatsache auf, uebernimmt sie zustimmend, gibt sie unwidersprochen als wahr wieder oder legt sie dem Leser als zutreffend nahe. @@ -181,7 +189,8 @@ async def _verify_article( (article["content_de"] if "content_de" in article.keys() else None) or (article["content_original"] if "content_original" in article.keys() else None) or "" - ).strip()[:VERIFY_CONTENT_CHARS] + ) + content = _URL_RE.sub("", content).strip()[:VERIFY_CONTENT_CHARS] if not content: # Ohne Fliesstext laesst sich die Haltung nicht serioes bestimmen. return [] diff --git a/src/static/css/style.css b/src/static/css/style.css index 236d9de..891c518 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -6172,3 +6172,110 @@ body.tutorial-active .tutorial-cursor { .pipeline-block.status-active { box-shadow: var(--glow-accent); } .pipeline-stage.is-looping .pipeline-loop { animation: none !important; opacity: 1; } } + +/* ────────────────────────────────────────────────────────────────── + FIMI / Counter-Disinformation (Andockpunkte 1-3) + Dezenter, hinweisender Ton (amber = --warning), keine Warnsirene. + Die Provenienz wird ueber Texte + Case-Links getragen, nicht ueber + Farbe. Kein Match -> kein Element, kein visueller Ballast. + ────────────────────────────────────────────────────────────────── */ + +/* Andockpunkt 1: Inline-Hinweis am Artikel (in der Quellen-Detailliste) */ +.fimi-hint { + flex-basis: 100%; + display: flex; + align-items: center; + gap: 6px; + margin-top: 5px; + padding: 4px 8px; + font-size: 11.5px; + line-height: 1.35; + background: rgba(245, 158, 11, 0.08); + border-left: 2px solid var(--warning); + border-radius: 3px; +} +.fimi-hint-icon { flex: 0 0 auto; font-size: 12px; color: var(--warning); } +.fimi-hint-text { color: var(--text-secondary); } +.fimi-hint-link { + margin-left: auto; + flex: 0 0 auto; + color: var(--warning); + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} +.fimi-hint-link:hover { text-decoration: underline; } +.source-overview-detail-list li.has-fimi-hint { flex-wrap: wrap; } + +/* Andockpunkt 2: empirischer Track-Record-Badge in der Quellen-Box */ +.fimi-source-badge { + display: inline-flex; + align-items: center; + margin-left: 6px; + padding: 1px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--warning); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); + border-radius: 10px; + white-space: nowrap; +} +.source-overview-item.has-fimi { box-shadow: inset 2px 0 0 var(--warning); } + +/* Andockpunkt 3: Qualitaetsleiste ueber dem Lagebild */ +.fimi-summary-bar { + margin: 0 0 12px 0; + padding: 10px 14px; + border-radius: 6px; + font-size: 13px; + line-height: 1.45; +} +.fimi-summary-bar:empty { display: none; } +.fimi-summary-bar--alert { + color: var(--text-primary); + background: rgba(245, 158, 11, 0.09); + border: 1px solid rgba(245, 158, 11, 0.30); +} +.fimi-summary-bar--clear { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + background: var(--bg-elevated); + border: 1px solid var(--border); +} +.fimi-summary-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.fimi-summary-icon { flex: 0 0 auto; color: var(--warning); font-size: 15px; } +.fimi-summary-bar--clear .fimi-summary-icon { color: var(--success); } +.fimi-summary-lead { flex: 1 1 240px; } +.fimi-summary-lead strong { color: var(--warning); } +.fimi-summary-toggle { + flex: 0 0 auto; + padding: 3px 10px; + font-size: 12px; + font-weight: 600; + color: var(--warning); + background: transparent; + border: 1px solid rgba(245, 158, 11, 0.4); + border-radius: 4px; + cursor: pointer; +} +.fimi-summary-toggle:hover { background: rgba(245, 158, 11, 0.12); } +.fimi-summary-claims { + list-style: none; + margin: 10px 0 0 0; + padding: 10px 0 0 0; + border-top: 1px solid rgba(245, 158, 11, 0.20); +} +.fimi-summary-claims li { + display: flex; + align-items: baseline; + gap: 8px; + padding: 4px 0; + font-size: 12.5px; + color: var(--text-secondary); +} +.fimi-claim-count { flex: 0 0 auto; font-weight: 700; color: var(--warning); min-width: 28px; } +.fimi-claim-text { flex: 1 1 auto; } diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 0f1e22b..d0b66d3 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -234,6 +234,7 @@
+
diff --git a/src/static/js/api.js b/src/static/js/api.js index 328b194..8c2ffde 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -181,6 +181,15 @@ const API = { return this._request('GET', `/incidents/${incidentId}/factchecks`); }, + // FIMI / Counter-Disinformation + getFimiMatches(incidentId) { + return this._request('GET', `/incidents/${incidentId}/fimi-matches`); + }, + + getFimiSummary(incidentId) { + return this._request('GET', `/incidents/${incidentId}/fimi-summary`); + }, + getPipeline(incidentId) { return this._request('GET', `/incidents/${incidentId}/pipeline`); }, diff --git a/src/static/js/app.js b/src/static/js/app.js index 8d8a552..65f1e27 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -884,6 +884,9 @@ const App = { // Quellenuebersicht aus Aggregat-Endpunkt (alle Quellen, nicht nur erste Seite) this._loadSourcesSummary(id).catch(err => console.warn('sources-summary:', err)); + // FIMI: Treffer pro Artikel + Lagebild-Aggregat (Counter-Disinformation) + this._loadFimiData(id).catch(err => console.warn('fimi-data:', err)); + // Wenn mehr Artikel existieren als initial geladen: progressiver Hintergrund-Load if (articlesTotal > articles.length) { this._loadRemainingArticlesInBackground(id).catch(err => console.warn('bg-articles:', err)); @@ -909,6 +912,44 @@ const App = { } }, + /** FIMI-Daten der Lage laden: Treffer pro Artikel + Aggregat fuers Lagebild. */ + async _loadFimiData(incidentId) { + let matches = {}, summary = null; + try { + const [m, s] = await Promise.all([ + API.getFimiMatches(incidentId), + API.getFimiSummary(incidentId), + ]); + matches = (m && m.matches_by_article) || {}; + summary = s || null; + } catch (err) { + console.warn('fimi-data:', err); + return; + } + if (this.currentIncidentId !== incidentId) return; // User hat gewechselt + this._currentFimiMatches = matches; + this._currentFimiSummary = summary; + this._renderFimiSummaryBar(); + }, + + /** Andockpunkt 3: Qualitaetsleiste ins Lagebild rendern. */ + _renderFimiSummaryBar() { + const host = document.getElementById('fimi-summary-bar'); + if (!host || typeof UI.renderFimiSummaryBar !== 'function') return; + host.innerHTML = UI.renderFimiSummaryBar(this._currentFimiSummary); + }, + + /** Narrative-Liste in der FIMI-Qualitaetsleiste auf-/zuklappen. */ + toggleFimiDetail(btn) { + const bar = btn.closest('.fimi-summary-bar'); + if (!bar) return; + const list = bar.querySelector('.fimi-summary-claims'); + if (!list) return; + const open = list.style.display !== 'none'; + list.style.display = open ? 'none' : ''; + btn.textContent = open ? 'Narrative anzeigen' : 'Narrative verbergen'; + }, + /** Quellenuebersicht der Lage nach Quellentyp filtern (Web/Telegram/X). */ filterSourceOverview(type, chipEl) { const content = document.getElementById('source-overview-content'); @@ -1009,10 +1050,16 @@ const App = { const inner = a.source_url ? `${headline}` : headline; - return `
  • + // Andockpunkt 1: FIMI-Hinweis, falls dieser Artikel eine widerlegte + // Behauptung verbreitet. Kein Match -> keine Zeile, kein Ballast. + const fimiMatches = (this._currentFimiMatches || {})[String(a.id)]; + const fimiHint = (fimiMatches && typeof UI.renderFimiHint === 'function') + ? UI.renderFimiHint(fimiMatches) : ''; + return ` ${numHtml} ${UI.escape(dateStr)} ${inner} + ${fimiHint}
  • `; }).join(''); detail.innerHTML = ``; diff --git a/src/static/js/components.js b/src/static/js/components.js index e8b705d..27cbe15 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -1058,8 +1058,14 @@ const UI = { const langs = (s.languages || ['de']).map(l => (l || 'de').toUpperCase()).join('/'); const sourceName = this.escape(s.source || 'Unbekannt'); const sType = s.source_type || 'web'; - html += `