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 += `
`;
+ }
+
// 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));