feat(export): Volltranskript als Anhang in DOCX/PDF (meeting/phone-call/interview)

LLM komprimiert das Transkript bisher auf wenige Sätze + 20 Excerpt-Einträge
— der Rest fiel aus dem Dokument. Pipeline reicht Whisper-Segmente jetzt an
build_docx/build_pdf durch; Profile mit `docx.include_full_transcript: true`
hängen das komplette Transkript (9 pt) am Ende an.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-05-18 14:35:34 +00:00
parent 60fbdbf036
commit 5f0b41161e
8 changed files with 99 additions and 10 deletions

View File

@ -146,16 +146,28 @@ class MacClient:
job_id, job_id,
) )
async def export_docx(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes: async def export_docx(
self,
data: dict,
profile: str | None = None,
job_id: int | None = None,
full_transcript: list[dict] | None = None,
) -> bytes:
return await self._post_bytes_retry( return await self._post_bytes_retry(
f"{self.base_url}/api/export/docx", f"{self.base_url}/api/export/docx",
{"data": data, "profile": profile}, {"data": data, "profile": profile, "full_transcript": full_transcript or []},
job_id, job_id,
) )
async def export_pdf(self, data: dict, profile: str | None = None, job_id: int | None = None) -> bytes: async def export_pdf(
self,
data: dict,
profile: str | None = None,
job_id: int | None = None,
full_transcript: list[dict] | None = None,
) -> bytes:
return await self._post_bytes_retry( return await self._post_bytes_retry(
f"{self.base_url}/api/export/pdf", f"{self.base_url}/api/export/pdf",
{"data": data, "profile": profile}, {"data": data, "profile": profile, "full_transcript": full_transcript or []},
job_id, job_id,
) )

View File

@ -166,6 +166,11 @@ async def run_pipeline(job_id: int) -> None:
protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8") protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, protocol_path=str(protocol_path), progress=85) _update(job_id, protocol_path=str(protocol_path), progress=85)
# Volltranskript-Segmente: optionaler Anhang ans DOCX/PDF, falls das
# Profil `docx.include_full_transcript` setzt. Renderer ignoriert die
# Liste sonst — wir reichen sie trotzdem immer durch.
full_segments = tx.get("segments", []) or []
# 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)
@ -174,7 +179,8 @@ async def run_pipeline(job_id: int) -> None:
log.info("[job %s] export docx", job_id) log.info("[job %s] export docx", job_id)
docx_bytes = await _with_heartbeat( docx_bytes = await _with_heartbeat(
job_id, job_id,
client.export_docx(protocol, profile=profile, job_id=job_id), client.export_docx(protocol, profile=profile, job_id=job_id,
full_transcript=full_segments),
) )
docx_path = job_dir / "protocol.docx" docx_path = job_dir / "protocol.docx"
docx_path.write_bytes(docx_bytes) docx_path.write_bytes(docx_bytes)
@ -189,7 +195,8 @@ async def run_pipeline(job_id: int) -> None:
log.info("[job %s] export pdf", job_id) log.info("[job %s] export pdf", job_id)
pdf_bytes = await _with_heartbeat( pdf_bytes = await _with_heartbeat(
job_id, job_id,
client.export_pdf(protocol, profile=profile, job_id=job_id), client.export_pdf(protocol, profile=profile, job_id=job_id,
full_transcript=full_segments),
) )
pdf_path = job_dir / "protocol.pdf" pdf_path = job_dir / "protocol.pdf"
pdf_path.write_bytes(pdf_bytes) pdf_path.write_bytes(pdf_bytes)

View File

@ -19,7 +19,11 @@ from docx.shared import Pt
from . import profiles from . import profiles
def build_docx(data: dict[str, Any], profile_name: str | None = None) -> bytes: def build_docx(
data: dict[str, Any],
profile_name: str | None = None,
full_transcript: list[dict] | None = None,
) -> bytes:
profile = profiles.get(profile_name) profile = profiles.get(profile_name)
cfg = profile.docx or {} cfg = profile.docx or {}
@ -37,6 +41,9 @@ def build_docx(data: dict[str, Any], profile_name: str | None = None) -> bytes:
for section in cfg.get("sections", []) or []: for section in cfg.get("sections", []) or []:
_render_section(doc, data, section) _render_section(doc, data, section)
if cfg.get("include_full_transcript") and full_transcript:
_render_full_transcript(doc, full_transcript)
if data.get("_parse_error") and data.get("_raw"): if data.get("_parse_error") and data.get("_raw"):
doc.add_heading("Roh-Antwort des Modells", level=1) doc.add_heading("Roh-Antwort des Modells", level=1)
doc.add_paragraph(data["_raw"]) doc.add_paragraph(data["_raw"])
@ -46,6 +53,23 @@ def build_docx(data: dict[str, Any], profile_name: str | None = None) -> bytes:
return buf.getvalue() return buf.getvalue()
def _render_full_transcript(doc, segments: list[dict]) -> None:
"""Hängt den Volltranskript-Anhang an: eine Überschrift, dann ein Absatz
pro Whisper-Segment in 9 pt (damit das Dokument nicht explodiert).
Leere Segmente werden übersprungen.
"""
doc.add_page_break()
doc.add_heading("Volltranskript", level=1)
for seg in segments:
text = (seg.get("text") if isinstance(seg, dict) else str(seg)) or ""
text = text.strip()
if not text:
continue
p = doc.add_paragraph()
run = p.add_run(text)
run.font.size = Pt(9)
def _render_section(doc, data: dict, section: dict) -> None: def _render_section(doc, data: dict, section: dict) -> None:
val = data.get(section["key"]) val = data.get(section["key"])
if val in (None, "", [], {}): if val in (None, "", [], {}):

View File

@ -69,7 +69,11 @@ def _para(text: str, style_name: str = "Body2") -> Paragraph:
return Paragraph(_esc(text).replace("\n", "<br/>"), _STYLES[style_name]) return Paragraph(_esc(text).replace("\n", "<br/>"), _STYLES[style_name])
def build_pdf(data: dict[str, Any], profile_name: str | None = None) -> bytes: def build_pdf(
data: dict[str, Any],
profile_name: str | None = None,
full_transcript: list[dict] | None = None,
) -> bytes:
profile = profiles.get(profile_name) profile = profiles.get(profile_name)
cfg = profile.docx or {} cfg = profile.docx or {}
@ -108,6 +112,9 @@ def build_pdf(data: dict[str, Any], profile_name: str | None = None) -> bytes:
for section in cfg.get("sections", []) or []: for section in cfg.get("sections", []) or []:
_render_section(story, data, section) _render_section(story, data, section)
if cfg.get("include_full_transcript") and full_transcript:
_render_full_transcript(story, full_transcript)
# Parse-Error-Fallback # Parse-Error-Fallback
if data.get("_parse_error") and data.get("_raw"): if data.get("_parse_error") and data.get("_raw"):
story.append(Paragraph("Roh-Antwort des Modells", _STYLES["SectionHeading"])) story.append(Paragraph("Roh-Antwort des Modells", _STYLES["SectionHeading"]))
@ -117,6 +124,27 @@ def build_pdf(data: dict[str, Any], profile_name: str | None = None) -> bytes:
return buf.getvalue() return buf.getvalue()
def _render_full_transcript(story: list, segments: list[dict]) -> None:
"""Anhang nach allen Sektionen: ein Absatz pro Segment in 9 pt.
Vor dem Block ein PageBreak, damit das Volltranskript auf einer
eigenen Seite beginnt und das Protokoll nicht zerreißt.
"""
from reportlab.platypus import PageBreak # local import — selten genutzt
story.append(PageBreak())
story.append(Paragraph("Volltranskript", _STYLES["SectionHeading"]))
transcript_style = ParagraphStyle(
name="TranscriptLine", parent=_STYLES["Body2"],
fontSize=9, leading=12, spaceAfter=3,
)
for seg in segments:
text = (seg.get("text") if isinstance(seg, dict) else str(seg)) or ""
text = text.strip()
if not text:
continue
story.append(Paragraph(_esc(text).replace("\n", "<br/>"), transcript_style))
def _render_section(story: list, data: dict, section: dict) -> None: def _render_section(story: list, data: dict, section: dict) -> None:
val = data.get(section["key"]) val = data.get(section["key"])
if val in (None, "", [], {}): if val in (None, "", [], {}):

View File

@ -82,11 +82,15 @@ class ProtocolIn(BaseModel):
class DocxIn(BaseModel): class DocxIn(BaseModel):
data: dict data: dict
profile: str | None = None profile: str | None = None
# Optional: Whisper-Segmente; werden vom Renderer nur genutzt, wenn das
# Profil `docx.include_full_transcript: true` setzt.
full_transcript: list[dict] = []
class PdfIn(BaseModel): class PdfIn(BaseModel):
data: dict data: dict
profile: str | None = None profile: str | None = None
full_transcript: list[dict] = []
class PreloadIn(BaseModel): class PreloadIn(BaseModel):
@ -170,7 +174,11 @@ async def protocol(payload: ProtocolIn):
@app.post("/api/export/docx") @app.post("/api/export/docx")
async def export_docx(payload: DocxIn): async def export_docx(payload: DocxIn):
data = build_docx(payload.data, profile_name=payload.profile) data = build_docx(
payload.data,
profile_name=payload.profile,
full_transcript=payload.full_transcript,
)
return Response( return Response(
content=data, content=data,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@ -180,7 +188,11 @@ async def export_docx(payload: DocxIn):
@app.post("/api/export/pdf") @app.post("/api/export/pdf")
async def export_pdf(payload: PdfIn): async def export_pdf(payload: PdfIn):
data = build_pdf(payload.data, profile_name=payload.profile) data = build_pdf(
payload.data,
profile_name=payload.profile,
full_transcript=payload.full_transcript,
)
return Response( return Response(
content=data, content=data,
media_type="application/pdf", media_type="application/pdf",

View File

@ -44,6 +44,7 @@ protocol_prompt: |
docx: docx:
title_field: title title_field: title
default_title: Bewerbergespräch default_title: Bewerbergespräch
include_full_transcript: true
meta: meta:
- { key: date, label: "Datum" } - { key: date, label: "Datum" }
- { key: candidate, label: "Bewerber/in" } - { key: candidate, label: "Bewerber/in" }

View File

@ -50,6 +50,10 @@ protocol_prompt: |
docx: docx:
title_field: title title_field: title
default_title: Sitzungsprotokoll default_title: Sitzungsprotokoll
# Hängt das vollständige Whisper-Transkript als Anhang ans Dokument.
# Pipeline reicht die Segmente an den Renderer durch; LLM-Komprimierung
# im transcript_excerpt-Feld bleibt unverändert.
include_full_transcript: true
meta: meta:
- { key: date, label: "Datum" } - { key: date, label: "Datum" }
- { key: topic, label: "Thema" } - { key: topic, label: "Thema" }

View File

@ -45,6 +45,7 @@ protocol_prompt: |
docx: docx:
title_field: title title_field: title
default_title: Telefonnotiz default_title: Telefonnotiz
include_full_transcript: true
meta: meta:
- { key: date, label: "Datum" } - { key: date, label: "Datum" }
- { key: caller, label: "Anrufer" } - { key: caller, label: "Anrufer" }