feat(klassifikation): Quellen-Klassifikation aus Monitor in Verwaltung verschoben
Service-Module (source_classifier, external_reputation) liegen jetzt in shared/services/, Endpoints unter /api/sources/classification/* sind hier statt im Monitor:
- classification/{stats,queue,bulk-classify,bulk-approve}
- {id}/classification/{approve,reject,reclassify}
- external-reputation/sync
modalSource erweitert um Klassifikations-Section (Politik, Medientyp, Reliability, state-affiliated, Land, 12 Alignment-Chips). Neuer Sub-Tab Klassifikation mit Review-Queue, Pending-Counter, Bulk-Actions. Auth via get_current_admin, Audit-Logging.
Begleit-Refactor: Monitor verliert die Klassifikations-UI/-Endpoints separat.
Dieser Commit ist enthalten in:
@@ -962,3 +962,162 @@ input[type="date"].filter-select { padding: 6px 10px; }
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* === Klassifikations-Review === */
|
||||
.sources-tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.review-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.review-toolbar-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.review-conf-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.review-toolbar-actions { display: flex; gap: 6px; }
|
||||
|
||||
.review-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.review-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.review-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.review-card-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.review-card-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
|
||||
.review-card-domain { font-size: 11px; color: var(--text-muted); }
|
||||
.review-global-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius);
|
||||
background: #5e35b1;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.review-card-confidence {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius);
|
||||
min-width: 60px;
|
||||
}
|
||||
.review-card-confidence .conf-value { font-size: 14px; font-weight: 700; }
|
||||
.review-card-confidence .conf-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.3px; opacity: 0.8; }
|
||||
.review-card-confidence.conf-high { background: rgba(34,197,94,0.15); color: var(--success); }
|
||||
.review-card-confidence.conf-medium { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||||
.review-card-confidence.conf-low { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||||
|
||||
.review-card-diff {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.review-diff-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr 24px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.review-diff-row.changed { background: rgba(245,158,11,0.10); }
|
||||
.review-diff-label { color: var(--text-secondary); font-weight: 500; }
|
||||
.review-diff-current { color: var(--text-muted); }
|
||||
.review-diff-arrow { text-align: center; color: var(--text-muted); font-weight: 600; }
|
||||
.review-diff-proposed { color: var(--text-primary); font-weight: 500; }
|
||||
.review-diff-row.changed .review-diff-proposed { color: var(--warning); font-weight: 600; }
|
||||
|
||||
.review-card-reasoning {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.review-card-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* Edit-Form: Klassifikations-Sektion */
|
||||
.sources-classification-section {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sources-classification-header {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.alignment-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.alignment-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.alignment-chip:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||
.alignment-chip.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
<button class="nav-tab active" data-subtab="global-sources">Grundquellen</button>
|
||||
<button class="nav-tab" data-subtab="tenant-sources">Kundenquellen</button>
|
||||
<button class="nav-tab" data-subtab="source-health">Quellen-Health</button>
|
||||
<button class="nav-tab" data-subtab="classification-review">Klassifikation <span class="sources-tab-badge" id="classificationPendingBadge">0</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Grundquellen -->
|
||||
@@ -406,6 +407,35 @@
|
||||
<div id="ht-verlauf" class="health-pane" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Klassifikations-Review -->
|
||||
<div class="section" id="sub-classification-review">
|
||||
<div class="action-bar review-toolbar">
|
||||
<div class="review-toolbar-info">
|
||||
<span><strong id="reviewPendingCount">0</strong> Vorschläge ausstehend</span>
|
||||
<label class="review-conf-filter">
|
||||
Mindest-Konfidenz:
|
||||
<select class="filter-select" id="reviewMinConfidence" onchange="loadClassificationQueue()">
|
||||
<option value="0">alle</option>
|
||||
<option value="0.5">0.5+</option>
|
||||
<option value="0.7">0.7+</option>
|
||||
<option value="0.85">0.85+</option>
|
||||
<option value="0.9">0.9+</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="review-toolbar-actions">
|
||||
<button class="btn btn-secondary btn-small" onclick="triggerExternalReputationSync()" title="IFCN-Faktenchecker und EUvsDisinfo neu syncen">Externe Daten syncen</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="triggerBulkClassify()" title="LLM-Klassifikation für noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
|
||||
<button class="btn btn-primary btn-small" onclick="bulkApproveHighConfidence()" title="Alle Vorschläge ab 0.85 Konfidenz übernehmen">Alle ≥ 0.85 genehmigen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="review-list" id="classificationReviewList">
|
||||
<div class="text-muted" style="padding:24px;text-align:center;">Lade Review-Queue…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /sec-sources -->
|
||||
|
||||
<!-- Audit-Log Section -->
|
||||
@@ -653,6 +683,96 @@
|
||||
<label for="sourceNotes">Notizen</label>
|
||||
<input type="text" id="sourceNotes" placeholder="Optional">
|
||||
</div>
|
||||
|
||||
<div class="sources-classification-section">
|
||||
<div class="sources-classification-header">Einordnung (Klassifikation)</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||
<div class="form-group">
|
||||
<label for="sourcePolitical">Politische Ausrichtung</label>
|
||||
<select id="sourcePolitical">
|
||||
<option value="">— unverändert —</option>
|
||||
<option value="na">Nicht eingeordnet</option>
|
||||
<option value="links_extrem">Links (extrem)</option>
|
||||
<option value="links">Links</option>
|
||||
<option value="mitte_links">Mitte-Links</option>
|
||||
<option value="liberal">Liberal</option>
|
||||
<option value="mitte">Mitte</option>
|
||||
<option value="konservativ">Konservativ</option>
|
||||
<option value="mitte_rechts">Mitte-Rechts</option>
|
||||
<option value="rechts">Rechts</option>
|
||||
<option value="rechts_extrem">Rechts (extrem)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sourceMediaType">Medientyp</label>
|
||||
<select id="sourceMediaType">
|
||||
<option value="">— unverändert —</option>
|
||||
<option value="sonstige">Sonstige</option>
|
||||
<option value="tageszeitung">Tageszeitung</option>
|
||||
<option value="wochenzeitung">Wochenzeitung</option>
|
||||
<option value="magazin">Magazin</option>
|
||||
<option value="tv_sender">TV-Sender</option>
|
||||
<option value="radio">Radio</option>
|
||||
<option value="oeffentlich_rechtlich">Öffentlich-Rechtlich</option>
|
||||
<option value="nachrichtenagentur">Nachrichtenagentur</option>
|
||||
<option value="online_only">Online-only</option>
|
||||
<option value="blog">Blog</option>
|
||||
<option value="telegram_kanal">Telegram-Kanal</option>
|
||||
<option value="telegram_bot">Telegram-Bot</option>
|
||||
<option value="podcast">Podcast</option>
|
||||
<option value="social_media">Social Media</option>
|
||||
<option value="imageboard">Imageboard</option>
|
||||
<option value="think_tank">Think Tank</option>
|
||||
<option value="ngo">NGO</option>
|
||||
<option value="behoerde">Behörde</option>
|
||||
<option value="staatsmedium">Staatsmedium</option>
|
||||
<option value="fachmedium">Fachmedium</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sourceReliability">Glaubwürdigkeit</label>
|
||||
<select id="sourceReliability">
|
||||
<option value="">— unverändert —</option>
|
||||
<option value="na">Nicht eingeordnet</option>
|
||||
<option value="sehr_hoch">Sehr hoch</option>
|
||||
<option value="hoch">Hoch</option>
|
||||
<option value="gemischt">Gemischt</option>
|
||||
<option value="niedrig">Niedrig</option>
|
||||
<option value="sehr_niedrig">Sehr niedrig</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px;">
|
||||
<div class="form-group">
|
||||
<label for="sourceCountryCode">Land (ISO 3166)</label>
|
||||
<input type="text" id="sourceCountryCode" maxlength="2" placeholder="z.B. DE, RU, US" style="text-transform:uppercase;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label" style="display:flex;align-items:center;gap:8px;margin-top:24px;">
|
||||
<input type="checkbox" id="sourceStateAffiliated">
|
||||
<span>Staatsnah / staatlich kontrolliert</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:8px;">
|
||||
<label>Geopolitische Nähe (Mehrfachauswahl)</label>
|
||||
<div id="sourceAlignmentChips" class="alignment-chips" onclick="handleAlignmentChipClick(event)">
|
||||
<button type="button" class="alignment-chip" data-alignment="prorussisch">prorussisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="proiranisch">proiranisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="prowestlich">prowestlich</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="proukrainisch">proukrainisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="prochinesisch">prochinesisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="projapanisch">projapanisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="proisraelisch">proisraelisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="propalaestinensisch">propalästinensisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="protuerkisch">protürkisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="panarabisch">panarabisch</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="neutral">neutral</button>
|
||||
<button type="button" class="alignment-chip" data-alignment="sonstige">sonstige</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sourceError" class="error-msg" style="display:none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -37,6 +37,7 @@ function setupSourceSubTabs() {
|
||||
if (subtab === "global-sources") loadGlobalSources();
|
||||
else if (subtab === "tenant-sources") loadTenantSources();
|
||||
else if (subtab === "source-health") loadHealthData();
|
||||
else if (subtab === "classification-review") loadClassificationQueue();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -280,6 +281,7 @@ function openNewGlobalSource() {
|
||||
editingSourceId = null;
|
||||
document.getElementById("sourceModalTitle").textContent = "Neue Grundquelle";
|
||||
document.getElementById("sourceForm").reset();
|
||||
setAlignmentChips([]);
|
||||
openModal("modalSource");
|
||||
}
|
||||
|
||||
@@ -298,6 +300,12 @@ function editGlobalSource(id) {
|
||||
document.getElementById("sourceLanguage").value = s.language || "";
|
||||
document.getElementById("sourceBias").value = s.bias || "";
|
||||
document.getElementById("sourceFetchStrategy").value = s.fetch_strategy || "default";
|
||||
document.getElementById("sourcePolitical").value = s.political_orientation || "";
|
||||
document.getElementById("sourceMediaType").value = s.media_type || "";
|
||||
document.getElementById("sourceReliability").value = s.reliability || "";
|
||||
document.getElementById("sourceCountryCode").value = s.country_code || "";
|
||||
document.getElementById("sourceStateAffiliated").checked = !!s.state_affiliated;
|
||||
setAlignmentChips(s.alignments || []);
|
||||
openModal("modalSource");
|
||||
}
|
||||
|
||||
@@ -328,6 +336,19 @@ function setupSourceForms() {
|
||||
fetch_strategy: document.getElementById("sourceFetchStrategy").value || "default",
|
||||
};
|
||||
|
||||
const pol = document.getElementById("sourcePolitical")?.value;
|
||||
if (pol) body.political_orientation = pol;
|
||||
const mt = document.getElementById("sourceMediaType")?.value;
|
||||
if (mt) body.media_type = mt;
|
||||
const rel = document.getElementById("sourceReliability")?.value;
|
||||
if (rel) body.reliability = rel;
|
||||
const cc = (document.getElementById("sourceCountryCode")?.value || "").trim().toUpperCase();
|
||||
if (cc) body.country_code = cc;
|
||||
if (editingSourceId) {
|
||||
body.state_affiliated = !!document.getElementById("sourceStateAffiliated")?.checked;
|
||||
body.alignments = getAlignmentChips();
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingSourceId) {
|
||||
await API.put("/api/sources/global/" + editingSourceId, body);
|
||||
@@ -641,6 +662,213 @@ async function addDiscoveredFeeds() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// === Klassifikations-Review ===
|
||||
|
||||
const POLITICAL_LABELS = {
|
||||
links_extrem: { short: "L+", full: "Links (extrem)" },
|
||||
links: { short: "L", full: "Links" },
|
||||
mitte_links: { short: "ML", full: "Mitte-Links" },
|
||||
liberal: { short: "LIB", full: "Liberal" },
|
||||
mitte: { short: "M", full: "Mitte" },
|
||||
konservativ: { short: "KON", full: "Konservativ" },
|
||||
mitte_rechts: { short: "MR", full: "Mitte-Rechts" },
|
||||
rechts: { short: "R", full: "Rechts" },
|
||||
rechts_extrem: { short: "R+", full: "Rechts (extrem)" },
|
||||
na: { short: "?", full: "Nicht eingeordnet" },
|
||||
};
|
||||
const RELIABILITY_LABELS = {
|
||||
sehr_hoch: "Sehr hoch", hoch: "Hoch", gemischt: "Gemischt",
|
||||
niedrig: "Niedrig", sehr_niedrig: "Sehr niedrig", na: "Nicht eingeordnet",
|
||||
};
|
||||
const MEDIA_TYPE_LABELS = {
|
||||
tageszeitung: "Tageszeitung", wochenzeitung: "Wochenzeitung", magazin: "Magazin",
|
||||
tv_sender: "TV-Sender", radio: "Radio", oeffentlich_rechtlich: "Öffentlich-Rechtlich",
|
||||
nachrichtenagentur: "Nachrichtenagentur", online_only: "Online-only", blog: "Blog",
|
||||
telegram_kanal: "Telegram-Kanal", telegram_bot: "Telegram-Bot", podcast: "Podcast",
|
||||
social_media: "Social Media", imageboard: "Imageboard", think_tank: "Think Tank",
|
||||
ngo: "NGO", behoerde: "Behörde", staatsmedium: "Staatsmedium",
|
||||
fachmedium: "Fachmedium", sonstige: "Sonstige",
|
||||
};
|
||||
const ALIGNMENT_LABELS = {
|
||||
prorussisch: "prorussisch", proiranisch: "proiranisch", prowestlich: "prowestlich",
|
||||
proukrainisch: "proukrainisch", prochinesisch: "prochinesisch", projapanisch: "projapanisch",
|
||||
proisraelisch: "proisraelisch", propalaestinensisch: "propalästinensisch",
|
||||
protuerkisch: "protürkisch", panarabisch: "panarabisch", neutral: "neutral", sonstige: "sonstige",
|
||||
};
|
||||
|
||||
function setAlignmentChips(active) {
|
||||
const chips = document.querySelectorAll("#sourceAlignmentChips .alignment-chip");
|
||||
const set = new Set((active || []).map((a) => (a || "").toLowerCase()));
|
||||
chips.forEach((chip) => {
|
||||
if (set.has(chip.dataset.alignment)) chip.classList.add("active");
|
||||
else chip.classList.remove("active");
|
||||
});
|
||||
}
|
||||
|
||||
function getAlignmentChips() {
|
||||
return Array.from(document.querySelectorAll("#sourceAlignmentChips .alignment-chip.active"))
|
||||
.map((chip) => chip.dataset.alignment);
|
||||
}
|
||||
|
||||
function handleAlignmentChipClick(e) {
|
||||
const chip = e.target.closest(".alignment-chip");
|
||||
if (!chip) return;
|
||||
e.preventDefault();
|
||||
chip.classList.toggle("active");
|
||||
}
|
||||
|
||||
async function refreshClassificationStats() {
|
||||
try {
|
||||
const stats = await API.get("/api/sources/classification/stats");
|
||||
const badge = document.getElementById("classificationPendingBadge");
|
||||
if (badge) badge.textContent = String(stats.pending_review || 0);
|
||||
} catch (_) { /* still ok */ }
|
||||
}
|
||||
|
||||
async function loadClassificationQueue() {
|
||||
const list = document.getElementById("classificationReviewList");
|
||||
if (!list) return;
|
||||
const minConf = parseFloat(document.getElementById("reviewMinConfidence")?.value || "0");
|
||||
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Lade…</div>';
|
||||
try {
|
||||
const items = await API.get(`/api/sources/classification/queue?limit=200&min_confidence=${minConf}`);
|
||||
const countEl = document.getElementById("reviewPendingCount");
|
||||
if (countEl) countEl.textContent = String(items.length);
|
||||
refreshClassificationStats();
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div class="text-muted" style="padding:24px;text-align:center;">Keine ausstehenden Vorschläge.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map((it) => renderClassificationQueueItem(it)).join("");
|
||||
} catch (err) {
|
||||
list.innerHTML = `<div class="text-danger" style="padding:24px;text-align:center;">Fehler: ${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderClassificationQueueItem(item) {
|
||||
const cur = item.current || {};
|
||||
const prop = item.proposed || {};
|
||||
const conf = prop.confidence || 0;
|
||||
const confPct = Math.round(conf * 100);
|
||||
const confClass = conf >= 0.85 ? "high" : conf >= 0.7 ? "medium" : "low";
|
||||
|
||||
const polFmt = (v) => (v && v !== "na" ? POLITICAL_LABELS[v]?.full || v : "–");
|
||||
const mtFmt = (v) => (v ? MEDIA_TYPE_LABELS[v] || v : "–");
|
||||
const relFmt = (v) => (v && v !== "na" ? RELIABILITY_LABELS[v] || v : "–");
|
||||
const stateFmt = (v) => (v ? "ja" : "nein");
|
||||
const ccFmt = (v) => v || "–";
|
||||
const alignFmt = (v) =>
|
||||
Array.isArray(v) && v.length > 0 ? v.map((a) => ALIGNMENT_LABELS[a] || a).join(", ") : "–";
|
||||
|
||||
const row = (label, c, p, fmt) => {
|
||||
const cs = fmt(c);
|
||||
const ps = fmt(p);
|
||||
const changed = cs !== ps;
|
||||
return `<div class="review-diff-row${changed ? " changed" : ""}">
|
||||
<span class="review-diff-label">${esc(label)}</span>
|
||||
<span class="review-diff-current">${esc(cs)}</span>
|
||||
<span class="review-diff-arrow">→</span>
|
||||
<span class="review-diff-proposed">${esc(ps)}</span>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const reasoning = prop.reasoning ? esc(prop.reasoning) : "";
|
||||
|
||||
return `<div class="review-card" data-source-id="${item.id}">
|
||||
<div class="review-card-header">
|
||||
<div class="review-card-title">
|
||||
<span class="review-card-name">${esc(item.name)}</span>
|
||||
${item.is_global ? '<span class="review-global-badge">Grundquelle</span>' : ""}
|
||||
<span class="review-card-domain">${esc(item.domain || "")}</span>
|
||||
</div>
|
||||
<div class="review-card-confidence conf-${confClass}" title="LLM-Konfidenz">
|
||||
<span class="conf-value">${confPct}%</span>
|
||||
<span class="conf-label">Konfidenz</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-card-diff">
|
||||
${row("Politik", cur.political_orientation, prop.political_orientation, polFmt)}
|
||||
${row("Medientyp", cur.media_type, prop.media_type, mtFmt)}
|
||||
${row("Glaubwürdigkeit", cur.reliability, prop.reliability, relFmt)}
|
||||
${row("Staatsnah", cur.state_affiliated, prop.state_affiliated, stateFmt)}
|
||||
${row("Land", cur.country_code, prop.country_code, ccFmt)}
|
||||
${row("Geopol. Nähe", cur.alignments, prop.alignments, alignFmt)}
|
||||
</div>
|
||||
${reasoning ? `<div class="review-card-reasoning"><strong>Begründung:</strong> ${reasoning}</div>` : ""}
|
||||
<div class="review-card-actions">
|
||||
<button class="btn btn-small btn-primary" onclick="approveClassification(${item.id})">Übernehmen</button>
|
||||
<button class="btn btn-small btn-secondary" onclick="rejectClassification(${item.id})">Verwerfen</button>
|
||||
<button class="btn btn-small btn-secondary" data-reclassify-id="${item.id}" onclick="reclassifySource(${item.id})">Neu klassifizieren</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function approveClassification(id) {
|
||||
try {
|
||||
await API.post(`/api/sources/${id}/classification/approve`, {});
|
||||
showToast("Klassifikation übernommen.", "success");
|
||||
loadClassificationQueue();
|
||||
} catch (err) {
|
||||
showToast("Approve fehlgeschlagen: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectClassification(id) {
|
||||
try {
|
||||
await API.post(`/api/sources/${id}/classification/reject`, {});
|
||||
showToast("Vorschlag verworfen.", "success");
|
||||
loadClassificationQueue();
|
||||
} catch (err) {
|
||||
showToast("Reject fehlgeschlagen: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function reclassifySource(id) {
|
||||
const btn = document.querySelector(`[data-reclassify-id="${id}"]`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = "..."; }
|
||||
try {
|
||||
await API.post(`/api/sources/${id}/classification/reclassify`, {});
|
||||
showToast("Neu klassifiziert.", "success");
|
||||
loadClassificationQueue();
|
||||
} catch (err) {
|
||||
showToast("Reclassify fehlgeschlagen: " + err.message, "error");
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Neu klassifizieren"; }
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerBulkClassify() {
|
||||
if (!confirm("Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Läuft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).")) return;
|
||||
try {
|
||||
const r = await API.post("/api/sources/classification/bulk-classify?limit=500&only_unclassified=true", {});
|
||||
showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). In ~10 min neu laden.`, "info");
|
||||
} catch (err) {
|
||||
showToast("Start fehlgeschlagen: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkApproveHighConfidence() {
|
||||
if (!confirm("Alle Vorschläge mit Konfidenz ≥ 0.85 genehmigen?")) return;
|
||||
try {
|
||||
const r = await API.post("/api/sources/classification/bulk-approve?min_confidence=0.85", {});
|
||||
showToast(`${r.approved} Vorschläge übernommen.`, "success");
|
||||
loadClassificationQueue();
|
||||
} catch (err) {
|
||||
showToast("Bulk-Approve fehlgeschlagen: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerExternalReputationSync() {
|
||||
if (!confirm("IFCN- und EUvsDisinfo-Datenbanken jetzt syncen? Läuft im Hintergrund (~30 Sek).")) return;
|
||||
try {
|
||||
await API.post("/api/sources/external-reputation/sync", {});
|
||||
showToast("Externer Sync gestartet. Quellenliste in ~30 Sek neu laden.", "info");
|
||||
} catch (err) {
|
||||
showToast("Sync fehlgeschlagen: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSourceInfo(id) {
|
||||
const row = document.getElementById("notes-" + id);
|
||||
if (!row) return;
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren