- 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>
356 Zeilen
12 KiB
JavaScript
356 Zeilen
12 KiB
JavaScript
/**
|
|
* API-Client für den OSINT Lagemonitor.
|
|
*/
|
|
|
|
class ApiError extends Error {
|
|
constructor(status, detail) {
|
|
super(detail || `Fehler ${status}`);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.detail = detail;
|
|
}
|
|
}
|
|
|
|
const API = {
|
|
baseUrl: '/api',
|
|
|
|
_getHeaders() {
|
|
const token = localStorage.getItem('osint_token');
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': token ? `Bearer ${token}` : '',
|
|
};
|
|
},
|
|
|
|
async _request(method, path, body = null, externalSignal = null) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
|
|
// Externen Abort weiterleiten an internen Controller
|
|
if (externalSignal) {
|
|
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
}
|
|
|
|
const options = {
|
|
method,
|
|
headers: this._getHeaders(),
|
|
signal: controller.signal,
|
|
};
|
|
if (body) {
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(`${this.baseUrl}${path}`, options);
|
|
} catch (err) {
|
|
clearTimeout(timeout);
|
|
if (err.name === 'AbortError') {
|
|
throw new Error('Zeitüberschreitung bei der Anfrage');
|
|
}
|
|
throw err;
|
|
}
|
|
clearTimeout(timeout);
|
|
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('osint_token');
|
|
localStorage.removeItem('osint_username');
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
let detail = data.detail;
|
|
if (Array.isArray(detail)) {
|
|
detail = detail.map(e => e.msg || JSON.stringify(e)).join('; ');
|
|
} else if (typeof detail === 'object' && detail !== null) {
|
|
detail = JSON.stringify(detail);
|
|
}
|
|
|
|
// Lizenz-Status aus Header auslesen (vom Backend gesetzt bei 403)
|
|
const licStatus = response.headers.get('X-License-Status');
|
|
if (response.status === 403 && licStatus && typeof App !== 'undefined') {
|
|
if (!App.user) App.user = {};
|
|
App.user.read_only = true;
|
|
App.user.read_only_reason = licStatus;
|
|
const warningEl = document.getElementById('header-license-warning');
|
|
if (warningEl) {
|
|
let text = 'Nur Lesezugriff';
|
|
if (licStatus === 'budget_exceeded') text = 'Token-Budget aufgebraucht – nur Lesezugriff. Bitte Verwaltung kontaktieren.';
|
|
else if (licStatus === 'expired') text = 'Lizenz abgelaufen – nur Lesezugriff';
|
|
else if (licStatus === 'no_license') text = 'Keine aktive Lizenz – nur Lesezugriff';
|
|
else if (licStatus === 'org_disabled') text = 'Organisation deaktiviert – nur Lesezugriff';
|
|
warningEl.textContent = text;
|
|
warningEl.classList.add('visible');
|
|
}
|
|
if (typeof App._updateRefreshButton === 'function') App._updateRefreshButton(false);
|
|
if (typeof UI !== 'undefined' && UI.showToast) {
|
|
UI.showToast(detail || 'Lizenz-Beschränkung – nur Lesezugriff', 'error');
|
|
}
|
|
}
|
|
|
|
throw new ApiError(response.status, detail);
|
|
}
|
|
|
|
if (response.status === 204) return null;
|
|
return response.json();
|
|
},
|
|
|
|
// Auth
|
|
getMe() {
|
|
return this._request('GET', '/auth/me');
|
|
},
|
|
|
|
// Incidents
|
|
listIncidents(statusFilter = null) {
|
|
const query = statusFilter ? `?status_filter=${statusFilter}` : '';
|
|
return this._request('GET', `/incidents${query}`);
|
|
},
|
|
|
|
enhanceDescription(title, description, type, signal = null) {
|
|
return this._request('POST', '/incidents/enhance-description', { title, description, type }, signal);
|
|
},
|
|
|
|
createIncident(data) {
|
|
return this._request('POST', '/incidents', data);
|
|
},
|
|
|
|
getRefreshingIncidents() {
|
|
return this._request('GET', '/incidents/refreshing');
|
|
},
|
|
|
|
getIncident(id) {
|
|
return this._request('GET', `/incidents/${id}`);
|
|
},
|
|
|
|
getIncidentSources(id) {
|
|
return this._request('GET', `/incidents/${id}/sources`);
|
|
},
|
|
|
|
updateIncident(id, data) {
|
|
return this._request('PUT', `/incidents/${id}`, data);
|
|
},
|
|
|
|
deleteIncident(id) {
|
|
return this._request('DELETE', `/incidents/${id}`);
|
|
},
|
|
|
|
getArticles(incidentId, { limit = 500, offset = 0, search = null } = {}) {
|
|
const params = new URLSearchParams();
|
|
params.set('limit', String(limit));
|
|
params.set('offset', String(offset));
|
|
if (search) params.set('search', search);
|
|
return this._request('GET', `/incidents/${incidentId}/articles?${params.toString()}`);
|
|
},
|
|
|
|
getArticlesSourcesSummary(incidentId) {
|
|
return this._request('GET', `/incidents/${incidentId}/articles/sources-summary`);
|
|
},
|
|
|
|
getArticlesTimelineBuckets(incidentId, granularity = 'day') {
|
|
return this._request('GET', `/incidents/${incidentId}/articles/timeline-buckets?granularity=${encodeURIComponent(granularity)}`);
|
|
},
|
|
|
|
getFactChecks(incidentId) {
|
|
return this._request('GET', `/incidents/${incidentId}/factchecks`);
|
|
},
|
|
|
|
getPipeline(incidentId) {
|
|
return this._request('GET', `/incidents/${incidentId}/pipeline`);
|
|
},
|
|
|
|
getSnapshots(incidentId) {
|
|
return this._request('GET', `/incidents/${incidentId}/snapshots`);
|
|
},
|
|
|
|
getSnapshot(incidentId, snapshotId) {
|
|
return this._request('GET', `/incidents/${incidentId}/snapshots/${snapshotId}`);
|
|
},
|
|
|
|
searchSnapshots(incidentId, query) {
|
|
return this._request('GET', `/incidents/${incidentId}/snapshots/search?q=${encodeURIComponent(query)}`);
|
|
},
|
|
|
|
getLocations(incidentId) {
|
|
return this._request('GET', `/incidents/${incidentId}/locations`);
|
|
},
|
|
|
|
triggerGeoparse(incidentId) {
|
|
return this._request('POST', `/incidents/${incidentId}/geoparse`);
|
|
},
|
|
|
|
getGeoparseStatus(incidentId) {
|
|
return this._request('GET', `/incidents/${incidentId}/geoparse-status`);
|
|
},
|
|
|
|
refreshIncident(id) {
|
|
return this._request('POST', `/incidents/${id}/refresh`);
|
|
},
|
|
|
|
getRefreshLog(incidentId, limit = 20) {
|
|
return this._request('GET', `/incidents/${incidentId}/refresh-log?limit=${limit}`);
|
|
},
|
|
|
|
// Sources (Quellenverwaltung)
|
|
listSources(params = {}) {
|
|
const query = new URLSearchParams();
|
|
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);
|
|
},
|
|
|
|
updateSource(id, data) {
|
|
return this._request('PUT', `/sources/${id}`, data);
|
|
},
|
|
|
|
deleteSource(id) {
|
|
return this._request('DELETE', `/sources/${id}`);
|
|
},
|
|
|
|
getSourceStats() {
|
|
return this._request('GET', '/sources/stats');
|
|
},
|
|
|
|
discoverMulti(url) {
|
|
return this._request('POST', '/sources/discover-multi', { url });
|
|
},
|
|
|
|
getMyExclusions() {
|
|
return this._request('GET', '/sources/my-exclusions');
|
|
},
|
|
|
|
blockDomain(domain, notes) {
|
|
return this._request('POST', '/sources/block-domain', { domain, notes });
|
|
},
|
|
|
|
unblockDomain(domain) {
|
|
return this._request('POST', '/sources/unblock-domain', { domain });
|
|
},
|
|
|
|
deleteDomain(domain) {
|
|
return this._request('DELETE', `/sources/domain/${encodeURIComponent(domain)}`);
|
|
},
|
|
|
|
cancelRefresh(id) {
|
|
return this._request('POST', `/incidents/${id}/cancel-refresh`);
|
|
},
|
|
|
|
// Notifications
|
|
listNotifications(limit = 50) {
|
|
return this._request('GET', `/notifications?limit=${limit}`);
|
|
},
|
|
|
|
markNotificationsRead(ids = null) {
|
|
return this._request('PUT', '/notifications/mark-read', { notification_ids: ids });
|
|
},
|
|
|
|
|
|
|
|
// Subscriptions (E-Mail-Benachrichtigungen)
|
|
getSubscription(incidentId) {
|
|
return this._request('GET', '/incidents/' + incidentId + '/subscription');
|
|
},
|
|
|
|
updateSubscription(incidentId, data) {
|
|
return this._request('PUT', '/incidents/' + incidentId + '/subscription', data);
|
|
},
|
|
|
|
// Feedback
|
|
sendFeedback(data) {
|
|
return this._request('POST', '/feedback', data);
|
|
},
|
|
|
|
async sendFeedbackForm(formData) {
|
|
const token = localStorage.getItem('osint_token');
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 60000);
|
|
const resp = await fetch(this.baseUrl + '/feedback', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': token ? 'Bearer ' + token : '' },
|
|
body: formData,
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeout);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.detail || 'Fehler ' + resp.status);
|
|
}
|
|
},
|
|
|
|
// Export
|
|
|
|
// Tutorial-Fortschritt
|
|
getTutorialState() {
|
|
return this._request('GET', '/tutorial/state');
|
|
},
|
|
|
|
saveTutorialState(data) {
|
|
return this._request('PUT', '/tutorial/state', data);
|
|
},
|
|
|
|
resetTutorialState() {
|
|
return this._request('DELETE', '/tutorial/state');
|
|
},
|
|
exportReport(id, format, scope, sections) {
|
|
const token = localStorage.getItem('osint_token');
|
|
let url = `${this.baseUrl}/incidents/${id}/export?format=${format}`;
|
|
if (sections && sections.length > 0) {
|
|
url += `§ions=${sections.join(',')}`;
|
|
} else if (scope) {
|
|
url += `&scope=${scope}`;
|
|
}
|
|
return fetch(url, {
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
});
|
|
},
|
|
|
|
// --- Global Admin: Org-Wechsel (herausnehmbar) ---
|
|
listOrganizations() {
|
|
return this._request('GET', '/auth/organizations');
|
|
},
|
|
|
|
switchOrg(organizationId) {
|
|
return this._request('POST', '/auth/switch-org', { organization_id: organizationId });
|
|
},
|
|
};
|