From b8c808cd874c7e0f79a7efae74b25f74ec52ea18 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Tue, 28 Apr 2026 08:00:31 +0200 Subject: [PATCH] #16 Claim-Match-Anzeige im Frontend (Stufe 2 Vorschau) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app.py | 32 ++++++++++++++++++++++++++++++-- webapp/index.html | 29 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/backend/app.py b/backend/app.py index 448f2da..fd0d4b5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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") diff --git a/webapp/index.html b/webapp/index.html index c9d428c..a817bcb 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -822,6 +822,15 @@ const AnalysisView = { if (this.mode === 'claims' && it.verifiable) { badges += `verifizierbar`; } + // 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 += `${rel} ${cnt}`; + } + } + } 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 = `
+ ${arrow} ${escHtml(m.relation)}: ${target}@p${m.target_idx}${m.reason ? ' — ' + escHtml(m.reason).slice(0, 80) : ''} +
`; + } else { + matchLink = `
+ ${arrow} ${escHtml(m.relation)} in ${target}@p${m.target_idx}${m.reason ? ' — ' + escHtml(m.reason).slice(0, 80) : ''} +
`; + } + } html += `
`; html += `${ts}`; html += badges; html += escHtml(text); html += answerLink; + html += matchLink; html += '
'; }); panel.innerHTML = html;