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, }