diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py index 8dd3354..d3883cb 100644 --- a/lxc-frontend/app/api/jobs.py +++ b/lxc-frontend/app/api/jobs.py @@ -90,6 +90,44 @@ def get_job(job_id: int, session: Session = Depends(get_session), user: User = D 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}") 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) diff --git a/lxc-frontend/app/services/pipeline.py b/lxc-frontend/app/services/pipeline.py index bc523f6..b0cfa0d 100644 --- a/lxc-frontend/app/services/pipeline.py +++ b/lxc-frontend/app/services/pipeline.py @@ -25,8 +25,19 @@ def _update(job_id: int, **fields) -> None: 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: - """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() with Session(engine) as s: @@ -37,40 +48,69 @@ async def run_pipeline(job_id: int) -> None: audio_path = settings.upload_dir / job.filename title = job.title or audio_path.stem 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.mkdir(parents=True, exist_ok=True) try: - _update(job_id, status=JobStatus.TRANSCRIBING, progress=10) - 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) + # 1) TRANSKRIPTION + if existing_tx_path: + log.info("[job %s] reuse transcript %s", job_id, existing_tx_path.name) + tx = json.loads(existing_tx_path.read_text(encoding="utf-8")) + _update(job_id, status=JobStatus.TRANSCRIBING, 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) - 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) + # 2) SUMMARY + if existing_sum_path: + log.info("[job %s] reuse summary %s", job_id, existing_sum_path.name) + summary = json.loads(existing_sum_path.read_text(encoding="utf-8")) + _update(job_id, status=JobStatus.SUMMARIZING, 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) - 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) + # 3) PROTOKOLL + if existing_pro_path: + log.info("[job %s] reuse protocol %s", job_id, existing_pro_path.name) + protocol = json.loads(existing_pro_path.read_text(encoding="utf-8")) + _update(job_id, status=JobStatus.PROTOCOLLING, 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) - 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) + # 4) DOCX + if existing_dx_path: + log.info("[job %s] reuse docx %s", job_id, existing_dx_path.name) + _update(job_id, status=JobStatus.DONE, progress=100, error="") + else: + _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) except Exception as e: # noqa: BLE001 log.exception("[job %s] failed", job_id) diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index d4e9d55..5d70573 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -290,9 +290,13 @@ function row(job) { ? `${escapeHtml(profileLabelOf(job.profile || "meeting"))}` : ""; + const retryBtn = job.status !== "done" + ? `` + : ""; const statusCell = ` ${escapeHtml(job.status)} ${job.error ? `
${escapeHtml(job.error)}
` : ""} + ${retryBtn} `; tr.innerHTML = ` @@ -337,6 +341,7 @@ function card(job) { ${job.progress}% ${job.error ? `
${escapeHtml(job.error)}
` : ""} + ${job.status !== "done" ? `` : ""}
aktualisiert: ${escapeHtml(fmtDate(job.updated_at))}
${dlButtons}
`; @@ -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 ────────────────────────────────────────────────────────────────── bootstrap(); checkMac(); diff --git a/lxc-frontend/app/static/style.css b/lxc-frontend/app/static/style.css index 7251357..0c7142b 100644 --- a/lxc-frontend/app/static/style.css +++ b/lxc-frontend/app/static/style.css @@ -530,6 +530,23 @@ select { 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 */ .tag-profile { display: inline-block;