mitai-jinkendo/backend/report_pdf_render.py
Lars 3ab5dae130
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
feat: add viz_bundle support to report generation and enhance schema
- Introduced the `viz_bundle` block type to the report profile schema, allowing for the inclusion of bundled visualizations in PDF reports.
- Updated the `build_structured_report_pdf` function to handle `VizBundleBlock` and append its content to the report.
- Enhanced the report catalog API to include details for the new `viz_bundle` block type.
- Added configuration editors for various visualization bundles in the frontend settings page.
- Updated tests to validate the new `viz_bundle` functionality and ensure proper handling of report profiles.
- Bumped application version to reflect these enhancements.
2026-04-29 11:46:34 +02:00

131 lines
4.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads.
"""
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
from report_chart_plotting import chart_payload_to_png
from report_profile_schema import (
AiInsightBlock,
ChartBlock,
ReportProfilePayload,
SectionBlock,
VizBundleBlock,
)
from report_viz_bundle_pdf import append_viz_bundle_to_story
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))
elif isinstance(block, VizBundleBlock):
append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config)
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()