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 JSONResponse, 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() # 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, 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: try: token = joblog.current_job.set(int(raw)) except ValueError: token = None try: 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) class SummarizeIn(BaseModel): transcript: str title: str = "" profile: str | None = None model: str | None = None class ProtocolIn(BaseModel): transcript: str summary: dict title: str = "" profile: str | None = None model: str | None = None class DocxIn(BaseModel): data: dict profile: str | None = None # Optional: Whisper-Segmente; werden vom Renderer nur genutzt, wenn das # Profil `docx.include_full_transcript: true` setzt. full_transcript: list[dict] = [] class PdfIn(BaseModel): data: dict profile: str | None = None full_transcript: list[dict] = [] class PreloadIn(BaseModel): profile: str | None = None model: 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.get("/api/models") async def list_models(): """Liste der lokal verfügbaren Ollama-Modelle + Default aus der Worker-.env. Das LXC-Frontend zeigt davon ein Dropdown — leerer Wert beim Job-Start = der Default hier wird genommen. """ try: models = await ollama_client.list_models() except Exception as e: # noqa: BLE001 log.warning("list_models: Ollama nicht erreichbar: %s", e) return {"models": [], "default": settings.ollama_model, "error": str(e)} return {"models": models, "default": settings.ollama_model} @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, model=payload.model ) @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, model=payload.model, ) @app.post("/api/export/docx") async def export_docx(payload: DocxIn): data = build_docx( payload.data, profile_name=payload.profile, full_transcript=payload.full_transcript, ) 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, full_transcript=payload.full_transcript, ) 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) use_model = (payload.model or "").strip() or settings.ollama_model log.info("preload model=%s profile=%s", use_model, profile_obj.name) url = f"{settings.ollama_url.rstrip('/')}/api/generate" body = { "model": use_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": use_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)}