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:
parent
dbf7bca6c7
commit
c6164d946c
@ -38,11 +38,16 @@ def _user_dict(u: User) -> dict:
|
||||
"username": u.username,
|
||||
"is_admin": u.is_admin,
|
||||
"default_profile": u.default_profile or "meeting",
|
||||
# Leerer String = User hat keine Modell-Wahl getroffen → Mac-Worker
|
||||
# nimmt seinen .env-Default. UI zeigt das als „Standard (Worker)".
|
||||
"default_model": u.default_model or "",
|
||||
}
|
||||
|
||||
|
||||
class UpdateMeIn(BaseModel):
|
||||
default_profile: str | None = Field(default=None, max_length=64)
|
||||
# Leerer String bedeutet bewusst „kein Override" — wird gespeichert.
|
||||
default_model: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@ -52,8 +57,14 @@ def me(user: User = Depends(current_user)):
|
||||
|
||||
@router.patch("/me")
|
||||
def update_me(payload: UpdateMeIn, user: User = Depends(current_user), session: Session = Depends(get_session)):
|
||||
changed = False
|
||||
if payload.default_profile is not None:
|
||||
user.default_profile = payload.default_profile.strip() or "meeting"
|
||||
changed = True
|
||||
if payload.default_model is not None:
|
||||
user.default_model = payload.default_model.strip()
|
||||
changed = True
|
||||
if changed:
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@ -59,6 +59,7 @@ async def create_job(
|
||||
audio: UploadFile = File(...),
|
||||
title: str = Form(""),
|
||||
profile: str = Form(""),
|
||||
model: str = Form(""),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_user),
|
||||
):
|
||||
@ -81,9 +82,13 @@ async def create_job(
|
||||
raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
|
||||
await f.write(chunk)
|
||||
|
||||
# Priorität: Form-Eingabe → User-Default → leer (= Worker-Default)
|
||||
chosen_model = model.strip() or (user.default_model or "").strip()
|
||||
|
||||
job = Job(
|
||||
owner_id=user.id,
|
||||
profile=(profile.strip() or user.default_profile or "meeting"),
|
||||
model=chosen_model,
|
||||
filename=safe,
|
||||
original_name=audio.filename,
|
||||
title=title.strip(),
|
||||
@ -162,23 +167,28 @@ async def retry_job(
|
||||
async def reprocess_job(
|
||||
job_id: int,
|
||||
background: BackgroundTasks,
|
||||
payload: dict = Body(default_factory=dict),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(current_user),
|
||||
):
|
||||
"""Erlaubt einen erneuten LLM-Lauf auf einem bereits abgeschlossenen Job.
|
||||
|
||||
Anders als /retry blockiert dieser Endpoint nicht bei status=done.
|
||||
Sichert Summary/Protokoll/DOCX/PDF in runs/<ts>-<modell>/ als Snapshot
|
||||
(Transkript bleibt im Hauptordner), startet die Pipeline neu — die
|
||||
Mac-aktuell konfigurierte Ollama-Variante wird dabei verwendet.
|
||||
Optionaler Body: `{"model": "qwen2.5:14b"}` — überschreibt das LLM nur
|
||||
für diesen Lauf. Leer/fehlend → bisheriges job.model bleibt, leer → Mac-Default.
|
||||
|
||||
Der bisherige Lauf (Summary/Protokoll/DOCX/PDF) wandert in
|
||||
runs/<ts>-<modell>/ als Snapshot. Transkript bleibt im Hauptordner.
|
||||
"""
|
||||
job = _require_access(session.get(Job, job_id), user)
|
||||
if job.status in _ACTIVE_STATUSES:
|
||||
raise HTTPException(409, "Job läuft gerade — bitte warten oder Diagnose prüfen")
|
||||
|
||||
model = await _current_mac_model()
|
||||
new_model_raw = (payload.get("model") or "").strip() if isinstance(payload, dict) else ""
|
||||
# Für den Snapshot zählt das Modell, das den *bisherigen* Output produziert hat —
|
||||
# also das vorherige job.model, oder fallback der aktuelle Worker-Default.
|
||||
snapshot_model = (job.model or "").strip() or await _current_mac_model()
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
run_id = f"{ts}-{_slug(model)}"
|
||||
run_id = f"{ts}-{_slug(snapshot_model)}"
|
||||
|
||||
job_dir = settings.result_dir / str(job.id)
|
||||
runs_dir = job_dir / "runs" / run_id
|
||||
@ -201,7 +211,7 @@ async def reprocess_job(
|
||||
json.dumps(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"model": model,
|
||||
"model": snapshot_model,
|
||||
"profile": job.profile,
|
||||
"snapshot_at": ts,
|
||||
"previous_status": job.status,
|
||||
@ -213,6 +223,9 @@ async def reprocess_job(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Neues Modell für den kommenden Lauf — nur wenn explizit mitgegeben.
|
||||
if new_model_raw:
|
||||
job.model = new_model_raw
|
||||
job.status = JobStatus.QUEUED
|
||||
job.progress = 0
|
||||
job.error = ""
|
||||
@ -223,7 +236,12 @@ async def reprocess_job(
|
||||
session.refresh(job)
|
||||
|
||||
background.add_task(run_pipeline, job.id)
|
||||
return {"job": _job_dict(job, user), "snapshot": run_id, "model_for_new_run": model}
|
||||
return {
|
||||
"job": _job_dict(job, user),
|
||||
"snapshot": run_id,
|
||||
"snapshot_model": snapshot_model,
|
||||
"next_model": job.model or "(worker default)",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{job_id}/runs")
|
||||
@ -327,6 +345,8 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
|
||||
"progress": j.progress,
|
||||
"error": j.error,
|
||||
"profile": j.profile or "meeting",
|
||||
# Leer = Worker-Default; UI zeigt das als „Standard".
|
||||
"model": j.model or "",
|
||||
"created_at": j.created_at.isoformat(),
|
||||
"updated_at": j.updated_at.isoformat(),
|
||||
"step_started_at": j.step_started_at.isoformat() if j.step_started_at else None,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)},
|
||||
)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"]]
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user