import json import logging import re from typing import Any import httpx from . import profiles from .config import settings log = logging.getLogger("mac-worker.ollama") 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 def render(template: str, **vars: str) -> str: """Ersetzt ausschließlich die übergebenen `{name}`-Platzhalter. Bewusst KEIN str.format() — sonst würden literale `{...}` im Template (z. B. JSON-Schema-Beispiele wie `{"owner", "task"}` in den Profil-Prompts) als Platzhalter interpretiert und einen KeyError werfen. """ out = template for k, v in vars.items(): out = out.replace("{" + k + "}", str(v)) return out async def _chat(prompt: str) -> str: """Ruft Ollama im Streaming-Modus auf. Streaming hat zwei wichtige Vorteile: 1. Read-Timeout gilt pro Chunk statt für die gesamte Antwort — lange Generierungen laufen nicht mehr in den Gesamt-Timeout. 2. Wir können in den Logs sehen, dass das Modell tatsächlich arbeitet (Anzahl Chunks). `keep_alive`: das Modell bleibt 30 min im RAM zwischen Calls, damit nachfolgende Anfragen nicht erneut die Ladezeit zahlen. """ url = f"{settings.ollama_url.rstrip('/')}/api/generate" payload = { "model": settings.ollama_model, "prompt": prompt, "stream": True, "keep_alive": "30m", "options": {"temperature": 0.2}, } log.info("Ollama generate model=%s prompt_len=%d (streaming)", settings.ollama_model, len(prompt)) timeout = httpx.Timeout( connect=15.0, read=settings.ollama_timeout, # pro Chunk — großzügig wegen initial Modell-Load write=30.0, pool=10.0, ) chunks: list[str] = [] chunk_count = 0 async with httpx.AsyncClient(timeout=timeout) as c: async with c.stream("POST", url, json=payload) as r: r.raise_for_status() async for line in r.aiter_lines(): if not line.strip(): continue try: obj = json.loads(line) except json.JSONDecodeError: log.warning("Ollama: ungültige JSON-Zeile übersprungen: %r", line[:100]) continue chunks.append(obj.get("response", "")) chunk_count += 1 if obj.get("done"): break log.info("Ollama: %d Chunks empfangen, Gesamtlänge %d Zeichen", chunk_count, sum(map(len, chunks))) return "".join(chunks) async def summarize(transcript: str, title: str = "", profile_name: str | None = None) -> dict[str, Any]: profile = profiles.get(profile_name) prompt = render( profile.summary_prompt, title=title or "(ohne Titel)", transcript=_truncate(transcript), ) log.info("Summarize profile=%s", profile.name) raw = await _chat(prompt) try: return _parse_json(raw) except Exception: # noqa: BLE001 log.warning("Failed to parse summary JSON — gebe Roh-Antwort als _raw zurück") return {"_raw": raw.strip(), "_parse_error": True} async def make_protocol( transcript: str, summary: dict, title: str = "", profile_name: str | None = None ) -> dict: profile = profiles.get(profile_name) prompt = render( profile.protocol_prompt, title=title or "(ohne Titel)", summary_json=json.dumps(summary, ensure_ascii=False), transcript=_truncate(transcript), ) log.info("Protocol profile=%s", profile.name) raw = await _chat(prompt) try: return _parse_json(raw) except Exception: # noqa: BLE001 log.warning("Failed to parse protocol JSON — Fallback auf Summary") fallback = dict(summary) if isinstance(summary, dict) else {} fallback.setdefault("title", title) return fallback 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()