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),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
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")
|
tenant_id = current_user.get("tenant_id")
|
||||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"""SELECT id, incident_id, summary, sources_json,
|
"""SELECT id, incident_id, article_count, fact_check_count, created_at,
|
||||||
article_count, fact_check_count, created_at
|
SUBSTR(summary, 1, 300) AS summary_preview
|
||||||
FROM incident_snapshots WHERE incident_id = ?
|
FROM incident_snapshots WHERE incident_id = ?
|
||||||
ORDER BY created_at DESC""",
|
ORDER BY created_at DESC""",
|
||||||
(incident_id,),
|
(incident_id,),
|
||||||
@@ -351,6 +356,55 @@ async def get_snapshots(
|
|||||||
return [dict(row) for row in rows]
|
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")
|
@router.get("/{incident_id}/factchecks")
|
||||||
async def get_factchecks(
|
async def get_factchecks(
|
||||||
incident_id: int,
|
incident_id: int,
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ const API = {
|
|||||||
return this._request('GET', `/incidents/${incidentId}/snapshots`);
|
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) {
|
getLocations(incidentId) {
|
||||||
return this._request('GET', `/incidents/${incidentId}/locations`);
|
return this._request('GET', `/incidents/${incidentId}/locations`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -420,6 +420,8 @@ const App = {
|
|||||||
_refreshingIncidents: new Set(),
|
_refreshingIncidents: new Set(),
|
||||||
_editingIncidentId: null,
|
_editingIncidentId: null,
|
||||||
_currentArticles: [],
|
_currentArticles: [],
|
||||||
|
_currentSnapshots: [],
|
||||||
|
_snapshotFullCache: new Map(),
|
||||||
_currentIncidentType: 'adhoc',
|
_currentIncidentType: 'adhoc',
|
||||||
_sidebarFilter: 'all',
|
_sidebarFilter: 'all',
|
||||||
_currentUsername: '',
|
_currentUsername: '',
|
||||||
@@ -954,6 +956,7 @@ const App = {
|
|||||||
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
|
// Timeline - Artikel + Snapshots zwischenspeichern und rendern
|
||||||
this._currentArticles = articles;
|
this._currentArticles = articles;
|
||||||
this._currentSnapshots = snapshots || [];
|
this._currentSnapshots = snapshots || [];
|
||||||
|
this._snapshotFullCache = new Map();
|
||||||
this._currentIncidentType = incident.type;
|
this._currentIncidentType = incident.type;
|
||||||
|
|
||||||
// Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
|
// Tab-Auswahl: gemerkt pro Lage (localStorage), Default = erster Tab
|
||||||
@@ -1001,7 +1004,9 @@ const App = {
|
|||||||
if (filterType === 'all' || filterType === 'snapshots') {
|
if (filterType === 'all' || filterType === 'snapshots') {
|
||||||
let snapshots = this._currentSnapshots || [];
|
let snapshots = this._currentSnapshots || [];
|
||||||
if (searchTerm) {
|
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 || '' }));
|
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.
|
* Snapshot/Lagebericht-Eintrag für den Zeitstrahl rendern.
|
||||||
|
* Volltext + sources_json werden erst beim Aufklappen lazy nachgeladen.
|
||||||
*/
|
*/
|
||||||
_renderSnapshotEntry(snapshot) {
|
_renderSnapshotEntry(snapshot) {
|
||||||
const time = snapshot.created_at
|
const time = snapshot.created_at
|
||||||
@@ -1514,24 +1520,58 @@ const App = {
|
|||||||
if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
|
if (snapshot.fact_check_count) stats.push(`${snapshot.fact_check_count} Fakten`);
|
||||||
const statsText = stats.join(', ');
|
const statsText = stats.join(', ');
|
||||||
|
|
||||||
// Vorschau: erste 200 Zeichen der Zusammenfassung
|
// Vorschau: erste 200 Zeichen aus summary_preview (vom Server gekuerzt) oder Fallback summary
|
||||||
const summaryText = snapshot.summary || '';
|
const previewText = snapshot.summary_preview || snapshot.summary || '';
|
||||||
const preview = summaryText.length > 200 ? summaryText.substring(0, 200) + '...' : summaryText;
|
const preview = previewText.length > 200 ? previewText.substring(0, 200) + '...' : previewText;
|
||||||
|
|
||||||
// Vollständige Zusammenfassung via UI.renderSummary
|
// Volltext aus Cache (falls bereits geladen), sonst Platzhalter fuer Lazy-Load
|
||||||
const fullSummary = UI.renderSummary(snapshot.summary, snapshot.sources_json, this._currentIncidentType);
|
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">
|
<div class="vt-snapshot-header">
|
||||||
<span class="vt-snapshot-badge">Lagebericht</span>
|
<span class="vt-snapshot-badge">Lagebericht</span>
|
||||||
<span class="vt-snapshot-time">${time}</span>
|
<span class="vt-snapshot-time">${time}</span>
|
||||||
<span class="vt-snapshot-stats">${UI.escape(statsText)}</span>
|
<span class="vt-snapshot-stats">${UI.escape(statsText)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vt-snapshot-preview">${UI.escape(preview)}</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>`;
|
</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).
|
* Timeline-Eintrag auf-/zuklappen (mutual-exclusive pro Zeitgruppe).
|
||||||
*/
|
*/
|
||||||
@@ -1544,6 +1584,10 @@ const App = {
|
|||||||
}
|
}
|
||||||
el.classList.toggle('expanded');
|
el.classList.toggle('expanded');
|
||||||
if (el.classList.contains('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(() => {
|
requestAnimationFrame(() => {
|
||||||
var scrollParent = el.closest('.ht-detail-content');
|
var scrollParent = el.closest('.ht-detail-content');
|
||||||
if (scrollParent && el.classList.contains('vt-snapshot')) {
|
if (scrollParent && el.classList.contains('vt-snapshot')) {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren