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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user