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:
parent
031d52c77a
commit
dbf7bca6c7
@ -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."""
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user