Files
Frank Schwenk 3f1da92cf4 feat: add Goldfish Recap YouTube summary PWA
YouTube subtitles via OpenRouter become persistent, shareable recaps
with timestamp jump links, PIN-protected creation, and Traefik deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:49:24 +02:00

182 lines
6.2 KiB
Python

from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Literal
import httpx
from pydantic import BaseModel, Field, ValidationError, field_validator
from server.settings import settings
from server.transcripts import Transcript, format_transcript_for_prompt, transcript_hash
logger = logging.getLogger(__name__)
RECAP_SYSTEM = (
"Du bist der Goldfish Recap Bot — trocken-witzig, ADHD-aware, nie herablassend. "
"Du fasst YouTube-Videos für Leute zusammen, die nebenbei gespielt haben oder das Video "
"nicht schauen wollen. Antworte NUR mit validem JSON, ohne Markdown oder Erklärungen."
)
WatchVerdict = Literal["watch", "skim", "skip"]
class RecapSection(BaseModel):
title: str = Field(min_length=1, max_length=120)
summary: str = Field(min_length=1, max_length=500)
timestamp_seconds: int = Field(ge=0)
emoji: str = Field(default="🐟", max_length=8)
class RecapPayload(BaseModel):
tldr: str = Field(min_length=1, max_length=400)
vibe_check: str = Field(min_length=1, max_length=200)
watch_verdict: WatchVerdict
goldfish_note: str = Field(min_length=1, max_length=300)
sections: list[RecapSection] = Field(min_length=2, max_length=12)
@field_validator("sections")
@classmethod
def sort_sections(cls, sections: list[RecapSection]) -> list[RecapSection]:
return sorted(sections, key=lambda s: s.timestamp_seconds)
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def _extract_json(text: str) -> dict[str, Any]:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
return json.loads(cleaned)
async def _call_openrouter(prompt: str, *, system: str = RECAP_SYSTEM) -> str | None:
if not settings.openrouter_api_key:
raise RuntimeError("OPENROUTER_API_KEY nicht gesetzt.")
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"HTTP-Referer": settings.app_url,
"X-Title": "Goldfish Recap",
},
json={
"model": settings.openrouter_model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"max_tokens": 4096,
"response_format": {"type": "json_object"},
},
)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
logger.error("OpenRouter error: %s", data["error"])
return None
choices = data.get("choices") or []
if not choices:
return None
content = (choices[0].get("message") or {}).get("content")
return str(content).strip() if content else None
def _build_prompt(
*,
title: str,
channel: str,
duration_sec: int | None,
language: str,
transcript_text: str,
) -> str:
duration_hint = f"{duration_sec // 60} Minuten" if duration_sec else "unbekannt"
return (
f"Video: {title}\n"
f"Kanal: {channel or 'unbekannt'}\n"
f"Dauer: ca. {duration_hint}\n"
f"Untertitel-Sprache: {language}\n\n"
"Erstelle ein Recap als JSON mit exakt diesen Feldern:\n"
"{\n"
' "tldr": "1-2 Sätze, das Wichtigste",\n'
' "vibe_check": "1 Satz Stimmung, z.B. Second-Screen-Energie 7/10",\n'
' "watch_verdict": "watch|skim|skip",\n'
' "goldfish_note": "witziger Abschluss für Goldfisch-Gedächtnis",\n'
' "sections": [\n'
" {\n"
' "title": "kurzer Abschnittstitel",\n'
' "summary": "2-3 Sätze",\n'
' "timestamp_seconds": 142,\n'
' "emoji": "🐟"\n'
" }\n"
" ]\n"
"}\n\n"
"Regeln:\n"
"- 4-8 sections, timestamp_seconds MÜSSEN aus dem Transcript stammen (keine erfundenen Zeiten)\n"
"- watch = lohnt sich ganz anzusehen, skim = nur Sprungmarken, skip = TL;DR reicht\n"
"- Deutsch, witzig aber informativ\n\n"
f"Transcript:\n{transcript_text}"
)
async def generate_recap(
*,
metadata: dict[str, Any],
transcript: Transcript,
duration_sec: int | None,
) -> dict[str, Any]:
transcript_text = format_transcript_for_prompt(transcript.segments)
prompt = _build_prompt(
title=metadata["title"],
channel=metadata.get("channel") or "",
duration_sec=duration_sec,
language=transcript.language,
transcript_text=transcript_text,
)
raw = await _call_openrouter(prompt)
if not raw:
raise RuntimeError("OpenRouter hat keine Antwort geliefert. Später nochmal versuchen.")
try:
parsed = _extract_json(raw)
payload = RecapPayload.model_validate(parsed)
except (json.JSONDecodeError, ValidationError) as exc:
logger.warning("Invalid recap JSON, retrying: %s", exc)
fix_prompt = (
f"Fixiere dieses JSON und gib NUR valides JSON zurück:\n{raw}\n\n"
f"Fehler: {exc}"
)
fixed = await _call_openrouter(fix_prompt)
if not fixed:
raise RuntimeError("Recap konnte nicht generiert werden.") from exc
payload = RecapPayload.model_validate(_extract_json(fixed))
now = utc_now_iso()
return {
"video_id": metadata["video_id"],
"title": metadata["title"],
"channel": metadata.get("channel"),
"thumbnail_url": metadata.get("thumbnail_url"),
"duration_sec": duration_sec,
"language": transcript.language,
"tldr": payload.tldr,
"vibe_check": payload.vibe_check,
"watch_verdict": payload.watch_verdict,
"goldfish_note": payload.goldfish_note,
"sections": [s.model_dump() for s in payload.sections],
"transcript_hash": transcript_hash(transcript.segments),
"model": settings.openrouter_model,
"created_at": now,
"updated_at": now,
}