root c6164d946c feat(model): wählbares LLM-Modell pro User & pro Job
Mac-Worker:
- /api/summarize, /api/protocol, /api/preload akzeptieren optionales
  model-Feld → überschreibt den .env-Default pro Call, kein Restart nötig
- /api/models listet die lokal verfügbaren Ollama-Modelle + Default

LXC:
- User.default_model + Job.model (Migration für SQLite)
- /api/auth/me liefert default_model, PATCH speichert ihn sofort
- /api/mac/models proxiet die Liste an die UI
- Pipeline reicht job.model an Mac-Worker durch; leer = Worker-Default
- create_job & reprocess akzeptieren model-Override
- Snapshot-Meta nutzt job.model (genauer als /health-Snapshot)

UI:
- Header-Dropdown "Modell:" neben "Profil:" — sofort-speichert User-Default
- Jeder Job zeigt sein Modell als Tag (lila); leer bedeutet Worker-Default
- Reprocess öffnet einen Modal-Picker (statt simplem confirm), Auswahl
  aller verfügbaren Modelle inkl. "Worker-Default"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:48:11 +00:00

227 lines
7.5 KiB
Python

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
class PdfIn(BaseModel):
data: dict
profile: str | None = None
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)
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)
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)}