diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py
index d3883cb..db527a2 100644
--- a/lxc-frontend/app/api/jobs.py
+++ b/lxc-frontend/app/api/jobs.py
@@ -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:
diff --git a/lxc-frontend/app/core/mac_client.py b/lxc-frontend/app/core/mac_client.py
index e0336fc..74ec779 100644
--- a/lxc-frontend/app/core/mac_client.py
+++ b/lxc-frontend/app/core/mac_client.py
@@ -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
diff --git a/lxc-frontend/app/core/migrate.py b/lxc-frontend/app/core/migrate.py
index cd9f357..fc64811 100644
--- a/lxc-frontend/app/core/migrate.py
+++ b/lxc-frontend/app/core/migrate.py
@@ -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."""
diff --git a/lxc-frontend/app/models/job.py b/lxc-frontend/app/models/job.py
index 2e79eec..a3df7f1 100644
--- a/lxc-frontend/app/models/job.py
+++ b/lxc-frontend/app/models/job.py
@@ -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))
diff --git a/lxc-frontend/app/services/pipeline.py b/lxc-frontend/app/services/pipeline.py
index b0cfa0d..38e7891 100644
--- a/lxc-frontend/app/services/pipeline.py
+++ b/lxc-frontend/app/services/pipeline.py
@@ -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)
diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js
index 5d70573..56174b3 100644
--- a/lxc-frontend/app/static/app.js
+++ b/lxc-frontend/app/static/app.js
@@ -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)" },
diff --git a/lxc-frontend/app/static/style.css b/lxc-frontend/app/static/style.css
index 0c7142b..00e66d7 100644
--- a/lxc-frontend/app/static/style.css
+++ b/lxc-frontend/app/static/style.css
@@ -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 {
diff --git a/mac-worker/app/core/pdf_export.py b/mac-worker/app/core/pdf_export.py
new file mode 100644
index 0000000..982134a
--- /dev/null
+++ b/mac-worker/app/core/pdf_export.py
@@ -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("<", "<")
+ .replace(">", ">")
+ )
+
+
+def _para(text: str, style_name: str = "Body2") -> Paragraph:
+ """Paragraph mit Newline →
."""
+ return Paragraph(_esc(text).replace("\n", "
"), _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"{_esc(label)}: {_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"{_esc(owner)}: {_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"[{_esc(t)}] {_esc(spk)}: {_esc(txt)}",
+ _STYLES["Body2"],
+ ))
+ else:
+ story.append(_para(str(item)))
+ else:
+ story.append(_para(str(val)))
+
+ story.append(Spacer(1, 4))
diff --git a/mac-worker/app/main.py b/mac-worker/app/main.py
index 25ebff1..73aadac 100644
--- a/mac-worker/app/main.py
+++ b/mac-worker/app/main.py
@@ -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"'},
+ )
diff --git a/mac-worker/requirements.txt b/mac-worker/requirements.txt
index 9c73d86..0de50bc 100644
--- a/mac-worker/requirements.txt
+++ b/mac-worker/requirements.txt
@@ -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"