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>
153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
from .config import settings
|
|
|
|
log = logging.getLogger("voice-agent.mac_client")
|
|
|
|
# Transient-Fehler, bei denen ein einmaliger Retry sinnvoll ist
|
|
# (typisch bei Ollama-Reload oder kurzem Mac-Worker-Restart).
|
|
_RETRY_EXCEPTIONS = (
|
|
httpx.RemoteProtocolError,
|
|
httpx.ConnectError,
|
|
httpx.ReadError,
|
|
)
|
|
|
|
|
|
class MacClient:
|
|
def __init__(self, base_url: str | None = None, timeout: int | None = None):
|
|
self.base_url = (base_url or settings.mac_api_url).rstrip("/")
|
|
self.timeout = timeout or settings.mac_api_timeout
|
|
|
|
@staticmethod
|
|
def _hdrs(job_id: int | None) -> dict[str, str] | None:
|
|
if job_id is None:
|
|
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))
|
|
self._raise_for_status(r, url)
|
|
return r.json()
|
|
except _RETRY_EXCEPTIONS as e:
|
|
if attempt == 0:
|
|
log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url)
|
|
await asyncio.sleep(3.0)
|
|
continue
|
|
raise
|
|
|
|
async def _post_bytes_retry(self, url: str, payload: dict, job_id: int | None) -> bytes:
|
|
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))
|
|
self._raise_for_status(r, url)
|
|
return r.content
|
|
except _RETRY_EXCEPTIONS as e:
|
|
if attempt == 0:
|
|
log.warning("[job %s] %s bei %s — Retry in 3s", job_id, type(e).__name__, url)
|
|
await asyncio.sleep(3.0)
|
|
continue
|
|
raise
|
|
|
|
async def health(self) -> dict:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"{self.base_url}/health")
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
async def list_profiles(self) -> list[dict]:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(f"{self.base_url}/api/profiles")
|
|
r.raise_for_status()
|
|
return r.json().get("profiles", [])
|
|
|
|
async def fetch_log(self, job_id: int, last: int = 200) -> list[str]:
|
|
"""Holt die letzten Mac-Worker-Logzeilen für einen Job (für die Diagnose-Anzeige)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=8) as c:
|
|
r = await c.get(f"{self.base_url}/api/jobs/{job_id}/log", params={"last": last})
|
|
r.raise_for_status()
|
|
return r.json().get("lines", [])
|
|
except Exception as e: # noqa: BLE001
|
|
log.warning("fetch_log job=%s failed: %s", job_id, e)
|
|
return []
|
|
|
|
async def preload(self, profile: str | None = None, job_id: int | None = None) -> dict:
|
|
"""Vor-Heizen: Ollama soll das Modell laden, bevor der echte Call läuft."""
|
|
return await self._post_json_retry(
|
|
f"{self.base_url}/api/preload",
|
|
{"profile": profile},
|
|
job_id,
|
|
)
|
|
|
|
async def transcribe(self, audio_path: Path, language: str = "de", job_id: int | None = None) -> dict:
|
|
# Kein Retry: Datei-Upload ist nicht idempotent, und die Transkription
|
|
# ist der teuerste Schritt — Fehler hier sollen sichtbar bleiben.
|
|
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
|
with audio_path.open("rb") as f:
|
|
files = {"audio": (audio_path.name, f, "application/octet-stream")}
|
|
data = {"language": language}
|
|
r = await c.post(
|
|
f"{self.base_url}/api/transcribe",
|
|
files=files,
|
|
data=data,
|
|
headers=self._hdrs(job_id),
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
async def summarize(self, transcript: str, title: str = "", profile: str | None = None,
|
|
job_id: int | None = None) -> dict:
|
|
return await self._post_json_retry(
|
|
f"{self.base_url}/api/summarize",
|
|
{"transcript": transcript, "title": title, "profile": profile},
|
|
job_id,
|
|
)
|
|
|
|
async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None,
|
|
job_id: int | None = None) -> dict:
|
|
return await self._post_json_retry(
|
|
f"{self.base_url}/api/protocol",
|
|
{"transcript": transcript, "summary": summary, "title": title, "profile": profile},
|
|
job_id,
|
|
)
|
|
|
|
async def export_docx(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes:
|
|
return await self._post_bytes_retry(
|
|
f"{self.base_url}/api/export/docx",
|
|
{"data": data, "profile": profile},
|
|
job_id,
|
|
)
|
|
|
|
async def export_pdf(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes:
|
|
return await self._post_bytes_retry(
|
|
f"{self.base_url}/api/export/pdf",
|
|
{"data": data, "profile": profile},
|
|
job_id,
|
|
)
|