feat(sources): externer Reputations-Layer (IFCN + EUvsDisinfo)
Externe Datenquellen (kostenlos, Open Data) ergaenzen die LLM-geschaetzte Reliability-Achse mit objektiven Signalen: - IFCN-Signatories (raw.githubusercontent.com/IFCN/verified-signatories): Plain-Text-Liste anerkannter Faktencheck-Organisationen. - EUvsDisinfo (Zenodo CSV): Pro-Kreml-Desinformations-Datenbank. Schema-Erweiterung: - ifcn_signatory, eu_disinfo_listed, eu_disinfo_case_count, eu_disinfo_last_seen, external_data_synced_at. Service src/services/external_reputation.py: - sync_ifcn_signatories(), sync_eu_disinfo(), apply_reputation_overrides(), sync_all() mit Domain-Normalisierung (lowercase, ohne www., ohne Schema). Reliability-Override-Regeln (laufen nach Approve und manuellem Sync): - ifcn_signatory=1 -> reliability=sehr_hoch - eu_disinfo_case_count >= 5 -> reliability=sehr_niedrig - eu_disinfo_case_count >= 1 -> Reliability eine Stufe runter (max niedrig) API: POST /api/sources/external-reputation/sync (Admin, BackgroundTask). Filter: ?ifcn_signatory=true, ?eu_disinfo_listed=true. UI: - Filter-Dropdown "Externe Reputation" im Quellen-Modal. - Badges: gruenes "IFCN" und rotes "EU-Desinfo (n)". - Tooltip macht Reliability-Quelle transparent: "(IFCN-Faktenchecker)", "(EU-Desinfo, n Faelle)" oder "(LLM-Schaetzung)". - "Externe Daten syncen"-Button im Review-Toolbar (Admin-only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -3759,6 +3759,32 @@ a.dev-source-pill:hover {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.source-ifcn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius);
|
||||
background: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
border: 1px solid #66bb6a;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.source-eu-disinfo-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius);
|
||||
background: #ffebee;
|
||||
color: #b71c1c;
|
||||
border: 1px solid #c62828;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.source-alignment-chip-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -538,6 +538,12 @@
|
||||
<option value="sehr_niedrig">Sehr niedrig</option>
|
||||
<option value="na">Nicht eingeordnet</option>
|
||||
</select>
|
||||
<label for="sources-filter-extern" class="sr-only">Externe Reputation filtern</label>
|
||||
<select id="sources-filter-extern" class="timeline-filter-select" onchange="App.filterSources()">
|
||||
<option value="">Externe Reputation: alle</option>
|
||||
<option value="ifcn">IFCN-Faktenchecker</option>
|
||||
<option value="eu_disinfo">EU-Desinfo gelistet</option>
|
||||
</select>
|
||||
<label for="sources-filter-alignment" class="sr-only">Geopolitische Nähe filtern</label>
|
||||
<select id="sources-filter-alignment" class="timeline-filter-select" onchange="App.filterSources()">
|
||||
<option value="">Alle Nähen</option>
|
||||
@@ -736,6 +742,7 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="review-toolbar-actions">
|
||||
<button class="btn btn-small btn-secondary" onclick="App.triggerExternalReputationSync()" title="IFCN-Faktenchecker-Liste und EUvsDisinfo-Daten synchronisieren">Externe Daten syncen</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="App.triggerBulkClassify()" title="LLM-Klassifikation fuer noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
|
||||
<button class="btn btn-small btn-primary" onclick="App.bulkApproveHighConfidence()" title="Alle Vorschlaege ueber dem Konfidenz-Schwellwert genehmigen">Alle ≥ 0.85 genehmigen</button>
|
||||
</div>
|
||||
|
||||
@@ -234,6 +234,9 @@ const API = {
|
||||
const qs = new URLSearchParams({ min_confidence: String(minConfidence) }).toString();
|
||||
return this._request('POST', `/sources/classification/bulk-approve?${qs}`);
|
||||
},
|
||||
triggerExternalReputationSync() {
|
||||
return this._request('POST', '/sources/external-reputation/sync');
|
||||
},
|
||||
|
||||
createSource(data) {
|
||||
return this._request('POST', '/sources', data);
|
||||
|
||||
@@ -2834,6 +2834,16 @@ async handleRefresh() {
|
||||
}
|
||||
},
|
||||
|
||||
async triggerExternalReputationSync() {
|
||||
if (!confirm('IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Lauft im Hintergrund (~30 Sek).')) return;
|
||||
try {
|
||||
await API.triggerExternalReputationSync();
|
||||
UI.showToast('Externer Sync gestartet. Quellenliste in 30 Sek neu laden.', 'info');
|
||||
} catch (err) {
|
||||
UI.showToast('Sync fehlgeschlagen: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
renderSourceStats(stats) {
|
||||
const bar = document.getElementById('sources-stats-bar');
|
||||
if (!bar) return;
|
||||
@@ -2866,6 +2876,7 @@ async handleRefresh() {
|
||||
const mediaTypeFilter = document.getElementById('sources-filter-mediatype')?.value || '';
|
||||
const reliabilityFilter = document.getElementById('sources-filter-reliability')?.value || '';
|
||||
const alignmentFilter = document.getElementById('sources-filter-alignment')?.value || '';
|
||||
const externFilter = document.getElementById('sources-filter-extern')?.value || '';
|
||||
const search = (document.getElementById('sources-search')?.value || '').toLowerCase();
|
||||
|
||||
// Alle Quellen nach Domain gruppieren
|
||||
@@ -2929,6 +2940,11 @@ async handleRefresh() {
|
||||
if (alignmentFilter) {
|
||||
if (!feeds.some(f => Array.isArray(f.alignments) && f.alignments.includes(alignmentFilter))) continue;
|
||||
}
|
||||
if (externFilter === 'ifcn') {
|
||||
if (!feeds.some(f => f.ifcn_signatory)) continue;
|
||||
} else if (externFilter === 'eu_disinfo') {
|
||||
if (!feeds.some(f => f.eu_disinfo_listed)) continue;
|
||||
}
|
||||
|
||||
// Suche
|
||||
if (search) {
|
||||
|
||||
@@ -1193,7 +1193,20 @@ const UI = {
|
||||
}
|
||||
const rel = feed.reliability;
|
||||
if (rel && rel !== 'na') {
|
||||
parts.push(`<span class="source-reliability-dot rel-${this.escape(rel)}" title="Glaubwürdigkeit: ${this.escape(this._reliabilityLabels[rel] || rel)}" aria-label="Glaubwürdigkeit: ${this.escape(this._reliabilityLabels[rel] || rel)}"></span>`);
|
||||
const relLabel = this._reliabilityLabels[rel] || rel;
|
||||
const relSource = feed.ifcn_signatory ? '(IFCN-Faktenchecker)'
|
||||
: (feed.eu_disinfo_listed ? `(EU-Desinfo, ${feed.eu_disinfo_case_count || 0} Fälle)`
|
||||
: '(LLM-Schätzung)');
|
||||
const relTitle = `Glaubwürdigkeit: ${relLabel} ${relSource}`;
|
||||
parts.push(`<span class="source-reliability-dot rel-${this.escape(rel)}" title="${this.escape(relTitle)}" aria-label="${this.escape(relTitle)}"></span>`);
|
||||
}
|
||||
if (feed.ifcn_signatory) {
|
||||
parts.push(`<span class="source-ifcn-badge" title="IFCN-zertifizierter Faktenchecker" aria-label="IFCN-Faktenchecker">✓ IFCN</span>`);
|
||||
}
|
||||
if (feed.eu_disinfo_listed) {
|
||||
const cnt = feed.eu_disinfo_case_count || 0;
|
||||
const title = `EUvsDisinfo: ${cnt} dokumentierte Desinformations-Fälle`;
|
||||
parts.push(`<span class="source-eu-disinfo-badge" title="${this.escape(title)}" aria-label="${this.escape(title)}">⚠ EU-Desinfo (${cnt})</span>`);
|
||||
}
|
||||
if (feed.state_affiliated) {
|
||||
parts.push(`<span class="source-state-badge" title="Staatsnah/-kontrolliert" aria-label="Staatsnah">⚑</span>`);
|
||||
@@ -1285,7 +1298,15 @@ const UI = {
|
||||
lines.push('Politisch: ' + (pl ? pl.full : firstFeed.political_orientation));
|
||||
}
|
||||
if (firstFeed.reliability && firstFeed.reliability !== 'na') {
|
||||
lines.push('Glaubwürdigkeit: ' + (this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability));
|
||||
const relLabel = this._reliabilityLabels[firstFeed.reliability] || firstFeed.reliability;
|
||||
const relSrc = firstFeed.ifcn_signatory ? ' (IFCN-Faktenchecker)'
|
||||
: (firstFeed.eu_disinfo_listed ? ` (EU-Desinfo, ${firstFeed.eu_disinfo_case_count || 0} Fälle)`
|
||||
: ' (LLM-Schätzung)');
|
||||
lines.push('Glaubwürdigkeit: ' + relLabel + relSrc);
|
||||
}
|
||||
if (firstFeed.ifcn_signatory) lines.push('IFCN-Faktenchecker: ja');
|
||||
if (firstFeed.eu_disinfo_listed) {
|
||||
lines.push(`EUvsDisinfo: ${firstFeed.eu_disinfo_case_count || 0} Fälle` + (firstFeed.eu_disinfo_last_seen ? ` (zuletzt ${firstFeed.eu_disinfo_last_seen})` : ''));
|
||||
}
|
||||
if (firstFeed.state_affiliated) lines.push('Staatsnah: ja');
|
||||
if (Array.isArray(firstFeed.alignments) && firstFeed.alignments.length > 0) {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren