neurodraw-backend / api /reports.py
ak332's picture
Upload 25 files
ab454db verified
Raw
History Blame Contribute Delete
8.42 kB
"""
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}"'},
)