#16 Claim-Match-Anzeige im Frontend (Stufe 2 Vorschau)

Backend:
- /api/podcasts/{id}/episodes/{ep}/claims liefert nun pro Claim match_counts
  (belegt/widerspricht/erweitert je Anzahl) und best_match (target_podcast,
  target_episode, target_idx, relation, reason, score).

Frontend (AnalysisView claims-Mode):
- Match-Badges in passender Farbe (gruen=belegt, rot=widerspricht, blau=erweitert)
  zeigen die Anzahl Bestaetigungen je Relation.
- Best-Match-Link unter dem Claim mit Pfeil (-> bei same-podcast, klickbar zu
  jumpAnswer; ↗ bei cross-podcast, nur als Hinweis sichtbar). Reason wird
  inline gekuerzt angezeigt.

Robustheit: alles greift nur wenn die claim_matches-Tabelle befuellt ist.
Solange match_claims.py noch nicht gelaufen ist, bleibt das UI unveraendert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-28 08:00:31 +02:00
parent c5489eabaa
commit b8c808cd87
2 changed files with 59 additions and 2 deletions

View File

@ -122,7 +122,7 @@ def _table_exists(db, name: str) -> bool:
@app.get("/api/podcasts/{podcast_id}/episodes/{episode_id}/claims")
def get_episode_claims(podcast_id: str, episode_id: str, claim_type: Optional[str] = None):
"""Claims (Behauptungen) für eine Episode."""
"""Claims (Behauptungen) für eine Episode, mit Match-Anzahlen je Relation (#16 Stufe 2)."""
db = get_db()
if not _table_exists(db, "claims"):
db.close()
@ -135,8 +135,36 @@ def get_episode_claims(podcast_id: str, episode_id: str, claim_type: Optional[st
params.append(claim_type)
sql += " ORDER BY paragraph_idx, id"
rows = db.execute(sql, params).fetchall()
claims_list = [dict(r) for r in rows]
# Match-Counts und besten Match je claim_id anhaengen, falls Tabelle existiert
if claims_list and _table_exists(db, "claim_matches"):
ids = [c["id"] for c in claims_list]
placeholder = ",".join("?" * len(ids))
match_rows = db.execute(
f"SELECT claim_id, relation, COUNT(*) c FROM claim_matches "
f"WHERE claim_id IN ({placeholder}) GROUP BY claim_id, relation",
ids,
).fetchall()
counts = {}
for r in match_rows:
counts.setdefault(r["claim_id"], {})[r["relation"]] = r["c"]
# bester Match je claim (fuer Quick-Link)
best_rows = db.execute(
f"SELECT cm.claim_id, cm.relation, cm.target_podcast, cm.target_episode, "
f"cm.target_idx, cm.reason, cm.score "
f"FROM claim_matches cm "
f"WHERE cm.claim_id IN ({placeholder}) "
f"AND cm.id IN (SELECT MIN(id) FROM claim_matches WHERE claim_id IN ({placeholder}) "
f"GROUP BY claim_id) ",
ids + ids,
).fetchall()
best = {r["claim_id"]: dict(r) for r in best_rows}
for c in claims_list:
c["match_counts"] = counts.get(c["id"], {})
c["best_match"] = best.get(c["id"])
db.close()
return {"available": True, "claims": [dict(r) for r in rows]}
return {"available": True, "claims": claims_list}
@app.get("/api/podcasts/{podcast_id}/episodes/{episode_id}/questions")

View File

@ -822,6 +822,15 @@ const AnalysisView = {
if (this.mode === 'claims' && it.verifiable) {
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;opacity:0.7">verifizierbar</span>`;
}
// Claim-Match-Badges (#16 Stufe 2)
if (this.mode === 'claims' && it.match_counts) {
const RC = {belegt: '#86efac', widerspricht: '#f87171', erweitert: '#60a5fa'};
for (const [rel, cnt] of Object.entries(it.match_counts)) {
if (cnt > 0 && RC[rel]) {
badges += `<span class="theme-tag" style="font-size:10px;margin-right:4px;color:${RC[rel]};border-color:${RC[rel]}44">${rel} ${cnt}</span>`;
}
}
}
let answerLink = '';
if (this.mode === 'questions') {
const a = it.answered;
@ -844,11 +853,31 @@ const AnalysisView = {
}
}
}
// Best-Match-Link fuer Claims
let matchLink = '';
if (this.mode === 'claims' && it.best_match) {
const m = it.best_match;
const RC = {belegt: '#86efac', widerspricht: '#f87171', erweitert: '#60a5fa'};
const col = RC[m.relation] || 'var(--text-muted)';
const samePodcast = !m.target_podcast || m.target_podcast === CURRENT_PODCAST;
const arrow = samePodcast ? '→' : '↗';
const target = samePodcast ? escHtml(m.target_episode) : `${escHtml(m.target_podcast)} / ${escHtml(m.target_episode)}`;
if (samePodcast) {
matchLink = `<div style="margin-top:4px;font-size:12px;color:${col}" onclick="event.stopPropagation(); AnalysisView.jumpAnswer('${m.target_episode}', ${m.target_idx})">
${arrow} ${escHtml(m.relation)}: ${target}@p${m.target_idx}${m.reason ? ' — ' + escHtml(m.reason).slice(0, 80) : ''}
</div>`;
} else {
matchLink = `<div style="margin-top:4px;font-size:12px;color:${col};opacity:0.85">
${arrow} ${escHtml(m.relation)} in ${target}@p${m.target_idx}${m.reason ? ' — ' + escHtml(m.reason).slice(0, 80) : ''}
</div>`;
}
}
html += `<div class="transcript-para" onclick="AnalysisView.jumpTo(${it.start_time || 0})">`;
html += `<span class="ts">${ts}</span>`;
html += badges;
html += escHtml(text);
html += answerLink;
html += matchLink;
html += '</div>';
});
panel.innerHTML = html;