From e678f75ee1eea19d024234c0e1314ed298fee015 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Thu, 23 Apr 2026 20:53:06 +0200 Subject: [PATCH] #8 Multi-Podcast-Dashboard, #9 PWA, #10 Cross-Podcast-Links, #12 Wort-Timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: /api/compare Endpoint für Podcast-Vergleich (Stats, gemeinsame Topics, Top-Querverbindungen), /api/.../words Endpoint für Wort-Timestamps - Frontend: Podcast-Vergleichsansicht mit Statistiken und Cross-Links, Cross-Podcast-Suche-Toggle, semantische Links im Transkript (lazy-loaded), Podcast-Switcher mit Zurück-Navigation - PWA: manifest.json, Service Worker (stale-while-revalidate für Assets, network-first für API, cache-on-success für Audio), Icons - Scripts: transcribe_words.py (mlx-whisper Batch-Transkription mit Wort-Timestamps), import_words.py (Wort-Timestamps in DB importieren) - Dockerfile: PWA-Assets in Container kopieren Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile | 2 + backend/app.py | 98 +++++++++++ scripts/import_words.py | 95 ++++++++++ scripts/transcribe_words.py | 138 +++++++++++++++ webapp/icon-192.png | Bin 0 -> 2395 bytes webapp/icon-512.png | Bin 0 -> 8007 bytes webapp/index.html | 339 +++++++++++++++++++++++++++++++++--- webapp/manifest.json | 22 +++ webapp/sw.js | 77 ++++++++ 9 files changed, 751 insertions(+), 20 deletions(-) create mode 100644 scripts/import_words.py create mode 100644 scripts/transcribe_words.py create mode 100644 webapp/icon-192.png create mode 100644 webapp/icon-512.png create mode 100644 webapp/manifest.json create mode 100644 webapp/sw.js diff --git a/Dockerfile b/Dockerfile index 8c67c2b..f8edbe4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,8 @@ COPY backend/ . # Copy webapp as static files COPY webapp/index.html webapp/d3.v7.min.js /static/ +COPY webapp/manifest.json webapp/sw.js /static/ +COPY webapp/icon-192.png webapp/icon-512.png /static/ EXPOSE 8000 diff --git a/backend/app.py b/backend/app.py index 3150dbf..c74f16b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -88,6 +88,32 @@ def get_transcript(podcast_id: str, episode_id: str): return {"paragraphs": [{"start": p["start_time"], "end": p["end_time"], "text": p["text"]} for p in paras]} +@app.get("/api/podcasts/{podcast_id}/transcript/{episode_id}/words") +def get_words(podcast_id: str, episode_id: str): + """Get word-level timestamps for an episode.""" + db = get_db() + # Check if words table exists + try: + words = db.execute( + "SELECT segment_idx, word_idx, word, start_time, end_time FROM words " + "WHERE podcast_id = ? AND episode_id = ? ORDER BY segment_idx, word_idx", + (podcast_id, episode_id) + ).fetchall() + except Exception: + db.close() + return {"words": [], "available": False} + db.close() + + if not words: + return {"words": [], "available": False} + + return { + "available": True, + "words": [{"seg": w["segment_idx"], "idx": w["word_idx"], + "word": w["word"], "start": w["start_time"], "end": w["end_time"]} for w in words] + } + + @app.get("/api/search") def search(q: str = Query(..., min_length=2), podcast_id: Optional[str] = None, limit: int = 50): """Full-text search across all transcripts.""" @@ -213,6 +239,78 @@ def get_precomputed_similar(podcast_id: str, episode_id: str, para_idx: int, lim } for r in rows] +@app.get("/api/compare") +def compare_podcasts(a: str = Query(...), b: str = Query(...)): + """Compare two podcasts: shared topics, stats, cross-links.""" + db = get_db() + + # Basic stats + stats = {} + for pid in (a, b): + podcast = db.execute("SELECT * FROM podcasts WHERE id = ?", (pid,)).fetchone() + if not podcast: + raise HTTPException(404, f"Podcast '{pid}' not found") + ep_count = db.execute("SELECT COUNT(*) as c FROM episodes WHERE podcast_id = ?", (pid,)).fetchone()["c"] + q_count = db.execute("SELECT COUNT(*) as c FROM quotes WHERE podcast_id = ?", (pid,)).fetchone()["c"] + p_count = db.execute("SELECT COUNT(*) as c FROM paragraphs WHERE podcast_id = ?", (pid,)).fetchone()["c"] + stats[pid] = {"name": podcast["name"], "episodes": ep_count, "quotes": q_count, "paragraphs": p_count} + + # Shared topics via topic tags + topics_a = db.execute( + "SELECT DISTINCT t.tag FROM topics t JOIN paragraphs p ON t.paragraph_id = p.id WHERE p.podcast_id = ?", (a,) + ).fetchall() + topics_b = db.execute( + "SELECT DISTINCT t.tag FROM topics t JOIN paragraphs p ON t.paragraph_id = p.id WHERE p.podcast_id = ?", (b,) + ).fetchall() + + set_a = {r["tag"] for r in topics_a} + set_b = {r["tag"] for r in topics_b} + shared = sorted(set_a & set_b) + only_a = sorted(set_a - set_b) + only_b = sorted(set_b - set_a) + + # Cross-podcast semantic links count + cross_links = 0 + top_links = [] + try: + cross_links = db.execute( + "SELECT COUNT(*) as c FROM semantic_links WHERE " + "(podcast_id = ? AND target_podcast = ?) OR (podcast_id = ? AND target_podcast = ?)", + (a, b, b, a) + ).fetchone()["c"] + + top_links = db.execute( + "SELECT sl.*, p1.text as source_text, p2.text as target_text, " + "e1.title as source_title, e2.title as target_title " + "FROM semantic_links sl " + "JOIN paragraphs p1 ON sl.podcast_id = p1.podcast_id AND sl.source_episode = p1.episode_id AND sl.source_idx = p1.idx " + "JOIN paragraphs p2 ON sl.target_podcast = p2.podcast_id AND sl.target_episode = p2.episode_id AND sl.target_idx = p2.idx " + "JOIN episodes e1 ON sl.podcast_id = e1.podcast_id AND sl.source_episode = e1.id " + "JOIN episodes e2 ON sl.target_podcast = e2.podcast_id AND sl.target_episode = e2.id " + "WHERE (sl.podcast_id = ? AND sl.target_podcast = ?) OR (sl.podcast_id = ? AND sl.target_podcast = ?) " + "ORDER BY sl.score DESC LIMIT 20", + (a, b, b, a) + ).fetchall() + except Exception: + pass # semantic_links table may not exist yet + + db.close() + + return { + "stats": stats, + "shared_topics": shared, + "only_in": {a: only_a, b: only_b}, + "cross_links_count": cross_links, + "top_cross_links": [{ + "source_podcast": r["podcast_id"], "source_episode": r["source_episode"], + "source_text": r["source_text"][:150], "source_title": r["source_title"], + "target_podcast": r["target_podcast"], "target_episode": r["target_episode"], + "target_text": r["target_text"][:150], "target_title": r["target_title"], + "score": r["score"] + } for r in top_links] + } + + @app.get("/api/semantic-search") def semantic_search(q: str = Query(..., min_length=3), podcast_id: Optional[str] = None, limit: int = 20): """Semantic search using query embedding.""" diff --git a/scripts/import_words.py b/scripts/import_words.py new file mode 100644 index 0000000..df4f7ce --- /dev/null +++ b/scripts/import_words.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Importiert Wort-Level-Timestamps in die SQLite-Datenbank. + +Liest *.words.json-Dateien und schreibt in die Tabelle `words`. + +Nutzung: + python3 import_words.py [db-pfad] + +Beispiel: + python3 import_words.py neu-denken ../data/neu-denken/words/ ../data/db.sqlite +""" + +import json +import os +import sys +import sqlite3 +from pathlib import Path + + +def init_words_table(db): + """Erstelle words-Tabelle falls nicht vorhanden.""" + db.executescript(""" + CREATE TABLE IF NOT EXISTS words ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + podcast_id TEXT NOT NULL, + episode_id TEXT NOT NULL, + segment_idx INTEGER NOT NULL, + word_idx INTEGER NOT NULL, + word TEXT NOT NULL, + start_time REAL NOT NULL, + end_time REAL NOT NULL, + UNIQUE(podcast_id, episode_id, segment_idx, word_idx) + ); + CREATE INDEX IF NOT EXISTS idx_words_episode ON words(podcast_id, episode_id); + CREATE INDEX IF NOT EXISTS idx_words_time ON words(podcast_id, episode_id, start_time); + """) + + +def import_words_file(db, podcast_id: str, words_file: Path): + """Importiere eine *.words.json-Datei.""" + data = json.loads(words_file.read_text()) + episode_name = data["episode"] + + # Episode-ID aus Dateinamen: S1E1-Wachstum → S1E1 + episode_id = episode_name.split("-")[0] + + # Alte Einträge löschen + db.execute("DELETE FROM words WHERE podcast_id = ? AND episode_id = ?", (podcast_id, episode_id)) + + count = 0 + for seg_idx, segment in enumerate(data.get("segments", [])): + for word_idx, w in enumerate(segment.get("words", [])): + db.execute( + "INSERT INTO words (podcast_id, episode_id, segment_idx, word_idx, word, start_time, end_time) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (podcast_id, episode_id, seg_idx, word_idx, w["word"], w["start"], w["end"]) + ) + count += 1 + + return count + + +def main(): + if len(sys.argv) < 3: + print(f"Nutzung: {sys.argv[0]} [db-pfad]") + sys.exit(1) + + podcast_id = sys.argv[1] + words_dir = Path(sys.argv[2]) + db_path = sys.argv[3] if len(sys.argv) > 3 else os.environ.get("DB_PATH", "data/db.sqlite") + + db = sqlite3.connect(db_path) + init_words_table(db) + + files = sorted(words_dir.glob("*.words.json")) + if not files: + print(f"Keine *.words.json-Dateien in {words_dir} gefunden.") + sys.exit(1) + + print(f"Importiere {len(files)} Dateien für Podcast '{podcast_id}'") + + total_words = 0 + for f in files: + count = import_words_file(db, podcast_id, f) + print(f" {f.stem}: {count} Wörter") + total_words += count + + db.commit() + db.close() + + print(f"Fertig: {total_words} Wörter importiert.") + + +if __name__ == "__main__": + main() diff --git a/scripts/transcribe_words.py b/scripts/transcribe_words.py new file mode 100644 index 0000000..197d35e --- /dev/null +++ b/scripts/transcribe_words.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Batch-Transkription mit wortgenauen Timestamps via mlx-whisper. + +Erzeugt pro Episode eine JSON-Datei mit Wort-Level-Timing. +Läuft auf Apple Silicon (mlx-metal). + +Nutzung: + python3 transcribe_words.py /pfad/zu/audio/ /pfad/zu/output/ + python3 transcribe_words.py /pfad/zu/audio/S1E1-Wachstum.m4a # einzelne Datei + +Modell: whisper-large-v3-turbo (schnell + genau, ~1.5 GB VRAM) +""" + +import json +import os +import sys +import time +from pathlib import Path + +# ── Config ── +MODEL = "mlx-community/whisper-large-v3-turbo" +LANGUAGE = "de" +AUDIO_EXTENSIONS = {".m4a", ".mp3", ".wav", ".flac", ".ogg", ".opus"} + + +def transcribe_episode(audio_path: str, output_dir: str) -> dict: + """Transkribiere eine Episode mit Wort-Timestamps.""" + import mlx_whisper + + name = Path(audio_path).stem + output_file = Path(output_dir) / f"{name}.words.json" + + # Skip wenn bereits vorhanden + if output_file.exists(): + print(f" ⏭ {name} — bereits vorhanden, überspringe") + return json.loads(output_file.read_text()) + + print(f" ▶ {name} — transkribiere…") + t0 = time.time() + + result = mlx_whisper.transcribe( + audio_path, + path_or_hf_repo=MODEL, + language=LANGUAGE, + word_timestamps=True, + verbose=False, + condition_on_previous_text=True, + initial_prompt="NEU DENKEN Podcast mit Maja Göpel. Themen: Wirtschaft, Demokratie, Sicherheit, Freiheit.", + ) + + elapsed = time.time() - t0 + + # Extrahiere Wörter aus Segmenten + words = [] + for segment in result.get("segments", []): + for w in segment.get("words", []): + words.append({ + "word": w["word"].strip(), + "start": round(w["start"], 3), + "end": round(w["end"], 3), + }) + + # Auch Segment-Level behalten (für Absatz-Mapping) + segments = [] + for seg in result.get("segments", []): + segments.append({ + "start": round(seg["start"], 3), + "end": round(seg["end"], 3), + "text": seg["text"].strip(), + "words": [{ + "word": w["word"].strip(), + "start": round(w["start"], 3), + "end": round(w["end"], 3), + } for w in seg.get("words", [])], + }) + + output = { + "episode": name, + "model": MODEL, + "language": LANGUAGE, + "duration_seconds": round(elapsed, 1), + "word_count": len(words), + "segment_count": len(segments), + "segments": segments, + } + + output_file.write_text(json.dumps(output, ensure_ascii=False, indent=2)) + print(f" ✓ {name} — {len(words)} Wörter, {len(segments)} Segmente, {elapsed:.0f}s") + return output + + +def main(): + if len(sys.argv) < 2: + print(f"Nutzung: {sys.argv[0]} [output-verzeichnis]") + sys.exit(1) + + input_path = Path(sys.argv[1]) + output_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else input_path if input_path.is_dir() else input_path.parent + + output_dir.mkdir(parents=True, exist_ok=True) + + # Einzelne Datei oder Verzeichnis? + if input_path.is_file(): + files = [input_path] + elif input_path.is_dir(): + files = sorted([f for f in input_path.iterdir() if f.suffix.lower() in AUDIO_EXTENSIONS]) + else: + print(f"Fehler: {input_path} existiert nicht.") + sys.exit(1) + + if not files: + print("Keine Audio-Dateien gefunden.") + sys.exit(1) + + print(f"Transkribiere {len(files)} Dateien → {output_dir}/") + print(f"Modell: {MODEL}") + print() + + total_t0 = time.time() + results = [] + + for i, f in enumerate(files, 1): + print(f"[{i}/{len(files)}] {f.name}") + try: + result = transcribe_episode(str(f), str(output_dir)) + results.append(result) + except Exception as e: + print(f" ✗ FEHLER: {e}") + + total_elapsed = time.time() - total_t0 + total_words = sum(r.get("word_count", 0) for r in results) + + print() + print(f"Fertig: {len(results)}/{len(files)} Episoden, {total_words} Wörter, {total_elapsed:.0f}s gesamt") + + +if __name__ == "__main__": + main() diff --git a/webapp/icon-192.png b/webapp/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b959067ce4671931a28dfd3550cbd7818ae922a1 GIT binary patch literal 2395 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 + + + + Podcast Mindmap