feat: PDF-Export zusätzlich zu DOCX

Mac-Worker:
- pdf_export.py mit reportlab (pure Python, kein externes Tool)
- Nutzt dieselbe profile.docx-Konfig wie der DOCX-Renderer →
  strukturell identisch, nur andere Optik
- POST /api/export/pdf
- requirements: reportlab==4.2.5

LXC:
- Job.pdf_path neu (Migration: ALTER TABLE job ADD COLUMN)
- Pipeline-Step nach DOCX: PDF wird ebenfalls erzeugt
  (Progress: 90 DOCX → 95 PDF → 100 done)
- /api/jobs/{id}/download/pdf
- /api/jobs/{id}/retry löscht PDF mit, damit es neu erzeugt wird
- Frontend: zusätzlicher Download-Button "PDF" (vorne im Array)
- Tabelle: Download-Spalte 320px, 3-spaltiges Grid (5 Buttons → 3+2)
- Cards (Mobile) bleiben 2-spaltig (5 Buttons → 3 Zeilen)
This commit is contained in:
root
2026-05-14 04:48:47 +00:00
parent 7d4961933e
commit 354e4c57eb
10 changed files with 222 additions and 9 deletions
+3 -1
View File
@@ -108,7 +108,7 @@ async def retry_job(
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"):
for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"):
p = getattr(job, attr)
if p:
try:
@@ -133,6 +133,7 @@ def download(job_id: int, kind: str, session: Session = Depends(get_session), us
job = _require_access(session.get(Job, job_id), user)
mapping = {
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
"pdf": (job.pdf_path, "application/pdf", "protocol.pdf"),
"transcript": (job.transcript_path, "application/json", "transcript.json"),
"summary": (job.summary_path, "application/json", "summary.json"),
"protocol": (job.protocol_path, "application/json", "protocol.json"),
@@ -162,6 +163,7 @@ def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
"summary": bool(j.summary_path),
"protocol": bool(j.protocol_path),
"docx": bool(j.docx_path),
"pdf": bool(j.pdf_path),
},
}
if user.is_admin:
+9
View File
@@ -55,3 +55,12 @@ class MacClient:
)
r.raise_for_status()
return r.content
async def export_pdf(self, data: dict, profile: str | None = None) -> bytes:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(
f"{self.base_url}/api/export/pdf",
json={"data": data, "profile": profile},
)
r.raise_for_status()
return r.content
+6
View File
@@ -40,6 +40,12 @@ def run_migrations() -> None:
log.info("Migration: füge user.default_profile hinzu (default 'meeting')")
conn.execute(text("ALTER TABLE user ADD COLUMN default_profile VARCHAR DEFAULT 'meeting'"))
# 4) Job.pdf_path nachrüsten (PDF-Export zusätzlich zu DOCX).
cols = _existing_columns(conn, "job")
if "pdf_path" not in cols:
log.info("Migration: füge job.pdf_path hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN pdf_path VARCHAR DEFAULT ''"))
def assign_orphan_jobs_to(user_id: int) -> int:
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
+1
View File
@@ -27,5 +27,6 @@ class Job(SQLModel, table=True):
summary_path: str = ""
protocol_path: str = ""
docx_path: str = ""
pdf_path: str = ""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+14 -2
View File
@@ -52,6 +52,7 @@ async def run_pipeline(job_id: int) -> None:
existing_sum_path = _existing(job.summary_path)
existing_pro_path = _existing(job.protocol_path)
existing_dx_path = _existing(job.docx_path)
existing_pdf_path = _existing(job.pdf_path)
job_dir = settings.result_dir / str(job_id)
job_dir.mkdir(parents=True, exist_ok=True)
@@ -103,14 +104,25 @@ async def run_pipeline(job_id: int) -> None:
# 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="")
_update(job_id, docx_path=str(docx_path), progress=95)
# 5) PDF
if existing_pdf_path:
log.info("[job %s] reuse pdf %s", job_id, existing_pdf_path.name)
_update(job_id, status=JobStatus.DONE, progress=100, error="")
else:
_update(job_id, status=JobStatus.EXPORTING, progress=96)
log.info("[job %s] export pdf", job_id)
pdf_bytes = await client.export_pdf(protocol, profile=profile)
pdf_path = job_dir / "protocol.pdf"
pdf_path.write_bytes(pdf_bytes)
_update(job_id, pdf_path=str(pdf_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)
+2 -1
View File
@@ -57,7 +57,8 @@ let mode = "login"; // "login" | "setup"
let availableProfiles = []; // [{ name, display_name, language }, ...]
const DOWNLOADS = [
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
{ kind: "pdf", label: "PDF", title: "Protokoll als PDF" },
{ kind: "docx", label: "DOCX", title: "Protokoll als Word-Dokument" },
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
+5 -5
View File
@@ -99,9 +99,9 @@ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: upp
/* Spaltenbreiten — Status bleibt schmal, Downloads bekommen Platz */
.col-id { width: 56px; }
.col-status { width: 110px; }
.col-progress { width: 130px; }
.col-updated { width: 130px; }
.col-dl { width: 280px; }
.col-progress { width: 120px; }
.col-updated { width: 120px; }
.col-dl { width: 320px; }
/* col-title nimmt den Rest */
.col-title { word-break: break-word; }
@@ -132,10 +132,10 @@ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: upp
overflow: hidden;
}
/* Download-Buttons — sauberes 2×2-Grid, alle gleich breit */
/* Download-Buttons — 3-spaltiges Grid (5 Buttons → 2 Zeilen 3+2) */
.dl {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
}
.btn-dl {