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

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:

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

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."""

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))

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)

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)" },

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 {

View File

@ -0,0 +1,165 @@
"""Profil-getriebenes PDF-Rendering mit reportlab.
Nutzt dieselbe `docx`-Konfig im Profil wie der DOCX-Renderer (meta + sections).
Damit ist das PDF strukturell konsistent zum DOCX, nur in anderer Optik.
"""
from __future__ import annotations
from io import BytesIO
from typing import Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
ListFlowable,
ListItem,
Paragraph,
SimpleDocTemplate,
Spacer,
)
from . import profiles
def _build_styles():
s = getSampleStyleSheet()
s.add(ParagraphStyle(
name="DocTitle", parent=s["Title"],
fontSize=20, spaceAfter=10, textColor=colors.HexColor("#1a1a1a"),
))
s.add(ParagraphStyle(
name="MetaLine", parent=s["Normal"],
fontSize=10, leading=13, textColor=colors.HexColor("#444444"),
))
s.add(ParagraphStyle(
name="SectionHeading", parent=s["Heading2"],
fontSize=13, spaceBefore=14, spaceAfter=6,
textColor=colors.HexColor("#1a1a1a"),
))
s.add(ParagraphStyle(
name="Body2", parent=s["BodyText"],
fontSize=10.5, leading=14,
))
s.add(ParagraphStyle(
name="BulletItem", parent=s["BodyText"],
fontSize=10.5, leading=14, leftIndent=0,
))
return s
_STYLES = _build_styles()
def _esc(value: Any) -> str:
"""XML-Escape für reportlab Paragraph-Markup."""
if value is None:
return ""
return (
str(value)
.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def _para(text: str, style_name: str = "Body2") -> Paragraph:
"""Paragraph mit Newline → <br/>."""
return Paragraph(_esc(text).replace("\n", "<br/>"), _STYLES[style_name])
def build_pdf(data: dict[str, Any], profile_name: str | None = None) -> bytes:
profile = profiles.get(profile_name)
cfg = profile.docx or {}
title_field = cfg.get("title_field", "title")
title = (data.get(title_field) or cfg.get("default_title") or profile.display_name).strip()
buf = BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
title=title,
author="Voice-Agent",
)
story: list = [Paragraph(_esc(title), _STYLES["DocTitle"])]
# Meta-Block
meta_paras = []
for meta in cfg.get("meta", []) or []:
val = data.get(meta["key"])
if val:
label = meta.get("label", meta["key"])
meta_paras.append(Paragraph(
f"<b>{_esc(label)}:</b> {_esc(val)}",
_STYLES["MetaLine"],
))
story.extend(meta_paras)
if meta_paras:
story.append(Spacer(1, 8))
# Sektionen
for section in cfg.get("sections", []) or []:
_render_section(story, data, section)
# Parse-Error-Fallback
if data.get("_parse_error") and data.get("_raw"):
story.append(Paragraph("Roh-Antwort des Modells", _STYLES["SectionHeading"]))
story.append(_para(str(data["_raw"])))
doc.build(story)
return buf.getvalue()
def _render_section(story: list, data: dict, section: dict) -> None:
val = data.get(section["key"])
if val in (None, "", [], {}):
return
label = section.get("label", section["key"])
sec_type = section.get("type", "text")
story.append(Paragraph(_esc(label), _STYLES["SectionHeading"]))
if sec_type == "text":
story.append(_para(str(val)))
elif sec_type == "list":
items = val if isinstance(val, list) else [val]
bullets = [ListItem(_para(str(i), "BulletItem"), leftIndent=12) for i in items]
story.append(ListFlowable(bullets, bulletType="bullet", leftIndent=15))
elif sec_type == "tasks":
items = val if isinstance(val, list) else [val]
bullets = []
for t in items:
if isinstance(t, dict):
owner = t.get("owner") or "n/a"
task = t.get("task") or t.get("description") or ""
bullets.append(ListItem(
Paragraph(f"<b>{_esc(owner)}:</b> {_esc(task)}", _STYLES["BulletItem"]),
leftIndent=12,
))
else:
bullets.append(ListItem(_para(str(t), "BulletItem"), leftIndent=12))
story.append(ListFlowable(bullets, bulletType="bullet", leftIndent=15))
elif sec_type == "transcript":
items = val if isinstance(val, list) else [val]
for item in items:
if isinstance(item, dict):
t = item.get("time", "")
spk = item.get("speaker", "")
txt = item.get("text", "")
story.append(Paragraph(
f"<b>[{_esc(t)}] {_esc(spk)}:</b> {_esc(txt)}",
_STYLES["Body2"],
))
else:
story.append(_para(str(item)))
else:
story.append(_para(str(val)))
story.append(Spacer(1, 4))

View File

@ -9,6 +9,7 @@ from pydantic import BaseModel
from app.core import ollama_client, profiles
from app.core.config import settings
from app.core.docx_export import build_docx
from app.core.pdf_export import build_pdf
from app.core.whisper_engine import engine as whisper
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
@ -35,6 +36,11 @@ class DocxIn(BaseModel):
profile: str | None = None
class PdfIn(BaseModel):
data: dict
profile: str | None = None
@app.get("/health")
async def health():
info = {
@ -99,3 +105,13 @@ async def export_docx(payload: DocxIn):
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": 'attachment; filename="protocol.docx"'},
)
@app.post("/api/export/pdf")
async def export_pdf(payload: PdfIn):
data = build_pdf(payload.data, profile_name=payload.profile)
return Response(
content=data,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="protocol.pdf"'},
)

View File

@ -5,6 +5,7 @@ pydantic-settings==2.7.0
httpx==0.28.1
python-docx==1.1.2
pyyaml==6.0.2
reportlab==4.2.5
# Whisper-Engines (eine wählen — siehe README):
# Apple Silicon (empfohlen):
lightning-whisper-mlx==0.0.10 ; sys_platform == "darwin" and platform_machine == "arm64"