Spaces:
Running
Running
| """ | |
| 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() | |
| 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}"'}, | |
| ) | |