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>
This commit is contained in:
root
2026-05-16 16:48:11 +00:00
co-authored by Claude Opus 4.7
parent dbf7bca6c7
commit c6164d946c
13 changed files with 386 additions and 54 deletions
+11
View File
@@ -38,11 +38,16 @@ def _user_dict(u: User) -> dict:
"username": u.username,
"is_admin": u.is_admin,
"default_profile": u.default_profile or "meeting",
# Leerer String = User hat keine Modell-Wahl getroffen → Mac-Worker
# nimmt seinen .env-Default. UI zeigt das als „Standard (Worker)".
"default_model": u.default_model or "",
}
class UpdateMeIn(BaseModel):
default_profile: str | None = Field(default=None, max_length=64)
# Leerer String bedeutet bewusst „kein Override" — wird gespeichert.
default_model: str | None = Field(default=None, max_length=128)
@router.get("/me")
@@ -52,8 +57,14 @@ def me(user: User = Depends(current_user)):
@router.patch("/me")
def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: Session = Depends(get_session)):
changed = False
if payload.default_profile is not None:
user.default_profile = payload.default_profile.strip() or "meeting"
changed = True
if payload.default_model is not None:
user.default_model = payload.default_model.strip()
changed = True
if changed:
session.add(user)
session.commit()
session.refresh(user)
+29 -9
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session, select
@@ -59,6 +59,7 @@ async def create_job(
audio: UploadFile = File(...),
title: str = Form(""),
profile: str = Form(""),
model: str = Form(""),
session: Session = Depends(get_session),
user: User = Depends(current_user),
):
@@ -81,9 +82,13 @@ async def create_job(
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(),
@@ -162,23 +167,28 @@ async def retry_job(
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.
Anders als /retry blockiert dieser Endpoint nicht bei status=done.
Sichert Summary/Protokoll/DOCX/PDF in runs/<ts>-<modell>/ als Snapshot
(Transkript bleibt im Hauptordner), startet die Pipeline neu — die
Mac-aktuell konfigurierte Ollama-Variante wird dabei verwendet.
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")
model = await _current_mac_model()
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(model)}"
run_id = f"{ts}-{_slug(snapshot_model)}"
job_dir = settings.result_dir / str(job.id)
runs_dir = job_dir / "runs" / run_id
@@ -201,7 +211,7 @@ async def reprocess_job(
json.dumps(
{
"run_id": run_id,
"model": model,
"model": snapshot_model,
"profile": job.profile,
"snapshot_at": ts,
"previous_status": job.status,
@@ -213,6 +223,9 @@ async def reprocess_job(
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 = ""
@@ -223,7 +236,12 @@ async def reprocess_job(
session.refresh(job)
background.add_task(run_pipeline, job.id)
return {"job": _job_dict(job, user), "snapshot": run_id, "model_for_new_run": model}
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")
@@ -327,6 +345,8 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
"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,