import json import logging import re from typing import Any import httpx from .config import settings log = logging.getLogger("mac-worker.ollama") SUMMARY_PROMPT = """Du bist ein präziser Protokollassistent für deutsche Geschäftssitzungen. Analysiere das folgende Sitzungstranskript und liefere ein JSON-Objekt mit: - "topic": Hauptthema der Sitzung (kurz) - "summary": Zusammenfassung in 3-6 Sätzen - "decisions": Liste der Beschlüsse (jeweils ein Satz) - "tasks": Liste der Aufgaben, jede mit "owner" (Name oder "n/a") und "task" - "participants": Liste der erkennbaren Teilnehmer (Namen oder "Sprecher 1/2/...") - "open_questions": Liste offener Fragen (kann leer sein) Antworte AUSSCHLIESSLICH mit gültigem JSON, keine Erläuterungen, kein Markdown-Codeblock. Sitzungstitel: {title} Transkript: \"\"\" {transcript} \"\"\" """ PROTOCOL_PROMPT = """Erstelle aus den folgenden Daten ein formelles deutsches Sitzungsprotokoll als JSON-Objekt mit folgenden Feldern: - "title": Protokoll-Titel - "date": Datum (YYYY-MM-DD, falls aus Transkript ableitbar, sonst leer) - "topic": Thema - "participants": Liste der Teilnehmer - "summary": Zusammenfassung - "decisions": Liste der Beschlüsse - "tasks": Liste der Aufgaben (jeweils mit "owner" und "task") - "open_questions": Liste offener Fragen - "next_meeting": Wenn erwähnt, sonst leer - "transcript_excerpt": Liste von Objekten mit "time", "speaker", "text" (max. 20 Einträge, zeitlich verteilt aus dem Transkript ziehen) Antworte AUSSCHLIESSLICH mit JSON, kein Markdown. Titel: {title} Bestehende Zusammenfassung: {summary_json} Transkript: \"\"\" {transcript} \"\"\" """ def _strip_codefence(s: str) -> str: s = s.strip() if s.startswith("```"): s = re.sub(r"^```(?:json)?", "", s, count=1).strip() if s.endswith("```"): s = s[:-3].strip() return s def _parse_json(text: str) -> dict: text = _strip_codefence(text) try: return json.loads(text) except json.JSONDecodeError: m = re.search(r"\{.*\}", text, re.S) if m: return json.loads(m.group(0)) raise def _truncate(text: str, max_chars: int = 60000) -> str: if len(text) <= max_chars: return text head = text[: max_chars // 2] tail = text[-max_chars // 2 :] return head + "\n\n[... gekürzt ...]\n\n" + tail async def _chat(prompt: str) -> str: url = f"{settings.ollama_url.rstrip('/')}/api/generate" payload = { "model": settings.ollama_model, "prompt": prompt, "stream": False, "options": {"temperature": 0.2}, } log.info("Ollama generate model=%s prompt_len=%d", settings.ollama_model, len(prompt)) async with httpx.AsyncClient(timeout=settings.ollama_timeout) as c: r = await c.post(url, json=payload) r.raise_for_status() data = r.json() return data.get("response", "") async def summarize(transcript: str, title: str = "") -> dict[str, Any]: prompt = SUMMARY_PROMPT.format(title=title or "(ohne Titel)", transcript=_truncate(transcript)) raw = await _chat(prompt) try: return _parse_json(raw) except Exception: # noqa: BLE001 log.warning("Failed to parse summary JSON, falling back to text") return { "topic": title, "summary": raw.strip(), "decisions": [], "tasks": [], "participants": [], "open_questions": [], } async def make_protocol(transcript: str, summary: dict, title: str = "") -> dict: prompt = PROTOCOL_PROMPT.format( title=title or "(ohne Titel)", summary_json=json.dumps(summary, ensure_ascii=False), transcript=_truncate(transcript), ) raw = await _chat(prompt) try: return _parse_json(raw) except Exception: # noqa: BLE001 log.warning("Failed to parse protocol JSON, falling back to structured summary") return { "title": title, "date": "", "topic": summary.get("topic", title), "participants": summary.get("participants", []), "summary": summary.get("summary", ""), "decisions": summary.get("decisions", []), "tasks": summary.get("tasks", []), "open_questions": summary.get("open_questions", []), "next_meeting": "", "transcript_excerpt": [], } async def health() -> dict: url = f"{settings.ollama_url.rstrip('/')}/api/tags" async with httpx.AsyncClient(timeout=10) as c: r = await c.get(url) r.raise_for_status() return r.json()