From dbf7bca6c7e413d948744397b4fcf8fa6679a26b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 16 May 2026 07:58:23 +0000 Subject: [PATCH] feat(jobs): "Mit anderem Modell verarbeiten" + Verlauf pro Job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Endpoint POST /api/jobs/{id}/reprocess: erlaubt einen LLM-Lauf auch auf done/failed-Jobs, sichert Summary/Protokoll/DOCX/PDF in results//runs/-/ als Snapshot, leert die DB-Pfade, startet die Pipeline mit dem aktuell auf dem Mac konfigurierten Ollama-Modell. Transkript bleibt erhalten. GET /api/jobs/{id}/runs liefert die Snapshot-Liste, GET /api/jobs/{id}/runs/{run_id}/{file} den Einzeldownload. UI: zwei neue Buttons auf done/failed-Jobs — "↻ Mit anderem Modell" und "Verlauf ▾". Reprocess-Dialog zeigt das aktuell konfigurierte Mac-Modell an, bevor er startet, damit kein Versehen passiert. Verlauf-Panel listet pro Lauf Modellname + Zeitstempel + Download- Links für die Snapshot-Dateien. Co-Authored-By: Claude Opus 4.7 (1M context) --- lxc-frontend/app/api/jobs.py | 154 +++++++++++++++++++++++++++ lxc-frontend/app/static/app.js | 167 +++++++++++++++++++++++++++++- lxc-frontend/app/static/style.css | 66 ++++++++++++ 3 files changed, 385 insertions(+), 2 deletions(-) diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py index 4a431d9..e095f9d 100644 --- a/lxc-frontend/app/api/jobs.py +++ b/lxc-frontend/app/api/jobs.py @@ -1,4 +1,7 @@ +import json +import re import secrets +from datetime import datetime, timezone from pathlib import Path import aiofiles @@ -17,6 +20,31 @@ router = APIRouter(prefix="/api/jobs", tags=["jobs"]) MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024 +# Status, in denen der Job aktiv läuft — Reprocess wird dann verweigert, +# damit kein Schreibwettlauf mit der laufenden Pipeline entsteht. +_ACTIVE_STATUSES = { + JobStatus.QUEUED, + JobStatus.TRANSCRIBING, + JobStatus.SUMMARIZING, + JobStatus.PROTOCOLLING, + JobStatus.EXPORTING, +} + + +def _slug(s: str) -> str: + """Dateisystem-sichere Variante eines Modellnamens (z. B. 'gpt-oss:latest' → 'gpt-oss_latest').""" + return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_") or "unknown" + + +async def _current_mac_model() -> str: + """Aktuelles Ollama-Modell vom Mac-Worker — fällt auf 'unknown' zurück, wenn unerreichbar.""" + try: + from app.core.mac_client import MacClient + h = await MacClient().health() + return h.get("ollama_model") or "unknown" + except Exception: # noqa: BLE001 + return "unknown" + def _require_access(job: Job | None, user: User) -> Job: """404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert).""" @@ -130,6 +158,132 @@ async def retry_job( return _job_dict(job, user) +@router.post("/{job_id}/reprocess") +async def reprocess_job( + job_id: int, + background: BackgroundTasks, + 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. + """ + 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() + ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + run_id = f"{ts}-{_slug(model)}" + + job_dir = settings.result_dir / str(job.id) + runs_dir = job_dir / "runs" / run_id + runs_dir.mkdir(parents=True, exist_ok=True) + + moved: list[str] = [] + for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"): + path_str = getattr(job, attr) + if path_str: + src = Path(path_str) + if src.exists(): + try: + src.replace(runs_dir / src.name) + moved.append(src.name) + except OSError: + pass + setattr(job, attr, "") + + (runs_dir / "meta.json").write_text( + json.dumps( + { + "run_id": run_id, + "model": model, + "profile": job.profile, + "snapshot_at": ts, + "previous_status": job.status, + "files": moved, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + job.status = JobStatus.QUEUED + job.progress = 0 + job.error = "" + job.step_started_at = None + job.last_heartbeat_at = None + session.add(job) + session.commit() + session.refresh(job) + + background.add_task(run_pipeline, job.id) + return {"job": _job_dict(job, user), "snapshot": run_id, "model_for_new_run": model} + + +@router.get("/{job_id}/runs") +def list_runs( + job_id: int, + session: Session = Depends(get_session), + user: User = Depends(current_user), +): + """Listet vergangene LLM-Läufe (Snapshots) eines Jobs.""" + job = _require_access(session.get(Job, job_id), user) + runs_dir = settings.result_dir / str(job.id) / "runs" + out: list[dict] = [] + if runs_dir.exists(): + for d in sorted(runs_dir.iterdir(), reverse=True): + if not d.is_dir(): + continue + meta: dict = {} + try: + meta = json.loads((d / "meta.json").read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + pass + out.append({ + "run_id": d.name, + "model": meta.get("model", "unknown"), + "snapshot_at": meta.get("snapshot_at", ""), + "profile": meta.get("profile", job.profile), + "files": [ + {"name": f.name, "size": f.stat().st_size} + for f in sorted(d.iterdir()) + if f.is_file() and f.name != "meta.json" + ], + }) + return {"runs": out} + + +@router.get("/{job_id}/runs/{run_id}/{filename}") +def download_run_artifact( + job_id: int, + run_id: str, + filename: str, + session: Session = Depends(get_session), + user: User = Depends(current_user), +): + """Liefert eine einzelne Datei aus einem Snapshot-Lauf zum Download.""" + job = _require_access(session.get(Job, job_id), user) + if "/" in run_id or ".." in run_id or "/" in filename or ".." in filename: + raise HTTPException(400, "Ungültige Pfad-Komponenten") + target = settings.result_dir / str(job.id) / "runs" / run_id / filename + if not target.exists(): + raise HTTPException(404, "Snapshot-Datei nicht gefunden") + media_map = { + ".json": "application/json", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".pdf": "application/pdf", + } + media = media_map.get(target.suffix.lower(), "application/octet-stream") + base = (job.title or Path(job.original_name).stem).replace("/", "_") + return FileResponse(target, media_type=media, filename=f"{base}-{run_id}-{filename}") + + @router.get("/{job_id}/diag") async def diag(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)): """Diagnose-Bündel: Job-Status + letzte Mac-Worker-Logzeilen für genau diesen Job.""" diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index 40198f1..8e5990e 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -85,9 +85,12 @@ function fmtSecs(sec) { const RUNNING_STATES = new Set(["queued", "transcribing", "summarizing", "protocolling", "exporting"]); const DIAG_STATES = new Set([...RUNNING_STATES, "failed"]); +// Auf welchen Jobs darf das LLM mit aktuellem Mac-Modell neu angefordert werden? +const REPROCESS_STATES = new Set(["done", "failed"]); -// Welche Jobs haben aktuell ein offenes Diag-Panel? Über Re-Renders hinweg merken. +// Offene Panels über Re-Renders hinweg merken — pro Panel eine Set-Instanz. const openDiagIds = new Set(); +const openRunsIds = new Set(); function liveTimingHtml(job) { if (!RUNNING_STATES.has(job.status) || !job.step_started_at) return ""; @@ -108,6 +111,17 @@ function diagButtonHtml(job) { return ``; } +function runsButtonHtml(job) { + if (!REPROCESS_STATES.has(job.status)) return ""; + const open = openRunsIds.has(String(job.id)); + return ``; +} + +function reprocessButtonHtml(job) { + if (!REPROCESS_STATES.has(job.status)) return ""; + return ``; +} + async function api(path, opts = {}) { const r = await fetch(path, { credentials: "same-origin", @@ -332,7 +346,9 @@ function row(job) { ${timing ? `
${timing}
` : ""} ${job.error ? `
${escapeHtml(job.error)}
` : ""} ${retryBtn} + ${reprocessButtonHtml(job)} ${diagButtonHtml(job)} + ${runsButtonHtml(job)} `; tr.innerHTML = ` @@ -355,6 +371,15 @@ function diagRow(job) { return tr; } +// Dritte Tabellenzeile für das Verlauf-Panel (vergangene LLM-Läufe). +function runsRow(job) { + const tr = document.createElement("tr"); + tr.className = "runs-row hidden"; + tr.dataset.runsId = String(job.id); + tr.innerHTML = `
Lade Verlauf …
`; + return tr; +} + function card(job) { const div = document.createElement("div"); div.className = "jc"; @@ -390,11 +415,16 @@ function card(job) { ${job.error ? `
${escapeHtml(job.error)}
` : ""}
${job.status !== "done" ? `` : ""} + ${reprocessButtonHtml(job)} ${diagButtonHtml(job)} + ${runsButtonHtml(job)}
+
aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}
${dlButtons}
`; @@ -417,13 +447,18 @@ async function loadJobs() { jobs.forEach((j) => { jobsBody.appendChild(row(j)); jobsBody.appendChild(diagRow(j)); + jobsBody.appendChild(runsRow(j)); jobsCards.appendChild(card(j)); }); - // Vorher offene Diagnosen wieder aufklappen + frisch nachladen. + // Vorher offene Panels wieder aufklappen + frisch nachladen. openDiagIds.forEach((id) => { document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden")); refreshDiag(id); }); + openRunsIds.forEach((id) => { + document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.remove("hidden")); + refreshRuns(id); + }); } catch (e) { console.error(e); } @@ -456,6 +491,67 @@ function renderDiag(diag) { `; } +function fmtBytes(n) { + if (!isFinite(n) || n < 0) return "?"; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / 1024 / 1024).toFixed(2)} MB`; +} + +function fmtRunStamp(s) { + // run-IDs haben das Format YYYYMMDDTHHMMSSZ- — wir machen daraus eine + // lesbare Zeit (UTC → lokal). + const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-/.exec(s || ""); + if (!m) return s || ""; + const [, y, mo, d, h, mi, se] = m; + const iso = `${y}-${mo}-${d}T${h}:${mi}:${se}Z`; + return new Date(iso).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" }); +} + +function renderRuns(jobId, data) { + const runs = data.runs || []; + if (!runs.length) { + return `
Noch keine alternativen Läufe gespeichert. Nutze „↻ Mit anderem Modell" um den aktuellen Mac-Modell-Output als neuen Lauf zu erzeugen — der bisherige bleibt dann im Verlauf erhalten.
`; + } + const blocks = runs.map((r) => { + const files = (r.files || []).map((f) => { + const url = `/api/jobs/${jobId}/runs/${encodeURIComponent(r.run_id)}/${encodeURIComponent(f.name)}`; + return `${escapeHtml(f.name)}`; + }).join(""); + return ` +
+
+ ${escapeHtml(r.model)} + ${escapeHtml(fmtRunStamp(r.run_id))} + Profil: ${escapeHtml(r.profile)} +
+
${files || '(keine Dateien)'}
+
`; + }).join(""); + return ` +
${runs.length} ${runs.length === 1 ? "vergangener Lauf" : "vergangene Läufe"} — neuestes Ergebnis siehst du wie gewohnt im Job selbst.
+
${blocks}
+ `; +} + +async function refreshRuns(jobId) { + let data; + try { + const r = await api(`/api/jobs/${jobId}/runs`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + data = await r.json(); + } catch (e) { + document.querySelectorAll(`[data-runs-id="${jobId}"] .runs-content`).forEach((el) => { + el.innerHTML = `
Verlauf konnte nicht geladen werden: ${escapeHtml(e.message)}
`; + }); + return; + } + const html = renderRuns(jobId, data); + document.querySelectorAll(`[data-runs-id="${jobId}"] .runs-content`).forEach((el) => { + el.innerHTML = html; + }); +} + async function refreshDiag(jobId) { let data; try { @@ -493,6 +589,25 @@ document.addEventListener("click", (ev) => { } }); +// Verlauf-Toggle (Snapshot-Liste vergangener LLM-Läufe) +document.addEventListener("click", (ev) => { + const btn = ev.target.closest(".btn-runs"); + if (!btn) return; + ev.preventDefault(); + const id = btn.dataset.id; + const wasOpen = openRunsIds.has(id); + if (wasOpen) { + openRunsIds.delete(id); + document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.add("hidden")); + btn.textContent = "Verlauf ▾"; + } else { + openRunsIds.add(id); + document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.remove("hidden")); + btn.textContent = "Verlauf ▴"; + refreshRuns(id); + } +}); + form.addEventListener("submit", (ev) => { ev.preventDefault(); // Quelle: Aufnahme bevorzugt, sonst File-Input @@ -816,6 +931,54 @@ 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. +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)"; + try { + const h = await api("/api/mac/health"); + if (h.ok) { + const j = await h.json(); + model = j.mac?.ollama_model || 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; + + btn.disabled = true; + const oldText = btn.textContent; + btn.textContent = "wird gestartet …"; + try { + const r = await api(`/api/jobs/${id}/reprocess`, { method: "POST" }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + alert(d.detail || "Konnte nicht neu verarbeiten."); + btn.disabled = false; + btn.textContent = oldText; + return; + } + // Verlauf öffnen, damit man den frischen Snapshot direkt sieht. + openRunsIds.add(String(id)); + loadJobs(); + } catch (e) { + alert("Netzwerkfehler beim Reprocess."); + btn.disabled = false; + btn.textContent = oldText; + } +}); + // ─── Init ────────────────────────────────────────────────────────────────── bootstrap(); checkMac(); diff --git a/lxc-frontend/app/static/style.css b/lxc-frontend/app/static/style.css index 31ba8c7..7ae88ef 100644 --- a/lxc-frontend/app/static/style.css +++ b/lxc-frontend/app/static/style.css @@ -569,6 +569,72 @@ select { } .live-timing { color: var(--muted); } +/* Reprocess-Button (mit anderem Modell neu verarbeiten) – violetter Akzent, + um ihn klar vom warning-gelben "Erneut versuchen" zu trennen */ +.btn-reprocess { + display: inline-block; + margin-top: 6px; + margin-left: 4px; + padding: 3px 8px; + font-size: 11px; + font-weight: 500; + background: rgba(155,89,182,.14); + color: #b07cd6; + border: 1px solid rgba(155,89,182,.45); + border-radius: 5px; + cursor: pointer; +} +.btn-reprocess:hover { background: rgba(155,89,182,.24); } +.btn-reprocess:disabled { opacity: .55; cursor: not-allowed; } + +/* Verlauf-Toggle – wie Diagnose, aber leicht andere Farbe */ +.btn-runs { + display: inline-block; + margin-top: 6px; + margin-left: 4px; + padding: 4px 10px; + font-size: 12px; + background: rgba(52,152,219,.10); + border: 1px solid rgba(52,152,219,.35); + color: #7fbce8; + border-radius: 5px; + cursor: pointer; +} +.btn-runs:hover { background: rgba(52,152,219,.20); } + +/* Verlauf-Panel: gleiches Grundlayout wie diag, separates Styling für Listen-Items */ +.runs-row > td { + background: rgba(52,152,219,.04); + border-top: 0; + padding: 10px 12px; +} +.runs-panel { + margin-top: 10px; + padding: 10px; + background: rgba(52,152,219,.05); + border: 1px solid rgba(52,152,219,.18); + border-radius: 6px; +} +.runs-meta { font-size: 12px; margin-bottom: 8px; } +.runs-list { display: flex; flex-direction: column; gap: 8px; } +.run-item { + padding: 8px 10px; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.08); + border-radius: 5px; +} +.run-head { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: baseline; + font-size: 12px; + margin-bottom: 6px; +} +.run-model code { font-size: 12px; padding: 1px 5px; } +.run-stamp { font-size: 11.5px; color: var(--muted); } +.run-files { display: flex; flex-wrap: wrap; gap: 6px; } + /* Diagnose-Button – wie btn-retry, aber neutraler Ton */ .btn-diag { display: inline-block;