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:
parent
bc497a1665
commit
031d52c77a
@ -28,13 +28,30 @@ class MacClient:
|
||||
return None
|
||||
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:
|
||||
"""JSON-POST mit einmaligem Retry bei transienten Verbindungsabbrüchen."""
|
||||
for attempt in (0, 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
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()
|
||||
except _RETRY_EXCEPTIONS as e:
|
||||
if attempt == 0:
|
||||
@ -48,7 +65,7 @@ class MacClient:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
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
|
||||
except _RETRY_EXCEPTIONS as e:
|
||||
if attempt == 0:
|
||||
|
||||
@ -39,6 +39,19 @@ def _truncate(text: str, max_chars: int = 60000) -> str:
|
||||
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.
|
||||
|
||||
@ -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]:
|
||||
profile = profiles.get(profile_name)
|
||||
prompt = profile.summary_prompt.format(
|
||||
prompt = render(
|
||||
profile.summary_prompt,
|
||||
title=title or "(ohne Titel)",
|
||||
transcript=_truncate(transcript),
|
||||
)
|
||||
@ -106,7 +120,8 @@ 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(
|
||||
prompt = render(
|
||||
profile.protocol_prompt,
|
||||
title=title or "(ohne Titel)",
|
||||
summary_json=json.dumps(summary, ensure_ascii=False),
|
||||
transcript=_truncate(transcript),
|
||||
|
||||
@ -82,3 +82,31 @@ def get(name: str | None) -> Profile:
|
||||
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
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import logging
|
||||
import secrets
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
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 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.
|
||||
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.middleware("http")
|
||||
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")
|
||||
token = None
|
||||
if raw:
|
||||
@ -33,8 +43,22 @@ async def attach_job_id(request: Request, call_next):
|
||||
token = joblog.current_job.set(int(raw))
|
||||
except ValueError:
|
||||
token = None
|
||||
try:
|
||||
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:
|
||||
if token is not None:
|
||||
joblog.current_job.reset(token)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user