feat(jobs): "Mit anderem Modell verarbeiten" + Verlauf pro Job

Neuer Endpoint POST /api/jobs/{id}/reprocess: erlaubt einen LLM-Lauf
auch auf done/failed-Jobs, sichert Summary/Protokoll/DOCX/PDF in
results/<id>/runs/<ts>-<modell>/ 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) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-16 07:58:23 +00:00
co-authored by Claude Opus 4.7
parent 031d52c77a
commit dbf7bca6c7
3 changed files with 385 additions and 2 deletions
+165 -2
View File
@@ -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 `<button type="button" class="btn-link btn-diag" data-id="${job.id}">Diagnose ${open ? "▴" : "▾"}</button>`;
}
function runsButtonHtml(job) {
if (!REPROCESS_STATES.has(job.status)) return "";
const open = openRunsIds.has(String(job.id));
return `<button type="button" class="btn-link btn-runs" data-id="${job.id}" title="Verlauf der LLM-Läufe (Snapshots) für diesen Job">Verlauf ${open ? "▴" : "▾"}</button>`;
}
function reprocessButtonHtml(job) {
if (!REPROCESS_STATES.has(job.status)) return "";
return `<button type="button" class="btn-link btn-reprocess" data-id="${job.id}" title="Mit dem aktuell auf dem Mac konfigurierten Ollama-Modell neu verarbeiten — bisheriges Ergebnis wird im Verlauf gesichert">↻ Mit anderem Modell</button>`;
}
async function api(path, opts = {}) {
const r = await fetch(path, {
credentials: "same-origin",
@@ -332,7 +346,9 @@ function row(job) {
${timing ? `<div class="status-timing">${timing}</div>` : ""}
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
${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 = `<td colspan="6"><div class="runs-content"><div class="muted">Lade Verlauf …</div></div></td>`;
return tr;
}
function card(job) {
const div = document.createElement("div");
div.className = "jc";
@@ -390,11 +415,16 @@ function card(job) {
${job.error ? `<div class="jc-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
<div class="jc-actions">
${job.status !== "done" ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}">↻ Erneut versuchen</button>` : ""}
${reprocessButtonHtml(job)}
${diagButtonHtml(job)}
${runsButtonHtml(job)}
</div>
<div class="diag-panel hidden" data-diag-id="${job.id}">
<div class="diag-content"><div class="muted">Lade Diagnose …</div></div>
</div>
<div class="runs-panel hidden" data-runs-id="${job.id}">
<div class="runs-content"><div class="muted">Lade Verlauf …</div></div>
</div>
<div class="jc-sub">aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}</div>
<div class="jc-dl">${dlButtons}</div>
`;
@@ -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-<modell> — 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 `<div class="muted">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.</div>`;
}
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 `<a class="btn-dl" href="${url}" title="${escapeHtml(f.name)} (${fmtBytes(f.size)}) herunterladen">${escapeHtml(f.name)}</a>`;
}).join("");
return `
<div class="run-item">
<div class="run-head">
<span class="run-model"><code>${escapeHtml(r.model)}</code></span>
<span class="run-stamp">${escapeHtml(fmtRunStamp(r.run_id))}</span>
<span class="muted">Profil: <code>${escapeHtml(r.profile)}</code></span>
</div>
<div class="run-files">${files || '<span class="muted">(keine Dateien)</span>'}</div>
</div>`;
}).join("");
return `
<div class="runs-meta muted">${runs.length} ${runs.length === 1 ? "vergangener Lauf" : "vergangene Läufe"} — neuestes Ergebnis siehst du wie gewohnt im Job selbst.</div>
<div class="runs-list">${blocks}</div>
`;
}
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 = `<div class="diag-err">Verlauf konnte nicht geladen werden: ${escapeHtml(e.message)}</div>`;
});
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();
+66
View File
@@ -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;