voice-agent/mac-worker/app/core/ollama_client.py
root 3a9881d36f feat: Profile-System für unterschiedliche Use-Cases (Sitzungsprotokoll, später Arzt-Diktat etc.)
Mac-Worker:
- profiles/meeting.yaml — extrahiert die bisherigen Prompts + DOCX-Layout
- core/profiles.py — YAML-Loader mit Cache und Fallback
- ollama_client.summarize/make_protocol nehmen profile_name
- docx_export: generischer Renderer aus profile.docx (meta + sections mit
  Typ text/list/tasks/transcript)
- /api/profiles listet verfügbare Profile
- pyyaml als Dependency

LXC:
- Job.profile + User.default_profile (Migration: ALTER TABLE)
- /api/profiles proxy mit 60s-Cache und Fallback
- Upload-Form akzeptiert profile (Server-Default: user.default_profile)
- Pipeline gibt Profile bei summarize/protocol/docx an Mac weiter
- PATCH /api/auth/me — User kann Standard-Profil ändern

Frontend:
- Profile-Dropdown im Upload (nur sichtbar wenn ≥2 Profile)
- Settings-Karte "Mein Standard-Profil" (nur wenn ≥2 Profile)
- Job-Zeile zeigt grünes Profile-Tag (nur wenn relevant)

Neue Profile = neue YAML-Datei in mac-worker/profiles/ — kein Code-Deploy
nötig, der Mac-Worker liest sie beim Start ein.
2026-05-14 03:11:30 +00:00

99 lines
2.9 KiB
Python

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
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 = "", profile_name: str | None = None) -> dict[str, Any]:
profile = profiles.get(profile_name)
prompt = profile.summary_prompt.format(
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 = profile.protocol_prompt.format(
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()