From 354e4c57ebdbb44e5ea075c0c86b0b39c3c51b8f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 May 2026 04:48:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20PDF-Export=20zus=C3=A4tzlich=20zu=20DOC?= =?UTF-8?q?X?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lxc-frontend/app/api/jobs.py | 4 +- lxc-frontend/app/core/mac_client.py | 9 ++ lxc-frontend/app/core/migrate.py | 6 + lxc-frontend/app/models/job.py | 1 + lxc-frontend/app/services/pipeline.py | 16 ++- lxc-frontend/app/static/app.js | 3 +- lxc-frontend/app/static/style.css | 10 +- mac-worker/app/core/pdf_export.py | 165 ++++++++++++++++++++++++++ mac-worker/app/main.py | 16 +++ mac-worker/requirements.txt | 1 + 10 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 mac-worker/app/core/pdf_export.py 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"