From cdb974d65863a7f933de7bfc3de73e097778f90a Mon Sep 17 00:00:00 2001 From: patrick-motsch Date: Sat, 7 Mar 2026 02:29:30 +0100 Subject: [PATCH] commcoach: rewrite PDF export - direct markdown-to-pdf via reportlab, remove broken RendererPdf dependency Made-with: Cursor --- .../commcoach/serviceCommcoachExport.py | 178 +++++++----------- 1 file changed, 65 insertions(+), 113 deletions(-) diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py index 1db6df4e..28a4ffc3 100644 --- a/modules/features/commcoach/serviceCommcoachExport.py +++ b/modules/features/commcoach/serviceCommcoachExport.py @@ -140,133 +140,85 @@ def buildSessionMarkdown(session: Dict[str, Any], messages: List[Dict[str, Any]] return "\n".join(lines) -class _DebugUtils: - """Minimal stub for services.utils required by RendererPdf.""" - @staticmethod - def debugLogToFile(message: str, context: str = "DEBUG"): - logger.debug(f"[{context}] {message}") - - -class _ServicesStub: - """Minimal services stub providing utils for RendererPdf.""" - def __init__(self): - self.utils = _DebugUtils() - async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any]], tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]], aiService=None) -> Optional[bytes]: - """Render a dossier as PDF using the existing RendererPdf.""" - try: - from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf - extractedContent = _buildPdfContent(context, sessions, tasks, scores, isDossier=True) - renderer = RendererPdf(services=_ServicesStub()) - docs = await renderer.render(extractedContent=extractedContent, title=context.get("title", "Dossier"), aiService=aiService) - if docs and len(docs) > 0: - return docs[0].documentData - except ImportError: - logger.warning("RendererPdf not available, falling back to markdown-based PDF") - except Exception as e: - logger.warning(f"PDF rendering failed: {e}") - return None + """Render a dossier as PDF directly from markdown content via reportlab.""" + md = buildDossierMarkdown(context, sessions, tasks, scores) + title = context.get("title", "Coaching Dossier") + return _markdownToPdf(md, title) async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any]], tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]], aiService=None) -> Optional[bytes]: - """Render a session as PDF.""" + """Render a session as PDF directly from markdown content via reportlab.""" + md = buildSessionMarkdown(session, messages, tasks, scores) + title = f"Coaching Session — {_formatDate(session.get('startedAt'))}" + return _markdownToPdf(md, title) + + +def _markdownToPdf(markdownText: str, title: str) -> Optional[bytes]: + """Convert markdown text to a styled PDF using reportlab.""" try: - from modules.services.serviceGeneration.renderers.rendererPdf import RendererPdf - title = f"Session {_formatDate(session.get('startedAt'))}" - extractedContent = _buildPdfContent({"title": title}, [session], tasks, scores, isDossier=False, messages=messages) - renderer = RendererPdf(services=_ServicesStub()) - docs = await renderer.render(extractedContent=extractedContent, title=title, aiService=aiService) - if docs and len(docs) > 0: - return docs[0].documentData + from reportlab.lib.pagesizes import A4 + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.enums import TA_LEFT + from reportlab.lib import colors except ImportError: - logger.warning("RendererPdf not available") + logger.warning("reportlab not available for PDF export") + return None + + try: + import io + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=60, leftMargin=60, topMargin=50, bottomMargin=40) + + baseStyles = getSampleStyleSheet() + sTitle = ParagraphStyle("DocTitle", parent=baseStyles["Title"], fontSize=18, spaceAfter=20, textColor=colors.HexColor("#1e40af")) + sH2 = ParagraphStyle("DocH2", parent=baseStyles["Heading2"], fontSize=13, spaceBefore=16, spaceAfter=6, textColor=colors.HexColor("#1f2937")) + sH3 = ParagraphStyle("DocH3", parent=baseStyles["Heading3"], fontSize=11, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#374151")) + sBody = ParagraphStyle("DocBody", parent=baseStyles["Normal"], fontSize=10, leading=14, spaceAfter=4, alignment=TA_LEFT) + sBullet = ParagraphStyle("DocBullet", parent=sBody, leftIndent=18, bulletIndent=6, spaceBefore=2, spaceAfter=2) + sMeta = ParagraphStyle("DocMeta", parent=sBody, fontSize=8, textColor=colors.grey) + + story = [Paragraph(title, sTitle), Spacer(1, 10)] + + for line in markdownText.split("\n"): + stripped = line.strip() + if not stripped: + story.append(Spacer(1, 4)) + elif stripped.startswith("# "): + continue + elif stripped.startswith("### "): + story.append(Paragraph(stripped[4:], sH3)) + elif stripped.startswith("## "): + story.append(Paragraph(stripped[3:], sH2)) + elif stripped.startswith("---"): + story.append(Spacer(1, 8)) + elif stripped.startswith("*") and stripped.endswith("*"): + story.append(Paragraph(f"{stripped.strip('*')}", sMeta)) + elif stripped.startswith("- [x] "): + story.append(Paragraph(f"\u2713 {stripped[6:]}", sBullet)) + elif stripped.startswith("- [ ] "): + story.append(Paragraph(f"\u25CB {stripped[6:]}", sBullet)) + elif stripped.startswith("- "): + story.append(Paragraph(f"\u2022 {stripped[2:]}", sBullet)) + else: + text = stripped.replace("**", "").replace("__", "") + text = text.replace("*", "").replace("_", "") + story.append(Paragraph(text, sBody)) + + doc.build(story) + buf.seek(0) + return buf.getvalue() except Exception as e: - logger.warning(f"Session PDF rendering failed: {e}") - return None + logger.warning(f"PDF generation failed: {e}") + return None -def _buildPdfContent(context, sessions, tasks, scores, isDossier=True, messages=None) -> Dict[str, Any]: - """Convert dossier/session data into the extractedContent format expected by RendererPdf.""" - title = context.get("title", "Export") - sections = [] - - sections.append({ - "id": "header", - "content_type": "heading", - "elements": [{"text": title, "level": 1}], - }) - - if isDossier and context.get("description"): - sections.append({ - "id": "desc", - "content_type": "paragraph", - "elements": [{"text": context.get("description")}], - }) - - completedSessions = [s for s in sessions if s.get("status") == "completed"] if isDossier else sessions - if completedSessions: - sessionRows = [] - for s in completedSessions: - sessionRows.append({ - "cells": [ - _formatDate(s.get("startedAt") or s.get("createdAt")), - str(s.get("competenceScore") or "--"), - s.get("summary", "")[:200] if s.get("summary") else "", - ] - }) - sections.append({ - "id": "sessions", - "content_type": "heading", - "elements": [{"text": "Sessions", "level": 2}], - }) - sections.append({ - "id": "sessions_table", - "content_type": "table", - "elements": [{ - "headers": ["Datum", "Score", "Zusammenfassung"], - "rows": sessionRows, - }], - }) - - if messages: - chatElements = [] - for msg in messages: - role = "Du" if msg.get("role") == "user" else "Coach" - chatElements.append({"text": f"{role}: {msg.get('content', '')}"}) - sections.append({ - "id": "chat", - "content_type": "heading", - "elements": [{"text": "Gesprächsverlauf", "level": 2}], - }) - sections.append({ - "id": "chat_content", - "content_type": "paragraph", - "elements": chatElements, - }) - - if tasks: - taskItems = [{"text": f"{'[x]' if t.get('status') == 'done' else '[ ]'} {t.get('title')}"} for t in tasks] - sections.append({ - "id": "tasks", - "content_type": "heading", - "elements": [{"text": "Aufgaben", "level": 2}], - }) - sections.append({ - "id": "task_list", - "content_type": "bullet_list", - "elements": taskItems, - }) - - return { - "metadata": {"title": title}, - "documents": [{"id": "main", "title": title, "sections": sections}], - } - def _formatDate(isoStr: Optional[str]) -> str: if not isoStr: