From 0d6ad8ea90da32e979ba7ea44fc976f5372329cb Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Mon, 20 Apr 2026 00:07:46 +0200 Subject: [PATCH] Incident-Response: sources_json nur noch via Lazy-Endpunkt, Sidebar schlank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - IncidentResponse: sources_json-Feld entfernt (Detail-GET liefert es nicht mehr mit). - Neues Schema IncidentListItem fuer GET /incidents (Sidebar): Ohne summary, ohne sources_json. Ein has_summary-Bit fuer Erster-Refresh-Erkennung, description bleibt fuer das Edit-Modal. - list_incidents selektiert nur die noetigen Spalten (kein SELECT *) — spart bei grossen Lagen Speicher + Serialisierung. - Neuer Endpunkt GET /incidents/{id}/sources liefert geparstes Sources-Array fuer Zitate-Lookups (Lazy). Frontend: - api.js: getIncidentSources(id). - app.js: loadIncidentDetail laedt /sources parallel, speichert Array in _currentSources. Alle renderSummary/Zusammenfassung/ LatestDevelopments-Aufrufe bekommen jetzt _currentSources statt incident.sources_json. inc.summary-Checks -> inc.has_summary. - components.js: _parseSources(input) akzeptiert Array ODER String (Rueckwaertskompatibilitaet). renderZusammenfassung, renderSummary, renderLatestDevelopments nutzen den Helper. Hintergrund: Die Sidebar-Liste lieferte bei 17 Lagen 1,23 MB (Iran allein 386 KB wegen sources_json + summary). Detail-Endpunkt lieferte sources_json (324 KB bei Iran) bei jedem Oeffnen mit. Beides jetzt radikal kleiner — die 324 KB Sources gibt's nur einmalig auf Anfrage. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/models.py | 35 ++++++++++++++++++++++++- src/routers/incidents.py | 52 +++++++++++++++++++++++++++++++++---- src/static/js/api.js | 4 +++ src/static/js/app.js | 25 +++++++++++------- src/static/js/components.js | 24 ++++++++++++----- 5 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/models.py b/src/models.py index 708a4e2..5a09b2b 100644 --- a/src/models.py +++ b/src/models.py @@ -78,6 +78,11 @@ class DescriptionEnhanceRequest(BaseModel): class IncidentResponse(BaseModel): + """Vollstaendige Lage-Details (fuer GET /incidents/{id}). + + Enthaelt summary + latest_developments, aber NICHT mehr sources_json — + das wird separat per GET /incidents/{id}/sources geladen (Lazy-Load). + """ id: int title: str description: Optional[str] @@ -90,7 +95,6 @@ class IncidentResponse(BaseModel): visibility: str = "public" summary: Optional[str] latest_developments: Optional[str] = None - sources_json: Optional[str] = None international_sources: bool = True include_telegram: bool = False created_by: int @@ -101,6 +105,35 @@ class IncidentResponse(BaseModel): source_count: int = 0 +class IncidentListItem(BaseModel): + """Schlankes Sidebar-Item (fuer GET /incidents). + + Enthaelt, was Sidebar und Edit-Dialog brauchen — kein summary, + kein sources_json. Statt summary-Volltext ein ``has_summary``-Bit, + damit das Frontend "erster Refresh"-Zustand erkennen kann. + description bleibt drin (kurz, vom Edit-Modal direkt genutzt). + """ + id: int + title: str + description: Optional[str] = None + type: str + status: str + refresh_mode: str + refresh_interval: int + refresh_start_time: Optional[str] = None + retention_days: int + visibility: str = "public" + international_sources: bool = True + include_telegram: bool = False + created_by: int + created_by_username: str = "" + created_at: str + updated_at: str + article_count: int = 0 + source_count: int = 0 + has_summary: bool = False + + # Sources (Quellenverwaltung) diff --git a/src/routers/incidents.py b/src/routers/incidents.py index 377d03c..511818a 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -1,7 +1,7 @@ """Incidents-Router: Lagen verwalten (Multi-Tenant).""" from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from fastapi.responses import StreamingResponse -from models import IncidentCreate, IncidentUpdate, IncidentResponse, SubscriptionUpdate, SubscriptionResponse, DescriptionEnhanceRequest +from models import IncidentCreate, IncidentUpdate, IncidentResponse, IncidentListItem, SubscriptionUpdate, SubscriptionResponse, DescriptionEnhanceRequest from auth import get_current_user from middleware.license_check import require_writable_license from database import db_dependency, get_db @@ -69,17 +69,30 @@ async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict return incident -@router.get("", response_model=list[IncidentResponse]) +@router.get("", response_model=list[IncidentListItem]) async def list_incidents( status_filter: str = None, current_user: dict = Depends(get_current_user), db: aiosqlite.Connection = Depends(db_dependency), ): - """Alle Lagen des Tenants auflisten (oeffentliche + eigene private).""" + """Alle Lagen des Tenants auflisten (oeffentliche + eigene private). + + Liefert schlanke Sidebar-Items — ohne summary, description, sources_json. + Volltexte kommen erst beim Oeffnen der Lage per GET /incidents/{id}. + """ tenant_id = current_user.get("tenant_id") user_id = current_user["id"] - query = "SELECT * FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)" + # Nur die fuer Sidebar + Edit-Dialog noetigen Spalten selektieren + # (spart bei Iran: 324 KB sources_json + 32 KB summary). + # has_summary als Bit — Frontend nutzt es zur Erkennung "erster Refresh". + query = ( + "SELECT id, title, description, type, status, refresh_mode, refresh_interval, " + "refresh_start_time, retention_days, visibility, " + "international_sources, include_telegram, created_by, created_at, updated_at, " + "CASE WHEN summary IS NOT NULL AND summary != '' THEN 1 ELSE 0 END AS has_summary " + "FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)" + ) params = [tenant_id, user_id] if status_filter: @@ -239,12 +252,41 @@ async def get_incident( current_user: dict = Depends(get_current_user), db: aiosqlite.Connection = Depends(db_dependency), ): - """Einzelne Lage abrufen.""" + """Einzelne Lage abrufen. + + sources_json wird NICHT mitgeliefert — fuer Zitate-Lookups + stattdessen GET /incidents/{id}/sources verwenden (lazy). + """ tenant_id = current_user.get("tenant_id") row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id) return await _enrich_incident(db, row) +@router.get("/{incident_id}/sources") +async def get_incident_sources( + incident_id: int, + current_user: dict = Depends(get_current_user), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Sources-Array einer Lage (geparst aus sources_json) fuer Zitate-Lookups.""" + tenant_id = current_user.get("tenant_id") + await _check_incident_access(db, incident_id, current_user["id"], tenant_id) + cursor = await db.execute( + "SELECT sources_json FROM incidents WHERE id = ?", + (incident_id,), + ) + row = await cursor.fetchone() + sources: list = [] + if row and row["sources_json"]: + try: + parsed = json.loads(row["sources_json"]) + if isinstance(parsed, list): + sources = parsed + except (json.JSONDecodeError, TypeError): + sources = [] + return {"incident_id": incident_id, "sources": sources} + + @router.put("/{incident_id}", response_model=IncidentResponse) async def update_incident( incident_id: int, diff --git a/src/static/js/api.js b/src/static/js/api.js index 4df5018..eb8e1a1 100644 --- a/src/static/js/api.js +++ b/src/static/js/api.js @@ -91,6 +91,10 @@ const API = { 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); }, diff --git a/src/static/js/app.js b/src/static/js/app.js index 7e8450a..402878d 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -422,6 +422,7 @@ const App = { _currentArticles: [], _currentSnapshots: [], _snapshotFullCache: new Map(), + _currentSources: [], _currentIncidentType: 'adhoc', _sidebarFilter: 'all', _currentUsername: '', @@ -586,7 +587,7 @@ const App = { this._refreshingIncidents.add(id); const d = details[String(id)] || {}; const inc = this.incidents.find(i => i.id === id); - const isFirst = inc && !inc.summary; + const isFirst = inc && !inc.has_summary; const isCurrent = (id === currentTask); // Use 'researching' as default step for the actively running task UI.showProgress(isCurrent ? 'researching' : 'queued', { started_at: d.started_at }, id, isFirst); @@ -598,7 +599,7 @@ const App = { queuedIds.forEach((id, idx) => { this._refreshingIncidents.add(id); const inc = this.incidents.find(i => i.id === id); - const isFirst = inc && !inc.summary; + const isFirst = inc && !inc.has_summary; UI.showProgress('queued', { queue_position: idx + 1 }, id, isFirst); }); } @@ -787,14 +788,18 @@ const App = { async loadIncidentDetail(id) { try { - const [incident, articlesResponse, factchecks, snapshots, locationsResponse] = await Promise.all([ + const [incident, articlesResponse, factchecks, snapshots, locationsResponse, sourcesResponse] = await Promise.all([ API.getIncident(id), API.getArticles(id, { limit: 500, offset: 0 }), API.getFactChecks(id), API.getSnapshots(id), API.getLocations(id).catch(() => []), + API.getIncidentSources(id).catch(() => ({ sources: [] })), ]); + // Sources-Array (ersetzt frueheres incident.sources_json — lazy via /sources-Endpunkt) + this._currentSources = (sourcesResponse && sourcesResponse.sources) || []; + // Articles: neue Shape {total, articles} oder alter nackter Array (Rueckwaertskompatibel) let articles, articlesTotal; if (Array.isArray(articlesResponse)) { @@ -921,13 +926,13 @@ const App = { if (incident.summary) { const { zusammenfassung, remaining } = UI.extractZusammenfassung(incident.summary); if (zusammenfassung) { - if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, incident.sources_json); + if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderZusammenfassung(zusammenfassung, this._currentSources); if (zusammenfassungCard) zusammenfassungCard.style.display = ''; - summaryText.innerHTML = UI.renderSummary(remaining, incident.sources_json, incident.type); + summaryText.innerHTML = UI.renderSummary(remaining, this._currentSources, incident.type); } else { if (zusammenfassungText) zusammenfassungText.innerHTML = 'Zusammenfassung wird beim n\u00e4chsten Refresh generiert.'; if (zusammenfassungCard) zusammenfassungCard.style.display = ''; - summaryText.innerHTML = UI.renderSummary(incident.summary, incident.sources_json, incident.type); + summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type); } } else { if (zusammenfassungCard) zusammenfassungCard.style.display = 'none'; @@ -939,12 +944,12 @@ const App = { if (zusammenfassungCard) zusammenfassungCard.style.display = ''; const devText = (incident.latest_developments || '').trim(); if (devText) { - if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, incident.sources_json); + if (zusammenfassungText) zusammenfassungText.innerHTML = UI.renderLatestDevelopments(devText, this._currentSources); } else if (zusammenfassungText) { zusammenfassungText.innerHTML = 'Noch keine Entwicklungen erfasst. Wird beim n\u00e4chsten Refresh generiert.'; } if (incident.summary) { - summaryText.innerHTML = UI.renderSummary(incident.summary, incident.sources_json, incident.type); + summaryText.innerHTML = UI.renderSummary(incident.summary, this._currentSources, incident.type); } else { summaryText.innerHTML = 'Noch kein Lagebild. Klicke auf "Aktualisieren" um die Recherche zu starten.'; } @@ -1833,7 +1838,7 @@ async handleRefresh() { } else { UI.showToast('Aktualisierung gestartet.', 'success'); var _inc2 = this.incidents.find(function(i) { return i.id === this.currentIncidentId; }.bind(this)); - UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.summary); + UI.showProgress('queued', {}, this.currentIncidentId, _inc2 && !_inc2.has_summary); } } catch (err) { this._refreshingIncidents.delete(this.currentIncidentId); @@ -2176,7 +2181,7 @@ async handleRefresh() { this._updateSidebarDot(msg.incident_id); // Detect first refresh: no summary means first run const inc = this.incidents.find(i => i.id === msg.incident_id); - const isFirst = inc && !inc.summary; + const isFirst = inc && !inc.has_summary; // Update progress state for ALL incidents (sidebar + popup if current) UI.showProgress(status, msg.data, msg.incident_id, isFirst); // Re-render sidebar so status is baked into HTML (survives future re-renders) diff --git a/src/static/js/components.js b/src/static/js/components.js index cf482a5..a58fb21 100644 --- a/src/static/js/components.js +++ b/src/static/js/components.js @@ -709,13 +709,27 @@ const UI = { return { zusammenfassung, remaining: remaining.trim() }; }, + /** + * Parst sources: akzeptiert Array (neu, vom /sources-Endpunkt) ODER + * JSON-String (alt, aus sources_json) fuer Rueckwaertskompatibilitaet. + */ + _parseSources(input) { + if (!input) return []; + if (Array.isArray(input)) return input; + try { + const parsed = JSON.parse(input); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } + }, + /** * Rendert die Zusammenfassung als HTML (Bullet Points). */ renderZusammenfassung(text, sourcesJson) { if (!text) return 'Noch keine Zusammenfassung.'; - let sources = []; - try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {} + const sources = this._parseSources(sourcesJson); // Nur Bullet-Point-Zeilen behalten, Fliesstext herausfiltern const bulletLines = text.split("\n").filter(line => line.trim().startsWith("- ")); const bulletText = bulletLines.length > 0 ? bulletLines.join("\n") : text; @@ -751,8 +765,7 @@ const UI = { */ renderLatestDevelopments(text, sourcesJson) { if (!text) return 'Noch keine Entwicklungen erfasst.'; - let sources = []; - try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {} + const sources = this._parseSources(sourcesJson); const bulletLines = text.split("\n").map(l => l.trim()).filter(l => l && (l.startsWith("- ") || l.startsWith("["))); if (bulletLines.length === 0) { @@ -869,8 +882,7 @@ const UI = { renderSummary(summary, sourcesJson, incidentType) { if (!summary) return 'Noch keine Zusammenfassung.'; - let sources = []; - try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {} + const sources = this._parseSources(sourcesJson); // Markdown-Rendering let html = this.escape(summary);