From e1f6f185249a7503c06cac812f2756efdc8740b4 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Tue, 28 Apr 2026 02:17:31 +0200 Subject: [PATCH] #14/#15/#16 Heatmaps und Drift-Kurve in den Analyse-Views Backend: - /api/analyses/density: Faktendichte je Episode in 20 Bins ueber die Paragraph-Achse, getrennt nach total und verifizierbar (#16). Frontend: - ShiftsView (#15): Inline-SVG-Sparkline ueber die gesamte Drift-Sequenz je Theme, mit Schwellen-Linie bei 50% und klickbaren Spike-Markern. - GapsView (#14): Cluster-Heatmap mit zwei Zeilen (LdN, NEU DENKEN), Cluster-Breite proportional zur Cluster-Groesse, Farbe interpoliert von kuehl (geringer Anteil im Podcast) zu warm (hoher Anteil); Klick filtert die darunter liegende Liste. - DensityView (#16): neue View 'Faktendichte', sortiert nach Claims/Absatz, pro Episode eine 20-Bin-Heatmap (gruen = verifizierbar, warm = normativ), Filter nach Podcast und Sortierung; Klick oeffnet die Episode. - AnalysisView (#17 questions): zeigt jetzt 'Antwort: @p'-Link fuer Fragen mit answered_by_episode; Klick navigiert zur Antwort-Stelle. - escAttr-Helper, hide-Cascade um DensityView erweitert, Buttons in Selector und init() hinzugefuegt. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app.py | 65 +++++++++++++ webapp/index.html | 235 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 289 insertions(+), 11 deletions(-) diff --git a/backend/app.py b/backend/app.py index 707e8d3..5a322a9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -243,6 +243,71 @@ def get_shifts_analysis(podcast: Optional[str] = None, theme: Optional[str] = No } +@app.get("/api/analyses/density") +def get_density(podcast_id: Optional[str] = None, bins: int = 20): + """Faktendichte (#16): claims-Verteilung je Episode in N Bins ueber die Paragraph-Achse.""" + db = get_db() + if not _table_exists(db, "claims"): + db.close() + return {"available": False, "episodes": []} + where = "WHERE 1=1" + params = [] + if podcast_id: + where += " AND e.podcast_id = ?" + params.append(podcast_id) + rows = db.execute( + f""" + SELECT e.podcast_id, e.id AS episode_id, e.title, e.guest, e.staffel, + (SELECT MAX(p.idx) FROM paragraphs p + WHERE p.podcast_id = e.podcast_id AND p.episode_id = e.id) AS max_para, + (SELECT COUNT(*) FROM paragraphs p + WHERE p.podcast_id = e.podcast_id AND p.episode_id = e.id) AS n_para + FROM episodes e + {where} + ORDER BY e.podcast_id, e.id + """, + params, + ).fetchall() + + out = [] + for r in rows: + n_para = r["n_para"] or 0 + max_para = r["max_para"] or 0 + if n_para == 0: + continue + claim_rows = db.execute( + "SELECT paragraph_idx, claim_type, verifiable FROM claims " + "WHERE podcast_id = ? AND episode_id = ?", + (r["podcast_id"], r["episode_id"]), + ).fetchall() + total_claims = len(claim_rows) + verifiable = sum(1 for c in claim_rows if c["verifiable"]) + bin_counts = [0] * bins + bin_verifiable = [0] * bins + denom = max(max_para, 1) + for c in claim_rows: + idx = c["paragraph_idx"] or 0 + b = min(bins - 1, int(idx * bins / (denom + 1))) + bin_counts[b] += 1 + if c["verifiable"]: + bin_verifiable[b] += 1 + out.append({ + "podcast_id": r["podcast_id"], + "episode_id": r["episode_id"], + "title": r["title"], + "guest": r["guest"], + "staffel": r["staffel"], + "n_paragraphs": n_para, + "total_claims": total_claims, + "verifiable_claims": verifiable, + "density_bins": bin_counts, + "verifiable_bins": bin_verifiable, + "claims_per_para": (total_claims / n_para) if n_para else 0.0, + }) + db.close() + return {"available": True, "bins": bins, "episodes": out} + + @app.get("/api/analyses/debates") def get_debates(topic: Optional[str] = None, source_podcast: Optional[str] = None, target_podcast: Optional[str] = None, limit: int = 200): diff --git a/webapp/index.html b/webapp/index.html index 7a81e28..c0c46e0 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -746,7 +746,7 @@ const AnalysisView = { async show(episodeId, mode) { if (!CURRENT_PODCAST) return; - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); this.episodeId = episodeId; this.mode = mode; this.visible = true; @@ -822,16 +822,33 @@ const AnalysisView = { if (this.mode === 'claims' && it.verifiable) { badges += `verifizierbar`; } + let answerLink = ''; if (this.mode === 'questions') { const a = it.answered; const lbl = {no:'offen', partial:'teilweise', yes:'beantwortet', self_answered:'selbst beantwortet'}[a] || a; const col = a === 'no' ? 'var(--accent-warm)' : (a === 'yes' ? 'var(--accent-green)' : 'var(--text-muted)'); badges += `${lbl}`; + + if (it.answered_by_episode && (a === 'yes' || a === 'partial')) { + const samePodcast = !it.answered_by_podcast || it.answered_by_podcast === CURRENT_PODCAST; + const arrow = samePodcast ? '→' : '↗'; + const target = samePodcast ? `${escHtml(it.answered_by_episode)}` : `${escHtml(it.answered_by_podcast)} / ${escHtml(it.answered_by_episode)}`; + if (samePodcast) { + answerLink = `
+ ${arrow} Antwort: ${target}@p${it.answered_by_idx} +
`; + } else { + answerLink = `
+ ${arrow} Antwort in ${target}@p${it.answered_by_idx} +
`; + } + } } html += `
`; html += `${ts}`; html += badges; html += escHtml(text); + html += answerLink; html += '
'; }); panel.innerHTML = html; @@ -844,6 +861,24 @@ const AnalysisView = { TranscriptView.show(this.episodeId, time); }, + jumpAnswer(episodeId, paraIdx) { + if (!CURRENT_PODCAST) return; + const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId); + if (!ep) return; + showEpisode(ep); + // Transkript an der Para-Stelle aufschlagen — paragraph_idx ueber Transkript laden + setTimeout(async () => { + try { + const r = await fetch(`${API_BASE}/api/podcasts/${CURRENT_PODCAST}/transcript/${episodeId}`); + const d = await r.json(); + const para = d.paragraphs && d.paragraphs[paraIdx]; + if (para && typeof para.start === 'number') { + TranscriptView.show(episodeId, para.start); + } + } catch (_) { /* fallback: Episode wurde geoeffnet */ } + }, 250); + }, + hide() { this.visible = false; this.episodeId = null; this.items = null; } }; @@ -855,7 +890,7 @@ const GapsView = { minSize: 0, async show() { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); this.visible = true; const panel = document.getElementById('panel'); panel.innerHTML = `

Leerstellen

Lädt …

`; @@ -885,7 +920,36 @@ const GapsView = { const chip = (label, count, active, onclick) => `${label}${count !== null ? ` (${count})` : ''}`; - html += `
`; + // Heatmap: Cluster x Podcast, Farbintensitaet nach Anteil im Podcast + const clusters = (d.clusters || []).slice().sort((a, b) => (b.size || 0) - (a.size || 0)); + if (clusters.length && podcasts.length >= 2) { + const maxSize = Math.max(...clusters.map(c => c.size || 0)) || 1; + html += `
Cluster-Verteilung (grosse Clustern zuerst, Farbe = Anteil je Podcast)
`; + html += `
`; + podcasts.forEach(p => { + html += `
${escHtml(p)}
`; + html += `
`; + clusters.forEach(c => { + const pSize = (c.per_podcast || {})[p] || 0; + const total = c.size || 1; + const share = pSize / total; + const widthPct = ((c.size || 0) / maxSize * 100).toFixed(2); + // Color: dark blue for low share, accent-warm for high share, opacity scaled + const intensity = Math.min(1, share); + // Lerp between cool (#1e3a8a) and warm (#dc7850) + const r = Math.round(30 + (220 - 30) * intensity); + const g = Math.round(58 + (120 - 58) * intensity); + const b = Math.round(138 + (80 - 138) * intensity); + const bg = `rgba(${r},${g},${b},${(0.25 + intensity * 0.65).toFixed(2)})`; + const title = `${c.label.split(',').slice(0, 4).join(',')} · ${p}: ${pSize}/${total} (${(share*100).toFixed(0)}%)`; + html += `
`; + }); + html += `
`; + }); + html += `
`; + } + + html += `
`; html += chip('alle Podcasts', d.gaps.length, !this.missingFilter, `GapsView.setMissing(null)`); podcasts.forEach(p => { const n = d.gaps.filter(g => g.missing_in === p).length; @@ -930,6 +994,12 @@ const GapsView = { if (ep) showEpisode(ep); }, + scrollToCluster(clusterId) { + // Filter auf Cluster-ID, falls eine entsprechende Gap existiert; sonst nur Heatmap-Hover. + const gap = (this.data?.gaps || []).find(g => g.cluster_id === clusterId); + if (gap && gap.missing_in) { this.setMissing(gap.missing_in); } + }, + hide() { this.visible = false; } }; @@ -942,7 +1012,7 @@ const ShiftsView = { expanded: {}, async show() { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); this.visible = true; const panel = document.getElementById('panel'); panel.innerHTML = `

Narrative Shifts

Lädt …

`; @@ -1013,6 +1083,36 @@ const ShiftsView = { if (spikes.length) html += ` · ${spikes.length} Spike${spikes.length > 1 ? 's' : ''}`; html += `
`; + // Inline-Sparkline der gesamten Drift-Sequenz + const allDrifts = s.drifts || []; + if (allDrifts.length >= 2) { + const W = 360, H = 44, PAD = 4; + const maxScale = Math.max(0.6, ...allDrifts.map(dr => dr.drift || 0)); + const pts = allDrifts.map((dr, i) => { + const x = PAD + (W - 2 * PAD) * i / (allDrifts.length - 1); + const y = H - PAD - (H - 2 * PAD) * Math.min(1, (dr.drift || 0) / maxScale); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + // Threshold-Linie bei 50% drift + const yThr = H - PAD - (H - 2 * PAD) * Math.min(1, 0.5 / maxScale); + // Spike-Marker + let spikeMarks = ''; + allDrifts.forEach((dr, i) => { + if ((dr.drift || 0) >= 0.5) { + const x = PAD + (W - 2 * PAD) * i / (allDrifts.length - 1); + const y = H - PAD - (H - 2 * PAD) * Math.min(1, (dr.drift || 0) / maxScale); + const safeFrom = escAttr(dr.from); + const safeTo = escAttr(dr.to); + spikeMarks += `${safeFrom} → ${safeTo}: ${(dr.drift*100).toFixed(0)}%`; + } + }); + html += ``; + html += ``; + html += ``; + html += spikeMarks; + html += ``; + } + // Top-Drifts (Spikes oder Top 3) const top = spikes.length ? spikes : (s.drifts || []).slice().sort((a,b) => (b.drift||0)-(a.drift||0)).slice(0, 3); top.forEach(dr => { @@ -1027,8 +1127,7 @@ const ShiftsView = { html += `
`; }); - // Toggle für vollständige Drift-Sequenz - const allDrifts = s.drifts || []; + // Toggle für vollständige Drift-Sequenz (allDrifts oben definiert) if (allDrifts.length > top.length) { html += `
${isOpen ? 'verkürzen' : `alle ${allDrifts.length} Übergänge zeigen`}
`; if (isOpen) { @@ -1289,6 +1388,115 @@ const ArgumentsView = { hide() { this.visible = false; } }; +// ── Density View (#16 Faktendichte / Claim-Density-Map) ── +const DensityView = { + visible: false, + data: null, + podcastFilter: null, + sort: 'density', // 'density' | 'order' + + async show() { + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); + this.visible = true; + const panel = document.getElementById('panel'); + panel.innerHTML = `

Faktendichte

Lädt …

`; + try { + const url = CURRENT_PODCAST ? `${API_BASE}/api/analyses/density?podcast_id=${CURRENT_PODCAST}` : `${API_BASE}/api/analyses/density`; + const r = await fetch(url); + const data = await r.json(); + if (!data.available) { + panel.innerHTML = `

Faktendichte

Keine Claim-Daten vorhanden.

`; + return; + } + this.data = data; + } catch (e) { + panel.innerHTML = `

Faktendichte

Fehler: ${escHtml(e.message)}

`; + return; + } + this.render(); + }, + + render() { + if (!this.visible || !this.data) return; + const panel = document.getElementById('panel'); + const eps = this.data.episodes || []; + const podcasts = [...new Set(eps.map(e => e.podcast_id))]; + let html = `

Faktendichte

`; + html += `

${eps.length} Episoden mit Claims · Heatmap je Episode in ${this.data.bins} Bins über die Paragraph-Achse

`; + + const chip = (label, count, active, onclick) => + `${label}${count !== null ? ` (${count})` : ''}`; + + if (podcasts.length > 1) { + html += `
`; + html += chip('alle', eps.length, !this.podcastFilter, `DensityView.setPodcast(null)`); + podcasts.forEach(p => { + const n = eps.filter(e => e.podcast_id === p).length; + html += chip(escHtml(p), n, this.podcastFilter === p, `DensityView.setPodcast('${p}')`); + }); + html += `
`; + } + + html += `
`; + html += chip('nach Faktendichte', null, this.sort === 'density', `DensityView.setSort('density')`); + html += chip('chronologisch', null, this.sort === 'order', `DensityView.setSort('order')`); + html += `
`; + + let filtered = eps; + if (this.podcastFilter) filtered = filtered.filter(e => e.podcast_id === this.podcastFilter); + if (this.sort === 'density') { + filtered = filtered.slice().sort((a, b) => (b.claims_per_para || 0) - (a.claims_per_para || 0)); + } else { + filtered = filtered.slice().sort((a, b) => a.episode_id.localeCompare(b.episode_id)); + } + + const maxBin = filtered.length + ? Math.max(...filtered.flatMap(e => e.density_bins || [])) + : 1; + + filtered.slice(0, 60).forEach(e => { + const click = `onclick="DensityView.jumpTo('${escAttr(e.podcast_id)}','${escAttr(e.episode_id)}')"`; + const verPct = e.total_claims ? ((e.verifiable_claims / e.total_claims) * 100).toFixed(0) : '0'; + const bins = e.density_bins || []; + const verBins = e.verifiable_bins || []; + // Heatmap inline + const cells = bins.map((c, i) => { + const v = verBins[i] || 0; + const intensity = c / (maxBin || 1); + // Verifizierbar = grün, restlich (Meinung) = warm + const greenShare = c > 0 ? v / c : 0; + const r = Math.round(220 - 100 * greenShare); + const g = Math.round(120 + 100 * greenShare); + const b = Math.round(80 + 50 * greenShare); + const op = (0.15 + intensity * 0.7).toFixed(2); + return `
`; + }).join(''); + html += `
`; + html += `
`; + html += `${escHtml(e.episode_id)} ${escHtml(e.title)}${e.guest ? ` · ${escHtml(e.guest)}` : ''}`; + html += `${e.total_claims} Claims · ${verPct}% verifizierbar · ${(e.claims_per_para || 0).toFixed(2)}/Absatz`; + html += `
`; + html += `
${cells}
`; + html += `
`; + }); + if (filtered.length > 60) { + html += `

… ${filtered.length - 60} weitere durch Filter eingrenzen.

`; + } + panel.innerHTML = html; + }, + + setPodcast(p) { this.podcastFilter = p; this.render(); }, + setSort(s) { this.sort = s; this.render(); }, + + jumpTo(podcastId, episodeId) { + if (CURRENT_PODCAST !== podcastId) return; + const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId); + if (ep) showEpisode(ep); + }, + + hide() { this.visible = false; } +}; + // ── Search ── const Search = { init() { @@ -1385,7 +1593,7 @@ const Search = { showResults(results, query) { const panel = document.getElementById('panel'); - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); if (results.length === 0) { panel.innerHTML = `

Keine Treffer für "${escHtml(query)}"

`; @@ -1413,7 +1621,7 @@ const Search = { }, showSemanticResults(results, query) { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); const panel = document.getElementById('panel'); let html = `

${results.length} semantische Treffer für "${escHtml(query)}" KI

`; results.forEach(r => { @@ -1426,7 +1634,7 @@ const Search = { }, showApiResults(results, query) { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); const panel = document.getElementById('panel'); let html = `

${results.length} Treffer für "${escHtml(query)}"

`; results.forEach(r => { @@ -1570,6 +1778,7 @@ function showPodcastSelector(podcasts) { selectorHtml += ''; selectorHtml += ''; selectorHtml += ''; + selectorHtml += ''; selectorHtml += ''; } @@ -1722,8 +1931,12 @@ function init() { +

` - : `

`; + : `

+ + +

`; // Panel kann von showPodcastSelector ueberschrieben worden sein — welcome-panel ggf. neu anlegen let welcome = document.getElementById('welcome-panel'); if (!welcome) { @@ -1956,7 +2169,7 @@ function drag(sim) { // ── Panel: Theme ── function showTheme(theme) { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); DensityView.hide(); const panel = document.getElementById('panel'); const td = DATA.themes.find(t => t.id === theme.id); const quotes = DATA.quotes.filter(q => q.themes.includes(theme.id));