feat(sources): Aufgaben-Posteingang ersetzt Quellen-Health und Klassifikation
- Neuer Unterreiter Aufgaben buendelt Health-Vorschlaege (Tabelle inkl. Loesung-suchen), Klassifikations-Review-Karten und den eingeklappten Verlauf (erledigte Vorschlaege + letzte Prueflaeufe). Die dritte Navigationsebene (healthSubTabs) entfaellt komplett. - Badge zeigt die Summe aus offenen Vorschlaegen und ausstehenden Klassifikationen, gespeist vom neuen billigen GET /api/sources/tasks/summary (gleiche Bedingung wie /classification/stats, laeuft nie auseinander). Badge laedt jetzt schon beim Klick auf den Quellen-Hauptreiter. - aufgaben.js NEU (uebernimmt Vorschlags-/Verlauf-Rendering aus source-health.js und den kompletten Klassifikations-Block aus sources.js), source-health.js GELOESCHT (60s-Cache, Checks-Tabelle mit eigener Pagination und Filtern entfaellt, die Details stecken jetzt als Ausklapp-Zeile in der Quellenliste). - Quellen hat damit 3 statt 5 Unterreiter: Quellenliste, Aufgaben, X-Recherche-Konten. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -56,9 +56,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
setupSourceSubTabs();
|
||||
setupSourceForms();
|
||||
|
||||
// Beim Tab-Wechsel auf "Quellen" laden
|
||||
// Beim Tab-Wechsel auf "Quellen" laden (+ Aufgaben-Badge aktualisieren)
|
||||
document.querySelectorAll('.nav-tab[data-section="sources"]').forEach((tab) => {
|
||||
tab.addEventListener("click", () => loadUnifiedSources());
|
||||
tab.addEventListener("click", () => {
|
||||
loadUnifiedSources();
|
||||
if (typeof refreshTasksBadge === "function") refreshTasksBadge();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,8 +75,7 @@ function setupSourceSubTabs() {
|
||||
document.getElementById("sub-" + subtab).classList.add("active");
|
||||
|
||||
if (subtab === "global-sources") loadUnifiedSources();
|
||||
else if (subtab === "source-health") loadHealthData();
|
||||
else if (subtab === "classification-review") loadClassificationQueue();
|
||||
else if (subtab === "aufgaben") loadAufgaben();
|
||||
else if (subtab === "x-scraper") loadXScraperAccounts();
|
||||
});
|
||||
});
|
||||
@@ -694,181 +696,6 @@ 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",
|
||||
};
|
||||
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 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)}
|
||||
</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("detail-" + id);
|
||||
if (!row) return;
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren