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>
205 lines
6.8 KiB
Python
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)}
|