Incident-Response: sources_json nur noch via Lazy-Endpunkt, Sidebar schlank
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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 = '<span style="color:var(--text-disabled);">Zusammenfassung wird beim n\u00e4chsten Refresh generiert.</span>';
|
||||
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 = '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst. Wird beim n\u00e4chsten Refresh generiert.</span>';
|
||||
}
|
||||
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 = '<span style="color:var(--text-disabled);">Noch kein Lagebild. Klicke auf "Aktualisieren" um die Recherche zu starten.</span>';
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 '<span style="color:var(--text-disabled);">Noch keine Zusammenfassung.</span>';
|
||||
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 '<span style="color:var(--text-disabled);">Noch keine Entwicklungen erfasst.</span>';
|
||||
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 '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
|
||||
|
||||
let sources = [];
|
||||
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
|
||||
const sources = this._parseSources(sourcesJson);
|
||||
|
||||
// Markdown-Rendering
|
||||
let html = this.escape(summary);
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren