From b73534d1c3549b84bdd1a60f6c7e474f090ba3c2 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Tue, 28 Apr 2026 02:04:49 +0200 Subject: [PATCH] #13/#18 Argumentketten- und Debatten-Views Backend: - /api/analyses/debates: liefert kuratierte Cross-Podcast-Gegenueberstellungen mit topic, agreement, divergence, insight, beiden Quellabsaetzen und Episodenmetadaten; Filter ueber topic, source_podcast, target_podcast. - /api/analyses/arguments: liefert klassifizierte Argumentketten mit relation, confidence, explanation und beiden Quellabsaetzen; Filter ueber relation, podcast, episode. Wortwoertlich identische gleicher_punkt-Paare werden ausgeblendet. Frontend: - DebatesView: Topic-Chips als Filter, Split-Screen-Quotes je Debatte, Chips fuer Uebereinstimmung/Divergenz/Erkenntnis, Klick fuehrt zur Episode mit Audio-Sprung. - ArgumentsView: farbcodierte Relations-Chips (erweitert blau, widerspricht rot, belegt gruen, relativiert grau, gleicher_punkt violett, kein_bezug grau), Konfidenz- Anzeige, Filter ueber Podcast, Klick fuehrt zur Episode-Stelle. - escAttr-Helper fuer onclick-Werte mit Anfuehrungszeichen. - hide-Cascade aller Views um die beiden neuen erweitert. - Buttons in showPodcastSelector und init() fuer beide Views. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app.py | 114 ++++++++++++++++++++ webapp/index.html | 259 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 364 insertions(+), 9 deletions(-) diff --git a/backend/app.py b/backend/app.py index 9a73c1e..707e8d3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -243,6 +243,120 @@ def get_shifts_analysis(podcast: Optional[str] = None, theme: Optional[str] = No } +@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): + """Cross-Podcast-Debatten (#18): kuratierte Gegenueberstellungen je Thema.""" + db = get_db() + if not _table_exists(db, "debates"): + db.close() + return {"available": False, "debates": []} + sql = ( + "SELECT d.id, d.topic, d.agreement, d.divergence, d.insight, d.score, " + "d.source_podcast, d.source_episode, d.source_idx, " + "d.target_podcast, d.target_episode, d.target_idx, " + "p1.text AS source_text, p1.start_time AS source_start, " + "p2.text AS target_text, p2.start_time AS target_start, " + "pc1.name AS source_pname, pc2.name AS target_pname, " + "e1.title AS source_title, e1.guest AS source_guest, " + "e2.title AS target_title, e2.guest AS target_guest " + "FROM debates d " + "JOIN paragraphs p1 ON d.source_podcast = p1.podcast_id AND d.source_episode = p1.episode_id AND d.source_idx = p1.idx " + "JOIN paragraphs p2 ON d.target_podcast = p2.podcast_id AND d.target_episode = p2.episode_id AND d.target_idx = p2.idx " + "JOIN podcasts pc1 ON d.source_podcast = pc1.id " + "JOIN podcasts pc2 ON d.target_podcast = pc2.id " + "JOIN episodes e1 ON d.source_podcast = e1.podcast_id AND d.source_episode = e1.id " + "JOIN episodes e2 ON d.target_podcast = e2.podcast_id AND d.target_episode = e2.id " + "WHERE d.topic IS NOT NULL AND d.topic != 'error' AND d.topic != ''" + ) + params = [] + if topic: + sql += " AND d.topic LIKE ?" + params.append(f"%{topic}%") + if source_podcast: + sql += " AND d.source_podcast = ?" + params.append(source_podcast) + if target_podcast: + sql += " AND d.target_podcast = ?" + params.append(target_podcast) + sql += " ORDER BY d.score DESC LIMIT ?" + params.append(limit) + rows = db.execute(sql, params).fetchall() + topics = db.execute( + "SELECT topic, COUNT(*) c FROM debates " + "WHERE topic IS NOT NULL AND topic != 'error' AND topic != '' " + "GROUP BY topic ORDER BY c DESC LIMIT 30" + ).fetchall() + podcasts = db.execute("SELECT id, name FROM podcasts").fetchall() + db.close() + return { + "available": True, + "podcasts": [dict(p) for p in podcasts], + "topics": [{"topic": t["topic"], "count": t["c"]} for t in topics], + "debates": [dict(r) for r in rows], + } + + +@app.get("/api/analyses/arguments") +def get_arguments(relation: Optional[str] = None, podcast_id: Optional[str] = None, + episode_id: Optional[str] = None, source_podcast: Optional[str] = None, + target_podcast: Optional[str] = None, limit: int = 200): + """Argumentketten (#13): klassifizierte Relationen zwischen Absatz-Paaren.""" + db = get_db() + if not _table_exists(db, "argument_links"): + db.close() + return {"available": False, "links": []} + sql = ( + "SELECT a.id, a.relation, a.confidence, a.explanation, a.score, " + "a.source_podcast, a.source_episode, a.source_idx, " + "a.target_podcast, a.target_episode, a.target_idx, " + "p1.text AS source_text, p1.start_time AS source_start, " + "p2.text AS target_text, p2.start_time AS target_start, " + "e1.title AS source_title, e1.guest AS source_guest, " + "e2.title AS target_title, e2.guest AS target_guest " + "FROM argument_links a " + "JOIN paragraphs p1 ON a.source_podcast = p1.podcast_id AND a.source_episode = p1.episode_id AND a.source_idx = p1.idx " + "JOIN paragraphs p2 ON a.target_podcast = p2.podcast_id AND a.target_episode = p2.episode_id AND a.target_idx = p2.idx " + "JOIN episodes e1 ON a.source_podcast = e1.podcast_id AND a.source_episode = e1.id " + "JOIN episodes e2 ON a.target_podcast = e2.podcast_id AND a.target_episode = e2.id " + "WHERE a.relation IS NOT NULL AND a.relation != 'error' " + # Wortwoertlich identische Paare als Sicherheitsnetz fuer kuenftige Re-Runs nicht zeigen. + "AND NOT (a.relation = 'gleicher_punkt' AND p1.text = p2.text)" + ) + params = [] + if relation: + sql += " AND a.relation = ?" + params.append(relation) + if podcast_id: + sql += " AND (a.source_podcast = ? OR a.target_podcast = ?)" + params.extend([podcast_id, podcast_id]) + if episode_id: + sql += " AND (a.source_episode = ? OR a.target_episode = ?)" + params.extend([episode_id, episode_id]) + if source_podcast: + sql += " AND a.source_podcast = ?" + params.append(source_podcast) + if target_podcast: + sql += " AND a.target_podcast = ?" + params.append(target_podcast) + sql += " ORDER BY a.confidence DESC, a.score DESC LIMIT ?" + params.append(limit) + rows = db.execute(sql, params).fetchall() + relations = db.execute( + "SELECT relation, COUNT(*) c FROM argument_links " + "WHERE relation IS NOT NULL AND relation != 'error' " + "GROUP BY relation ORDER BY c DESC" + ).fetchall() + podcasts = db.execute("SELECT id, name FROM podcasts").fetchall() + db.close() + return { + "available": True, + "podcasts": [dict(p) for p in podcasts], + "relations": [{"relation": r["relation"], "count": r["c"]} for r in relations], + "links": [dict(r) for r in rows], + } + + @app.get("/api/search") def search(q: str = Query(..., min_length=2), podcast_id: Optional[str] = None, limit: int = 50): """Full-text search across all transcripts.""" diff --git a/webapp/index.html b/webapp/index.html index 6994909..7a81e28 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(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); this.episodeId = episodeId; this.mode = mode; this.visible = true; @@ -855,7 +855,7 @@ const GapsView = { minSize: 0, async show() { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); this.visible = true; const panel = document.getElementById('panel'); panel.innerHTML = `

Leerstellen

Lädt …

`; @@ -942,7 +942,7 @@ const ShiftsView = { expanded: {}, async show() { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.hide(); this.visible = true; const panel = document.getElementById('panel'); panel.innerHTML = `

Narrative Shifts

Lädt …

`; @@ -1060,6 +1060,235 @@ const ShiftsView = { hide() { this.visible = false; } }; +// ── Debates View (#18 Cross-Podcast-Debatte) ── +const DebatesView = { + visible: false, + data: null, + topicFilter: null, + expanded: {}, + + async show() { + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); ArgumentsView.hide(); + this.visible = true; + const panel = document.getElementById('panel'); + panel.innerHTML = `

Debatten

Lädt …

`; + try { + const r = await fetch(`${API_BASE}/api/analyses/debates?limit=200`); + const data = await r.json(); + if (!data.available) { + panel.innerHTML = `

Debatten

Keine Debatten-Analyse vorhanden.

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

Debatten

Fehler: ${escHtml(e.message)}

`; + return; + } + this.render(); + }, + + render() { + if (!this.visible || !this.data) return; + const panel = document.getElementById('panel'); + const d = this.data; + let html = `

Debatten

`; + html += `

${d.debates.length} kuratierte Gegenüberstellungen über ${d.podcasts.length} Podcasts

`; + + const chip = (label, count, active, onclick) => + `${label}${count !== null ? ` (${count})` : ''}`; + + // Topic-Filter (Top 12 + alle) + html += `
`; + html += chip('alle Themen', d.debates.length, !this.topicFilter, `DebatesView.setTopic(null)`); + (d.topics || []).slice(0, 12).forEach(t => { + html += chip(escHtml(t.topic), t.count, this.topicFilter === t.topic, `DebatesView.setTopic('${escAttr(t.topic)}')`); + }); + html += `
`; + + let filtered = d.debates; + if (this.topicFilter) filtered = filtered.filter(x => x.topic === this.topicFilter); + + if (filtered.length === 0) { + html += `

Keine Debatten zum Filter.

`; + panel.innerHTML = html; + return; + } + + filtered.forEach(deb => { + const key = `${deb.id}`; + const isOpen = this.expanded[key]; + const srcEpClick = deb.source_podcast === CURRENT_PODCAST + ? `onclick="DebatesView.jumpTo('${deb.source_episode}', ${deb.source_start || 0})"` : ''; + const tgtEpClick = deb.target_podcast === CURRENT_PODCAST + ? `onclick="DebatesView.jumpTo('${deb.target_episode}', ${deb.target_start || 0})"` : ''; + html += `
`; + html += `
`; + html += `${escHtml(deb.topic)}`; + html += `Score ${(deb.score || 0).toFixed(2)}`; + html += `
`; + // Split-Screen Quotes + html += `
`; + html += `
`; + html += `
${escHtml(deb.source_pname)} · ${escHtml(deb.source_episode)}${deb.source_guest ? ' (' + escHtml(deb.source_guest) + ')' : ''}
`; + html += `
${escHtml((deb.source_text || '').slice(0, 240))}${(deb.source_text || '').length > 240 ? '…' : ''}
`; + html += `
`; + html += `
`; + html += `
${escHtml(deb.target_pname)} · ${escHtml(deb.target_episode)}${deb.target_guest ? ' (' + escHtml(deb.target_guest) + ')' : ''}
`; + html += `
${escHtml((deb.target_text || '').slice(0, 240))}${(deb.target_text || '').length > 240 ? '…' : ''}
`; + html += `
`; + html += `
`; + // Synthese + if (deb.agreement) { + html += `
Übereinstimmung ${escHtml(deb.agreement)}
`; + } + if (deb.divergence && deb.divergence.toLowerCase() !== 'keine wesentliche divergenz') { + html += `
Divergenz ${escHtml(deb.divergence)}
`; + } + if (deb.insight) { + html += `
Erkenntnis ${escHtml(deb.insight)}
`; + } + html += `
`; + }); + panel.innerHTML = html; + }, + + setTopic(t) { this.topicFilter = t; this.render(); }, + + jumpTo(episodeId, startTime) { + if (!CURRENT_PODCAST) return; + const ep = DATA && DATA.episodes && DATA.episodes.find(e => e.id === episodeId); + if (ep) { + showEpisode(ep); + if (startTime) setTimeout(() => playFrom(startTime, ep), 200); + } + }, + + hide() { this.visible = false; } +}; + +// ── Arguments View (#13 Argumentketten-Tracker) ── +const ArgumentsView = { + visible: false, + data: null, + relationFilter: null, + podcastFilter: null, + + RELATION_COLORS: { + 'erweitert': '#60a5fa', // blau + 'widerspricht': '#f87171', // rot + 'belegt': '#86efac', // gruen + 'relativiert': '#9ca3af', // grau + 'gleicher_punkt': '#a78bfa', // violett + 'kein_bezug': '#525252' + }, + + async show() { + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); + this.visible = true; + const panel = document.getElementById('panel'); + panel.innerHTML = `

Argumentketten

Lädt …

`; + try { + const r = await fetch(`${API_BASE}/api/analyses/arguments?limit=200`); + const data = await r.json(); + if (!data.available) { + panel.innerHTML = `

Argumentketten

Keine Argument-Analyse vorhanden.

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

Argumentketten

Fehler: ${escHtml(e.message)}

`; + return; + } + this.render(); + }, + + render() { + if (!this.visible || !this.data) return; + const panel = document.getElementById('panel'); + const d = this.data; + let html = `

Argumentketten

`; + html += `

Wie sich Aussagen logisch zueinander verhalten · ${d.links.length} Verknüpfungen sichtbar

`; + + const chip = (label, count, active, onclick, color) => { + const ac = active ? `background:${color || 'var(--accent)'}33;border-color:${color || 'var(--accent)'};color:var(--text)` : (color ? `border-color:${color}66;color:${color}` : ''); + return `${label}${count !== null ? ` (${count})` : ''}`; + }; + + // Relation-Filter + html += `
`; + html += chip('alle Relationen', d.links.length, !this.relationFilter, `ArgumentsView.setRelation(null)`); + (d.relations || []).forEach(r => { + html += chip(r.relation, r.count, this.relationFilter === r.relation, `ArgumentsView.setRelation('${r.relation}')`, this.RELATION_COLORS[r.relation]); + }); + html += `
`; + + // Podcast-Filter + if ((d.podcasts || []).length > 1) { + html += `
`; + html += chip('alle Podcasts', null, !this.podcastFilter, `ArgumentsView.setPodcast(null)`); + d.podcasts.forEach(p => { + html += chip(escHtml(p.name), null, this.podcastFilter === p.id, `ArgumentsView.setPodcast('${p.id}')`); + }); + html += `
`; + } + + let filtered = d.links; + if (this.relationFilter) filtered = filtered.filter(x => x.relation === this.relationFilter); + if (this.podcastFilter) filtered = filtered.filter(x => x.source_podcast === this.podcastFilter || x.target_podcast === this.podcastFilter); + + if (filtered.length === 0) { + html += `

Keine Verknüpfungen zum Filter.

`; + panel.innerHTML = html; + return; + } + + filtered.slice(0, 80).forEach(a => { + const c = this.RELATION_COLORS[a.relation] || '#888'; + const arrow = a.relation === 'widerspricht' ? '⇄' : (a.relation === 'gleicher_punkt' ? '≡' : '→'); + const conf = (a.confidence || 0) * 100; + const srcEpClick = a.source_podcast === CURRENT_PODCAST + ? `onclick="ArgumentsView.jumpTo('${a.source_episode}', ${a.source_start || 0})"` : ''; + const tgtEpClick = a.target_podcast === CURRENT_PODCAST + ? `onclick="ArgumentsView.jumpTo('${a.target_episode}', ${a.target_start || 0})"` : ''; + html += `
`; + html += `
`; + html += `${escHtml(a.relation)} ${arrow}`; + html += `Konfidenz ${conf.toFixed(0)}%`; + html += `
`; + html += `
`; + html += `
A: ${escHtml(a.source_episode)}${a.source_guest ? ' · ' + escHtml(a.source_guest) : ''}
`; + html += `
${escHtml((a.source_text || '').slice(0, 220))}${(a.source_text || '').length > 220 ? '…' : ''}
`; + html += `
`; + html += `
`; + html += `
B: ${escHtml(a.target_episode)}${a.target_guest ? ' · ' + escHtml(a.target_guest) : ''}
`; + html += `
${escHtml((a.target_text || '').slice(0, 220))}${(a.target_text || '').length > 220 ? '…' : ''}
`; + html += `
`; + if (a.explanation && !a.explanation.startsWith('rerun-failed')) { + html += `
${escHtml(a.explanation)}
`; + } + html += `
`; + }); + if (filtered.length > 80) { + html += `

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

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

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

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

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

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

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

`; results.forEach(r => { @@ -1339,6 +1568,8 @@ function showPodcastSelector(podcasts) { selectorHtml += ''; selectorHtml += ''; selectorHtml += ''; + selectorHtml += ''; + selectorHtml += ''; selectorHtml += ''; } @@ -1486,8 +1717,13 @@ function init() { ? ` ${escHtml(name)}` : `${escHtml(name)}`; const gapsBtn = ALL_PODCASTS.length > 1 - ? `

` - : ''; + ? `

+ + + + +

` + : `

`; // Panel kann von showPodcastSelector ueberschrieben worden sein — welcome-panel ggf. neu anlegen let welcome = document.getElementById('welcome-panel'); if (!welcome) { @@ -1720,7 +1956,7 @@ function drag(sim) { // ── Panel: Theme ── function showTheme(theme) { - TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); + TranscriptView.hide(); AnalysisView.hide(); GapsView.hide(); ShiftsView.hide(); DebatesView.hide(); ArgumentsView.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)); @@ -1838,6 +2074,11 @@ function escHtml(s) { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +function escAttr(s) { + // Fuer Werte in inline onclick='...': Anfuehrungszeichen, Slashes, Backslashes neutralisieren. + return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '"').replace(/