From bcfdac158e41ac97ae1bc0823e93d4ecb1260dce Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 25 Jul 2026 18:11:19 +0000 Subject: [PATCH] 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 --- CLAUDE.md | 8 +- src/routers/sources.py | 33 +++ src/static/dashboard.html | 36 +-- src/static/js/app.js | 6 +- src/static/js/aufgaben.js | 412 +++++++++++++++++++++++++++++ src/static/js/source-health.js | 455 --------------------------------- src/static/js/sources.js | 185 +------------- tests/test_api_smoke.py | 1 + 8 files changed, 472 insertions(+), 664 deletions(-) create mode 100644 src/static/js/aufgaben.js delete mode 100644 src/static/js/source-health.js diff --git a/CLAUDE.md b/CLAUDE.md index 96c4363..3350c42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ src/: organizations.py: "CRUD Mandanten (organizations + Org-Settings + Token-Budget)" licenses.py: "CRUD Lizenzen (Org-Lizenzen, Ablauf, Nutzer-Limit, Module, Credits-Stellwerte inkl. Periode/Übertrag; Helper insert_license_row wird auch vom Org-Anlegen genutzt)" users.py: "CRUD User pro Org, Magic-Link-Einladung an info@aegis-sight.de" - sources.py: "Grundquellen, Tenant-Quellen-Übersicht, Discovery, Health-Check, KI-Vorschläge" + sources.py: "Vereinte Quellenliste (GET /api/sources?scope=all|global|tenant inkl. org_name + health_status), Stats/Languages, Einzelquellen-Health (GET /{id}/health), Aufgaben-Badge (GET /tasks/summary), Promote, Discovery, PDF-Upload, KI-Vorschläge, Klassifikation" dashboard.py: "Aggregat-Endpoints für Übersichts-Tab" token_usage.py: "Token-Verbrauch pro Org/Monat, Credits-Stellwerte je Lizenz (PUT /budget). Laufzeitfelder (credits_used-Reset, credits_carried, credits_period_start, budget_warning_sent) verwaltet der MONITOR, das Portal setzt nur Stellwerte" pricing.py: "Preis-Empfehlung aus Ist-Kosten (GET /cost-basis) und pflegbare Preistabelle billing_tariff (GET/PUT /tariff). Die Tabelle ist die Quelle der Buchung im Monitor, der Rechner kann sie speichern" @@ -61,15 +61,15 @@ src/: static/: index.html: "Login (Passwort)" - dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Lizenzen, Quellen, Audit)" + dashboard.html: "Hauptdashboard mit Tabs (Dashboard, Orgs, Quellen, Audit). Quellen hat 3 Unterreiter (Quellenliste, Aufgaben, X-Recherche-Konten)" favicon.svg: "AegisSight Logo" css/: "Stylesheets (Dark Theme)" js/: app.js: "Hauptlogik, Login, Tab-Switching, Dashboard-Render, ThemeManager (Dark/Light, localStorage portal_theme)" a11y.js: "Barrierefreiheits-Panel (Kontrast, Focus, Schrift, Animationen). IDENTISCHE Datei wie im Monitor-Repo, bei Aenderungen dort mitziehen" statistik.js: "Statistik-Tab, selbst-injizierend wie rechner.js. Inline-SVG-Charts (keine Bibliothek), Serienfarben mit dem dataviz-Validator gegen beide Themes geprüft" - sources.js: "Grundquellen + Kundenquellen Management" - source-health.js: "Quellen-Health & KI-Vorschläge" + sources.js: "Vereinte Quellenliste (Grund- + Kundenquellen, Herkunfts-Filter, Health-Ausklapp mit Deaktivieren, Bulk-Promote, Modal, Discovery, PDF-Upload)" + aufgaben.js: "Aufgaben-Posteingang (Health-Vorschläge + Klassifikations-Review + Verlauf, Badge via GET /tasks/summary)" audit.js: "Audit-Log Tab" migrations/: diff --git a/src/routers/sources.py b/src/routers/sources.py index e45cb28..afd5b7a 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -112,6 +112,39 @@ async def get_sources_meta(admin: dict = Depends(get_current_admin)): return get_meta() +@router.get("/tasks/summary") +async def tasks_summary( + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Billiger Zähler für den Aufgaben-Badge im Quellen-Reiter. + + classification_pending nutzt DIESELBE Bedingung wie /classification/stats, + damit Badge und Review-Karten nie auseinanderlaufen. + """ + suggestions_pending = 0 + cur = await db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='source_suggestions'" + ) + if await cur.fetchone(): + cur = await db.execute( + "SELECT COUNT(*) AS cnt FROM source_suggestions WHERE status = 'pending'" + ) + suggestions_pending = (await cur.fetchone())["cnt"] + + cur = await db.execute( + """SELECT COUNT(*) AS cnt FROM sources + WHERE status = 'active' AND proposed_political_orientation IS NOT NULL""" + ) + classification_pending = (await cur.fetchone())["cnt"] + + return { + "suggestions_pending": suggestions_pending, + "classification_pending": classification_pending, + "total": suggestions_pending + classification_pending, + } + + class GlobalSourceCreate(BaseModel): name: str = Field(min_length=1, max_length=200) diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 3e0a54b..92e3ba3 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -286,8 +286,7 @@
@@ -350,26 +349,14 @@
- -
- -
- - -
- - -
+ +
- 0 Vorschläge ausstehend + 0 Vorschläge offen + 0 Klassifikationen ausstehend
-
`; - - if (filtered.length > 0) { - healthHtml += ` -
- - - - - - ${filtered - .map( - (c) => { - // Domain + Sprache in Tooltip vom Quellnamen, statt eigene Spalten. - const tipParts = []; - if (c.domain) tipParts.push(c.domain); - if (c.language) tipParts.push(c.language); - const nameTip = tipParts.length ? ` title="${esc(tipParts.join(" · "))}"` : ""; - return ` - - ${esc(c.name)} - - - - - - `; - } - ) - .join("")} - -
QuelleTypOrgStatusDetailsAktion
${CHECK_TYPE_LABELS[c.check_type] || c.check_type}${c.tenant_id == null ? 'global' : esc(c.org_name || ("Org " + c.tenant_id))}${c.status === "error" ? "Fehler" : (c.status === "warning" ? "Warnung" : "OK")}${esc(c.message || "")}${( - (c.status === "error" && c.check_type === "reachability") || - (c.status === "warning" && c.check_type === "feed_validity") - ) ? `` : ""}
-
`; - } else if (hasMore) { - // 0 Treffer in der Page, aber es gibt noch ungeladene Items. - // Hinweis, dass der Filter erst über die volle Liste sicher ist. - healthHtml += ` -
- Keine Treffer in den geladenen ${allChecks.length} von ${totalAll} Items mit dem aktuellen Filter. - - Alle ${totalAll} Health-Checks laden - und Filter erneut anwenden. -
`; - } else { - healthHtml += '
Keine Ergebnisse mit diesen Filtern.
'; - } - - // Footer mit Mehr-laden-Buttons, falls Backend has_more meldet - if (hasMore) { - const remaining = Math.max(0, totalAll - allChecks.length); - healthHtml += ` -
- ${allChecks.length} von ${totalAll} geladen - - -
`; - } - - healthHtml += "
"; - } else { - healthHtml = ` -
-

Health-Check Ergebnisse

-
Noch kein Health-Check durchgeführt.
-
`; - } - - // History-View: letzte Runs. Kompakt: Total raus (= errors+warnings+ok), - // Spalten-Widths explizit, Zahlen zentriert, Run-ID gekürzt + leiser. - let runsHtml = ""; - if (healthHistoryCache.length > 0) { - runsHtml = ` -
-

Verlauf der Health-Check-Runs

-
- - - - - - - - - - - - - - - - - - - ${healthHistoryCache.map(r => ` - - - - - - - `).join("")} - -
ZeitpunktRun-IDFehlerWarnungenOK
${formatDateTime(r.archived_at)}${esc(String(r.run_id || "").slice(0, 12))}${r.errors || 0}${r.warnings || 0}${r.ok || 0}
-
-
`; - } - - // Statt einer monolithischen Render: drei Sub-Panes, einer pro Sub-Tab. - paneSuggestions.innerHTML = suggestionsHtml; - paneChecks.innerHTML = healthHtml; - paneVerlauf.innerHTML = historyHtml + runsHtml; - - // Tab-Label "Vorschläge" mit Counter der offenen Vorschläge anreichern. - const tabBtnSugg = document.querySelector('#healthSubTabs .nav-tab[data-healthtab="suggestions"]'); - if (tabBtnSugg) { - const open = pendingSuggestions.length; - tabBtnSugg.textContent = open > 0 ? `Vorschläge (${open} offen)` : "Vorschläge"; - } -} - -// --- Vorschlag annehmen/ablehnen --- -async function handleSuggestion(id, accept) { - const action = accept ? "annehmen" : "ablehnen"; - const suggestion = suggestionsCache.find((s) => s.id === id); - if (!suggestion) return; - - const ok = await showConfirm("Vorschlag " + (action === "annehmen" ? "annehmen" : "ablehnen"), `Soll "${suggestion.title}" ${action}?`); - if (!ok) return; - - try { - const result = await API.put("/api/sources/suggestions/" + id, { accept }); - if (result.action) { - showToast("Ergebnis: " + result.action, "success"); - } - loadHealthData(true); - // Grundquellen-Liste auch aktualisieren - if (typeof loadUnifiedSources === "function") loadUnifiedSources(); - } catch (err) { - showToast("Fehler: " + err.message, "error"); - } -} - -// --- Sonnet-Recherche für kaputte Quelle --- -async function searchFix(btn) { - const sourceId = btn.dataset.sourceId; - const sourceName = btn.dataset.sourceName; - const ok = await showConfirm("Lösung suchen", `Sonnet mit WebSearch nach einer Lösung für "${sourceName}" suchen lassen? Das kann einige Minuten dauern.`); - if (!ok) return; - - btn.disabled = true; - btn.textContent = "Sucht..."; - - try { - const result = await API.post("/api/sources/health/search-fix/" + sourceId); - - let msg = result.summary || "Keine Zusammenfassung"; - if (result.solutions && result.solutions.length > 0) { - msg += "\n\nGefundene Lösungen als Vorschläge gespeichert."; - } - if (result.cost_usd) { - msg += `\n\nKosten: $${result.cost_usd.toFixed(2)}`; - } - showToast(msg, "info"); - loadHealthData(true); - } catch (err) { - showToast("Fehler: " + err.message, "error"); - } finally { - btn.disabled = false; - btn.textContent = "Lösung suchen"; - } -} diff --git a/src/static/js/sources.js b/src/static/js/sources.js index d894f3f..ea03f85 100644 --- a/src/static/js/sources.js +++ b/src/static/js/sources.js @@ -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 = '
Lade…
'; - 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 = '
Keine ausstehenden Vorschläge.
'; - return; - } - list.innerHTML = items.map((it) => renderClassificationQueueItem(it)).join(""); - } catch (err) { - list.innerHTML = `
Fehler: ${esc(err.message)}
`; - } -} - -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 `
- ${esc(label)} - ${esc(cs)} - - ${esc(ps)} -
`; - }; - - const reasoning = prop.reasoning ? esc(prop.reasoning) : ""; - - return `
-
-
- ${esc(item.name)} - ${item.is_global ? 'Grundquelle' : ""} - ${esc(item.domain || "")} -
-
- ${confPct}% - Konfidenz -
-
-
- ${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)} -
- ${reasoning ? `
Begründung: ${reasoning}
` : ""} -
- - - -
-
`; -} - -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; diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index d34830c..d4516a0 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -40,6 +40,7 @@ AUTH_PROTECTED = [ ("DELETE", "/api/users/1"), ("GET", "/api/dashboard/stats"), ("GET", "/api/sources/meta"), + ("GET", "/api/sources/tasks/summary"), ("GET", "/api/sources"), ("POST", "/api/sources/global"), ("PUT", "/api/sources/global/1"),