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,
"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)

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,

View File

@ -86,6 +86,13 @@ class MacClient:
r.raise_for_status()
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]:
"""Holt die letzten Mac-Worker-Logzeilen für einen Job (für die Diagnose-Anzeige)."""
try:
@ -97,11 +104,12 @@ class MacClient:
log.warning("fetch_log job=%s failed: %s", job_id, e)
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."""
return await self._post_json_retry(
f"{self.base_url}/api/preload",
{"profile": profile},
{"profile": profile, "model": model},
job_id,
)
@ -122,18 +130,19 @@ class MacClient:
return r.json()
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(
f"{self.base_url}/api/summarize",
{"transcript": transcript, "title": title, "profile": profile},
{"transcript": transcript, "title": title, "profile": profile, "model": model},
job_id,
)
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(
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,
)

View File

@ -55,6 +55,16 @@ def run_migrations() -> None:
log.info("Migration: füge job.last_heartbeat_at hinzu")
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:
"""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}
except Exception as e: # noqa: BLE001
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 = ""
docx_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)
last_heartbeat_at: Optional[datetime] = Field(default=None)
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
is_admin: bool = Field(default=False)
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))

View File

@ -97,6 +97,8 @@ async def run_pipeline(job_id: int) -> None:
audio_path = settings.upload_dir / job.filename
title = job.title or audio_path.stem
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_sum_path = _existing(job.summary_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)
else:
_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:
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
log.warning("[job %s] preload failed (non-fatal): %s", job_id, e)
_update(job_id, progress=55)
log.info("[job %s] summarize", job_id)
summary = await _with_heartbeat(
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.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)
protocol = await _with_heartbeat(
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.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 headerProfileWrap = document.getElementById("header-profile-wrap");
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 adminToggle = document.getElementById("admin-toggle");
@ -52,9 +54,11 @@ const recordResult = document.getElementById("record-result");
const recordFilename = document.getElementById("record-filename");
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 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 = [
{ kind: "pdf", label: "PDF", title: "Protokoll als PDF" },
@ -184,6 +188,7 @@ function enterApp() {
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
adminCard.classList.toggle("hidden", !currentUser.is_admin);
loadProfiles().then(applyProfileUI);
loadModels().then(applyModelUI);
loadJobs();
if (currentUser.is_admin) loadUsers();
}
@ -226,6 +231,68 @@ function applyProfileUI() {
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.
headerProfile.addEventListener("change", async () => {
const newDefault = headerProfile.value;
@ -336,6 +403,11 @@ function row(job) {
const profileTag = showProfileTag
? `<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"
? `<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 = `
<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-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>
@ -388,6 +460,9 @@ function card(job) {
const profileTag = showProfileTag
? `<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
? `<div class="jc-sub">von ${escapeHtml(job.owner_username)}</div>` : "";
@ -401,7 +476,7 @@ function card(job) {
div.innerHTML = `
<div class="jc-head">
<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>
${ownerLine}
</div>
@ -931,37 +1006,83 @@ document.addEventListener("click", async (ev) => {
}
});
// Reprocess-Button: erlaubt LLM-Lauf auch auf done/failed-Jobs, sichert vorheriges
// Ergebnis als Snapshot und nutzt das aktuell auf dem Mac konfigurierte Modell.
function askModelChoice(jobId, currentJobModel) {
// 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) => {
const btn = ev.target.closest(".btn-reprocess");
if (!btn) return;
ev.preventDefault();
const id = btn.dataset.id;
// Aktuelles Mac-Modell ziehen, damit der Bestätigungsdialog ehrlich anzeigt,
// welches Modell jetzt gleich verwendet wird.
let model = "(unbekannt)";
// Aktuelles job.model aus der gerenderten Zeile lesen (Tag-Inhalt) —
// notfalls aus letztem Job-Render-Stand greifen. Simpel: nochmal /api/jobs/{id}.
let currentJobModel = "";
try {
const h = await api("/api/mac/health");
if (h.ok) {
const j = await h.json();
model = j.mac?.ollama_model || model;
}
const r = await api(`/api/jobs/${id}`);
if (r.ok) currentJobModel = (await r.json()).model || "";
} catch {}
const ok = confirm(
`Job #${id} erneut mit dem aktuellen Mac-Modell "${model}" verarbeiten?\n\n` +
`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;
const chosen = await askModelChoice(id, currentJobModel);
if (chosen === null) return; // Abbruch
btn.disabled = true;
const oldText = btn.textContent;
btn.textContent = "wird gestartet …";
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) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Konnte nicht neu verarbeiten.");
@ -969,7 +1090,6 @@ document.addEventListener("click", async (ev) => {
btn.textContent = oldText;
return;
}
// Verlauf öffnen, damit man den frischen Snapshot direkt sieht.
openRunsIds.add(String(id));
loadJobs();
} catch (e) {

View File

@ -32,6 +32,11 @@
<span class="muted desktop-only">Profil:</span>
<select id="header-profile" class="header-profile" title="Standard-Profil — wird sofort gespeichert"></select>
</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>
</div>
</div>

View File

@ -561,6 +561,80 @@ select {
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) */
.status-timing, .jc-timing {
margin-top: 4px;

View File

@ -52,7 +52,15 @@ def render(template: str, **vars: str) -> str:
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.
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,
damit nachfolgende Anfragen nicht erneut die Ladezeit zahlen.
"""
use_model = _resolve_model(model)
url = f"{settings.ollama_url.rstrip('/')}/api/generate"
payload = {
"model": settings.ollama_model,
"model": use_model,
"prompt": prompt,
"stream": True,
"keep_alive": "30m",
"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(
connect=15.0,
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)
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)
prompt = render(
profile.summary_prompt,
title=title or "(ohne Titel)",
transcript=_truncate(transcript),
)
log.info("Summarize profile=%s", profile.name)
raw = await _chat(prompt)
log.info("Summarize profile=%s model=%s", profile.name, _resolve_model(model))
raw = await _chat(prompt, model=model)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
@ -117,7 +128,8 @@ async def summarize(transcript: str, title: str = "", profile_name: str | None =
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:
profile = profiles.get(profile_name)
prompt = render(
@ -126,8 +138,8 @@ async def make_protocol(
summary_json=json.dumps(summary, ensure_ascii=False),
transcript=_truncate(transcript),
)
log.info("Protocol profile=%s", profile.name)
raw = await _chat(prompt)
log.info("Protocol profile=%s model=%s", profile.name, _resolve_model(model))
raw = await _chat(prompt, model=model)
try:
return _parse_json(raw)
except Exception: # noqa: BLE001
@ -143,3 +155,23 @@ async def health() -> dict:
r = await c.get(url)
r.raise_for_status()
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
title: str = ""
profile: str | None = None
model: str | None = None
class ProtocolIn(BaseModel):
@ -75,6 +76,7 @@ class ProtocolIn(BaseModel):
summary: dict
title: str = ""
profile: str | None = None
model: str | None = None
class DocxIn(BaseModel):
@ -89,6 +91,7 @@ class PdfIn(BaseModel):
class PreloadIn(BaseModel):
profile: str | None = None
model: str | None = None
@app.get("/health")
@ -114,6 +117,21 @@ 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:
@ -135,7 +153,9 @@ async def transcribe(audio: UploadFile = File(...), language: str = Form("de")):
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)
return await ollama_client.summarize(
payload.transcript, title=payload.title, profile_name=payload.profile, model=payload.model
)
@app.post("/api/protocol")
@ -143,7 +163,8 @@ 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
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').
"""
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"
body = {
"model": settings.ollama_model,
"model": use_model,
"prompt": "ping",
"stream": False,
"keep_alive": "30m",
@ -195,7 +217,7 @@ async def preload(payload: PreloadIn):
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": 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")