feat(sources): Review-Queue-UI fuer LLM-Klassifikations-Vorschlaege (Admin)

- Tab-Schalter im Quellen-Modal: "Quellenliste" vs. "Klassifikations-Review"
  (Review-Tab nur fuer org_admin sichtbar, mit Pending-Counter-Badge).
- Review-Karten zeigen Diff aktueller Wert -> LLM-Vorschlag pro Achse,
  Konfidenz-Indikator (gruen/gelb/rot), LLM-Begruendung, Buttons fuer
  Uebernehmen / Verwerfen / Neu klassifizieren.
- Toolbar: Konfidenz-Filter, "Klassifikation starten" (Bulk im Hintergrund),
  "Alle >= 0.85 genehmigen" (Bulk-Approve).
- API-Wrapper in api.js fuer alle 6 neuen Endpoints + erweiterte listSources-Filter.
- Backend-Endpoint POST /api/sources/classification/bulk-approve (Admin-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Code
2026-05-07 19:00:47 +00:00
Ursprung 62ba38ae46
Commit 48a60d7579
6 geänderte Dateien mit 505 neuen und 0 gelöschten Zeilen

Datei anzeigen

@@ -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);
},

Datei anzeigen

@@ -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 = '<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade...</div>';
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 = '<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Keine ausstehenden Vorschlaege.</div>';
return;
}
list.innerHTML = items.map(item => UI.renderClassificationQueueItem(item)).join('');
} catch (err) {
list.innerHTML = `<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;color:var(--danger);">Fehler: ${err.message}</div>`;
}
},
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;

Datei anzeigen

@@ -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 `<div class="review-diff-row${changed ? ' changed' : ''}">
<span class="review-diff-label">${this.escape(label)}</span>
<span class="review-diff-current">${this.escape(c)}</span>
<span class="review-diff-arrow">→</span>
<span class="review-diff-proposed">${this.escape(p)}</span>
</div>`;
};
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 ? '<span class="review-global-badge">Grundquelle</span>' : '';
const reasoning = prop.reasoning ? this.escape(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">${this.escape(item.name)}</span>
${globalBadge}
<span class="review-card-domain">${this.escape(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">
${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)}
</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="App.approveClassification(${item.id})">Übernehmen</button>
<button class="btn btn-small btn-secondary" onclick="App.rejectClassification(${item.id})">Verwerfen</button>
<button class="btn btn-small btn-secondary" data-reclassify-id="${item.id}" onclick="App.reclassifySource(${item.id})">Neu klassifizieren</button>
</div>
</div>`;
},
_renderClassificationBadges(feed) {
const parts = [];
const pol = feed.political_orientation;