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:
@@ -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