2026-04-29 11:28:04 +02:00
|
|
|
|
"""
|
2026-04-29 11:46:34 +02:00
|
|
|
|
PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads.
|
2026-04-29 11:28:04 +02:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import io
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
from xml.sax.saxutils import escape
|
|
|
|
|
|
|
|
|
|
|
|
from reportlab.lib.pagesizes import A4
|
|
|
|
|
|
from reportlab.lib.styles import getSampleStyleSheet
|
|
|
|
|
|
from reportlab.lib.units import mm
|
|
|
|
|
|
from reportlab.platypus import Image as RLImage
|
|
|
|
|
|
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
|
|
|
|
|
|
|
|
|
|
|
from db import get_cursor, get_db
|
|
|
|
|
|
from report_chart_fetch import fetch_chart_payload
|
2026-04-29 11:46:34 +02:00
|
|
|
|
from report_chart_plotting import chart_payload_to_png
|
2026-04-29 11:28:04 +02:00
|
|
|
|
from report_profile_schema import (
|
|
|
|
|
|
AiInsightBlock,
|
|
|
|
|
|
ChartBlock,
|
|
|
|
|
|
ReportProfilePayload,
|
|
|
|
|
|
SectionBlock,
|
2026-04-29 11:46:34 +02:00
|
|
|
|
VizBundleBlock,
|
2026-04-29 11:28:04 +02:00
|
|
|
|
)
|
2026-04-29 11:46:34 +02:00
|
|
|
|
from report_viz_bundle_pdf import append_viz_bundle_to_story
|
2026-04-29 11:28:04 +02:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
_CONTENT_TRUNCATE = 12000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
|
|
|
|
|
|
"""Returns (heading, body_text)."""
|
|
|
|
|
|
if not insight_id:
|
|
|
|
|
|
return (
|
|
|
|
|
|
"KI-Auswertung",
|
|
|
|
|
|
"(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse "
|
|
|
|
|
|
"verknüpfen.)",
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s",
|
|
|
|
|
|
(insight_id, profile_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
row = cur.fetchone()
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.")
|
|
|
|
|
|
scope = row.get("scope") or "Analyse"
|
|
|
|
|
|
content = row.get("content") or ""
|
|
|
|
|
|
if len(content) > _CONTENT_TRUNCATE:
|
|
|
|
|
|
content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]"
|
|
|
|
|
|
created = row.get("created")
|
|
|
|
|
|
sub = f"{scope}" + (f" · {created}" if created else "")
|
|
|
|
|
|
return (sub, content)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("report pdf insight load failed: %s", e)
|
|
|
|
|
|
return ("KI-Auswertung", "Fehler beim Laden des Eintrags.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_structured_report_pdf(
|
|
|
|
|
|
*,
|
|
|
|
|
|
profile_id: str,
|
|
|
|
|
|
profile_name: str,
|
|
|
|
|
|
payload: ReportProfilePayload,
|
|
|
|
|
|
) -> bytes:
|
|
|
|
|
|
"""Vollständiges PDF als Bytes (A4)."""
|
|
|
|
|
|
buf = io.BytesIO()
|
|
|
|
|
|
doc = SimpleDocTemplate(
|
|
|
|
|
|
buf,
|
|
|
|
|
|
pagesize=A4,
|
|
|
|
|
|
leftMargin=14 * mm,
|
|
|
|
|
|
rightMargin=14 * mm,
|
|
|
|
|
|
topMargin=16 * mm,
|
|
|
|
|
|
bottomMargin=16 * mm,
|
|
|
|
|
|
)
|
|
|
|
|
|
styles = getSampleStyleSheet()
|
|
|
|
|
|
story: list[Any] = []
|
|
|
|
|
|
|
|
|
|
|
|
title = (payload.document_title or "").strip() or f"{profile_name} – Bericht"
|
|
|
|
|
|
story.append(Paragraph(escape(title), styles["Title"]))
|
|
|
|
|
|
story.append(Spacer(1, 6 * mm))
|
|
|
|
|
|
|
|
|
|
|
|
for block in payload.blocks:
|
|
|
|
|
|
if isinstance(block, SectionBlock):
|
|
|
|
|
|
story.append(Spacer(1, 4 * mm))
|
|
|
|
|
|
story.append(Paragraph(escape(block.title), styles["Heading2"]))
|
|
|
|
|
|
story.append(Spacer(1, 2 * mm))
|
2026-04-29 11:46:34 +02:00
|
|
|
|
elif isinstance(block, VizBundleBlock):
|
|
|
|
|
|
append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config)
|
2026-04-29 11:28:04 +02:00
|
|
|
|
elif isinstance(block, ChartBlock):
|
|
|
|
|
|
try:
|
|
|
|
|
|
chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("chart fetch %s: %s", block.chart_id, e)
|
|
|
|
|
|
story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"]))
|
|
|
|
|
|
continue
|
|
|
|
|
|
meta = chart.get("metadata") or {}
|
|
|
|
|
|
if meta.get("confidence") == "insufficient":
|
|
|
|
|
|
msg = meta.get("message") or "Nicht genug Daten"
|
|
|
|
|
|
story.append(Paragraph(f"<i>{block.chart_id}</i>: {msg}", styles["Normal"]))
|
|
|
|
|
|
story.append(Spacer(1, 3 * mm))
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
png = chart_payload_to_png(chart)
|
|
|
|
|
|
img_buf = io.BytesIO(png)
|
|
|
|
|
|
iw = 170 * mm
|
|
|
|
|
|
ih = 85 * mm
|
|
|
|
|
|
story.append(RLImage(img_buf, width=iw, height=ih))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("chart render %s: %s", block.chart_id, e)
|
|
|
|
|
|
story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"]))
|
|
|
|
|
|
story.append(Spacer(1, 4 * mm))
|
|
|
|
|
|
elif isinstance(block, AiInsightBlock):
|
|
|
|
|
|
heading, body = _insight_text(profile_id, block.insight_id)
|
|
|
|
|
|
if block.title.strip():
|
|
|
|
|
|
story.append(Paragraph(escape(block.title), styles["Heading3"]))
|
|
|
|
|
|
else:
|
|
|
|
|
|
story.append(Paragraph(escape(heading), styles["Heading3"]))
|
|
|
|
|
|
for para in body.split("\n\n"):
|
|
|
|
|
|
p = (para or "").strip()
|
|
|
|
|
|
if p:
|
|
|
|
|
|
story.append(Paragraph(escape(p), styles["BodyText"]))
|
|
|
|
|
|
story.append(Spacer(1, 4 * mm))
|
|
|
|
|
|
|
|
|
|
|
|
doc.build(story)
|
|
|
|
|
|
return buf.getvalue()
|