root 031d52c77a fix(profiles): KeyError bei JSON-Beispielen in Prompt-Templates
Bug: 9 von 17 Profilen (phone-call, brainstorm, one-on-one, postmortem,
schichtuebergabe, pflegedoku, visite, biografie) zeigen in ihren Prompts
JSON-Schema-Beispiele wie {"owner", "task"}. Python's str.format() liest
diese geschweiften Klammern als Platzhalter und wirft KeyError →
HTTP 500 vom Mac-Worker → Job FAILED bei summarize/protocol.

Fix:
- Neuer render(template, **vars) Helper in ollama_client: ersetzt nur
  explizit benannte Platzhalter, lässt alles andere literal stehen.
- summarize/make_protocol nutzen diesen Helper statt .format()
- profiles.validate_all() warnt beim Worker-Start, wenn ein erwarteter
  Platzhalter fehlt — Defekte werden früh sichtbar
- Mac-Worker-Middleware fängt unhandled exceptions und liefert JSON-Body
  mit Type+Message+Traceback-Tail (statt generic "Internal Server Error")
- LXC MacClient liest diesen Error-Body und packt ihn in die HTTPStatusError-
  Message → landet im Job-error-Feld + Diagnose-Panel mit echter Wurzel-Info

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:55:55 +00:00

113 lines
3.5 KiB
Python

"""Lädt Verarbeitungs-Profile aus mac-worker/profiles/*.yaml.
Ein Profil definiert:
- Prompts für Summary und Protocol
- DOCX-Layout (Meta-Zeilen + Sektionen mit Typ)
Sind keine Profile vorhanden, wirft get() FileNotFoundError beim ersten Zugriff.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
import yaml
log = logging.getLogger("mac-worker.profiles")
PROFILES_DIR = Path(__file__).resolve().parent.parent.parent / "profiles"
DEFAULT_PROFILE_NAME = "meeting"
@dataclass
class Profile:
name: str
display_name: str
language: str = "de"
summary_prompt: str = ""
protocol_prompt: str = ""
docx: dict = field(default_factory=dict)
_cache: dict[str, Profile] = {}
def _load() -> dict[str, Profile]:
if _cache:
return _cache
if not PROFILES_DIR.exists():
log.warning("Profile-Verzeichnis %s existiert nicht", PROFILES_DIR)
return _cache
for f in sorted(PROFILES_DIR.glob("*.yaml")):
try:
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
p = Profile(
name=data["name"],
display_name=data.get("display_name", data["name"]),
language=data.get("language", "de"),
summary_prompt=data.get("summary_prompt", ""),
protocol_prompt=data.get("protocol_prompt", ""),
docx=data.get("docx") or {},
)
_cache[p.name] = p
log.info("Profil geladen: %s (%s)", p.name, p.display_name)
except Exception as e: # noqa: BLE001
log.error("Profil %s konnte nicht geladen werden: %s", f.name, e)
return _cache
def list_all() -> list[dict]:
"""Zur Auslieferung über /api/profiles."""
return [
{"name": p.name, "display_name": p.display_name, "language": p.language}
for p in _load().values()
]
def get(name: str | None) -> Profile:
profiles = _load()
if not profiles:
raise FileNotFoundError(
f"Keine Profile gefunden in {PROFILES_DIR} — bitte mind. eine YAML-Datei anlegen."
)
if name and name in profiles:
return profiles[name]
if DEFAULT_PROFILE_NAME in profiles:
return profiles[DEFAULT_PROFILE_NAME]
return next(iter(profiles.values()))
def reload() -> None:
"""Cache leeren (z. B. für Tests oder Hot-Reload via SIGHUP)."""
_cache.clear()
# Erwartete Platzhalter pro Prompt — Vorhandensein wird beim Start gewarnt
# (fehlt nicht-fatal, weil ein Profil bewusst weniger nutzen darf).
_REQUIRED_VARS = {
"summary_prompt": ["title", "transcript"],
"protocol_prompt": ["title", "transcript", "summary_json"],
}
def validate_all() -> list[str]:
"""Beim Worker-Start aufrufen: prüft jedes Profil auf grundlegende Lesbarkeit
und Platzhalter-Vorkommen. Gibt eine Liste von Warnungen zurück
(defekte Profile bleiben dennoch gelistet — Aufruf scheitert dann zur Laufzeit).
"""
warnings: list[str] = []
for p in _load().values():
for field_name, required in _REQUIRED_VARS.items():
template = getattr(p, field_name, "")
if not template:
continue
for var in required:
placeholder = "{" + var + "}"
if placeholder not in template:
msg = f"Profil '{p.name}': {field_name} enthält keinen {placeholder}"
warnings.append(msg)
log.warning(msg)
return warnings