diff --git a/lxc-frontend/app/core/mac_client.py b/lxc-frontend/app/core/mac_client.py index c5a8c1d..bcc225f 100644 --- a/lxc-frontend/app/core/mac_client.py +++ b/lxc-frontend/app/core/mac_client.py @@ -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: diff --git a/mac-worker/app/core/ollama_client.py b/mac-worker/app/core/ollama_client.py index b066122..7a4c001 100644 --- a/mac-worker/app/core/ollama_client.py +++ b/mac-worker/app/core/ollama_client.py @@ -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), diff --git a/mac-worker/app/core/profiles.py b/mac-worker/app/core/profiles.py index 904ba97..215b901 100644 --- a/mac-worker/app/core/profiles.py +++ b/mac-worker/app/core/profiles.py @@ -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 diff --git a/mac-worker/app/main.py b/mac-worker/app/main.py index a13d8e0..cb0f3cb 100644 --- a/mac-worker/app/main.py +++ b/mac-worker/app/main.py @@ -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: @@ -34,7 +44,21 @@ async def attach_job_id(request: Request, call_next): except ValueError: token = None 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: if token is not None: joblog.current_job.reset(token)