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

366 lines
13 KiB
Python

import json
import re
import secrets
from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session, select
from app.core.auth import current_user
from app.core.config import settings
from app.core.db import get_session
from app.models.job import Job, JobStatus # noqa: F401
from app.models.user import User
from app.services.pipeline import run_pipeline
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
# Status, in denen der Job aktiv läuft — Reprocess wird dann verweigert,
# damit kein Schreibwettlauf mit der laufenden Pipeline entsteht.
_ACTIVE_STATUSES = {
JobStatus.QUEUED,
JobStatus.TRANSCRIBING,
JobStatus.SUMMARIZING,
JobStatus.PROTOCOLLING,
JobStatus.EXPORTING,
}
def _slug(s: str) -> str:
"""Dateisystem-sichere Variante eines Modellnamens (z. B. 'gpt-oss:latest''gpt-oss_latest')."""
return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") or "unknown"
async def _current_mac_model() -> str:
"""Aktuelles Ollama-Modell vom Mac-Worker — fällt auf 'unknown' zurück, wenn unerreichbar."""
try:
from app.core.mac_client import MacClient
h = await MacClient().health()
return h.get("ollama_model") or "unknown"
except Exception: # noqa: BLE001
return "unknown"
def _require_access(job: Job | None, user: User) -> Job:
"""404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert)."""
if not job or (job.owner_id != user.id and not user.is_admin):
raise HTTPException(404, "Job nicht gefunden")
return job
@router.post("")
async def create_job(
background: BackgroundTasks,
audio: UploadFile = File(...),
title: str = Form(""),
profile: str = Form(""),
model: str = Form(""),
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
if not audio.filename:
raise HTTPException(400, "Dateiname fehlt")
ext = Path(audio.filename).suffix.lower().lstrip(".")
if ext not in settings.allowed_ext_set:
raise HTTPException(400, f"Format '{ext}' nicht erlaubt. Erlaubt: {sorted(settings.allowed_ext_set)}")
safe = secrets.token_hex(8) + "." + ext
dest = settings.upload_dir / safe
size = 0
limit = MAX_BYTES()
async with aiofiles.open(dest, "wb") as f:
while chunk := await audio.read(1024 * 1024):
size += len(chunk)
if size > limit:
await f.close()
dest.unlink(missing_ok=True)
raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
await f.write(chunk)
# Priorität: Form-Eingabe → User-Default → leer (= Worker-Default)
chosen_model = model.strip() or (user.default_model or "").strip()
job = Job(
owner_id=user.id,
profile=(profile.strip() or user.default_profile or "meeting"),
model=chosen_model,
filename=safe,
original_name=audio.filename,
title=title.strip(),
)
session.add(job)
session.commit()
session.refresh(job)
background.add_task(run_pipeline, job.id)
return _job_dict(job, user)
@router.get("")
def list_jobs(session: Session = Depends(get_session), user: User = Depends(current_user)):
stmt = select(Job).order_by(Job.created_at.desc())
if not user.is_admin:
stmt = stmt.where(Job.owner_id == user.id)
jobs = session.exec(stmt).all()
# Admin sieht zusätzlich Owner-Username — kleine Map für effiziente Anzeige.
owner_names: dict[int, str] = {}
if user.is_admin:
owner_ids = {j.owner_id for j in jobs if j.owner_id is not None}
if owner_ids:
owners = session.exec(select(User).where(User.id.in_(owner_ids))).all() # type: ignore[attr-defined]
owner_names = {u.id: u.username for u in owners}
return [_job_dict(j, user, owner_names.get(j.owner_id) if user.is_admin else None) for j in jobs]
@router.get("/{job_id}")
def get_job(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
job = _require_access(session.get(Job, job_id), user)
return _job_dict(job, user)
@router.post("/{job_id}/retry")
async def retry_job(
job_id: int,
background: BackgroundTasks,
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Verarbeitung neu anstoßen.
Bereits vorhandenes Transkript wird beibehalten (teuerster Schritt),
Summary/Protokoll/DOCX werden gelöscht und neu erzeugt. Sinnvoll wenn
der Job bei summarize/protocol/docx hängt oder failed ist.
"""
job = _require_access(session.get(Job, job_id), user)
if job.status == JobStatus.DONE:
raise HTTPException(400, "Job ist bereits abgeschlossen")
# Post-Transkriptions-Artefakte löschen, damit sie neu erzeugt werden.
for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"):
p = getattr(job, attr)
if p:
try:
Path(p).unlink(missing_ok=True)
except OSError:
pass
setattr(job, attr, "")
job.status = JobStatus.QUEUED
job.progress = 0
job.error = ""
job.step_started_at = None
job.last_heartbeat_at = None
session.add(job)
session.commit()
session.refresh(job)
background.add_task(run_pipeline, job.id)
return _job_dict(job, user)
@router.post("/{job_id}/reprocess")
async def reprocess_job(
job_id: int,
background: BackgroundTasks,
payload: dict = Body(default_factory=dict),
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Erlaubt einen erneuten LLM-Lauf auf einem bereits abgeschlossenen Job.
Optionaler Body: `{"model": "qwen2.5:14b"}` — überschreibt das LLM nur
für diesen Lauf. Leer/fehlend → bisheriges job.model bleibt, leer → Mac-Default.
Der bisherige Lauf (Summary/Protokoll/DOCX/PDF) wandert in
runs/<ts>-<modell>/ als Snapshot. Transkript bleibt im Hauptordner.
"""
job = _require_access(session.get(Job, job_id), user)
if job.status in _ACTIVE_STATUSES:
raise HTTPException(409, "Job läuft gerade — bitte warten oder Diagnose prüfen")
new_model_raw = (payload.get("model") or "").strip() if isinstance(payload, dict) else ""
# Für den Snapshot zählt das Modell, das den *bisherigen* Output produziert hat —
# also das vorherige job.model, oder fallback der aktuelle Worker-Default.
snapshot_model = (job.model or "").strip() or await _current_mac_model()
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
run_id = f"{ts}-{_slug(snapshot_model)}"
job_dir = settings.result_dir / str(job.id)
runs_dir = job_dir / "runs" / run_id
runs_dir.mkdir(parents=True, exist_ok=True)
moved: list[str] = []
for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"):
path_str = getattr(job, attr)
if path_str:
src = Path(path_str)
if src.exists():
try:
src.replace(runs_dir / src.name)
moved.append(src.name)
except OSError:
pass
setattr(job, attr, "")
(runs_dir / "meta.json").write_text(
json.dumps(
{
"run_id": run_id,
"model": snapshot_model,
"profile": job.profile,
"snapshot_at": ts,
"previous_status": job.status,
"files": moved,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
# Neues Modell für den kommenden Lauf — nur wenn explizit mitgegeben.
if new_model_raw:
job.model = new_model_raw
job.status = JobStatus.QUEUED
job.progress = 0
job.error = ""
job.step_started_at = None
job.last_heartbeat_at = None
session.add(job)
session.commit()
session.refresh(job)
background.add_task(run_pipeline, job.id)
return {
"job": _job_dict(job, user),
"snapshot": run_id,
"snapshot_model": snapshot_model,
"next_model": job.model or "(worker default)",
}
@router.get("/{job_id}/runs")
def list_runs(
job_id: int,
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Listet vergangene LLM-Läufe (Snapshots) eines Jobs."""
job = _require_access(session.get(Job, job_id), user)
runs_dir = settings.result_dir / str(job.id) / "runs"
out: list[dict] = []
if runs_dir.exists():
for d in sorted(runs_dir.iterdir(), reverse=True):
if not d.is_dir():
continue
meta: dict = {}
try:
meta = json.loads((d / "meta.json").read_text(encoding="utf-8"))
except Exception: # noqa: BLE001
pass
out.append({
"run_id": d.name,
"model": meta.get("model", "unknown"),
"snapshot_at": meta.get("snapshot_at", ""),
"profile": meta.get("profile", job.profile),
"files": [
{"name": f.name, "size": f.stat().st_size}
for f in sorted(d.iterdir())
if f.is_file() and f.name != "meta.json"
],
})
return {"runs": out}
@router.get("/{job_id}/runs/{run_id}/{filename}")
def download_run_artifact(
job_id: int,
run_id: str,
filename: str,
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
"""Liefert eine einzelne Datei aus einem Snapshot-Lauf zum Download."""
job = _require_access(session.get(Job, job_id), user)
if "/" in run_id or ".." in run_id or "/" in filename or ".." in filename:
raise HTTPException(400, "Ungültige Pfad-Komponenten")
target = settings.result_dir / str(job.id) / "runs" / run_id / filename
if not target.exists():
raise HTTPException(404, "Snapshot-Datei nicht gefunden")
media_map = {
".json": "application/json",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf": "application/pdf",
}
media = media_map.get(target.suffix.lower(), "application/octet-stream")
base = (job.title or Path(job.original_name).stem).replace("/", "_")
return FileResponse(target, media_type=media, filename=f"{base}-{run_id}-{filename}")
@router.get("/{job_id}/diag")
async def diag(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
"""Diagnose-Bündel: Job-Status + letzte Mac-Worker-Logzeilen für genau diesen Job."""
from app.core.mac_client import MacClient
job = _require_access(session.get(Job, job_id), user)
client = MacClient()
mac_log = await client.fetch_log(job_id, last=200)
return {
"job": _job_dict(job, user),
"step_started_at": job.step_started_at.isoformat() if job.step_started_at else None,
"last_heartbeat_at": job.last_heartbeat_at.isoformat() if job.last_heartbeat_at else None,
"mac_log": mac_log,
}
@router.get("/{job_id}/download/{kind}")
def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
job = _require_access(session.get(Job, job_id), user)
mapping = {
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
"pdf": (job.pdf_path, "application/pdf", "protocol.pdf"),
"transcript": (job.transcript_path, "application/json", "transcript.json"),
"summary": (job.summary_path, "application/json", "summary.json"),
"protocol": (job.protocol_path, "application/json", "protocol.json"),
}
if kind not in mapping:
raise HTTPException(400, "Unbekannter Download-Typ")
path, media, name = mapping[kind]
if not path or not Path(path).exists():
raise HTTPException(404, "Datei noch nicht verfügbar")
base = (job.title or Path(job.original_name).stem).replace("/", "_")
return FileResponse(path, media_type=media, filename=f"{base}-{name}")
def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
d = {
"id": j.id,
"title": j.title,
"original_name": j.original_name,
"status": j.status,
"progress": j.progress,
"error": j.error,
"profile": j.profile or "meeting",
# Leer = Worker-Default; UI zeigt das als „Standard".
"model": j.model or "",
"created_at": j.created_at.isoformat(),
"updated_at": j.updated_at.isoformat(),
"step_started_at": j.step_started_at.isoformat() if j.step_started_at else None,
"last_heartbeat_at": j.last_heartbeat_at.isoformat() if j.last_heartbeat_at else None,
"has": {
"transcript": bool(j.transcript_path),
"summary": bool(j.summary_path),
"protocol": bool(j.protocol_path),
"docx": bool(j.docx_path),
"pdf": bool(j.pdf_path),
},
}
if user.is_admin:
d["owner_id"] = j.owner_id
d["owner_username"] = owner_username or ""
return d