Snapshots: Liste ohne Volltext, Lazy-Load + serverseitige Suche
Backend:
- GET /{id}/snapshots liefert nur noch schlanke Shape (Metadaten +
SUBSTR(summary,1,300) AS summary_preview), kein Volltext, kein sources_json.
- Neuer Endpunkt GET /{id}/snapshots/{snapshot_id} fuer Volltext-Lazy-Load.
- Neuer Endpunkt GET /{id}/snapshots/search?q=... fuer serverseitige
Volltextsuche ueber alle Snapshots einer Lage.
Frontend:
- api.js: getSnapshot() und searchSnapshots() ergaenzt.
- app.js: _snapshotFullCache, Volltext wird beim Aufklappen eines
Snapshot-Eintrags per lazyLoadSnapshotDetail() nachgeladen und gecacht.
- Suche ueber Snapshots filtert weiterhin clientseitig ueber summary_preview.
Hintergrund: Bei grossen Lagen (Iran-Lage: 347 Snapshots) fiel die
Snapshots-Listenantwort mit Volltext-Summaries auf ~54 MB. Die Liste
faellt damit auf ~150 KB; Volltexte werden nur on-demand geladen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -337,12 +337,17 @@ async def get_snapshots(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Lageberichte (Snapshots) einer Lage abrufen."""
|
||||
"""Lageberichte (Snapshots) einer Lage abrufen — schlanke Liste.
|
||||
|
||||
Liefert nur Metadaten und einen 300-Zeichen-Preview des Summary.
|
||||
Der Volltext (summary + sources_json) wird per Einzel-Endpunkt
|
||||
``GET /{incident_id}/snapshots/{snapshot_id}`` bei Bedarf geladen.
|
||||
"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, incident_id, summary, sources_json,
|
||||
article_count, fact_check_count, created_at
|
||||
"""SELECT id, incident_id, article_count, fact_check_count, created_at,
|
||||
SUBSTR(summary, 1, 300) AS summary_preview
|
||||
FROM incident_snapshots WHERE incident_id = ?
|
||||
ORDER BY created_at DESC""",
|
||||
(incident_id,),
|
||||
@@ -351,6 +356,55 @@ async def get_snapshots(
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/{incident_id}/snapshots/search")
|
||||
async def search_snapshots(
|
||||
incident_id: int,
|
||||
q: str = Query(..., min_length=2, max_length=200),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Volltextsuche über alle Snapshots einer Lage.
|
||||
|
||||
Liefert dieselbe schlanke Shape wie der Listen-Endpunkt,
|
||||
gefiltert per ``summary LIKE '%q%'``.
|
||||
"""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
like = f"%{q}%"
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, incident_id, article_count, fact_check_count, created_at,
|
||||
SUBSTR(summary, 1, 300) AS summary_preview
|
||||
FROM incident_snapshots
|
||||
WHERE incident_id = ? AND summary LIKE ?
|
||||
ORDER BY created_at DESC""",
|
||||
(incident_id, like),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/{incident_id}/snapshots/{snapshot_id}")
|
||||
async def get_snapshot(
|
||||
incident_id: int,
|
||||
snapshot_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Einzelnen Snapshot mit vollem Summary + sources_json abrufen (Lazy-Load)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, incident_id, summary, sources_json,
|
||||
article_count, fact_check_count, created_at
|
||||
FROM incident_snapshots WHERE id = ? AND incident_id = ?""",
|
||||
(snapshot_id, incident_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Snapshot nicht gefunden")
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/{incident_id}/factchecks")
|
||||
async def get_factchecks(
|
||||
incident_id: int,
|
||||
|
||||
@@ -111,6 +111,14 @@ const API = {
|
||||
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`);
|
||||
},
|
||||
|
||||
@@ -420,6 +420,8 @@ const App = {
|
||||
_refreshingIncidents: new Set(),
|
||||
_editingIncidentId: null,
|
||||
_currentArticles: [],
|
||||
_currentSnapshots: [],
|
||||
_snapshotFullCache: new Map(),
|
||||
_currentIncidentType: 'adhoc',
|
||||
_sidebarFilter: 'all',
|
||||
_currentUsername: '',
|
||||
@@ -954,6 +956,7 @@ const App = {
|
||||
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
|
||||
this._currentArticles = articles;
|
||||
this._currentSnapshots = snapshots || [];
|
||||
this._snapshotFullCache = new Map();
|
||||
this._currentIncidentType = incident.type;
|
||||
|
||||
// Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
|
||||
@@ -1001,7 +1004,9 @@ const App = {
|
||||
if (filterType === 'all' || filterType === 'snapshots') {
|
||||
let snapshots = this._currentSnapshots || [];
|
||||
if (searchTerm) {
|
||||
snapshots = snapshots.filter(s => (s.summary || '').toLowerCase().includes(searchTerm));
|
||||
// Suche erfolgt clientseitig ueber Preview (Snapshots-Liste enthaelt keinen Volltext mehr).
|
||||
// Die asynchrone Volltext-Server-Suche wird separat ausgeloest (rerenderTimeline).
|
||||
snapshots = snapshots.filter(s => (s.summary_preview || s.summary || '').toLowerCase().includes(searchTerm));
|
||||
}
|
||||
snapshots.forEach(s => entries.push({ kind: 'snapshot', data: s, timestamp: s.created_at || '' }));
|
||||
}
|
||||
@@ -1503,6 +1508,7 @@ const App = {
|
||||
|
||||
/**
|
||||
* Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
|
||||
* Volltext + sources_json werden erst beim Aufklappen lazy nachgeladen.
|
||||
*/
|
||||
_renderSnapshotEntry(snapshot) {
|
||||
const time = snapshot.created_at
|
||||
@@ -1514,24 +1520,58 @@ const App = {
|
||||
if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
|
||||
const statsText = stats.join(', ');
|
||||
|
||||
// Vorschau: erste 200 Zeichen der Zusammenfassung
|
||||
const summaryText = snapshot.summary || '';
|
||||
const preview = summaryText.length > 200 ? summaryText.substring(0, 200) + '...' : summaryText;
|
||||
// Vorschau: erste 200 Zeichen aus summary_preview (vom Server gekuerzt) oder Fallback summary
|
||||
const previewText = snapshot.summary_preview || snapshot.summary || '';
|
||||
const preview = previewText.length > 200 ? previewText.substring(0, 200) + '...' : previewText;
|
||||
|
||||
// Vollständige Zusammenfassung via UI.renderSummary
|
||||
const fullSummary = UI.renderSummary(snapshot.summary, snapshot.sources_json, this._currentIncidentType);
|
||||
// Volltext aus Cache (falls bereits geladen), sonst Platzhalter fuer Lazy-Load
|
||||
const cached = this._snapshotFullCache && this._snapshotFullCache.get(snapshot.id);
|
||||
const detailHtml = cached
|
||||
? UI.renderSummary(cached.summary, cached.sources_json, this._currentIncidentType)
|
||||
: '<div class="vt-snapshot-loading">Lagebericht wird geladen…</div>';
|
||||
const loadedAttr = cached ? ' data-loaded="yes"' : '';
|
||||
|
||||
return `<div class="vt-entry vt-snapshot expandable" onclick="App.toggleTimelineEntry(this)">
|
||||
return `<div class="vt-entry vt-snapshot expandable" data-snapshot-id="${snapshot.id}"${loadedAttr} onclick="App.toggleTimelineEntry(this)">
|
||||
<div class="vt-snapshot-header">
|
||||
<span class="vt-snapshot-badge">Lagebericht</span>
|
||||
<span class="vt-snapshot-time">${time}</span>
|
||||
<span class="vt-snapshot-stats">${UI.escape(statsText)}</span>
|
||||
</div>
|
||||
<div class="vt-snapshot-preview">${UI.escape(preview)}</div>
|
||||
<div class="vt-snapshot-detail">${fullSummary}</div>
|
||||
<div class="vt-snapshot-detail">${detailHtml}</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Volltext eines Snapshots bei Bedarf nachladen und in das DOM einsetzen.
|
||||
* Ergebnis wird in _snapshotFullCache gecacht.
|
||||
*/
|
||||
async lazyLoadSnapshotDetail(el) {
|
||||
if (!el || el.dataset.loaded === 'yes' || el.dataset.loaded === 'loading') return;
|
||||
const snapId = parseInt(el.dataset.snapshotId || '0', 10);
|
||||
if (!snapId || !this.currentIncidentId) return;
|
||||
el.dataset.loaded = 'loading';
|
||||
try {
|
||||
let snap = this._snapshotFullCache.get(snapId);
|
||||
if (!snap) {
|
||||
snap = await API.getSnapshot(this.currentIncidentId, snapId);
|
||||
this._snapshotFullCache.set(snapId, snap);
|
||||
}
|
||||
const detailEl = el.querySelector('.vt-snapshot-detail');
|
||||
if (detailEl) {
|
||||
detailEl.innerHTML = UI.renderSummary(snap.summary, snap.sources_json, this._currentIncidentType);
|
||||
}
|
||||
el.dataset.loaded = 'yes';
|
||||
// Nach dem Laden die Timeline-Kachel an neue Hoehe anpassen
|
||||
if (el.classList.contains('expanded')) this._resizeTimelineTile();
|
||||
} catch (err) {
|
||||
console.error('Snapshot-Volltext laden fehlgeschlagen:', err);
|
||||
el.dataset.loaded = '';
|
||||
const detailEl = el.querySelector('.vt-snapshot-detail');
|
||||
if (detailEl) detailEl.innerHTML = '<div class="vt-snapshot-error">Fehler beim Laden des Lageberichts.</div>';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
|
||||
*/
|
||||
@@ -1544,6 +1584,10 @@ const App = {
|
||||
}
|
||||
el.classList.toggle('expanded');
|
||||
if (el.classList.contains('expanded')) {
|
||||
// Snapshots: Volltext lazy nachladen (nur wenn noch nicht geladen)
|
||||
if (el.classList.contains('vt-snapshot') && el.dataset.snapshotId) {
|
||||
this.lazyLoadSnapshotDetail(el);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
var scrollParent = el.closest('.ht-detail-content');
|
||||
if (scrollParent && el.classList.contains('vt-snapshot')) {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren