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)}