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>
181 lines
5.7 KiB
Python
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)}
|