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:
Claude Code
2026-06-14 09:43:11 +00:00
Ursprung 46b2acfc36
Commit acac401034
6 geänderte Dateien mit 244 neuen und 4 gelöschten Zeilen

Datei anzeigen

@@ -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`);
},

Datei anzeigen

@@ -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>`;

Datei anzeigen

@@ -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}&nbsp;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">&#9888;</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">&#10003;</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}&times;</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">&#9888;</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 '';