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:
@@ -937,3 +937,62 @@ async def trigger_bulk_classify(
|
|||||||
raise HTTPException(status_code=400, detail="limit muss zwischen 1 und 500 liegen")
|
raise HTTPException(status_code=400, detail="limit muss zwischen 1 und 500 liegen")
|
||||||
background_tasks.add_task(_bulk_classify_background, limit, only_unclassified)
|
background_tasks.add_task(_bulk_classify_background, limit, only_unclassified)
|
||||||
return {"status": "started", "limit": limit, "only_unclassified": 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}
|
||||||
|
|||||||
@@ -3503,6 +3503,204 @@ a.dev-source-pill:hover {
|
|||||||
color: var(--info);
|
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) */
|
/* Klassifikations-Badges (politisch / reliability / alignments / state) */
|
||||||
.source-classification-badges {
|
.source-classification-badges {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -456,6 +456,15 @@
|
|||||||
<!-- Stats-Leiste -->
|
<!-- Stats-Leiste -->
|
||||||
<div class="sources-stats-bar" id="sources-stats-bar"></div>
|
<div class="sources-stats-bar" id="sources-stats-bar"></div>
|
||||||
|
|
||||||
|
<!-- Tabs: Liste vs. Klassifikations-Review -->
|
||||||
|
<div class="sources-tabs" role="tablist">
|
||||||
|
<button type="button" class="sources-tab active" id="sources-tab-list" role="tab" aria-selected="true" onclick="App.switchSourcesTab('list')">Quellenliste</button>
|
||||||
|
<button type="button" class="sources-tab" id="sources-tab-review" role="tab" aria-selected="false" onclick="App.switchSourcesTab('review')" style="display:none;">Klassifikations-Review <span id="sources-review-count" class="sources-tab-badge">0</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View: Quellenliste -->
|
||||||
|
<div id="sources-list-view">
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="sources-toolbar">
|
<div class="sources-toolbar">
|
||||||
<div class="sources-filters">
|
<div class="sources-filters">
|
||||||
@@ -706,6 +715,35 @@
|
|||||||
<div class="sources-list" id="sources-list">
|
<div class="sources-list" id="sources-list">
|
||||||
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Quellen...</div>
|
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Quellen...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /sources-list-view -->
|
||||||
|
|
||||||
|
<!-- View: Klassifikations-Review (Admin-only) -->
|
||||||
|
<div id="sources-review-view" style="display:none;">
|
||||||
|
<div class="review-toolbar">
|
||||||
|
<div class="review-toolbar-info">
|
||||||
|
<span><strong id="review-pending-count">0</strong> Vorschlaege ausstehend</span>
|
||||||
|
<label class="review-conf-filter">
|
||||||
|
Mindest-Konfidenz:
|
||||||
|
<select id="review-min-confidence" onchange="App.loadClassificationQueue()">
|
||||||
|
<option value="0">alle</option>
|
||||||
|
<option value="0.5">0.5+</option>
|
||||||
|
<option value="0.7">0.7+</option>
|
||||||
|
<option value="0.85">0.85+</option>
|
||||||
|
<option value="0.9">0.9+</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="review-toolbar-actions">
|
||||||
|
<button class="btn btn-small btn-secondary" onclick="App.triggerBulkClassify()" title="LLM-Klassifikation fuer noch unklassifizierte Quellen starten">+ Klassifikation starten</button>
|
||||||
|
<button class="btn btn-small btn-primary" onclick="App.bulkApproveHighConfidence()" title="Alle Vorschlaege ueber dem Konfidenz-Schwellwert genehmigen">Alle ≥ 0.85 genehmigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="review-list" id="sources-review-list">
|
||||||
|
<div class="empty-state-text" style="padding:var(--sp-3xl);text-align:center;">Lade Review-Queue...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -198,10 +198,43 @@ const API = {
|
|||||||
if (params.source_type) query.set('source_type', params.source_type);
|
if (params.source_type) query.set('source_type', params.source_type);
|
||||||
if (params.category) query.set('category', params.category);
|
if (params.category) query.set('category', params.category);
|
||||||
if (params.source_status) query.set('source_status', params.source_status);
|
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();
|
const qs = query.toString();
|
||||||
return this._request('GET', `/sources${qs ? '?' + qs : ''}`);
|
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) {
|
createSource(data) {
|
||||||
return this._request('POST', '/sources', data);
|
return this._request('POST', '/sources', data);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2702,6 +2702,12 @@ async handleRefresh() {
|
|||||||
async openSourceManagement() {
|
async openSourceManagement() {
|
||||||
openModal('modal-sources');
|
openModal('modal-sources');
|
||||||
await this.loadSources();
|
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() {
|
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) {
|
renderSourceStats(stats) {
|
||||||
const bar = document.getElementById('sources-stats-bar');
|
const bar = document.getElementById('sources-stats-bar');
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
|
|||||||
@@ -1119,6 +1119,71 @@ const UI = {
|
|||||||
sonstige: 'sonstige',
|
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) {
|
_renderClassificationBadges(feed) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const pol = feed.political_orientation;
|
const pol = feed.political_orientation;
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren