feat: kontinuierliche LXC↔Mac-Diagnose + Auto-Retry
Diagnose-Sichtbarkeit (Plan A):
- Job.step_started_at + last_heartbeat_at, Heartbeat-Task pingt alle 3 s
während laufender Mac-Calls
- Mac-Worker hält per X-Job-Id Header einen Log-Ringbuffer pro Job
(200 Zeilen, 1 h TTL); GET /api/jobs/{id}/log liefert ihn aus
- LXC-Endpoint GET /api/jobs/{id}/diag bündelt Job-Status, Stage-Sekunden,
Heartbeat-Alter und Mac-Worker-Log
- Frontend: Live-Timing im Status-Pill ("läuft seit Xs · letzter Ping vor Ys"),
klappbares Diagnose-Panel mit Mac-Log für FAILED + langlaufende Jobs
Robustheit (Plan B):
- MacClient: einmaliger Retry mit 3 s Backoff bei RemoteProtocolError /
ConnectError / ReadError für summarize/protocol/export
- Mac-Worker /api/preload heizt Ollama vor (keep_alive 30 m, num_predict 1);
Pipeline ruft Preload vor summarize, damit Modell-Reload nicht mitten
in der Generierung passiert (Hauptursache "Server disconnected")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,62 @@
|
||||
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)}
|
||||
|
||||
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()
|
||||
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))
|
||||
r.raise_for_status()
|
||||
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")
|
||||
@@ -20,47 +69,67 @@ class MacClient:
|
||||
r.raise_for_status()
|
||||
return r.json().get("profiles", [])
|
||||
|
||||
async def transcribe(self, audio_path: Path, language: str = "de") -> dict:
|
||||
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)
|
||||
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) -> dict:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
r = await c.post(
|
||||
f"{self.base_url}/api/summarize",
|
||||
json={"transcript": transcript, "title": title, "profile": profile},
|
||||
)
|
||||
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) -> dict:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
r = await c.post(
|
||||
f"{self.base_url}/api/protocol",
|
||||
json={"transcript": transcript, "summary": summary, "title": title, "profile": profile},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
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) -> bytes:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
r = await c.post(
|
||||
f"{self.base_url}/api/export/docx",
|
||||
json={"data": data, "profile": profile},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
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) -> bytes:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as c:
|
||||
r = await c.post(
|
||||
f"{self.base_url}/api/export/pdf",
|
||||
json={"data": data, "profile": profile},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -46,6 +46,15 @@ def run_migrations() -> None:
|
||||
log.info("Migration: füge job.pdf_path hinzu")
|
||||
conn.execute(text("ALTER TABLE job ADD COLUMN pdf_path VARCHAR DEFAULT ''"))
|
||||
|
||||
# 5) Diagnose-Felder: step_started_at, last_heartbeat_at
|
||||
cols = _existing_columns(conn, "job")
|
||||
if "step_started_at" not in cols:
|
||||
log.info("Migration: füge job.step_started_at hinzu")
|
||||
conn.execute(text("ALTER TABLE job ADD COLUMN step_started_at DATETIME"))
|
||||
if "last_heartbeat_at" not in cols:
|
||||
log.info("Migration: füge job.last_heartbeat_at hinzu")
|
||||
conn.execute(text("ALTER TABLE job ADD COLUMN last_heartbeat_at DATETIME"))
|
||||
|
||||
|
||||
def assign_orphan_jobs_to(user_id: int) -> int:
|
||||
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
|
||||
|
||||
Reference in New Issue
Block a user