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 @@
+
+
+
+
+
+
+
+
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 `
+
+
+ ${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;