#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: <Episode>@p<idx>'-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) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-28 02:17:31 +02:00
parent b73534d1c3
commit e1f6f18524
2 changed files with 289 additions and 11 deletions

View File

@ -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):

View File

@ -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 += `<span class="theme-tag" style="font-size:10px;margin-right:4px;opacity:0.7">verifizierbar</span>`;
}
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 += `<span class="theme-tag" style="font-size:10px;margin-right:4px;color:${col};border-color:${col}44">${lbl}</span>`;
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 = `<div style="margin-top:4px;font-size:12px;color:var(--accent-green)" onclick="event.stopPropagation(); AnalysisView.jumpAnswer('${it.answered_by_episode}', ${it.answered_by_idx})">
${arrow} Antwort: ${target}@p${it.answered_by_idx}
</div>`;
} else {
answerLink = `<div style="margin-top:4px;font-size:12px;color:var(--accent-green);opacity:0.85">
${arrow} Antwort in ${target}@p${it.answered_by_idx}
</div>`;
}
}
}
html += `<div class="transcript-para" onclick="AnalysisView.jumpTo(${it.start_time || 0})">`;
html += `<span class="ts">${ts}</span>`;
html += badges;
html += escHtml(text);
html += answerLink;
html += '</div>';
});
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 = `<h2>Leerstellen</h2><p class="subtitle">Lädt …</p>`;
@ -885,7 +920,36 @@ const GapsView = {
const chip = (label, count, active, onclick) =>
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
// 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 += `<div style="margin-top:14px"><strong style="font-size:13px">Cluster-Verteilung</strong> <span class="subtitle">(grosse Clustern zuerst, Farbe = Anteil je Podcast)</span></div>`;
html += `<div style="display:grid;grid-template-columns:120px 1fr;gap:6px;margin-top:6px;align-items:center;font-size:12px">`;
podcasts.forEach(p => {
html += `<div style="text-align:right;color:var(--text-muted);padding-right:6px">${escHtml(p)}</div>`;
html += `<div style="display:flex;gap:1px;height:28px">`;
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 += `<div title="${escAttr(title)}" style="flex:0 0 ${widthPct}%;min-width:6px;background:${bg};border-radius:2px;cursor:pointer" onclick="GapsView.scrollToCluster(${c.id})"></div>`;
});
html += `</div>`;
});
html += `</div>`;
}
html += `<div style="margin-top:14px;display:flex;flex-wrap:wrap;gap:6px">`;
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 = `<h2>Narrative Shifts</h2><p class="subtitle">Lädt …</p>`;
@ -1013,6 +1083,36 @@ const ShiftsView = {
if (spikes.length) html += ` · <span style="color:var(--accent-warm)">${spikes.length} Spike${spikes.length > 1 ? 's' : ''}</span>`;
html += `</div>`;
// 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 += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--accent-warm)" style="cursor:pointer" onclick="ShiftsView.jumpTo('${escAttr(s.podcast)}','${safeTo}')"><title>${safeFrom} → ${safeTo}: ${(dr.drift*100).toFixed(0)}%</title></circle>`;
}
});
html += `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;margin:6px 0">`;
html += `<line x1="${PAD}" y1="${yThr.toFixed(1)}" x2="${W-PAD}" y2="${yThr.toFixed(1)}" stroke="var(--border)" stroke-dasharray="2,3" stroke-width="0.6"/>`;
html += `<polyline fill="none" stroke="var(--accent)" stroke-width="1.4" points="${pts}"/>`;
html += spikeMarks;
html += `</svg>`;
}
// 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 += `</div>`;
});
// 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 += `<div style="margin-top:6px"><span class="theme-tag" style="cursor:pointer;font-size:11px" onclick="ShiftsView.toggle('${key}')">${isOpen ? 'verkürzen' : `alle ${allDrifts.length} Übergänge zeigen`}</span></div>`;
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 = `<h2>Faktendichte</h2><p class="subtitle">Lädt …</p>`;
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 = `<h2>Faktendichte</h2><p class="subtitle">Keine Claim-Daten vorhanden.</p>`;
return;
}
this.data = data;
} catch (e) {
panel.innerHTML = `<h2>Faktendichte</h2><p style="color:var(--accent-warm)">Fehler: ${escHtml(e.message)}</p>`;
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 = `<h2>Faktendichte</h2>`;
html += `<p class="subtitle">${eps.length} Episoden mit Claims · Heatmap je Episode in ${this.data.bins} Bins über die Paragraph-Achse</p>`;
const chip = (label, count, active, onclick) =>
`<span class="theme-tag" style="cursor:pointer;${active ? 'background:var(--accent)33;border-color:var(--accent);color:var(--text)' : ''}" onclick="${onclick}">${label}${count !== null ? ` (${count})` : ''}</span>`;
if (podcasts.length > 1) {
html += `<div style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">`;
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 += `</div>`;
}
html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:6px">`;
html += chip('nach Faktendichte', null, this.sort === 'density', `DensityView.setSort('density')`);
html += chip('chronologisch', null, this.sort === 'order', `DensityView.setSort('order')`);
html += `</div>`;
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 `<div title="Bin ${i+1}: ${c} Claims (${v} verifizierbar)" style="flex:1;background:rgba(${r},${g},${b},${op});border-radius:1.5px"></div>`;
}).join('');
html += `<div class="transcript-para" style="cursor:pointer" ${click}>`;
html += `<div style="display:flex;justify-content:space-between;gap:8px;margin-bottom:4px;align-items:baseline">`;
html += `<span><strong>${escHtml(e.episode_id)}</strong> ${escHtml(e.title)}${e.guest ? ` · ${escHtml(e.guest)}` : ''}</span>`;
html += `<span class="ts">${e.total_claims} Claims · ${verPct}% verifizierbar · ${(e.claims_per_para || 0).toFixed(2)}/Absatz</span>`;
html += `</div>`;
html += `<div style="display:flex;gap:1px;height:18px">${cells}</div>`;
html += `</div>`;
});
if (filtered.length > 60) {
html += `<p class="subtitle" style="margin-top:8px">… ${filtered.length - 60} weitere durch Filter eingrenzen.</p>`;
}
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 = `<p class="subtitle">Keine Treffer für "${escHtml(query)}"</p>`;
@ -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 = `<h2>${results.length} semantische Treffer für "${escHtml(query)}" <span class="semantic-badge">KI</span></h2>`;
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 = `<h2>${results.length} Treffer für "${escHtml(query)}"</h2>`;
results.forEach(r => {
@ -1570,6 +1778,7 @@ function showPodcastSelector(podcasts) {
selectorHtml += '<button class="compare-btn" onclick="ShiftsView.show()">Narrative Shifts</button>';
selectorHtml += '<button class="compare-btn" onclick="DebatesView.show()">Debatten</button>';
selectorHtml += '<button class="compare-btn" onclick="ArgumentsView.show()">Argumentketten</button>';
selectorHtml += '<button class="compare-btn" onclick="DensityView.show()">Faktendichte</button>';
selectorHtml += '</div>';
}
@ -1722,8 +1931,12 @@ function init() {
<button class="transcript-toggle" onclick="ShiftsView.show()">Narrative Shifts</button>
<button class="transcript-toggle" onclick="DebatesView.show()">Debatten</button>
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
<button class="transcript-toggle" onclick="DensityView.show()">Faktendichte</button>
</p>`
: `<p style="margin-top:12px"><button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button></p>`;
: `<p style="margin-top:12px;display:flex;flex-wrap:wrap;gap:6px">
<button class="transcript-toggle" onclick="ArgumentsView.show()">Argumentketten</button>
<button class="transcript-toggle" onclick="DensityView.show()">Faktendichte</button>
</p>`;
// 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));