diff --git a/backend/app.py b/backend/app.py index d828ff0..adb4202 100644 --- a/backend/app.py +++ b/backend/app.py @@ -77,6 +77,73 @@ def get_podcast(podcast_id: str): } +@app.get("/api/podcasts/{podcast_id}/network") +def get_podcast_network(podcast_id: str, top_per_episode: int = 2, min_score: float = 0.65): + """Mindmap-Querverbindungen (#19): Top-N Episode-Episode-Links per Aehnlichkeit + sowie Episode-Pair-Aggregate aus argument_links.""" + db = get_db() + + # Episode-Episode similarity: pro source-Episode die staerksten N Targets + sim_rows = db.execute( + "SELECT source_episode, target_podcast, target_episode, MAX(score) AS top_score, COUNT(*) AS n_links " + "FROM semantic_links " + "WHERE podcast_id = ? AND source_episode != target_episode " + "GROUP BY source_episode, target_podcast, target_episode " + "HAVING top_score >= ? " + "ORDER BY source_episode, top_score DESC", + (podcast_id, min_score), + ).fetchall() + + # In Python auf top_per_episode pro Source einschraenken + per_src = {} + sim_links = [] + for r in sim_rows: + src = r["source_episode"] + if per_src.get(src, 0) >= top_per_episode: + continue + sim_links.append({ + "source_episode": src, + "target_podcast": r["target_podcast"], + "target_episode": r["target_episode"], + "score": r["top_score"], + "n_paragraph_links": r["n_links"], + }) + per_src[src] = per_src.get(src, 0) + 1 + + # Argument-Links: aggregiert pro Episode-Pair und Relation + arg_links = [] + if _table_exists(db, "argument_links"): + arg_rows = db.execute( + "SELECT source_episode, target_podcast, target_episode, relation, COUNT(*) AS n " + "FROM argument_links " + "WHERE source_podcast = ? AND relation NOT IN ('error', 'kein_bezug') " + "GROUP BY source_episode, target_podcast, target_episode, relation " + "ORDER BY n DESC", + (podcast_id,), + ).fetchall() + # Aggregate per Episode-Pair + pair_map = {} + for r in arg_rows: + key = (r["source_episode"], r["target_podcast"], r["target_episode"]) + pair_map.setdefault(key, {})[r["relation"]] = r["n"] + for (src, tp, te), counts in pair_map.items(): + arg_links.append({ + "source_episode": src, + "target_podcast": tp, + "target_episode": te, + "counts": counts, + }) + + db.close() + return { + "podcast_id": podcast_id, + "top_per_episode": top_per_episode, + "min_score": min_score, + "similarity_links": sim_links, + "argument_links": arg_links, + } + + @app.get("/api/podcasts/{podcast_id}/transcript/{episode_id}") def get_transcript(podcast_id: str, episode_id: str): db = get_db() diff --git a/webapp/index.html b/webapp/index.html index 62d18bb..b592885 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -407,7 +407,9 @@ // ============================================================ let DATA = null; +let NETWORK = null; // Mindmap-Querverbindungen (#19): similarity + argument links let TRANSCRIPTS = null; // loaded on demand +const LINK_VISIBILITY = { 'quote-theme': false, 'episode-similar': true, 'arg-belegt': true, 'arg-widerspricht': true, 'arg-erweitert': true, 'arg-relativiert': false }; let simulation = null; // Audio state — completely independent from panel state @@ -1939,8 +1941,12 @@ async function loadApp() { async function selectPodcast(podcastId, fromUrl = false) { try { - const resp = await fetch(`${API_BASE}/api/podcasts/${podcastId}`); + const [resp, netResp] = await Promise.all([ + fetch(`${API_BASE}/api/podcasts/${podcastId}`), + fetch(`${API_BASE}/api/podcasts/${podcastId}/network?top_per_episode=2`).catch(() => null), + ]); DATA = await resp.json(); + NETWORK = netResp && netResp.ok ? await netResp.json() : null; CURRENT_PODCAST = podcastId; if (!fromUrl && window.history && window.location.pathname !== `/${podcastId}`) { window.history.pushState({ podcast: podcastId }, '', `/${podcastId}`); @@ -2286,6 +2292,37 @@ function buildGraph() { isTopQuote: q.isTopQuote, verbatim: q.verbatim, r: (q.isTopQuote ? 6 : 4) * sc, color: ep ? ep.color : '#666', staffel: ep ? ep.staffel : 0 }); links.push({ source: q.episode, target: q.id, type: 'episode-quote' }); + // #19: Quote ↔ Theme-Tags (zusaetzlich zur Episode-Verbindung) + if (hasThemes && Array.isArray(q.themes)) { + q.themes.forEach(tid => { + if (DATA.themes.find(t => t.id === tid)) { + links.push({ source: q.id, target: tid, type: 'quote-theme' }); + } + }); + } + }); + } + + // #19: Episode ↔ Episode (semantische Aehnlichkeit) und Argument-Pair-Links + if (NETWORK) { + (NETWORK.similarity_links || []).forEach(s => { + // Nur intra-podcast und nur wenn beide Endpunkte als Episoden-Knoten existieren + if (s.target_podcast === CURRENT_PODCAST && episodeMap[s.source_episode] && episodeMap[s.target_episode]) { + links.push({ source: s.source_episode, target: s.target_episode, type: 'episode-similar', score: s.score }); + } + }); + (NETWORK.argument_links || []).forEach(a => { + if (a.target_podcast !== CURRENT_PODCAST) return; + if (!episodeMap[a.source_episode] || !episodeMap[a.target_episode]) return; + // Pro Pair: dominante Relation als eigene Linie (top-count entscheidet) + const counts = a.counts || {}; + const order = ['widerspricht', 'belegt', 'erweitert', 'relativiert']; + let dom = null; + for (const r of order) { + if (counts[r]) { dom = r; break; } + } + if (!dom) return; + links.push({ source: a.source_episode, target: a.target_episode, type: `arg-${dom}`, n: counts[dom] }); }); } @@ -2293,8 +2330,18 @@ function buildGraph() { .force('link', d3.forceLink(links).id(d => d.id).distance(d => { if (d.type === 'center-theme') return 160 * sc; if (d.type === 'theme-episode') return 100 * sc; + if (d.type === 'episode-similar') return 90 * sc; + if (d.type && d.type.startsWith('arg-')) return 110 * sc; + if (d.type === 'quote-theme') return 70 * sc; return 50 * sc; - }).strength(d => d.type === 'center-theme' ? 0.8 : d.type === 'theme-episode' ? 0.3 : 0.2)) + }).strength(d => { + if (d.type === 'center-theme') return 0.8; + if (d.type === 'theme-episode') return 0.3; + if (d.type === 'episode-similar') return 0.05; + if (d.type && d.type.startsWith('arg-')) return 0.08; + if (d.type === 'quote-theme') return 0.05; + return 0.2; + })) .force('charge', d3.forceManyBody().strength(d => { if (d.type === 'center') return -800 * sc; if (d.type === 'theme') return -400 * sc; @@ -2310,11 +2357,28 @@ function buildGraph() { const g = svg.append('g'); + // #19: Link-Style je Typ + const LINK_STYLE = { + 'episode-similar': { stroke: '#7dd3fc', dash: '4,4', width: 1.0, opacity: 0.55 }, + 'arg-belegt': { stroke: '#86efac', dash: null, width: 1.6, opacity: 0.85 }, + 'arg-widerspricht': { stroke: '#f87171', dash: null, width: 1.8, opacity: 0.9 }, + 'arg-erweitert': { stroke: '#60a5fa', dash: null, width: 1.4, opacity: 0.75 }, + 'arg-relativiert': { stroke: '#9ca3af', dash: '2,3', width: 1.0, opacity: 0.6 }, + 'quote-theme': { stroke: '#a3a3a3', dash: '1,3', width: 0.6, opacity: 0.35 }, + }; + const linkEls = g.append('g').selectAll('line').data(links).join('line') .attr('class', d => `link link-${d.type}`) .attr('stroke', d => { + if (LINK_STYLE[d.type]) return LINK_STYLE[d.type].stroke; const src = nodes.find(n => n.id === (typeof d.source === 'object' ? d.source.id : d.source)); return src ? src.color : '#374151'; + }) + .attr('stroke-dasharray', d => LINK_STYLE[d.type]?.dash || null) + .attr('stroke-width', d => LINK_STYLE[d.type]?.width ?? 1) + .attr('opacity', d => { + if (LINK_STYLE[d.type] && LINK_VISIBILITY[d.type] === false) return 0; + return LINK_STYLE[d.type]?.opacity ?? 1; }); const nodeG = g.append('g'); @@ -2353,6 +2417,50 @@ function buildGraph() { window._nodes = nodes; window._quoteNodes = quoteNodes; window._epNodes = epNodes; window._themeNodes = themeNodes; window._linkEls = linkEls; + window._linkStyle = LINK_STYLE; + + // #19: Toggle-Panel rechts oben in der Mindmap + renderLinkToggles(); +} + +function renderLinkToggles() { + const mm = document.getElementById('mindmap'); + let panel = document.getElementById('link-toggles'); + if (panel) panel.remove(); + panel = document.createElement('div'); + panel.id = 'link-toggles'; + panel.style.cssText = 'position:absolute;top:8px;right:8px;background:rgba(20,24,32,0.85);border:1px solid var(--border);border-radius:6px;padding:6px 8px;font-size:11px;display:flex;flex-direction:column;gap:3px;z-index:5;backdrop-filter:blur(4px)'; + const labels = { + 'episode-similar': 'aehnliche Episoden', + 'arg-belegt': 'belegt', + 'arg-widerspricht': 'widerspricht', + 'arg-erweitert': 'erweitert', + 'arg-relativiert': 'relativiert', + 'quote-theme': 'Quote-Theme', + }; + panel.innerHTML = `