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

205 lines
6.8 KiB
Python

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 JSONResponse, Response
from pydantic import BaseModel
from app.core import joblog, ollama_client, profiles
from app.core.config import settings
from app.core.docx_export import build_docx
from app.core.pdf_export import build_pdf
from app.core.whisper_engine import engine as whisper
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
log = logging.getLogger("mac-worker")
# Per-Job Log-Spiegel installieren — fängt alle Logs während eines Requests
# 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,
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:
try:
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)
class SummarizeIn(BaseModel):
transcript: str
title: str = ""
profile: str | None = None
class ProtocolIn(BaseModel):
transcript: str
summary: dict
title: str = ""
profile: str | None = None
class DocxIn(BaseModel):
data: dict
profile: str | None = None
class PdfIn(BaseModel):
data: dict
profile: str | None = None
class PreloadIn(BaseModel):
profile: str | None = None
@app.get("/health")
async def health():
info = {
"status": "ok",
"whisper_engine": settings.whisper_engine,
"whisper_model": settings.whisper_model,
"ollama_model": settings.ollama_model,
"ollama_reachable": False,
"profiles": [p["name"] for p in profiles.list_all()],
}
try:
await ollama_client.health()
info["ollama_reachable"] = True
except Exception as e: # noqa: BLE001
info["ollama_error"] = str(e)
return info
@app.get("/api/profiles")
def list_profiles():
return {"profiles": profiles.list_all()}
@app.post("/api/transcribe")
async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
if not audio.filename:
raise HTTPException(400, "Dateiname fehlt")
suffix = Path(audio.filename).suffix or ".bin"
tmp = settings.work_dir / (secrets.token_hex(8) + suffix)
try:
with tmp.open("wb") as f:
while chunk := await audio.read(1024 * 1024):
f.write(chunk)
log.info("Transcribing %s (%.1f MB) lang=%s", audio.filename, tmp.stat().st_size / 1024 / 1024, language)
result = whisper.transcribe(tmp, language=language)
return result
finally:
tmp.unlink(missing_ok=True)
@app.post("/api/summarize")
async def summarize(payload: SummarizeIn):
if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript")
return await ollama_client.summarize(payload.transcript, title=payload.title, profile_name=payload.profile)
@app.post("/api/protocol")
async def protocol(payload: ProtocolIn):
if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript")
return await ollama_client.make_protocol(
payload.transcript, payload.summary, title=payload.title, profile_name=payload.profile
)
@app.post("/api/export/docx")
async def export_docx(payload: DocxIn):
data = build_docx(payload.data, profile_name=payload.profile)
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": 'attachment; filename="protocol.docx"'},
)
@app.post("/api/export/pdf")
async def export_pdf(payload: PdfIn):
data = build_pdf(payload.data, profile_name=payload.profile)
return Response(
content=data,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="protocol.pdf"'},
)
@app.post("/api/preload")
async def preload(payload: PreloadIn):
"""Forciert Ollama-Modell-Load (keep_alive 30 m) mit Mini-Prompt.
Wird vom LXC-Frontend vor summarize aufgerufen, damit Ollama den
Modell-Reload nicht *während* der eigentlichen Generierung macht
(häufige Ursache von 'Server disconnected without sending a response').
"""
profile_obj = profiles.get(payload.profile)
log.info("preload model=%s profile=%s", settings.ollama_model, profile_obj.name)
url = f"{settings.ollama_url.rstrip('/')}/api/generate"
body = {
"model": settings.ollama_model,
"prompt": "ping",
"stream": False,
"keep_alive": "30m",
"options": {"num_predict": 1, "temperature": 0},
}
timeout = httpx.Timeout(connect=15.0, read=300.0, write=15.0, pool=10.0)
try:
async with httpx.AsyncClient(timeout=timeout) as c:
r = await c.post(url, json=body)
r.raise_for_status()
data = r.json()
except Exception as e: # noqa: BLE001
log.warning("preload failed: %s", e)
raise HTTPException(503, f"Ollama preload fehlgeschlagen: {e}") from e
log.info("preload ok load_duration=%dms", int(data.get("load_duration", 0) / 1_000_000))
return {"ok": True, "model": settings.ollama_model, "load_duration_ns": data.get("load_duration", 0)}
@app.get("/api/jobs/{job_id}/log")
async def job_log(job_id: int, last: int = 200):
"""Liefert die letzten Log-Zeilen, die beim Bearbeiten dieses Jobs entstanden sind."""
return {"job_id": job_id, "lines": joblog.get(job_id, last)}