#19 Mindmap zur Mindmap: Querverbindungen sichtbar

Backend:
- /api/podcasts/{id}/network: liefert Top-N Episode-Episode-Aehnlichkeitslinks
  (aus semantic_links, default top 2 je Source ab Score 0.65) und aggregiert
  argument_links pro Episode-Paar inkl. Relations-Counts.

Frontend (buildGraph + neue Helfer):
- selectPodcast laedt /network parallel zum podcast-Endpoint und legt es als
  globales NETWORK ab.
- Drei neue Link-Klassen mit eigenem Style:
  * episode-similar (gestrichelt hellblau): Aehnlichkeit zwischen Episoden.
  * arg-belegt / -widerspricht / -erweitert / -relativiert (gruen / rot / blau /
    grau gestrichelt): dominante Relation je Episode-Paar.
  * quote-theme (sehr duenn gestrichelt): Quotes verbunden zu allen Theme-Tags
    aus ihrem themes-Array, nicht nur zur eigenen Episode.
- Force-Simulation: niedrige Strength fuer die neuen Links, damit die
  Hierarchie-Anordnung stabil bleibt.
- Toggle-Panel rechts oben in der Mindmap mit Checkboxen je Verbindungstyp;
  Quote-Theme und arg-relativiert sind default aus, Rest an.

Damit sind Aehnlichkeit, Argumentbeziehungen und Quote-Theme-Vernetzung direkt
in der Mindmap sichtbar, statt nur ueber separate Listen-Views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-28 18:09:33 +02:00
parent a5a0bcc260
commit 3243c23adb
2 changed files with 177 additions and 2 deletions

View File

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

View File

@ -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 = `<div style="font-weight:600;color:var(--text-muted);margin-bottom:2px">Verbindungen</div>`;
Object.keys(labels).forEach(t => {
const style = window._linkStyle?.[t];
const checked = LINK_VISIBILITY[t] !== false;
panel.innerHTML += `<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" data-link="${t}" ${checked ? 'checked' : ''} style="margin:0">
<span style="display:inline-block;width:18px;height:2px;background:${style?.stroke || '#888'};${style?.dash ? 'background:repeating-linear-gradient(90deg,' + style.stroke + ' 0,' + style.stroke + ' 2px,transparent 2px,transparent 5px)' : ''}"></span>
<span>${labels[t]}</span>
</label>`;
});
mm.style.position = 'relative';
mm.appendChild(panel);
panel.querySelectorAll('input').forEach(cb => {
cb.addEventListener('change', () => {
LINK_VISIBILITY[cb.dataset.link] = cb.checked;
if (window._linkEls) {
window._linkEls.attr('opacity', d => {
if (window._linkStyle[d.type] && LINK_VISIBILITY[d.type] === false) return 0;
return window._linkStyle[d.type]?.opacity ?? 1;
});
}
});
});
}
function updateVisibility() {