root f408a555f8 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>
2026-05-14 09:12:27 +00:00

181 lines
5.7 KiB
Python

import logging
import secrets
from pathlib import Path
import httpx
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import 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()
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."""
raw = request.headers.get("x-job-id")
token = None
if raw:
try:
token = joblog.current_job.set(int(raw))
except ValueError:
token = None
try:
return await call_next(request)
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)}