From c6164d946c1601c5e4a94b6e8526481f50df7dff Mon Sep 17 00:00:00 2001 From: root Date: Sat, 16 May 2026 16:48:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(model):=20w=C3=A4hlbares=20LLM-Modell=20pr?= =?UTF-8?q?o=20User=20&=20pro=20Job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lxc-frontend/app/api/auth.py | 11 ++ lxc-frontend/app/api/jobs.py | 38 ++++-- lxc-frontend/app/core/mac_client.py | 21 +++- lxc-frontend/app/core/migrate.py | 10 ++ lxc-frontend/app/main.py | 18 +++ lxc-frontend/app/models/job.py | 4 + lxc-frontend/app/models/user.py | 3 + lxc-frontend/app/services/pipeline.py | 12 +- lxc-frontend/app/static/app.js | 162 ++++++++++++++++++++++---- lxc-frontend/app/static/index.html | 5 + lxc-frontend/app/static/style.css | 74 ++++++++++++ mac-worker/app/core/ollama_client.py | 50 ++++++-- mac-worker/app/main.py | 32 ++++- 13 files changed, 386 insertions(+), 54 deletions(-) diff --git a/lxc-frontend/app/api/auth.py b/lxc-frontend/app/api/auth.py index 4f88050..a4f2426 100644 --- a/lxc-frontend/app/api/auth.py +++ b/lxc-frontend/app/api/auth.py @@ -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) diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py index e095f9d..7eddd95 100644 --- a/lxc-frontend/app/api/jobs.py +++ b/lxc-frontend/app/api/jobs.py @@ -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/-/ 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/-/ 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, diff --git a/lxc-frontend/app/core/mac_client.py b/lxc-frontend/app/core/mac_client.py index bcc225f..079f407 100644 --- a/lxc-frontend/app/core/mac_client.py +++ b/lxc-frontend/app/core/mac_client.py @@ -86,6 +86,13 @@ class MacClient: r.raise_for_status() return r.json().get("profiles", []) + async def list_models(self) -> dict: + """Liefert {models: [...], 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, ) diff --git a/lxc-frontend/app/core/migrate.py b/lxc-frontend/app/core/migrate.py index 62f41b4..799fc5b 100644 --- a/lxc-frontend/app/core/migrate.py +++ b/lxc-frontend/app/core/migrate.py @@ -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.""" diff --git a/lxc-frontend/app/main.py b/lxc-frontend/app/main.py index c0b19b1..14e3f53 100644 --- a/lxc-frontend/app/main.py +++ b/lxc-frontend/app/main.py @@ -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)}, + ) diff --git a/lxc-frontend/app/models/job.py b/lxc-frontend/app/models/job.py index 6b4eb20..ac3a220 100644 --- a/lxc-frontend/app/models/job.py +++ b/lxc-frontend/app/models/job.py @@ -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)) diff --git a/lxc-frontend/app/models/user.py b/lxc-frontend/app/models/user.py index 0539d02..77d970f 100644 --- a/lxc-frontend/app/models/user.py +++ b/lxc-frontend/app/models/user.py @@ -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)) diff --git a/lxc-frontend/app/services/pipeline.py b/lxc-frontend/app/services/pipeline.py index 11b900d..9f3d029 100644 --- a/lxc-frontend/app/services/pipeline.py +++ b/lxc-frontend/app/services/pipeline.py @@ -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") diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index 8e5990e..bf6ff3e 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -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 ? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}` : ""; + // 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 + ? `${escapeHtml(job.model)}` + : ""; const retryBtn = job.status !== "done" ? `` @@ -353,7 +425,7 @@ function row(job) { tr.innerHTML = ` #${job.id} - ${escapeHtml(title)} ${profileTag}
${escapeHtml(job.original_name)}${ownerLine} + ${escapeHtml(title)} ${profileTag} ${modelTag}
${escapeHtml(job.original_name)}${ownerLine} ${statusCell}
${job.progress}% ${fmtDate(job.updated_at)} @@ -388,6 +460,9 @@ function card(job) { const profileTag = showProfileTag ? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}` : ""; + const modelTag = job.model + ? `${escapeHtml(job.model)}` + : ""; const ownerLine = currentUser?.is_admin && job.owner_username ? `
von ${escapeHtml(job.owner_username)}
` : ""; @@ -401,7 +476,7 @@ function card(job) { div.innerHTML = `
-
${escapeHtml(title)} ${profileTag}
+
${escapeHtml(title)} ${profileTag} ${modelTag}
${escapeHtml(job.original_name)} · #${job.id}
${ownerLine}
@@ -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 . 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 = [``] + .concat(availableModels.map((m) => { + const sel = m.name === currentJobModel ? "selected" : ""; + const sz = m.size ? ` — ${(m.size / 1024 / 1024 / 1024).toFixed(1)} GB` : ""; + return ``; + })).join(""); + const hint = currentJobModel + ? `Bisher: ${escapeHtml(currentJobModel)}` + : `Bisher: (Worker-Default)`; + dlg.innerHTML = ` +
+

Job #${jobId} mit anderem Modell verarbeiten

+

${hint}. Bisheriges Ergebnis wird im Verlauf gesichert, Transkript bleibt erhalten.

+ +
+ + +
+
+ `; + 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) { diff --git a/lxc-frontend/app/static/index.html b/lxc-frontend/app/static/index.html index 0bf8f40..7aa4b78 100644 --- a/lxc-frontend/app/static/index.html +++ b/lxc-frontend/app/static/index.html @@ -32,6 +32,11 @@ Profil: +
diff --git a/lxc-frontend/app/static/style.css b/lxc-frontend/app/static/style.css index 7ae88ef..1acbfd6 100644 --- a/lxc-frontend/app/static/style.css +++ b/lxc-frontend/app/static/style.css @@ -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; diff --git a/mac-worker/app/core/ollama_client.py b/mac-worker/app/core/ollama_client.py index 7a4c001..818e617 100644 --- a/mac-worker/app/core/ollama_client.py +++ b/mac-worker/app/core/ollama_client.py @@ -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"]] diff --git a/mac-worker/app/main.py b/mac-worker/app/main.py index cb0f3cb..6b045c2 100644 --- a/mac-worker/app/main.py +++ b/mac-worker/app/main.py @@ -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")