commcoach: remove silent fallbacks in PDF export, fix markdown-to-XML conversion with proper regex

Made-with: Cursor
This commit is contained in:
patrick-motsch 2026-03-07 02:38:42 +01:00
parent cdb974d658
commit 1e678a8897
2 changed files with 66 additions and 63 deletions

View file

@ -937,10 +937,8 @@ async def exportDossier(
if format == "pdf": if format == "pdf":
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores) pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores)
if pdfBytes: return Response(content=pdfBytes, media_type="application/pdf",
return Response(content=pdfBytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
format = "md"
md = buildDossierMarkdown(ctx, sessions, tasks, scores) md = buildDossierMarkdown(ctx, sessions, tasks, scores)
return Response(content=md, media_type="text/markdown", return Response(content=md, media_type="text/markdown",
@ -976,10 +974,8 @@ async def exportSession(
if format == "pdf": if format == "pdf":
pdfBytes = await renderSessionPdf(session, messages, tasks, scores) pdfBytes = await renderSessionPdf(session, messages, tasks, scores)
if pdfBytes: return Response(content=pdfBytes, media_type="application/pdf",
return Response(content=pdfBytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'})
headers={"Content-Disposition": f'attachment; filename="session_{sessionId[:8]}.pdf"'})
format = "md"
md = buildSessionMarkdown(session, messages, tasks, scores) md = buildSessionMarkdown(session, messages, tasks, scores)
return Response(content=md, media_type="text/markdown", return Response(content=md, media_type="text/markdown",

View file

@ -143,7 +143,7 @@ def buildSessionMarkdown(session: Dict[str, Any], messages: List[Dict[str, Any]]
async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any]], async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any]],
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]], tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]],
aiService=None) -> Optional[bytes]: aiService=None) -> bytes:
"""Render a dossier as PDF directly from markdown content via reportlab.""" """Render a dossier as PDF directly from markdown content via reportlab."""
md = buildDossierMarkdown(context, sessions, tasks, scores) md = buildDossierMarkdown(context, sessions, tasks, scores)
title = context.get("title", "Coaching Dossier") title = context.get("title", "Coaching Dossier")
@ -152,71 +152,78 @@ async def renderDossierPdf(context: Dict[str, Any], sessions: List[Dict[str, Any
async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any]], async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any]],
tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]], tasks: List[Dict[str, Any]], scores: List[Dict[str, Any]],
aiService=None) -> Optional[bytes]: aiService=None) -> bytes:
"""Render a session as PDF directly from markdown content via reportlab.""" """Render a session as PDF directly from markdown content via reportlab."""
md = buildSessionMarkdown(session, messages, tasks, scores) md = buildSessionMarkdown(session, messages, tasks, scores)
title = f"Coaching Session — {_formatDate(session.get('startedAt'))}" title = f"Coaching Session — {_formatDate(session.get('startedAt'))}"
return _markdownToPdf(md, title) return _markdownToPdf(md, title)
def _markdownToPdf(markdownText: str, title: str) -> Optional[bytes]: def _markdownToPdf(markdownText: str, title: str) -> bytes:
"""Convert markdown text to a styled PDF using reportlab.""" """Convert markdown text to a styled PDF using reportlab. Raises on failure."""
try: import re as _re
from reportlab.lib.pagesizes import A4 import io
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.enums import TA_LEFT from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib import colors from reportlab.lib.enums import TA_LEFT
except ImportError: from reportlab.lib import colors
logger.warning("reportlab not available for PDF export")
return None
try: buf = io.BytesIO()
import io doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=60, leftMargin=60, topMargin=50, bottomMargin=40)
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, rightMargin=60, leftMargin=60, topMargin=50, bottomMargin=40)
baseStyles = getSampleStyleSheet() baseStyles = getSampleStyleSheet()
sTitle = ParagraphStyle("DocTitle", parent=baseStyles["Title"], fontSize=18, spaceAfter=20, textColor=colors.HexColor("#1e40af")) 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")) 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")) 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) 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) sBullet = ParagraphStyle("DocBullet", parent=sBody, leftIndent=18, bulletIndent=6, spaceBefore=2, spaceAfter=2)
sMeta = ParagraphStyle("DocMeta", parent=sBody, fontSize=8, textColor=colors.grey) sMeta = ParagraphStyle("DocMeta", parent=sBody, fontSize=8, textColor=colors.grey)
story = [Paragraph(title, sTitle), Spacer(1, 10)] story = [Paragraph(_escXml(title), sTitle), Spacer(1, 10)]
for line in markdownText.split("\n"): for line in markdownText.split("\n"):
stripped = line.strip() stripped = line.strip()
if not stripped: if not stripped:
story.append(Spacer(1, 4)) story.append(Spacer(1, 4))
elif stripped.startswith("# "): elif stripped.startswith("# "):
continue continue
elif stripped.startswith("### "): elif stripped.startswith("### "):
story.append(Paragraph(stripped[4:], sH3)) story.append(Paragraph(_escXml(stripped[4:]), sH3))
elif stripped.startswith("## "): elif stripped.startswith("## "):
story.append(Paragraph(stripped[3:], sH2)) story.append(Paragraph(_escXml(stripped[3:]), sH2))
elif stripped.startswith("---"): elif stripped.startswith("---"):
story.append(Spacer(1, 8)) story.append(Spacer(1, 8))
elif stripped.startswith("*") and stripped.endswith("*"): elif stripped.startswith("*") and stripped.endswith("*") and not stripped.startswith("**"):
story.append(Paragraph(f"<i>{stripped.strip('*')}</i>", sMeta)) story.append(Paragraph(f"<i>{_escXml(stripped.strip('*'))}</i>", sMeta))
elif stripped.startswith("- [x] "): elif stripped.startswith("- [x] "):
story.append(Paragraph(f"\u2713 {stripped[6:]}", sBullet)) story.append(Paragraph(f"\u2713 {_mdToXml(stripped[6:])}", sBullet))
elif stripped.startswith("- [ ] "): elif stripped.startswith("- [ ] "):
story.append(Paragraph(f"\u25CB {stripped[6:]}", sBullet)) story.append(Paragraph(f"\u25CB {_mdToXml(stripped[6:])}", sBullet))
elif stripped.startswith("- "): elif stripped.startswith("- "):
story.append(Paragraph(f"\u2022 {stripped[2:]}", sBullet)) story.append(Paragraph(f"\u2022 {_mdToXml(stripped[2:])}", sBullet))
else: else:
text = stripped.replace("**", "<b>").replace("__", "<b>") story.append(Paragraph(_mdToXml(stripped), sBody))
text = text.replace("*", "<i>").replace("_", "<i>")
story.append(Paragraph(text, sBody))
doc.build(story) doc.build(story)
buf.seek(0) buf.seek(0)
return buf.getvalue() return buf.getvalue()
except Exception as e:
logger.warning(f"PDF generation failed: {e}")
return None def _escXml(text: str) -> str:
"""Escape text for reportlab XML paragraphs."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _mdToXml(text: str) -> str:
"""Convert markdown inline formatting to reportlab XML. Bold, italic, escape the rest."""
import re as _re
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = _re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
text = _re.sub(r'__(.+?)__', r'<b>\1</b>', text)
text = _re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
text = _re.sub(r'_(.+?)_', r'<i>\1</i>', text)
return text