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 += `
+ // Andockpunkt 2: empirischer Track-Record. Nur bei Treffern, dezent.
+ const fimiN = s.fimi_match_count || 0;
+ const fimiBadge = fimiN > 0
+ ? `
${fimiN} FIMI`
+ : '';
+ html += `
${sourceName}
+ ${fimiBadge}
${langs}
${s.article_count}
`;
@@ -1069,6 +1075,67 @@ const UI = {
return html;
},
+ /**
+ * Andockpunkt 1: dezenter Inline-Hinweis an einem Artikel, der sich mit
+ * einer bei EUvsDisinfo widerlegten Falschbehauptung deckt. Provenienz-
+ * Leitplanke: nennt die Quelle (EUvsDisinfo), verlinkt den Case, wertet
+ * nicht selbst. matches: Array aus dem fimi-matches-Endpunkt.
+ */
+ renderFimiHint(matches) {
+ if (!matches || matches.length === 0) return '';
+ const n = matches.length;
+ const top = matches[0];
+ const claimText = this.escape(top.claim_text || '');
+ const passage = top.passage ? this.escape(top.passage) : '';
+ let tip = `Bei EUvsDisinfo als widerlegt geführte Behauptung: ${claimText}`;
+ if (passage) tip += ` | Im Artikel: ${passage}`;
+ const label = n === 1
+ ? 'Deckt sich mit einer von EUvsDisinfo widerlegten Falschbehauptung'
+ : `Deckt sich mit ${n} von EUvsDisinfo widerlegten Falschbehauptungen`;
+ const link = top.case_url
+ ? `
Beleg ansehen`
+ : '';
+ return `
+ ⚠
+ ${label}
+ ${link}
+
`;
+ },
+
+ /**
+ * Andockpunkt 3: Qualitaetsachse fuers Lagebild. Verdichtet die
+ * Einzeltreffer auf Lage-Ebene. Bei 0 Treffern eine ruhige Entwarnung,
+ * sonst eine zurueckhaltende Hinweisleiste mit aufklappbaren Narrativen.
+ */
+ renderFimiSummaryBar(s) {
+ if (!s || !s.articles_checked) return '';
+ const matched = s.articles_with_match || 0;
+ const checked = s.articles_checked || 0;
+ const distinct = s.distinct_claims || 0;
+ if (matched === 0) {
+ return `
+ ✓
+ Keine bekannten Falschbehauptungen unter ${checked} geprüften Artikeln.
+
`;
+ }
+ const topClaims = (s.top_claims || []).slice(0, 6);
+ const claimList = topClaims.map(c => {
+ const txt = this.escape(c.claim_text || '');
+ const link = c.case_url
+ ? `
Beleg`
+ : '';
+ return `
${c.article_count}× ${txt} ${link}`;
+ }).join('');
+ return `
+
+ ⚠
+ ${matched} von ${checked} geprüften Artikeln decken sich mit ${distinct} bei EUvsDisinfo widerlegten Falschbehauptungen.
+
+
+
+
`;
+ },
+
renderSourceOverview(articles) {
if (!articles || articles.length === 0) return '';