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>
This commit is contained in:
root 2026-05-15 06:55:55 +00:00
parent bc497a1665
commit 031d52c77a
4 changed files with 91 additions and 7 deletions

View File

@ -28,13 +28,30 @@ class MacClient:
return None return None
return {"X-Job-Id": str(job_id)} return {"X-Job-Id": str(job_id)}
@staticmethod
def _raise_for_status(r: httpx.Response, url: str) -> None:
"""Wie raise_for_status, aber zieht den 'error'-Feld aus JSON-Body in die Exception."""
if r.status_code < 400:
return
detail = ""
try:
j = r.json()
detail = j.get("error") or j.get("detail") or ""
except Exception: # noqa: BLE001
detail = (r.text or "").strip()[:300]
raise httpx.HTTPStatusError(
f"HTTP {r.status_code} {url}{detail}" if detail else f"HTTP {r.status_code} {url}",
request=r.request,
response=r,
)
async def _post_json_retry(self, url: str, payload: dict, job_id: int | None) -> dict: async def _post_json_retry(self, url: str, payload: dict, job_id: int | None) -> dict:
"""JSON-POST mit einmaligem Retry bei transienten Verbindungsabbrüchen.""" """JSON-POST mit einmaligem Retry bei transienten Verbindungsabbrüchen."""
for attempt in (0, 1): for attempt in (0, 1):
try: try:
async with httpx.AsyncClient(timeout=self.timeout) as c: async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(url, json=payload, headers=self._hdrs(job_id)) r = await c.post(url, json=payload, headers=self._hdrs(job_id))
r.raise_for_status() self._raise_for_status(r, url)
return r.json() return r.json()
except _RETRY_EXCEPTIONS as e: except _RETRY_EXCEPTIONS as e:
if attempt == 0: if attempt == 0:
@ -48,7 +65,7 @@ class MacClient:
try: try:
async with httpx.AsyncClient(timeout=self.timeout) as c: async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(url, json=payload, headers=self._hdrs(job_id)) r = await c.post(url, json=payload, headers=self._hdrs(job_id))
r.raise_for_status() self._raise_for_status(r, url)
return r.content return r.content
except _RETRY_EXCEPTIONS as e: except _RETRY_EXCEPTIONS as e:
if attempt == 0: if attempt == 0:

View File

@ -39,6 +39,19 @@ def _truncate(text: str, max_chars: int = 60000) -> str:
return head + "\n\n[... gekürzt ...]\n\n" + tail 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: async def _chat(prompt: str) -> str:
"""Ruft Ollama im Streaming-Modus auf. """Ruft Ollama im Streaming-Modus auf.
@ -89,7 +102,8 @@ async def _chat(prompt: str) -> str:
async def summarize(transcript: str, title: str = "", profile_name: str | None = None) -> dict[str, Any]: async def summarize(transcript: str, title: str = "", profile_name: str | None = None) -> dict[str, Any]:
profile = profiles.get(profile_name) profile = profiles.get(profile_name)
prompt = profile.summary_prompt.format( prompt = render(
profile.summary_prompt,
title=title or "(ohne Titel)", title=title or "(ohne Titel)",
transcript=_truncate(transcript), transcript=_truncate(transcript),
) )
@ -106,7 +120,8 @@ async def make_protocol(
transcript: str, summary: dict, title: str = "", profile_name: str | None = None transcript: str, summary: dict, title: str = "", profile_name: str | None = None
) -> dict: ) -> dict:
profile = profiles.get(profile_name) profile = profiles.get(profile_name)
prompt = profile.protocol_prompt.format( prompt = render(
profile.protocol_prompt,
title=title or "(ohne Titel)", title=title or "(ohne Titel)",
summary_json=json.dumps(summary, ensure_ascii=False), summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript), transcript=_truncate(transcript),

View File

@ -82,3 +82,31 @@ def get(name: str | None) -> Profile:
def reload() -> None: def reload() -> None:
"""Cache leeren (z. B. für Tests oder Hot-Reload via SIGHUP).""" """Cache leeren (z. B. für Tests oder Hot-Reload via SIGHUP)."""
_cache.clear() _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

View File

@ -1,10 +1,11 @@
import logging import logging
import secrets import secrets
import traceback
from pathlib import Path from pathlib import Path
import httpx import httpx
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from app.core import joblog, ollama_client, profiles from app.core import joblog, ollama_client, profiles
@ -20,12 +21,21 @@ log = logging.getLogger("mac-worker")
# mit X-Job-Id Header und legt sie im Ringbuffer ab. # mit X-Job-Id Header und legt sie im Ringbuffer ab.
joblog.install() joblog.install()
# Profile-Sanity-Check beim Boot — defekte Templates werden hier sichtbar,
# bevor sie in einem echten Job mit 500 abblitzen.
_profile_warnings = profiles.validate_all()
log.info("Profile geladen: %d (%d Warnungen)", len(profiles.list_all()), len(_profile_warnings))
app = FastAPI(title="Voice-Agent Mac-Worker") app = FastAPI(title="Voice-Agent Mac-Worker")
@app.middleware("http") @app.middleware("http")
async def attach_job_id(request: Request, call_next): async def attach_job_id(request: Request, call_next):
"""Bindet X-Job-Id-Header an die ContextVar, damit Logs zugeordnet werden.""" """Bindet X-Job-Id-Header an die ContextVar, damit Logs zugeordnet werden,
und liefert bei Exceptions einen JSON-Body mit Typ + Message zurück
(statt FastAPI's generischem 'Internal Server Error') — damit die Wurzel
direkt im LXC-error-Feld und im Diagnose-Panel landet.
"""
raw = request.headers.get("x-job-id") raw = request.headers.get("x-job-id")
token = None token = None
if raw: if raw:
@ -34,7 +44,21 @@ async def attach_job_id(request: Request, call_next):
except ValueError: except ValueError:
token = None token = None
try: try:
return await call_next(request) try:
return await call_next(request)
except HTTPException:
raise # FastAPI handhabt das selbst
except Exception as e: # noqa: BLE001
tb_tail = "".join(traceback.format_exception(type(e), e, e.__traceback__))[-1200:]
log.exception("unhandled error on %s %s", request.method, request.url.path)
return JSONResponse(
status_code=500,
content={
"error": f"{type(e).__name__}: {e}",
"path": str(request.url.path),
"traceback": tb_tail,
},
)
finally: finally:
if token is not None: if token is not None:
joblog.current_job.reset(token) joblog.current_job.reset(token)