feat: Retry-Funktion + idempotente Pipeline (Transkript bleibt erhalten)
Backend:
- POST /api/jobs/{id}/retry — löscht summary/protocol/docx, queued den Job
neu und startet Pipeline. Transkript bleibt erhalten (teurer Schritt).
- Pipeline überspringt Steps, deren Output-Datei schon existiert →
retry rennt direkt in summarize ohne erneute Transkription.
Frontend:
- Kleiner gelber '↻ Erneut versuchen'-Button in jeder Job-Zeile/-Karte
wenn Status != done
- Confirm-Dialog erklärt was passiert
- Event-Delegation, funktioniert für Tabelle (Desktop) und Cards (Mobile)
This commit is contained in:
parent
24ac3ac058
commit
220b7ccbc3
@ -90,6 +90,44 @@ def get_job(job_id: int, session: Session = Depends(get_session), user: User = D
|
|||||||
return _job_dict(job, user)
|
return _job_dict(job, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/retry")
|
||||||
|
async def retry_job(
|
||||||
|
job_id: int,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
):
|
||||||
|
"""Verarbeitung neu anstoßen.
|
||||||
|
|
||||||
|
Bereits vorhandenes Transkript wird beibehalten (teuerster Schritt),
|
||||||
|
Summary/Protokoll/DOCX werden gelöscht und neu erzeugt. Sinnvoll wenn
|
||||||
|
der Job bei summarize/protocol/docx hängt oder failed ist.
|
||||||
|
"""
|
||||||
|
job = _require_access(session.get(Job, job_id), user)
|
||||||
|
if job.status == JobStatus.DONE:
|
||||||
|
raise HTTPException(400, "Job ist bereits abgeschlossen")
|
||||||
|
|
||||||
|
# Post-Transkriptions-Artefakte löschen, damit sie neu erzeugt werden.
|
||||||
|
for attr in ("summary_path", "protocol_path", "docx_path"):
|
||||||
|
p = getattr(job, attr)
|
||||||
|
if p:
|
||||||
|
try:
|
||||||
|
Path(p).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
setattr(job, attr, "")
|
||||||
|
|
||||||
|
job.status = JobStatus.QUEUED
|
||||||
|
job.progress = 0
|
||||||
|
job.error = ""
|
||||||
|
session.add(job)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(job)
|
||||||
|
|
||||||
|
background.add_task(run_pipeline, job.id)
|
||||||
|
return _job_dict(job, user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{job_id}/download/{kind}")
|
@router.get("/{job_id}/download/{kind}")
|
||||||
def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
|
def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
|
||||||
job = _require_access(session.get(Job, job_id), user)
|
job = _require_access(session.get(Job, job_id), user)
|
||||||
|
|||||||
@ -25,8 +25,19 @@ def _update(job_id: int, **fields) -> None:
|
|||||||
s.commit()
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _existing(path_str: str) -> Path | None:
|
||||||
|
if not path_str:
|
||||||
|
return None
|
||||||
|
p = Path(path_str)
|
||||||
|
return p if p.exists() and p.stat().st_size > 0 else None
|
||||||
|
|
||||||
|
|
||||||
async def run_pipeline(job_id: int) -> None:
|
async def run_pipeline(job_id: int) -> None:
|
||||||
"""Run the full pipeline: transcribe → summarize → protocol → DOCX."""
|
"""Pipeline: transcribe → summarize → protocol → DOCX.
|
||||||
|
|
||||||
|
Idempotent: bereits vorhandene Zwischenergebnisse werden weiterverwendet,
|
||||||
|
damit ein 'Erneut versuchen' nicht die teure Transkription wiederholt.
|
||||||
|
"""
|
||||||
client = MacClient()
|
client = MacClient()
|
||||||
|
|
||||||
with Session(engine) as s:
|
with Session(engine) as s:
|
||||||
@ -37,40 +48,69 @@ async def run_pipeline(job_id: int) -> None:
|
|||||||
audio_path = settings.upload_dir / job.filename
|
audio_path = settings.upload_dir / job.filename
|
||||||
title = job.title or audio_path.stem
|
title = job.title or audio_path.stem
|
||||||
profile = job.profile or "meeting"
|
profile = job.profile or "meeting"
|
||||||
|
existing_tx_path = _existing(job.transcript_path)
|
||||||
|
existing_sum_path = _existing(job.summary_path)
|
||||||
|
existing_pro_path = _existing(job.protocol_path)
|
||||||
|
existing_dx_path = _existing(job.docx_path)
|
||||||
|
|
||||||
job_dir = settings.result_dir / str(job_id)
|
job_dir = settings.result_dir / str(job_id)
|
||||||
job_dir.mkdir(parents=True, exist_ok=True)
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_update(job_id, status=JobStatus.TRANSCRIBING, progress=10)
|
# 1) TRANSKRIPTION
|
||||||
log.info("[job %s] transcribe (profile=%s)", job_id, profile)
|
if existing_tx_path:
|
||||||
tx = await client.transcribe(audio_path, language="de")
|
log.info("[job %s] reuse transcript %s", job_id, existing_tx_path.name)
|
||||||
transcript_path = job_dir / "transcript.json"
|
tx = json.loads(existing_tx_path.read_text(encoding="utf-8"))
|
||||||
transcript_path.write_text(json.dumps(tx, ensure_ascii=False, indent=2), encoding="utf-8")
|
_update(job_id, status=JobStatus.TRANSCRIBING, progress=45)
|
||||||
_update(job_id, transcript_path=str(transcript_path), progress=45)
|
else:
|
||||||
|
_update(job_id, status=JobStatus.TRANSCRIBING, progress=10, error="")
|
||||||
|
log.info("[job %s] transcribe (profile=%s)", job_id, profile)
|
||||||
|
tx = await client.transcribe(audio_path, language="de")
|
||||||
|
transcript_path = job_dir / "transcript.json"
|
||||||
|
transcript_path.write_text(json.dumps(tx, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
_update(job_id, transcript_path=str(transcript_path), progress=45)
|
||||||
|
|
||||||
transcript_text = tx.get("text") or "\n".join(s.get("text", "") for s in tx.get("segments", []))
|
transcript_text = tx.get("text") or "\n".join(
|
||||||
|
s.get("text", "") for s in tx.get("segments", [])
|
||||||
|
)
|
||||||
|
|
||||||
_update(job_id, status=JobStatus.SUMMARIZING, progress=55)
|
# 2) SUMMARY
|
||||||
log.info("[job %s] summarize", job_id)
|
if existing_sum_path:
|
||||||
summary = await client.summarize(transcript_text, title=title, profile=profile)
|
log.info("[job %s] reuse summary %s", job_id, existing_sum_path.name)
|
||||||
summary_path = job_dir / "summary.json"
|
summary = json.loads(existing_sum_path.read_text(encoding="utf-8"))
|
||||||
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
_update(job_id, status=JobStatus.SUMMARIZING, progress=70)
|
||||||
_update(job_id, summary_path=str(summary_path), progress=70)
|
else:
|
||||||
|
_update(job_id, status=JobStatus.SUMMARIZING, progress=55)
|
||||||
|
log.info("[job %s] summarize", job_id)
|
||||||
|
summary = await client.summarize(transcript_text, title=title, profile=profile)
|
||||||
|
summary_path = job_dir / "summary.json"
|
||||||
|
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
_update(job_id, summary_path=str(summary_path), progress=70)
|
||||||
|
|
||||||
_update(job_id, status=JobStatus.PROTOCOLLING, progress=75)
|
# 3) PROTOKOLL
|
||||||
log.info("[job %s] protocol", job_id)
|
if existing_pro_path:
|
||||||
protocol = await client.protocol(transcript_text, summary, title=title, profile=profile)
|
log.info("[job %s] reuse protocol %s", job_id, existing_pro_path.name)
|
||||||
protocol_path = job_dir / "protocol.json"
|
protocol = json.loads(existing_pro_path.read_text(encoding="utf-8"))
|
||||||
protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8")
|
_update(job_id, status=JobStatus.PROTOCOLLING, progress=85)
|
||||||
_update(job_id, protocol_path=str(protocol_path), progress=85)
|
else:
|
||||||
|
_update(job_id, status=JobStatus.PROTOCOLLING, progress=75)
|
||||||
|
log.info("[job %s] protocol", job_id)
|
||||||
|
protocol = await client.protocol(transcript_text, summary, title=title, profile=profile)
|
||||||
|
protocol_path = job_dir / "protocol.json"
|
||||||
|
protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
_update(job_id, protocol_path=str(protocol_path), progress=85)
|
||||||
|
|
||||||
_update(job_id, status=JobStatus.EXPORTING, progress=90)
|
# 4) DOCX
|
||||||
log.info("[job %s] export docx", job_id)
|
if existing_dx_path:
|
||||||
docx_bytes = await client.export_docx(protocol, profile=profile)
|
log.info("[job %s] reuse docx %s", job_id, existing_dx_path.name)
|
||||||
docx_path = job_dir / "protocol.docx"
|
_update(job_id, status=JobStatus.DONE, progress=100, error="")
|
||||||
docx_path.write_bytes(docx_bytes)
|
else:
|
||||||
_update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100)
|
_update(job_id, status=JobStatus.EXPORTING, progress=90)
|
||||||
|
log.info("[job %s] export docx", job_id)
|
||||||
|
docx_bytes = await client.export_docx(protocol, profile=profile)
|
||||||
|
docx_path = job_dir / "protocol.docx"
|
||||||
|
docx_path.write_bytes(docx_bytes)
|
||||||
|
_update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100, error="")
|
||||||
log.info("[job %s] done", job_id)
|
log.info("[job %s] done", job_id)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.exception("[job %s] failed", job_id)
|
log.exception("[job %s] failed", job_id)
|
||||||
|
|||||||
@ -290,9 +290,13 @@ function row(job) {
|
|||||||
? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
|
? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</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>`
|
||||||
|
: "";
|
||||||
const statusCell = `
|
const statusCell = `
|
||||||
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
|
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
|
||||||
${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}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
@ -337,6 +341,7 @@ function card(job) {
|
|||||||
<small class="muted">${job.progress}%</small>
|
<small class="muted">${job.progress}%</small>
|
||||||
</div>
|
</div>
|
||||||
${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>` : ""}
|
||||||
|
${job.status !== "done" ? `<button type="button" class="btn-link btn-retry" data-id="${job.id}">↻ Erneut versuchen</button>` : ""}
|
||||||
<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>
|
||||||
`;
|
`;
|
||||||
@ -666,6 +671,28 @@ adminToggle.addEventListener("click", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retry-Button: funktioniert für Tabelle und Karten gleichzeitig (Event-Delegation)
|
||||||
|
document.addEventListener("click", async (ev) => {
|
||||||
|
const btn = ev.target.closest(".btn-retry");
|
||||||
|
if (!btn) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
if (!confirm("Verarbeitung erneut starten?\nDas Transkript bleibt erhalten — nur Zusammenfassung, Protokoll und DOCX werden neu erzeugt.")) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "wird gestartet …";
|
||||||
|
try {
|
||||||
|
const r = await api(`/api/jobs/${id}/retry`, { method: "POST" });
|
||||||
|
if (!r.ok) {
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
alert(d.detail || "Konnte nicht neu starten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadJobs();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Netzwerkfehler beim Neustart.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Init ──────────────────────────────────────────────────────────────────
|
// ─── Init ──────────────────────────────────────────────────────────────────
|
||||||
bootstrap();
|
bootstrap();
|
||||||
checkMac();
|
checkMac();
|
||||||
|
|||||||
@ -530,6 +530,23 @@ select {
|
|||||||
main { padding-bottom: max(20px, env(safe-area-inset-bottom)); }
|
main { padding-bottom: max(20px, env(safe-area-inset-bottom)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Retry-Button in der Status-Spalte */
|
||||||
|
.btn-retry {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(241,196,15,.12);
|
||||||
|
color: var(--warn);
|
||||||
|
border: 1px solid rgba(241,196,15,.4);
|
||||||
|
border-radius: 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-retry:hover { background: rgba(241,196,15,.22); text-decoration: none; }
|
||||||
|
.btn-retry:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
|
||||||
/* Profile-Tag in der Job-Zeile */
|
/* Profile-Tag in der Job-Zeile */
|
||||||
.tag-profile {
|
.tag-profile {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user