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
parent 031d52c77a
commit dbf7bca6c7
3 changed files with 385 additions and 2 deletions

View File

@ -1,4 +1,7 @@
import json
import re
import secrets import secrets
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@ -17,6 +20,31 @@ router = APIRouter(prefix="/api/jobs", tags=["jobs"])
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024 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: def _require_access(job: Job | None, user: User) -> Job:
"""404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert).""" """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) 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/<ts>-<modell>/ 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") @router.get("/{job_id}/diag")
async def diag(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)): 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.""" """Diagnose-Bündel: Job-Status + letzte Mac-Worker-Logzeilen für genau diesen Job."""

View File

@ -85,9 +85,12 @@ function fmtSecs(sec) {
const RUNNING_STATES = new Set(["queued", "transcribing", "summarizing", "protocolling", "exporting"]); const RUNNING_STATES = new Set(["queued", "transcribing", "summarizing", "protocolling", "exporting"]);
const DIAG_STATES = new Set([...RUNNING_STATES, "failed"]); 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 openDiagIds = new Set();
const openRunsIds = new Set();
function liveTimingHtml(job) { function liveTimingHtml(job) {
if (!RUNNING_STATES.has(job.status) || !job.step_started_at) return ""; 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>`; 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 = {}) { async function api(path, opts = {}) {
const r = await fetch(path, { const r = await fetch(path, {
credentials: "same-origin", credentials: "same-origin",
@ -332,7 +346,9 @@ function row(job) {
${timing ? `<div class="status-timing">${timing}</div>` : ""} ${timing ? `<div class="status-timing">${timing}</div>` : ""}
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""} ${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
${retryBtn} ${retryBtn}
${reprocessButtonHtml(job)}
${diagButtonHtml(job)} ${diagButtonHtml(job)}
${runsButtonHtml(job)}
`; `;
tr.innerHTML = ` tr.innerHTML = `
@ -355,6 +371,15 @@ function diagRow(job) {
return tr; 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) { function card(job) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "jc"; div.className = "jc";
@ -390,11 +415,16 @@ function card(job) {
${job.error ? `<div class="jc-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""} ${job.error ? `<div class="jc-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
<div class="jc-actions"> <div class="jc-actions">
${job.status !== "done" ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}">↻ Erneut versuchen</button>` : ""} ${job.status !== "done" ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}">↻ Erneut versuchen</button>` : ""}
${reprocessButtonHtml(job)}
${diagButtonHtml(job)} ${diagButtonHtml(job)}
${runsButtonHtml(job)}
</div> </div>
<div class="diag-panel hidden" data-diag-id="${job.id}"> <div class="diag-panel hidden" data-diag-id="${job.id}">
<div class="diag-content"><div class="muted">Lade Diagnose </div></div> <div class="diag-content"><div class="muted">Lade Diagnose </div></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-sub">aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}</div>
<div class="jc-dl">${dlButtons}</div> <div class="jc-dl">${dlButtons}</div>
`; `;
@ -417,13 +447,18 @@ async function loadJobs() {
jobs.forEach((j) => { jobs.forEach((j) => {
jobsBody.appendChild(row(j)); jobsBody.appendChild(row(j));
jobsBody.appendChild(diagRow(j)); jobsBody.appendChild(diagRow(j));
jobsBody.appendChild(runsRow(j));
jobsCards.appendChild(card(j)); jobsCards.appendChild(card(j));
}); });
// Vorher offene Diagnosen wieder aufklappen + frisch nachladen. // Vorher offene Panels wieder aufklappen + frisch nachladen.
openDiagIds.forEach((id) => { openDiagIds.forEach((id) => {
document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden")); document.querySelectorAll(`[data-diag-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
refreshDiag(id); refreshDiag(id);
}); });
openRunsIds.forEach((id) => {
document.querySelectorAll(`[data-runs-id="${id}"]`).forEach((el) => el.classList.remove("hidden"));
refreshRuns(id);
});
} catch (e) { } catch (e) {
console.error(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) { async function refreshDiag(jobId) {
let data; let data;
try { 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) => { form.addEventListener("submit", (ev) => {
ev.preventDefault(); ev.preventDefault();
// Quelle: Aufnahme bevorzugt, sonst File-Input // 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 ────────────────────────────────────────────────────────────────── // ─── Init ──────────────────────────────────────────────────────────────────
bootstrap(); bootstrap();
checkMac(); checkMac();

View File

@ -569,6 +569,72 @@ select {
} }
.live-timing { color: var(--muted); } .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 */ /* Diagnose-Button wie btn-retry, aber neutraler Ton */
.btn-diag { .btn-diag {
display: inline-block; display: inline-block;