""" api/reports.py — PDF report generation for a given case. """ import io import logging from datetime import datetime from fastapi import APIRouter, HTTPException, Request from fastapi.responses import StreamingResponse logger = logging.getLogger(__name__) router = APIRouter() DISCLAIMER_TEXT = ( "RESEARCH & SCREENING DEMONSTRATION ONLY — NOT A MEDICAL DIAGNOSIS. " "This report is generated by an AI research system and must not be used " "for clinical decision-making. Results should not replace consultation with " "a qualified neurologist or healthcare professional." ) def _generate_pdf(case: dict) -> bytes: """Generate a PDF report for a case using ReportLab.""" from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm from reportlab.platypus import ( HRFlowable, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) buf = io.BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, rightMargin=2.5 * cm, leftMargin=2.5 * cm, topMargin=2.5 * cm, bottomMargin=2.5 * cm, ) styles = getSampleStyleSheet() teal = colors.HexColor("#0D7377") ink = colors.HexColor("#1A1A1A") muted = colors.HexColor("#6B6B6B") title_style = ParagraphStyle( "Title", parent=styles["Heading1"], fontSize=22, textColor=teal, spaceAfter=4, ) subtitle_style = ParagraphStyle( "Subtitle", parent=styles["Normal"], fontSize=10, textColor=muted, spaceAfter=12, ) section_style = ParagraphStyle( "Section", parent=styles["Heading2"], fontSize=12, textColor=ink, spaceBefore=16, spaceAfter=6, ) body_style = ParagraphStyle( "Body", parent=styles["Normal"], fontSize=9, textColor=ink, leading=14, spaceAfter=8, ) disclaimer_style = ParagraphStyle( "Disclaimer", parent=styles["Normal"], fontSize=8, textColor=colors.HexColor("#92400E"), backColor=colors.HexColor("#FEF3C7"), leading=12, spaceAfter=8, spaceBefore=8, leftIndent=8, rightIndent=8, ) story = [] # Header story.append(Paragraph("NeuroDraw", title_style)) story.append(Paragraph("Parkinson's Handwriting Analysis — Research Report", subtitle_style)) story.append(HRFlowable(width="100%", thickness=1, color=teal)) story.append(Spacer(1, 0.4 * cm)) # Case metadata case_id = case.get("case_id", "N/A") timestamp = case.get("timestamp", "N/A") mode = case.get("mode", "auto").replace("_", " ").title() try: dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) formatted_time = dt.strftime("%d %B %Y, %H:%M UTC") except Exception: formatted_time = timestamp meta_data = [ ["Case ID", case_id], ["Generated", formatted_time], ["Analysis Mode", mode], ["Mock Results", "Yes — model files not loaded" if case.get("is_mock") else "No — real inference"], ] meta_table = Table(meta_data, colWidths=[4 * cm, 13 * cm]) meta_table.setStyle(TableStyle([ ("FONTSIZE", (0, 0), (-1, -1), 9), ("TEXTCOLOR", (0, 0), (0, -1), muted), ("TEXTCOLOR", (1, 0), (1, -1), ink), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ])) story.append(meta_table) story.append(Spacer(1, 0.4 * cm)) # Routing result story.append(Paragraph("Drawing Classification", section_style)) routing = case.get("routing_result") or {} routing_data = [ ["Detected Type", routing.get("label", "N/A")], ["Router Confidence", f"{routing.get('confidence', 0):.1%}"], ["Borderline", "Yes" if routing.get("is_borderline") else "No"], ] if case.get("type_agnostic_mode"): routing_data.append(["Mode", "Type-agnostic demo (Garbage gate bypassed)"]) routing_table = Table(routing_data, colWidths=[4 * cm, 13 * cm]) routing_table.setStyle(TableStyle([ ("FONTSIZE", (0, 0), (-1, -1), 9), ("TEXTCOLOR", (0, 0), (0, -1), muted), ("TEXTCOLOR", (1, 0), (1, -1), ink), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ])) story.append(routing_table) # Quality check story.append(Paragraph("Image Quality Assessment", section_style)) quality = case.get("quality_check") or {} checks = quality.get("checks") or [] if checks: check_data = [["Check", "Status", "Detail"]] for c in checks: check_data.append([ c.get("name", ""), c.get("status", "").upper(), c.get("message", ""), ]) check_table = Table(check_data, colWidths=[3.5 * cm, 2 * cm, 11.5 * cm]) check_table.setStyle(TableStyle([ ("FONTSIZE", (0, 0), (-1, -1), 8), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E4E4E0")), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#E4E4E0")), ])) story.append(check_table) # Model results story.append(Paragraph("Model Analysis Results", section_style)) def result_rows(result_dict: dict) -> list: if not result_dict: return [] return [ ["Model", result_dict.get("model_name", "N/A")], ["Assessment", result_dict.get("label", "N/A") + "-consistent"], ["Raw Probability", f"{result_dict.get('prob', 0):.4f}"], ["Inference Time", f"{result_dict.get('inference_time_ms', 0):.0f} ms"], ["Mock Result", "Yes" if result_dict.get("is_mock") else "No"], ] all_results = case.get("all_results") if all_results: for key, res in all_results.items(): if res: rows = result_rows(res) t = Table(rows, colWidths=[4 * cm, 13 * cm]) t.setStyle(TableStyle([ ("FONTSIZE", (0, 0), (-1, -1), 9), ("TEXTCOLOR", (0, 0), (0, -1), muted), ("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2), ])) story.append(t) story.append(Spacer(1, 0.2 * cm)) else: for res in [case.get("specialist_result"), case.get("unified_result")]: if res: rows = result_rows(res) t = Table(rows, colWidths=[4 * cm, 13 * cm]) t.setStyle(TableStyle([ ("FONTSIZE", (0, 0), (-1, -1), 9), ("TEXTCOLOR", (0, 0), (0, -1), muted), ("TOPPADDING", (0, 0), (-1, -1), 2), ("BOTTOMPADDING", (0, 0), (-1, -1), 2), ])) story.append(t) story.append(Spacer(1, 0.2 * cm)) # Explanation explanation = case.get("explanation", "") if explanation: story.append(Paragraph("Interpretation", section_style)) story.append(Paragraph(explanation, body_style)) # Disclaimer story.append(Spacer(1, 0.6 * cm)) story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#E4E4E0"))) story.append(Spacer(1, 0.3 * cm)) story.append(Paragraph(DISCLAIMER_TEXT, disclaimer_style)) doc.build(story) buf.seek(0) return buf.read() @router.post("/reports/{case_id}") async def generate_report(case_id: str, request: Request): """Generate and stream a PDF report for the given case.""" case = request.app.state.cases.get(case_id) if not case: raise HTTPException(status_code=404, detail=f"Case '{case_id}' not found") try: pdf_bytes = _generate_pdf(case) except Exception as exc: logger.error("PDF generation failed for case %s: %s", case_id, exc) raise HTTPException(status_code=500, detail="PDF generation failed") filename = f"neurodraw_report_{case_id[:8]}.pdf" return StreamingResponse( io.BytesIO(pdf_bytes), media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, )