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")
|
raise HTTPException(400, "Job ist bereits abgeschlossen")
|
||||||
|
|
||||||
# Post-Transkriptions-Artefakte löschen, damit sie neu erzeugt werden.
|
# 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)
|
p = getattr(job, attr)
|
||||||
if p:
|
if p:
|
||||||
try:
|
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)
|
job = _require_access(session.get(Job, job_id), user)
|
||||||
mapping = {
|
mapping = {
|
||||||
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
|
"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"),
|
"transcript": (job.transcript_path, "application/json", "transcript.json"),
|
||||||
"summary": (job.summary_path, "application/json", "summary.json"),
|
"summary": (job.summary_path, "application/json", "summary.json"),
|
||||||
"protocol": (job.protocol_path, "application/json", "protocol.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),
|
"summary": bool(j.summary_path),
|
||||||
"protocol": bool(j.protocol_path),
|
"protocol": bool(j.protocol_path),
|
||||||
"docx": bool(j.docx_path),
|
"docx": bool(j.docx_path),
|
||||||
|
"pdf": bool(j.pdf_path),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if user.is_admin:
|
if user.is_admin:
|
||||||
|
|||||||
@ -55,3 +55,12 @@ class MacClient:
|
|||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.content
|
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')")
|
log.info("Migration: füge user.default_profile hinzu (default 'meeting')")
|
||||||
conn.execute(text("ALTER TABLE user ADD COLUMN default_profile VARCHAR 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:
|
def assign_orphan_jobs_to(user_id: int) -> int:
|
||||||
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
|
"""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 = ""
|
summary_path: str = ""
|
||||||
protocol_path: str = ""
|
protocol_path: str = ""
|
||||||
docx_path: str = ""
|
docx_path: str = ""
|
||||||
|
pdf_path: str = ""
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_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_sum_path = _existing(job.summary_path)
|
||||||
existing_pro_path = _existing(job.protocol_path)
|
existing_pro_path = _existing(job.protocol_path)
|
||||||
existing_dx_path = _existing(job.docx_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 = settings.result_dir / str(job_id)
|
||||||
job_dir.mkdir(parents=True, exist_ok=True)
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@ -103,14 +104,25 @@ async def run_pipeline(job_id: int) -> None:
|
|||||||
# 4) DOCX
|
# 4) DOCX
|
||||||
if existing_dx_path:
|
if existing_dx_path:
|
||||||
log.info("[job %s] reuse docx %s", job_id, existing_dx_path.name)
|
log.info("[job %s] reuse docx %s", job_id, existing_dx_path.name)
|
||||||
_update(job_id, status=JobStatus.DONE, progress=100, error="")
|
|
||||||
else:
|
else:
|
||||||
_update(job_id, status=JobStatus.EXPORTING, progress=90)
|
_update(job_id, status=JobStatus.EXPORTING, progress=90)
|
||||||
log.info("[job %s] export docx", job_id)
|
log.info("[job %s] export docx", job_id)
|
||||||
docx_bytes = await client.export_docx(protocol, profile=profile)
|
docx_bytes = await client.export_docx(protocol, profile=profile)
|
||||||
docx_path = job_dir / "protocol.docx"
|
docx_path = job_dir / "protocol.docx"
|
||||||
docx_path.write_bytes(docx_bytes)
|
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)
|
log.info("[job %s] done", job_id)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.exception("[job %s] failed", job_id)
|
log.exception("[job %s] failed", job_id)
|
||||||
|
|||||||
@ -57,7 +57,8 @@ let mode = "login"; // "login" | "setup"
|
|||||||
let availableProfiles = []; // [{ name, display_name, language }, ...]
|
let availableProfiles = []; // [{ name, display_name, language }, ...]
|
||||||
|
|
||||||
const DOWNLOADS = [
|
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: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
|
||||||
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
|
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
|
||||||
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (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 */
|
/* Spaltenbreiten — Status bleibt schmal, Downloads bekommen Platz */
|
||||||
.col-id { width: 56px; }
|
.col-id { width: 56px; }
|
||||||
.col-status { width: 110px; }
|
.col-status { width: 110px; }
|
||||||
.col-progress { width: 130px; }
|
.col-progress { width: 120px; }
|
||||||
.col-updated { width: 130px; }
|
.col-updated { width: 120px; }
|
||||||
.col-dl { width: 280px; }
|
.col-dl { width: 320px; }
|
||||||
/* col-title nimmt den Rest */
|
/* col-title nimmt den Rest */
|
||||||
|
|
||||||
.col-title { word-break: break-word; }
|
.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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Download-Buttons — sauberes 2×2-Grid, alle gleich breit */
|
/* Download-Buttons — 3-spaltiges Grid (5 Buttons → 2 Zeilen 3+2) */
|
||||||
.dl {
|
.dl {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.btn-dl {
|
.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 import ollama_client, profiles
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.docx_export import build_docx
|
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
|
from app.core.whisper_engine import engine as whisper
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
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
|
profile: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PdfIn(BaseModel):
|
||||||
|
data: dict
|
||||||
|
profile: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
info = {
|
info = {
|
||||||
@ -99,3 +105,13 @@ async def export_docx(payload: DocxIn):
|
|||||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
headers={"Content-Disposition": 'attachment; filename="protocol.docx"'},
|
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
|
httpx==0.28.1
|
||||||
python-docx==1.1.2
|
python-docx==1.1.2
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
|
reportlab==4.2.5
|
||||||
# Whisper-Engines (eine wählen — siehe README):
|
# Whisper-Engines (eine wählen — siehe README):
|
||||||
# Apple Silicon (empfohlen):
|
# Apple Silicon (empfohlen):
|
||||||
lightning-whisper-mlx==0.0.10 ; sys_platform == "darwin" and platform_machine == "arm64"
|
lightning-whisper-mlx==0.0.10 ; sys_platform == "darwin" and platform_machine == "arm64"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user