diff --git a/src/routers/sources.py b/src/routers/sources.py index 9907e8d..25a898f 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -937,3 +937,62 @@ async def trigger_bulk_classify( raise HTTPException(status_code=400, detail="limit muss zwischen 1 und 500 liegen") background_tasks.add_task(_bulk_classify_background, limit, only_unclassified) return {"status": "started", "limit": limit, "only_unclassified": only_unclassified} + + +@router.post("/classification/bulk-approve") +async def bulk_approve_classifications( + min_confidence: float = 0.85, + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Genehmigt alle Pending-Vorschlaege ueber dem confidence-Schwellwert (nur Admins). + + Globale Quellen werden nur bearbeitet, wenn der Aufrufer org_admin ist; + Tenant-eigene Quellen sowieso. + """ + if current_user.get("role") != "org_admin": + raise HTTPException(status_code=403, detail="Nur Admins koennen Bulk-Approve nutzen") + tenant_id = current_user.get("tenant_id") + cursor = await db.execute( + """SELECT id, proposed_political_orientation, proposed_media_type, + proposed_reliability, proposed_state_affiliated, + proposed_country_code, proposed_alignments_json, tenant_id + FROM sources + WHERE proposed_political_orientation IS NOT NULL + AND COALESCE(proposed_confidence, 0) >= ? + AND (tenant_id IS NULL OR tenant_id = ?)""", + (min_confidence, tenant_id), + ) + rows = [dict(r) for r in await cursor.fetchall()] + approved_ids: list[int] = [] + for src in rows: + try: + proposed_aligns = json.loads(src.get("proposed_alignments_json") or "[]") + except (json.JSONDecodeError, TypeError): + proposed_aligns = [] + await db.execute( + """UPDATE sources SET + political_orientation = ?, + media_type = ?, + reliability = ?, + state_affiliated = ?, + country_code = ?, + classification_source = 'llm_approved', + classified_at = CURRENT_TIMESTAMP + WHERE id = ?""", + ( + src["proposed_political_orientation"], + src["proposed_media_type"], + src["proposed_reliability"], + 1 if src.get("proposed_state_affiliated") else 0, + src.get("proposed_country_code"), + src["id"], + ), + ) + await _replace_alignments( + db, src["id"], [a for a in proposed_aligns if a in ALLOWED_ALIGNMENTS] + ) + await _clear_proposed(db, src["id"]) + approved_ids.append(src["id"]) + await db.commit() + return {"approved_count": len(approved_ids), "min_confidence": min_confidence} diff --git a/src/static/css/style.css b/src/static/css/style.css index 3bc671c..777d490 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -3503,6 +3503,204 @@ a.dev-source-pill:hover { color: var(--info); } +/* Sources-Modal: Tabs */ +.sources-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.1)); + margin-bottom: 12px; +} +.sources-tab { + background: transparent; + border: none; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #555); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + display: inline-flex; + align-items: center; + gap: 8px; +} +.sources-tab:hover { + color: var(--text-primary, #222); +} +.sources-tab.active { + color: var(--primary, #2a81cb); + border-bottom-color: var(--primary, #2a81cb); +} +.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(--primary, #2a81cb); + color: #fff; + font-size: 10px; + font-weight: 700; +} + +/* Review-Queue */ +.review-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--cat-sonstige-bg, #f6f6fa); + 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; +} +.review-conf-filter { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary, #555); +} +.review-conf-filter select { + padding: 2px 6px; + font-size: 12px; + border-radius: var(--radius); + border: 1px solid var(--border-color, rgba(0,0,0,0.15)); +} +.review-toolbar-actions { + display: flex; + gap: 6px; +} + +.review-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.review-card { + background: var(--surface, #fff); + border: 1px solid var(--border-color, rgba(0,0,0,0.08)); + 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; +} +.review-card-domain { + font-size: 11px; + color: var(--text-disabled, #888); +} +.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: #e8f5e9; color: #2e7d32; } +.review-card-confidence.conf-medium { background: #fff8e1; color: #ef6c00; } +.review-card-confidence.conf-low { background: #ffebee; color: #c62828; } + +.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: 110px 1fr 24px 1fr; + align-items: center; + gap: 8px; + padding: 3px 6px; + border-radius: 3px; +} +.review-diff-row.changed { + background: #fff8e1; +} +.review-diff-label { + color: var(--text-secondary, #555); + font-weight: 500; +} +.review-diff-current { + color: var(--text-disabled, #888); +} +.review-diff-arrow { + text-align: center; + color: var(--text-disabled, #888); + font-weight: 600; +} +.review-diff-proposed { + color: var(--text-primary, #222); + font-weight: 500; +} +.review-diff-row.changed .review-diff-proposed { + color: #ef6c00; + font-weight: 600; +} + +.review-card-reasoning { + font-size: 12px; + color: var(--text-secondary, #555); + background: var(--cat-sonstige-bg, #f6f6fa); + padding: 8px 10px; + border-radius: var(--radius); + margin-bottom: 10px; + line-height: 1.5; +} +.review-card-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + /* Klassifikations-Badges (politisch / reliability / alignments / state) */ .source-classification-badges { display: inline-flex; diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 43a81dd..8e73d59 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -456,6 +456,15 @@
+ +
+ + +
+ + +
+
@@ -706,6 +715,35 @@
Lade Quellen...
+ +
+ + + +
diff --git a/src/static/js/api.js b/src/static/js/api.js index 310476d..b2b1fd9 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -198,10 +198,43 @@ const API = { if (params.source_type) query.set('source_type', params.source_type); if (params.category) query.set('category', params.category); if (params.source_status) query.set('source_status', params.source_status); + if (params.political_orientation) query.set('political_orientation', params.political_orientation); + if (params.media_type) query.set('media_type', params.media_type); + if (params.reliability) query.set('reliability', params.reliability); + if (params.alignment) query.set('alignment', params.alignment); + if (params.state_affiliated !== undefined && params.state_affiliated !== null) { + query.set('state_affiliated', String(params.state_affiliated)); + } const qs = query.toString(); return this._request('GET', `/sources${qs ? '?' + qs : ''}`); }, + // Sources: Klassifikations-Review (LLM) + getClassificationStats() { + return this._request('GET', '/sources/classification/stats'); + }, + getClassificationQueue(limit = 50, minConfidence = 0.0) { + const qs = new URLSearchParams({ limit: String(limit), min_confidence: String(minConfidence) }).toString(); + return this._request('GET', `/sources/classification/queue?${qs}`); + }, + approveClassification(id) { + return this._request('POST', `/sources/${id}/classification/approve`); + }, + rejectClassification(id) { + return this._request('POST', `/sources/${id}/classification/reject`); + }, + reclassifySource(id) { + return this._request('POST', `/sources/${id}/classification/reclassify`); + }, + triggerBulkClassify(limit = 50, onlyUnclassified = true) { + const qs = new URLSearchParams({ limit: String(limit), only_unclassified: String(onlyUnclassified) }).toString(); + return this._request('POST', `/sources/classification/bulk-classify?${qs}`); + }, + bulkApproveClassifications(minConfidence = 0.85) { + const qs = new URLSearchParams({ min_confidence: String(minConfidence) }).toString(); + return this._request('POST', `/sources/classification/bulk-approve?${qs}`); + }, + createSource(data) { return this._request('POST', '/sources', data); }, diff --git a/src/static/js/app.js b/src/static/js/app.js index 1aff794..1f8d0b4 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -2702,6 +2702,12 @@ async handleRefresh() { async openSourceManagement() { openModal('modal-sources'); await this.loadSources(); + // Admin sieht den Review-Tab + const reviewTab = document.getElementById('sources-tab-review'); + if (reviewTab && this.user && this.user.role === 'org_admin') { + reviewTab.style.display = ''; + this._refreshReviewBadge().catch(() => {}); + } }, async loadSources() { @@ -2722,6 +2728,112 @@ async handleRefresh() { } }, + async _refreshReviewBadge() { + try { + const stats = await API.getClassificationStats(); + const badge = document.getElementById('sources-review-count'); + if (badge) badge.textContent = String(stats.pending_review || 0); + } catch (_) { /* still ok */ } + }, + + switchSourcesTab(tab) { + const listView = document.getElementById('sources-list-view'); + const reviewView = document.getElementById('sources-review-view'); + const tabList = document.getElementById('sources-tab-list'); + const tabReview = document.getElementById('sources-tab-review'); + if (!listView || !reviewView) return; + if (tab === 'review') { + listView.style.display = 'none'; + reviewView.style.display = ''; + if (tabList) { tabList.classList.remove('active'); tabList.setAttribute('aria-selected', 'false'); } + if (tabReview) { tabReview.classList.add('active'); tabReview.setAttribute('aria-selected', 'true'); } + this.loadClassificationQueue(); + } else { + listView.style.display = ''; + reviewView.style.display = 'none'; + if (tabList) { tabList.classList.add('active'); tabList.setAttribute('aria-selected', 'true'); } + if (tabReview) { tabReview.classList.remove('active'); tabReview.setAttribute('aria-selected', 'false'); } + } + }, + + async loadClassificationQueue() { + const list = document.getElementById('sources-review-list'); + if (!list) return; + const minConf = parseFloat(document.getElementById('review-min-confidence')?.value || '0'); + list.innerHTML = '
Lade...
'; + try { + const items = await API.getClassificationQueue(200, minConf); + this._reviewItems = items; + const countEl = document.getElementById('review-pending-count'); + if (countEl) countEl.textContent = String(items.length); + if (items.length === 0) { + list.innerHTML = '
Keine ausstehenden Vorschlaege.
'; + return; + } + list.innerHTML = items.map(item => UI.renderClassificationQueueItem(item)).join(''); + } catch (err) { + list.innerHTML = `
Fehler: ${err.message}
`; + } + }, + + async approveClassification(id) { + try { + await API.approveClassification(id); + UI.showToast('Klassifikation uebernommen.', 'success'); + await this.loadClassificationQueue(); + this._refreshReviewBadge(); + } catch (err) { + UI.showToast('Approve fehlgeschlagen: ' + err.message, 'error'); + } + }, + + async rejectClassification(id) { + try { + await API.rejectClassification(id); + UI.showToast('Vorschlag verworfen.', 'success'); + await this.loadClassificationQueue(); + this._refreshReviewBadge(); + } catch (err) { + UI.showToast('Reject fehlgeschlagen: ' + err.message, 'error'); + } + }, + + async reclassifySource(id) { + const btn = document.querySelector(`[data-reclassify-id="${id}"]`); + if (btn) { btn.disabled = true; btn.textContent = '...'; } + try { + await API.reclassifySource(id); + UI.showToast('Neu klassifiziert.', 'success'); + await this.loadClassificationQueue(); + } catch (err) { + UI.showToast('Reclassify fehlgeschlagen: ' + err.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Neu klassifizieren'; } + } + }, + + async triggerBulkClassify() { + if (!confirm('Bulk-Klassifikation aller noch nicht klassifizierten Quellen starten? Lauft im Hintergrund (~3-5 Sek pro Quelle, ~0.02 USD pro Quelle).')) return; + try { + const r = await API.triggerBulkClassify(500, true); + UI.showToast(`Bulk-Klassifikation gestartet (limit=${r.limit}). Nachschauen mit Reload.`, 'info'); + } catch (err) { + UI.showToast('Start fehlgeschlagen: ' + err.message, 'error'); + } + }, + + async bulkApproveHighConfidence() { + if (!confirm('Alle Vorschlaege mit Konfidenz >= 0.85 genehmigen?')) return; + try { + const r = await API.bulkApproveClassifications(0.85); + UI.showToast(`${r.approved_count} Vorschlaege uebernommen.`, 'success'); + await this.loadClassificationQueue(); + this._refreshReviewBadge(); + } catch (err) { + UI.showToast('Bulk-Approve fehlgeschlagen: ' + err.message, 'error'); + } + }, + renderSourceStats(stats) { const bar = document.getElementById('sources-stats-bar'); if (!bar) return; diff --git a/src/static/js/components.js b/src/static/js/components.js index d0a2cd8..338802e 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -1119,6 +1119,71 @@ const UI = { sonstige: 'sonstige', }, + /** + * Eintrag in der Klassifikations-Review-Queue. + * Zeigt Diff zwischen aktuellem Wert und LLM-Vorschlag. + */ + 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 diffRow = (label, currentVal, proposedVal, formatter) => { + const fmt = formatter || (v => v == null || v === '' ? '–' : String(v)); + const c = fmt(currentVal); + const p = fmt(proposedVal); + const changed = c !== p; + return `
+ ${this.escape(label)} + ${this.escape(c)} + + ${this.escape(p)} +
`; + }; + + const polFmt = v => (v && v !== 'na') ? (this._politicalLabels[v]?.full || v) : '–'; + const mtFmt = v => (v && v !== 'sonstige') ? (this._mediaTypeLabels[v] || v) : (v === 'sonstige' ? 'Sonstige' : '–'); + const relFmt = v => (v && v !== 'na') ? (this._reliabilityLabels[v] || v) : '–'; + const stateFmt = v => v ? 'ja' : 'nein'; + const ccFmt = v => v || '–'; + const alignFmt = v => (Array.isArray(v) && v.length > 0) + ? v.map(a => this._alignmentLabels[a] || a).join(', ') + : '–'; + + const globalBadge = item.is_global ? 'Grundquelle' : ''; + const reasoning = prop.reasoning ? this.escape(prop.reasoning) : ''; + + return `
+
+
+ ${this.escape(item.name)} + ${globalBadge} + ${this.escape(item.domain || '')} +
+
+ ${confPct}% + Konfidenz +
+
+
+ ${diffRow('Politik', cur.political_orientation, prop.political_orientation, polFmt)} + ${diffRow('Medientyp', cur.media_type, prop.media_type, mtFmt)} + ${diffRow('Glaubwürdigkeit', cur.reliability, prop.reliability, relFmt)} + ${diffRow('Staatsnah', cur.state_affiliated, prop.state_affiliated, stateFmt)} + ${diffRow('Land', cur.country_code, prop.country_code, ccFmt)} + ${diffRow('Geopol. Nähe', cur.alignments, prop.alignments, alignFmt)} +
+ ${reasoning ? `
Begründung: ${reasoning}
` : ''} +
+ + + +
+
`; + }, + _renderClassificationBadges(feed) { const parts = []; const pol = feed.political_orientation;