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:
parent
60fbdbf036
commit
5f0b41161e
@ -146,16 +146,28 @@ class MacClient:
|
||||
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(
|
||||
f"{self.base_url}/api/export/docx",
|
||||
{"data": data, "profile": profile},
|
||||
{"data": data, "profile": profile, "full_transcript": full_transcript or []},
|
||||
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(
|
||||
f"{self.base_url}/api/export/pdf",
|
||||
{"data": data, "profile": profile},
|
||||
{"data": data, "profile": profile, "full_transcript": full_transcript or []},
|
||||
job_id,
|
||||
)
|
||||
|
||||
@ -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")
|
||||
_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
|
||||
if existing_dx_path:
|
||||
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)
|
||||
docx_bytes = await _with_heartbeat(
|
||||
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.write_bytes(docx_bytes)
|
||||
@ -189,7 +195,8 @@ async def run_pipeline(job_id: int) -> None:
|
||||
log.info("[job %s] export pdf", job_id)
|
||||
pdf_bytes = await _with_heartbeat(
|
||||
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.write_bytes(pdf_bytes)
|
||||
|
||||
@ -19,7 +19,11 @@ from docx.shared import Pt
|
||||
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)
|
||||
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 []:
|
||||
_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"):
|
||||
doc.add_heading("Roh-Antwort des Modells", level=1)
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
val = data.get(section["key"])
|
||||
if val in (None, "", [], {}):
|
||||
|
||||
@ -69,7 +69,11 @@ def _para(text: str, style_name: str = "Body2") -> Paragraph:
|
||||
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)
|
||||
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 []:
|
||||
_render_section(story, data, section)
|
||||
|
||||
if cfg.get("include_full_transcript") and full_transcript:
|
||||
_render_full_transcript(story, full_transcript)
|
||||
|
||||
# Parse-Error-Fallback
|
||||
if data.get("_parse_error") and data.get("_raw"):
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
val = data.get(section["key"])
|
||||
if val in (None, "", [], {}):
|
||||
|
||||
@ -82,11 +82,15 @@ class ProtocolIn(BaseModel):
|
||||
class DocxIn(BaseModel):
|
||||
data: dict
|
||||
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):
|
||||
data: dict
|
||||
profile: str | None = None
|
||||
full_transcript: list[dict] = []
|
||||
|
||||
|
||||
class PreloadIn(BaseModel):
|
||||
@ -170,7 +174,11 @@ async def protocol(payload: ProtocolIn):
|
||||
|
||||
@app.post("/api/export/docx")
|
||||
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(
|
||||
content=data,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
@ -180,7 +188,11 @@ async def export_docx(payload: DocxIn):
|
||||
|
||||
@app.post("/api/export/pdf")
|
||||
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(
|
||||
content=data,
|
||||
media_type="application/pdf",
|
||||
|
||||
@ -44,6 +44,7 @@ protocol_prompt: |
|
||||
docx:
|
||||
title_field: title
|
||||
default_title: Bewerbergespräch
|
||||
include_full_transcript: true
|
||||
meta:
|
||||
- { key: date, label: "Datum" }
|
||||
- { key: candidate, label: "Bewerber/in" }
|
||||
|
||||
@ -50,6 +50,10 @@ protocol_prompt: |
|
||||
docx:
|
||||
title_field: title
|
||||
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:
|
||||
- { key: date, label: "Datum" }
|
||||
- { key: topic, label: "Thema" }
|
||||
|
||||
@ -45,6 +45,7 @@ protocol_prompt: |
|
||||
docx:
|
||||
title_field: title
|
||||
default_title: Telefonnotiz
|
||||
include_full_transcript: true
|
||||
meta:
|
||||
- { key: date, label: "Datum" }
|
||||
- { key: caller, label: "Anrufer" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user