#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:
parent
a5a0bcc260
commit
3243c23adb
@ -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()
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user