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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -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 []
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
<span class="lagebild-timestamp" id="lagebild-timestamp"></span>
|
||||
</div>
|
||||
<div id="summary-content">
|
||||
<div id="fimi-summary-bar"></div>
|
||||
<div id="summary-text" class="summary-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
? `<a href="${UI.escape(a.source_url)}" target="_blank" rel="noopener">${headline}</a>`
|
||||
: headline;
|
||||
return `<li>
|
||||
// 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 `<li${fimiMatches ? ' class="has-fimi-hint"' : ''}>
|
||||
${numHtml}
|
||||
<span class="source-overview-detail-date">${UI.escape(dateStr)}</span>
|
||||
<span class="source-overview-detail-headline">${inner}</span>
|
||||
${fimiHint}
|
||||
</li>`;
|
||||
}).join('');
|
||||
detail.innerHTML = `<ul class="source-overview-detail-list">${items}</ul>`;
|
||||
|
||||
@@ -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 += `<div class="source-overview-item" data-source="${sourceName}" data-type="${sType}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
|
||||
// Andockpunkt 2: empirischer Track-Record. Nur bei Treffern, dezent.
|
||||
const fimiN = s.fimi_match_count || 0;
|
||||
const fimiBadge = fimiN > 0
|
||||
? `<span class="fimi-source-badge" title="${fimiN} ${fimiN === 1 ? 'Artikel dieser Quelle deckt' : 'Artikel dieser Quelle decken'} sich mit einer bei EUvsDisinfo widerlegten Falschbehauptung">${fimiN} FIMI</span>`
|
||||
: '';
|
||||
html += `<div class="source-overview-item${fimiN > 0 ? ' has-fimi' : ''}" data-source="${sourceName}" data-type="${sType}" tabindex="0" role="button" aria-expanded="false" onclick="App.toggleSourceOverviewDetail(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.toggleSourceOverviewDetail(this);}">
|
||||
<span class="source-overview-name">${sourceName}</span>
|
||||
${fimiBadge}
|
||||
<span class="source-overview-lang">${langs}</span>
|
||||
<span class="source-overview-count">${s.article_count}</span>
|
||||
</div>`;
|
||||
@@ -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
|
||||
? `<a href="${this.escape(top.case_url)}" target="_blank" rel="noopener" class="fimi-hint-link" onclick="event.stopPropagation()">Beleg ansehen</a>`
|
||||
: '';
|
||||
return `<div class="fimi-hint" title="${tip}">
|
||||
<span class="fimi-hint-icon" aria-hidden="true">⚠</span>
|
||||
<span class="fimi-hint-text">${label}</span>
|
||||
${link}
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 `<div class="fimi-summary-bar fimi-summary-bar--clear">
|
||||
<span class="fimi-summary-icon" aria-hidden="true">✓</span>
|
||||
<span>Keine bekannten Falschbehauptungen unter ${checked} geprüften Artikeln.</span>
|
||||
</div>`;
|
||||
}
|
||||
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
|
||||
? `<a href="${this.escape(c.case_url)}" target="_blank" rel="noopener" class="fimi-hint-link">Beleg</a>`
|
||||
: '';
|
||||
return `<li><span class="fimi-claim-count">${c.article_count}×</span> <span class="fimi-claim-text">${txt}</span> ${link}</li>`;
|
||||
}).join('');
|
||||
return `<div class="fimi-summary-bar fimi-summary-bar--alert">
|
||||
<div class="fimi-summary-head">
|
||||
<span class="fimi-summary-icon" aria-hidden="true">⚠</span>
|
||||
<span class="fimi-summary-lead"><strong>${matched}</strong> von ${checked} geprüften Artikeln decken sich mit <strong>${distinct}</strong> bei EUvsDisinfo widerlegten Falschbehauptungen.</span>
|
||||
<button type="button" class="fimi-summary-toggle" onclick="App.toggleFimiDetail(this)">Narrative anzeigen</button>
|
||||
</div>
|
||||
<ul class="fimi-summary-claims" style="display:none;">${claimList}</ul>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
renderSourceOverview(articles) {
|
||||
if (!articles || articles.length === 0) return '';
|
||||
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren