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:
parent
7d4961933e
commit
354e4c57eb
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)" },
|
||||
|
||||
@ -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 {
|
||||
|
||||
165
mac-worker/app/core/pdf_export.py
Normal file
165
mac-worker/app/core/pdf_export.py
Normal 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("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
@ -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"'},
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user