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
parent dbf7bca6c7
commit c6164d946c
13 changed files with 386 additions and 54 deletions

View File

@ -38,11 +38,16 @@ def _user_dict(u: User) -> dict:
"username": u.username, "username": u.username,
"is_admin": u.is_admin, "is_admin": u.is_admin,
"default_profile": u.default_profile or "meeting", "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): class UpdateMeIn(BaseModel):
default_profile: str | None = Field(default=None, max_length=64) 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") @router.get("/me")
@ -52,8 +57,14 @@ def me(user: User = Depends(current_user)):
@router.patch("/me") @router.patch("/me")
def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: Session = Depends(get_session)): def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: Session = Depends(get_session)):
changed = False
if payload.default_profile is not None: if payload.default_profile is not None:
user.default_profile = payload.default_profile.strip() or "meeting" 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.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)

View File

@ -5,7 +5,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import aiofiles 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 fastapi.responses import FileResponse
from sqlmodel import Session, select from sqlmodel import Session, select
@ -59,6 +59,7 @@ async def create_job(
audio: UploadFile = File(...), audio: UploadFile = File(...),
title: str = Form(""), title: str = Form(""),
profile: str = Form(""), profile: str = Form(""),
model: str = Form(""),
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(current_user), 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)") raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
await f.write(chunk) 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( job = Job(
owner_id=user.id, owner_id=user.id,
profile=(profile.strip() or user.default_profile or "meeting"), profile=(profile.strip() or user.default_profile or "meeting"),
model=chosen_model,
filename=safe, filename=safe,
original_name=audio.filename, original_name=audio.filename,
title=title.strip(), title=title.strip(),
@ -162,23 +167,28 @@ async def retry_job(
async def reprocess_job( async def reprocess_job(
job_id: int, job_id: int,
background: BackgroundTasks, background: BackgroundTasks,
payload: dict = Body(default_factory=dict),
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(current_user), user: User = Depends(current_user),
): ):
"""Erlaubt einen erneuten LLM-Lauf auf einem bereits abgeschlossenen Job. """Erlaubt einen erneuten LLM-Lauf auf einem bereits abgeschlossenen Job.
Anders als /retry blockiert dieser Endpoint nicht bei status=done. Optionaler Body: `{"model": "qwen2.5:14b"}` überschreibt das LLM nur
Sichert Summary/Protokoll/DOCX/PDF in runs/<ts>-<modell>/ als Snapshot für diesen Lauf. Leer/fehlend bisheriges job.model bleibt, leer Mac-Default.
(Transkript bleibt im Hauptordner), startet die Pipeline neu die
Mac-aktuell konfigurierte Ollama-Variante wird dabei verwendet. 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) job = _require_access(session.get(Job, job_id), user)
if job.status in _ACTIVE_STATUSES: if job.status in _ACTIVE_STATUSES:
raise HTTPException(409, "Job läuft gerade — bitte warten oder Diagnose prüfen") 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") 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) job_dir = settings.result_dir / str(job.id)
runs_dir = job_dir / "runs" / run_id runs_dir = job_dir / "runs" / run_id
@ -201,7 +211,7 @@ async def reprocess_job(
json.dumps( json.dumps(
{ {
"run_id": run_id, "run_id": run_id,
"model": model, "model": snapshot_model,
"profile": job.profile, "profile": job.profile,
"snapshot_at": ts, "snapshot_at": ts,
"previous_status": job.status, "previous_status": job.status,
@ -213,6 +223,9 @@ async def reprocess_job(
encoding="utf-8", 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.status = JobStatus.QUEUED
job.progress = 0 job.progress = 0
job.error = "" job.error = ""
@ -223,7 +236,12 @@ async def reprocess_job(
session.refresh(job) session.refresh(job)
background.add_task(run_pipeline, job.id) 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") @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, "progress": j.progress,
"error": j.error, "error": j.error,
"profile": j.profile or "meeting", "profile": j.profile or "meeting",
# Leer = Worker-Default; UI zeigt das als „Standard".
"model": j.model or "",
"created_at": j.created_at.isoformat(), "created_at": j.created_at.isoformat(),
"updated_at": j.updated_at.isoformat(), "updated_at": j.updated_at.isoformat(),
"step_started_at": j.step_started_at.isoformat() if j.step_started_at else None, "step_started_at": j.step_started_at.isoformat() if j.step_started_at else None,

View File

@ -86,6 +86,13 @@ class MacClient:
r.raise_for_status() r.raise_for_status()
return r.json().get("profiles", []) return r.json().get("profiles", [])
async def list_models(self) -> dict:
"""Liefert {models: [...], default: <worker .env default>}."""
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/api/models")
r.raise_for_status()
return r.json()
async def fetch_log(self, job_id: int, last: int = 200) -> list[str]: async def fetch_log(self, job_id: int, last: int = 200) -> list[str]:
"""Holt die letzten Mac-Worker-Logzeilen für einen Job (für die Diagnose-Anzeige).""" """Holt die letzten Mac-Worker-Logzeilen für einen Job (für die Diagnose-Anzeige)."""
try: try:
@ -97,11 +104,12 @@ class MacClient:
log.warning("fetch_log job=%s failed: %s", job_id, e) log.warning("fetch_log job=%s failed: %s", job_id, e)
return [] return []
async def preload(self, profile: str | None = None, job_id: int | None = None) -> dict: async def preload(self, profile: str | None = None, model: str | None = None,
job_id: int | None = None) -> dict:
"""Vor-Heizen: Ollama soll das Modell laden, bevor der echte Call läuft.""" """Vor-Heizen: Ollama soll das Modell laden, bevor der echte Call läuft."""
return await self._post_json_retry( return await self._post_json_retry(
f"{self.base_url}/api/preload", f"{self.base_url}/api/preload",
{"profile": profile}, {"profile": profile, "model": model},
job_id, job_id,
) )
@ -122,18 +130,19 @@ class MacClient:
return r.json() return r.json()
async def summarize(self, transcript: str, title: str = "", profile: str | None = None, async def summarize(self, transcript: str, title: str = "", profile: str | None = None,
job_id: int | None = None) -> dict: model: str | None = None, job_id: int | None = None) -> dict:
return await self._post_json_retry( return await self._post_json_retry(
f"{self.base_url}/api/summarize", f"{self.base_url}/api/summarize",
{"transcript": transcript, "title": title, "profile": profile}, {"transcript": transcript, "title": title, "profile": profile, "model": model},
job_id, job_id,
) )
async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None, async def protocol(self, transcript: str, summary: dict, title: str = "", profile: str | None = None,
job_id: int | None = None) -> dict: model: str | None = None, job_id: int | None = None) -> dict:
return await self._post_json_retry( return await self._post_json_retry(
f"{self.base_url}/api/protocol", f"{self.base_url}/api/protocol",
{"transcript": transcript, "summary": summary, "title": title, "profile": profile}, {"transcript": transcript, "summary": summary, "title": title, "profile": profile,
"model": model},
job_id, job_id,
) )

View File

@ -55,6 +55,16 @@ def run_migrations() -> None:
log.info("Migration: füge job.last_heartbeat_at hinzu") log.info("Migration: füge job.last_heartbeat_at hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN last_heartbeat_at DATETIME")) conn.execute(text("ALTER TABLE job ADD COLUMN last_heartbeat_at DATETIME"))
# 6) Modell-Auswahl: user.default_model + job.model
user_cols = _existing_columns(conn, "user")
if user_cols and "default_model" not in user_cols:
log.info("Migration: füge user.default_model hinzu (leer = Worker-Default)")
conn.execute(text("ALTER TABLE user ADD COLUMN default_model VARCHAR DEFAULT ''"))
cols = _existing_columns(conn, "job")
if "model" not in cols:
log.info("Migration: füge job.model hinzu (leer = Worker-Default)")
conn.execute(text("ALTER TABLE job ADD COLUMN model VARCHAR DEFAULT ''"))
def assign_orphan_jobs_to(user_id: int) -> int: def assign_orphan_jobs_to(user_id: int) -> int:
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück.""" """Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""

View File

@ -81,3 +81,21 @@ async def mac_health():
return {"reachable": True, "mac": data} return {"reachable": True, "mac": data}
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return JSONResponse(status_code=502, content={"reachable": False, "error": str(e)}) return JSONResponse(status_code=502, content={"reachable": False, "error": str(e)})
@app.get("/api/mac/models")
async def mac_models():
"""Proxiet die Modell-Liste vom Mac-Worker an die UI.
Antwort: {"models": [{"name", "size", "modified_at"}], "default": "<.env-Default>"}
Bei nicht erreichbarem Mac: leere Liste + Hinweis, damit die UI eine
klare Fehlermeldung anzeigen kann statt ein leeres Dropdown.
"""
try:
data = await MacClient().list_models()
return data
except Exception as e: # noqa: BLE001
return JSONResponse(
status_code=502,
content={"models": [], "default": "", "error": str(e)},
)

View File

@ -28,6 +28,10 @@ class Job(SQLModel, table=True):
protocol_path: str = "" protocol_path: str = ""
docx_path: str = "" docx_path: str = ""
pdf_path: str = "" pdf_path: str = ""
# LLM-Modell für diesen Job — leer = Worker-Default. Wird beim Erstellen
# aus dem Upload-Form oder dem User-Default gefüllt; bleibt auch nach
# Reprocess als Audit-Spur erhalten (mit dem aktuell gewählten Modell).
model: str = ""
step_started_at: Optional[datetime] = Field(default=None) step_started_at: Optional[datetime] = Field(default=None)
last_heartbeat_at: Optional[datetime] = Field(default=None) last_heartbeat_at: Optional[datetime] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@ -10,4 +10,7 @@ class User(SQLModel, table=True):
password_hash: str password_hash: str
is_admin: bool = Field(default=False) is_admin: bool = Field(default=False)
default_profile: str = Field(default="meeting") default_profile: str = Field(default="meeting")
# Leerer Wert = der Worker-Default aus der Mac-.env wird verwendet.
# Sonst überschreibt diese Auswahl das LLM pro Job — kein Worker-Restart nötig.
default_model: str = Field(default="")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@ -97,6 +97,8 @@ async def run_pipeline(job_id: int) -> None:
audio_path = settings.upload_dir / job.filename audio_path = settings.upload_dir / job.filename
title = job.title or audio_path.stem title = job.title or audio_path.stem
profile = job.profile or "meeting" profile = job.profile or "meeting"
# Leeres `model` → Mac-Worker nutzt seinen .env-Default.
model = (job.model or "").strip() or None
existing_tx_path = _existing(job.transcript_path) existing_tx_path = _existing(job.transcript_path)
existing_sum_path = _existing(job.summary_path) existing_sum_path = _existing(job.summary_path)
existing_pro_path = _existing(job.protocol_path) existing_pro_path = _existing(job.protocol_path)
@ -131,16 +133,17 @@ async def run_pipeline(job_id: int) -> None:
_update(job_id, status=JobStatus.SUMMARIZING, progress=70) _update(job_id, status=JobStatus.SUMMARIZING, progress=70)
else: else:
_start_step(job_id, JobStatus.SUMMARIZING, 50) _start_step(job_id, JobStatus.SUMMARIZING, 50)
log.info("[job %s] preload model", job_id) log.info("[job %s] preload model=%s", job_id, model or "(worker default)")
try: try:
await _with_heartbeat(job_id, client.preload(profile=profile, job_id=job_id)) await _with_heartbeat(job_id, client.preload(profile=profile, model=model, job_id=job_id))
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.warning("[job %s] preload failed (non-fatal): %s", job_id, e) log.warning("[job %s] preload failed (non-fatal): %s", job_id, e)
_update(job_id, progress=55) _update(job_id, progress=55)
log.info("[job %s] summarize", job_id) log.info("[job %s] summarize", job_id)
summary = await _with_heartbeat( summary = await _with_heartbeat(
job_id, job_id,
client.summarize(transcript_text, title=title, profile=profile, job_id=job_id), client.summarize(transcript_text, title=title, profile=profile, model=model,
job_id=job_id),
) )
summary_path = job_dir / "summary.json" summary_path = job_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
@ -156,7 +159,8 @@ async def run_pipeline(job_id: int) -> None:
log.info("[job %s] protocol", job_id) log.info("[job %s] protocol", job_id)
protocol = await _with_heartbeat( protocol = await _with_heartbeat(
job_id, job_id,
client.protocol(transcript_text, summary, title=title, profile=profile, job_id=job_id), client.protocol(transcript_text, summary, title=title, profile=profile, model=model,
job_id=job_id),
) )
protocol_path = job_dir / "protocol.json" protocol_path = job_dir / "protocol.json"
protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8") protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8")

View File

@ -36,6 +36,8 @@ const profileSelect = document.getElementById("profile-select");
const defaultProfileName = document.getElementById("default-profile-name"); const defaultProfileName = document.getElementById("default-profile-name");
const headerProfileWrap = document.getElementById("header-profile-wrap"); const headerProfileWrap = document.getElementById("header-profile-wrap");
const headerProfile = document.getElementById("header-profile"); const headerProfile = document.getElementById("header-profile");
const headerModelWrap = document.getElementById("header-model-wrap");
const headerModel = document.getElementById("header-model");
const jobsCards = document.getElementById("jobs-cards"); const jobsCards = document.getElementById("jobs-cards");
const adminToggle = document.getElementById("admin-toggle"); const adminToggle = document.getElementById("admin-toggle");
@ -52,9 +54,11 @@ const recordResult = document.getElementById("record-result");
const recordFilename = document.getElementById("record-filename"); const recordFilename = document.getElementById("record-filename");
const recordDiscardBtn = document.getElementById("record-discard"); const recordDiscardBtn = document.getElementById("record-discard");
let currentUser = null; // { id, username, is_admin, default_profile } let currentUser = null; // { id, username, is_admin, default_profile, default_model }
let mode = "login"; // "login" | "setup" let mode = "login"; // "login" | "setup"
let availableProfiles = []; // [{ name, display_name, language }, ...] let availableProfiles = []; // [{ name, display_name, language }, ...]
let availableModels = []; // [{ name, size, modified_at }, ...]
let workerDefaultModel = ""; // Mac-Worker .env-Default (Fallback wenn User nichts wählt)
const DOWNLOADS = [ const DOWNLOADS = [
{ kind: "pdf", label: "PDF", title: "Protokoll als PDF" }, { kind: "pdf", label: "PDF", title: "Protokoll als PDF" },
@ -184,6 +188,7 @@ function enterApp() {
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin); userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
adminCard.classList.toggle("hidden", !currentUser.is_admin); adminCard.classList.toggle("hidden", !currentUser.is_admin);
loadProfiles().then(applyProfileUI); loadProfiles().then(applyProfileUI);
loadModels().then(applyModelUI);
loadJobs(); loadJobs();
if (currentUser.is_admin) loadUsers(); if (currentUser.is_admin) loadUsers();
} }
@ -226,6 +231,68 @@ function applyProfileUI() {
defaultProfileName.textContent = profileLabelOf(def); defaultProfileName.textContent = profileLabelOf(def);
} }
async function loadModels() {
try {
const r = await api("/api/mac/models");
const data = await r.json().catch(() => ({}));
availableModels = data.models || [];
workerDefaultModel = data.default || "";
} catch (e) {
console.error("Modelle laden fehlgeschlagen", e);
availableModels = [];
workerDefaultModel = "";
}
}
function modelLabelOf(name) {
if (!name) return workerDefaultModel ? `Standard (${workerDefaultModel})` : "Standard";
return name;
}
function applyModelUI() {
// Wenn der Mac keine Modelle meldet, Dropdown verstecken — wir wollen
// keinen leeren Selector zeigen, der dem User vortäuscht, er hätte Wahl.
const showDropdown = availableModels.length > 0;
headerModelWrap.classList.toggle("hidden", !showDropdown);
if (!showDropdown) return;
headerModel.innerHTML = "";
// Erste Option: kein Override → Worker-Default
const opt0 = document.createElement("option");
opt0.value = "";
opt0.textContent = workerDefaultModel
? `Worker-Default (${workerDefaultModel})`
: "Worker-Default";
headerModel.appendChild(opt0);
availableModels.forEach((m) => {
const o = document.createElement("option");
o.value = m.name;
const sizeGb = m.size ? `${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : "";
o.textContent = `${m.name}${sizeGb}`;
headerModel.appendChild(o);
});
headerModel.value = currentUser.default_model || "";
}
headerModel.addEventListener("change", async () => {
const newDefault = headerModel.value;
headerModel.disabled = true;
try {
const r = await api("/api/auth/me", {
method: "PATCH",
body: JSON.stringify({ default_model: newDefault }),
});
if (!r.ok) throw new Error("save failed");
currentUser = await r.json();
} catch (e) {
alert("Konnte Standard-Modell nicht speichern.");
headerModel.value = currentUser.default_model || "";
} finally {
headerModel.disabled = false;
}
});
// Sofort-Speichern, sobald der User im Header das Standard-Profil ändert. // Sofort-Speichern, sobald der User im Header das Standard-Profil ändert.
headerProfile.addEventListener("change", async () => { headerProfile.addEventListener("change", async () => {
const newDefault = headerProfile.value; const newDefault = headerProfile.value;
@ -336,6 +403,11 @@ function row(job) {
const profileTag = showProfileTag const profileTag = showProfileTag
? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>` ? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
: ""; : "";
// Modell-Tag erscheint nur bei expliziter Auswahl — leeres job.model heißt
// "Worker-Default", und das wollen wir nicht jedem Job anhängen.
const modelTag = job.model
? `<span class="tag-model" title="LLM-Modell für diesen Job">${escapeHtml(job.model)}</span>`
: "";
const retryBtn = job.status !== "done" const retryBtn = job.status !== "done"
? `<button type="button" class="btn-link btn-retry" data-id="${job.id}" title="Verarbeitung wiederholen (Transkript bleibt erhalten)">↻ Erneut versuchen</button>` ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}" title="Verarbeitung wiederholen (Transkript bleibt erhalten)">↻ Erneut versuchen</button>`
@ -353,7 +425,7 @@ function row(job) {
tr.innerHTML = ` tr.innerHTML = `
<td class="col-id">#${job.id}</td> <td class="col-id">#${job.id}</td>
<td class="col-title">${escapeHtml(title)} ${profileTag}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td> <td class="col-title">${escapeHtml(title)} ${profileTag} ${modelTag}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td>
<td class="col-status">${statusCell}</td> <td class="col-status">${statusCell}</td>
<td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td> <td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
<td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td> <td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td>
@ -388,6 +460,9 @@ function card(job) {
const profileTag = showProfileTag const profileTag = showProfileTag
? `<span class="tag-profile">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>` ? `<span class="tag-profile">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
: ""; : "";
const modelTag = job.model
? `<span class="tag-model" title="LLM-Modell">${escapeHtml(job.model)}</span>`
: "";
const ownerLine = currentUser?.is_admin && job.owner_username const ownerLine = currentUser?.is_admin && job.owner_username
? `<div class="jc-sub">von ${escapeHtml(job.owner_username)}</div>` : ""; ? `<div class="jc-sub">von ${escapeHtml(job.owner_username)}</div>` : "";
@ -401,7 +476,7 @@ function card(job) {
div.innerHTML = ` div.innerHTML = `
<div class="jc-head"> <div class="jc-head">
<div> <div>
<div class="jc-title">${escapeHtml(title)} ${profileTag}</div> <div class="jc-title">${escapeHtml(title)} ${profileTag} ${modelTag}</div>
<div class="jc-sub">${escapeHtml(job.original_name)} · <span class="jc-id">#${job.id}</span></div> <div class="jc-sub">${escapeHtml(job.original_name)} · <span class="jc-id">#${job.id}</span></div>
${ownerLine} ${ownerLine}
</div> </div>
@ -931,37 +1006,83 @@ document.addEventListener("click", async (ev) => {
} }
}); });
// Reprocess-Button: erlaubt LLM-Lauf auch auf done/failed-Jobs, sichert vorheriges function askModelChoice(jobId, currentJobModel) {
// Ergebnis als Snapshot und nutzt das aktuell auf dem Mac konfigurierte Modell. // Minimal-Modal mit nativem <dialog>. Resolved auf `null` (Abbruch) oder
// den Modellnamen ("" = Worker-Default).
return new Promise((resolve) => {
if (!availableModels.length) {
// Wenn Mac keine Liste meldet → klassischer Fallback-Bestätigungsdialog.
const ok = confirm(
`Job #${jobId} erneut verarbeiten?\n\n` +
`Bisheriges Ergebnis wird im Verlauf gesichert. Das Transkript bleibt unverändert.`
);
resolve(ok ? (currentJobModel || "") : null);
return;
}
const dlg = document.createElement("dialog");
dlg.className = "model-picker";
const opts = [`<option value="">— Worker-Default${workerDefaultModel ? ` (${escapeHtml(workerDefaultModel)})` : ""} —</option>`]
.concat(availableModels.map((m) => {
const sel = m.name === currentJobModel ? "selected" : "";
const sz = m.size ? `${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : "";
return `<option value="${escapeHtml(m.name)}" ${sel}>${escapeHtml(m.name)}${sz}</option>`;
})).join("");
const hint = currentJobModel
? `Bisher: <code>${escapeHtml(currentJobModel)}</code>`
: `Bisher: <code>(Worker-Default)</code>`;
dlg.innerHTML = `
<form method="dialog">
<h3>Job #${jobId} mit anderem Modell verarbeiten</h3>
<p class="muted">${hint}. Bisheriges Ergebnis wird im Verlauf gesichert, Transkript bleibt erhalten.</p>
<label class="model-picker-label">Modell für den nächsten Lauf
<select name="model">${opts}</select>
</label>
<div class="dlg-actions">
<button type="submit" value="cancel">Abbrechen</button>
<button type="submit" value="ok" class="primary">Starten</button>
</div>
</form>
`;
document.body.appendChild(dlg);
dlg.addEventListener("close", () => {
const action = dlg.returnValue;
const sel = dlg.querySelector("select[name=model]");
const value = sel ? sel.value : "";
dlg.remove();
resolve(action === "ok" ? value : null);
});
dlg.showModal();
});
}
// Reprocess-Button: erlaubt LLM-Lauf auch auf done/failed-Jobs. Öffnet einen
// Modell-Picker — Snapshot des bisherigen Outputs wird automatisch gesichert.
document.addEventListener("click", async (ev) => { document.addEventListener("click", async (ev) => {
const btn = ev.target.closest(".btn-reprocess"); const btn = ev.target.closest(".btn-reprocess");
if (!btn) return; if (!btn) return;
ev.preventDefault(); ev.preventDefault();
const id = btn.dataset.id; const id = btn.dataset.id;
// Aktuelles Mac-Modell ziehen, damit der Bestätigungsdialog ehrlich anzeigt, // Aktuelles job.model aus der gerenderten Zeile lesen (Tag-Inhalt) —
// welches Modell jetzt gleich verwendet wird. // notfalls aus letztem Job-Render-Stand greifen. Simpel: nochmal /api/jobs/{id}.
let model = "(unbekannt)"; let currentJobModel = "";
try { try {
const h = await api("/api/mac/health"); const r = await api(`/api/jobs/${id}`);
if (h.ok) { if (r.ok) currentJobModel = (await r.json()).model || "";
const j = await h.json();
model = j.mac?.ollama_model || model;
}
} catch {} } catch {}
const ok = confirm( const chosen = await askModelChoice(id, currentJobModel);
`Job #${id} erneut mit dem aktuellen Mac-Modell "${model}" verarbeiten?\n\n` + if (chosen === null) return; // Abbruch
`Das bisherige Ergebnis (Summary, Protokoll, DOCX, PDF) wird im Verlauf als Snapshot gesichert. ` +
`Das Transkript bleibt unverändert — nur die LLM-Schritte laufen neu.`
);
if (!ok) return;
btn.disabled = true; btn.disabled = true;
const oldText = btn.textContent; const oldText = btn.textContent;
btn.textContent = "wird gestartet …"; btn.textContent = "wird gestartet …";
try { try {
const r = await api(`/api/jobs/${id}/reprocess`, { method: "POST" }); const r = await api(`/api/jobs/${id}/reprocess`, {
method: "POST",
body: JSON.stringify({ model: chosen }),
});
if (!r.ok) { if (!r.ok) {
const d = await r.json().catch(() => ({})); const d = await r.json().catch(() => ({}));
alert(d.detail || "Konnte nicht neu verarbeiten."); alert(d.detail || "Konnte nicht neu verarbeiten.");
@ -969,7 +1090,6 @@ document.addEventListener("click", async (ev) => {
btn.textContent = oldText; btn.textContent = oldText;
return; return;
} }
// Verlauf öffnen, damit man den frischen Snapshot direkt sieht.
openRunsIds.add(String(id)); openRunsIds.add(String(id));
loadJobs(); loadJobs();
} catch (e) { } catch (e) {

View File

@ -32,6 +32,11 @@
<span class="muted desktop-only">Profil:</span> <span class="muted desktop-only">Profil:</span>
<select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select> <select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select>
</span> </span>
<span id="header-model-wrap" class="header-profile-wrap hidden">
<span class="muted desktop-only">·</span>
<span class="muted desktop-only">Modell:</span>
<select id="header-model" class="header-profile" title="Standard-LLM — wird sofort gespeichert. Leer = Worker-Default."></select>
</span>
<button type="button" id="logout-btn" class="btn-link">Abmelden</button> <button type="button" id="logout-btn" class="btn-link">Abmelden</button>
</div> </div>
</div> </div>

View File

@ -561,6 +561,80 @@ select {
vertical-align: middle; vertical-align: middle;
} }
/* Modell-Tag — gleicher Stil wie Profile-Tag, andere Farbe (lila) */
.tag-model {
display: inline-block;
padding: 1px 8px;
margin-left: 4px;
border-radius: 4px;
background: rgba(155,89,182,.10);
border: 1px solid rgba(155,89,182,.4);
color: #b07cd6;
font-size: 11px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
vertical-align: middle;
}
/* Modal für Modell-Auswahl beim Reprocess */
dialog.model-picker {
border: 1px solid rgba(255,255,255,.15);
border-radius: 8px;
background: #1a1d23;
color: inherit;
padding: 18px 20px;
max-width: 460px;
width: 90vw;
}
dialog.model-picker::backdrop {
background: rgba(0,0,0,.55);
}
dialog.model-picker h3 {
margin: 0 0 6px 0;
font-size: 15px;
}
dialog.model-picker .model-picker-label {
display: block;
margin: 12px 0 4px;
font-size: 13px;
}
dialog.model-picker select {
display: block;
width: 100%;
margin-top: 6px;
padding: 6px 8px;
background: rgba(0,0,0,.30);
border: 1px solid rgba(255,255,255,.20);
color: inherit;
border-radius: 4px;
font-size: 13px;
}
dialog.model-picker .dlg-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
}
dialog.model-picker button {
padding: 6px 14px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.18);
color: inherit;
border-radius: 4px;
cursor: pointer;
}
dialog.model-picker button.primary {
background: rgba(155,89,182,.20);
border-color: rgba(155,89,182,.55);
color: #d3afe6;
}
dialog.model-picker button:hover { background: rgba(255,255,255,.12); }
dialog.model-picker code {
background: rgba(255,255,255,.06);
padding: 1px 5px;
border-radius: 3px;
font-size: 11.5px;
}
/* Live-Timing direkt unter dem Status-Pill (läuft seit · letzter Ping vor) */ /* Live-Timing direkt unter dem Status-Pill (läuft seit · letzter Ping vor) */
.status-timing, .jc-timing { .status-timing, .jc-timing {
margin-top: 4px; margin-top: 4px;

View File

@ -52,7 +52,15 @@ def render(template: str, **vars: str) -> str:
return out return out
async def _chat(prompt: str) -> str: def _resolve_model(model: str | None) -> str:
"""Wählt das tatsächlich zu verwendende Ollama-Modell.
Leerer oder fehlender `model`-Parameter Worker-Default aus .env.
"""
return (model or "").strip() or settings.ollama_model
async def _chat(prompt: str, model: str | None = None) -> str:
"""Ruft Ollama im Streaming-Modus auf. """Ruft Ollama im Streaming-Modus auf.
Streaming hat zwei wichtige Vorteile: Streaming hat zwei wichtige Vorteile:
@ -64,15 +72,16 @@ async def _chat(prompt: str) -> str:
`keep_alive`: das Modell bleibt 30 min im RAM zwischen Calls, `keep_alive`: das Modell bleibt 30 min im RAM zwischen Calls,
damit nachfolgende Anfragen nicht erneut die Ladezeit zahlen. damit nachfolgende Anfragen nicht erneut die Ladezeit zahlen.
""" """
use_model = _resolve_model(model)
url = f"{settings.ollama_url.rstrip('/')}/api/generate" url = f"{settings.ollama_url.rstrip('/')}/api/generate"
payload = { payload = {
"model": settings.ollama_model, "model": use_model,
"prompt": prompt, "prompt": prompt,
"stream": True, "stream": True,
"keep_alive": "30m", "keep_alive": "30m",
"options": {"temperature": 0.2}, "options": {"temperature": 0.2},
} }
log.info("Ollama generate model=%s prompt_len=%d (streaming)", settings.ollama_model, len(prompt)) log.info("Ollama generate model=%s prompt_len=%d (streaming)", use_model, len(prompt))
timeout = httpx.Timeout( timeout = httpx.Timeout(
connect=15.0, connect=15.0,
read=settings.ollama_timeout, # pro Chunk — großzügig wegen initial Modell-Load read=settings.ollama_timeout, # pro Chunk — großzügig wegen initial Modell-Load
@ -100,15 +109,17 @@ async def _chat(prompt: str) -> str:
return "".join(chunks) return "".join(chunks)
async def summarize(transcript: str, title: str = "", profile_name: str | None = None) -> dict[str, Any]: async def summarize(
transcript: str, title: str = "", profile_name: str | None = None, model: str | None = None,
) -> dict[str, Any]:
profile = profiles.get(profile_name) profile = profiles.get(profile_name)
prompt = render( prompt = render(
profile.summary_prompt, profile.summary_prompt,
title=title or "(ohne Titel)", title=title or "(ohne Titel)",
transcript=_truncate(transcript), transcript=_truncate(transcript),
) )
log.info("Summarize profile=%s", profile.name) log.info("Summarize profile=%s model=%s", profile.name, _resolve_model(model))
raw = await _chat(prompt) raw = await _chat(prompt, model=model)
try: try:
return _parse_json(raw) return _parse_json(raw)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@ -117,7 +128,8 @@ async def summarize(transcript: str, title: str = "", profile_name: str | None =
async def make_protocol( async def make_protocol(
transcript: str, summary: dict, title: str = "", profile_name: str | None = None transcript: str, summary: dict, title: str = "", profile_name: str | None = None,
model: str | None = None,
) -> dict: ) -> dict:
profile = profiles.get(profile_name) profile = profiles.get(profile_name)
prompt = render( prompt = render(
@ -126,8 +138,8 @@ async def make_protocol(
summary_json=json.dumps(summary, ensure_ascii=False), summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript), transcript=_truncate(transcript),
) )
log.info("Protocol profile=%s", profile.name) log.info("Protocol profile=%s model=%s", profile.name, _resolve_model(model))
raw = await _chat(prompt) raw = await _chat(prompt, model=model)
try: try:
return _parse_json(raw) return _parse_json(raw)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@ -143,3 +155,23 @@ async def health() -> dict:
r = await c.get(url) r = await c.get(url)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def list_models() -> list[dict]:
"""Liefert die lokal gepullten Ollama-Modelle inkl. Größe und Mod-Zeit.
Antwort-Form pro Eintrag: {"name", "size", "modified_at"} (Größe in Bytes).
"""
url = f"{settings.ollama_url.rstrip('/')}/api/tags"
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(url)
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("models", []):
out.append({
"name": m.get("name") or m.get("model") or "",
"size": m.get("size", 0),
"modified_at": m.get("modified_at", ""),
})
return [m for m in out if m["name"]]

View File

@ -68,6 +68,7 @@ class SummarizeIn(BaseModel):
transcript: str transcript: str
title: str = "" title: str = ""
profile: str | None = None profile: str | None = None
model: str | None = None
class ProtocolIn(BaseModel): class ProtocolIn(BaseModel):
@ -75,6 +76,7 @@ class ProtocolIn(BaseModel):
summary: dict summary: dict
title: str = "" title: str = ""
profile: str | None = None profile: str | None = None
model: str | None = None
class DocxIn(BaseModel): class DocxIn(BaseModel):
@ -89,6 +91,7 @@ class PdfIn(BaseModel):
class PreloadIn(BaseModel): class PreloadIn(BaseModel):
profile: str | None = None profile: str | None = None
model: str | None = None
@app.get("/health") @app.get("/health")
@ -114,6 +117,21 @@ def list_profiles():
return {"profiles": profiles.list_all()} 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") @app.post("/api/transcribe")
async def transcribe(audio: UploadFile = File(...), language: str = Form("de")): async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
if not audio.filename: if not audio.filename:
@ -135,7 +153,9 @@ async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
async def summarize(payload: SummarizeIn): async def summarize(payload: SummarizeIn):
if not payload.transcript.strip(): if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript") raise HTTPException(400, "Leerer Transkript")
return await ollama_client.summarize(payload.transcript, title=payload.title, profile_name=payload.profile) return await ollama_client.summarize(
payload.transcript, title=payload.title, profile_name=payload.profile, model=payload.model
)
@app.post("/api/protocol") @app.post("/api/protocol")
@ -143,7 +163,8 @@ async def protocol(payload: ProtocolIn):
if not payload.transcript.strip(): if not payload.transcript.strip():
raise HTTPException(400, "Leerer Transkript") raise HTTPException(400, "Leerer Transkript")
return await ollama_client.make_protocol( return await ollama_client.make_protocol(
payload.transcript, payload.summary, title=payload.title, profile_name=payload.profile payload.transcript, payload.summary, title=payload.title, profile_name=payload.profile,
model=payload.model,
) )
@ -176,10 +197,11 @@ async def preload(payload: PreloadIn):
(häufige Ursache von 'Server disconnected without sending a response'). (häufige Ursache von 'Server disconnected without sending a response').
""" """
profile_obj = profiles.get(payload.profile) profile_obj = profiles.get(payload.profile)
log.info("preload model=%s profile=%s", settings.ollama_model, profile_obj.name) 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" url = f"{settings.ollama_url.rstrip('/')}/api/generate"
body = { body = {
"model": settings.ollama_model, "model": use_model,
"prompt": "ping", "prompt": "ping",
"stream": False, "stream": False,
"keep_alive": "30m", "keep_alive": "30m",
@ -195,7 +217,7 @@ async def preload(payload: PreloadIn):
log.warning("preload failed: %s", e) log.warning("preload failed: %s", e)
raise HTTPException(503, f"Ollama preload fehlgeschlagen: {e}") from 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)) 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)} return {"ok": True, "model": use_model, "load_duration_ns": data.get("load_duration", 0)}
@app.get("/api/jobs/{job_id}/log") @app.get("/api/jobs/{job_id}/log")