Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- app/services/pdf_generator.py +291 -213
- requirements.txt +1 -0
app/services/pdf_generator.py
CHANGED
|
@@ -8,7 +8,7 @@ Supports multiple report types:
|
|
| 8 |
- conversation : Export of chat conversation
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
-
import csv, json, io
|
| 12 |
from datetime import datetime
|
| 13 |
from pathlib import Path
|
| 14 |
from sqlalchemy.orm import Session
|
|
@@ -17,6 +17,8 @@ from .groq_client import complete_once
|
|
| 17 |
from .medical_context import build_context
|
| 18 |
from ..models import Scan, Patient, ChatMessage
|
| 19 |
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# ── colour palette ──────────────────────────────────────────────────────
|
| 22 |
_PRIMARY = "#1e40af"
|
|
@@ -31,17 +33,21 @@ _TEXT = "#1e293b"
|
|
| 31 |
_TEXT_MUTED = "#64748b"
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def _base_css() -> str:
|
| 35 |
return f"""
|
| 36 |
@page {{
|
| 37 |
size: A4;
|
| 38 |
-
margin:
|
| 39 |
-
@top-center {{
|
| 40 |
-
content: "";
|
| 41 |
-
}}
|
| 42 |
@bottom-center {{
|
| 43 |
content: counter(page) " of " counter(pages);
|
| 44 |
-
font-size:
|
| 45 |
color: {_TEXT_MUTED};
|
| 46 |
font-family: 'Helvetica Neue', Arial, sans-serif;
|
| 47 |
}}
|
|
@@ -51,74 +57,69 @@ body {{
|
|
| 51 |
font-family: 'Helvetica Neue', Arial, sans-serif;
|
| 52 |
color: {_TEXT};
|
| 53 |
line-height: 1.55;
|
| 54 |
-
font-size:
|
| 55 |
margin: 0;
|
| 56 |
padding: 0;
|
| 57 |
}}
|
| 58 |
|
| 59 |
-
/* header band */
|
| 60 |
.report-header {{
|
| 61 |
-
display: flex;
|
| 62 |
-
justify-content: space-between;
|
| 63 |
-
align-items: flex-start;
|
| 64 |
border-bottom: 3px solid {_PRIMARY};
|
| 65 |
-
padding-bottom:
|
| 66 |
-
margin-bottom:
|
| 67 |
}}
|
| 68 |
-
.report-header
|
|
|
|
|
|
|
| 69 |
font-size: 22px;
|
| 70 |
font-weight: 700;
|
| 71 |
color: {_PRIMARY};
|
| 72 |
letter-spacing: -0.5px;
|
| 73 |
}}
|
| 74 |
-
.
|
| 75 |
-
font-size:
|
| 76 |
color: {_TEXT_MUTED};
|
| 77 |
text-transform: uppercase;
|
| 78 |
letter-spacing: 1.5px;
|
| 79 |
margin-top: 2px;
|
| 80 |
}}
|
| 81 |
-
.
|
| 82 |
text-align: right;
|
| 83 |
font-size: 9px;
|
| 84 |
color: {_TEXT_MUTED};
|
| 85 |
line-height: 1.6;
|
| 86 |
}}
|
| 87 |
|
| 88 |
-
/* patient info
|
| 89 |
-
.patient-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
background: {_BG_LIGHT};
|
| 94 |
border: 1px solid {_BORDER};
|
| 95 |
border-radius: 6px;
|
| 96 |
-
padding: 0;
|
| 97 |
-
margin-bottom: 16px;
|
| 98 |
-
overflow: hidden;
|
| 99 |
-
}}
|
| 100 |
-
.patient-box .cell {{
|
| 101 |
-
flex: 1 1 auto;
|
| 102 |
-
min-width: 120px;
|
| 103 |
-
padding: 10px 14px;
|
| 104 |
-
border-right: 1px solid {_BORDER};
|
| 105 |
-
border-bottom: 1px solid {_BORDER};
|
| 106 |
}}
|
| 107 |
-
.patient-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
text-transform: uppercase;
|
| 111 |
-
letter-spacing:
|
| 112 |
color: {_TEXT_MUTED};
|
| 113 |
-
margin-bottom:
|
|
|
|
| 114 |
}}
|
| 115 |
-
.patient-
|
| 116 |
-
font-size:
|
| 117 |
font-weight: 600;
|
| 118 |
color: {_TEXT};
|
|
|
|
| 119 |
}}
|
| 120 |
|
| 121 |
-
/* badges */
|
| 122 |
.badge {{
|
| 123 |
display: inline-block;
|
| 124 |
padding: 2px 10px;
|
|
@@ -131,118 +132,179 @@ body {{
|
|
| 131 |
.badge-red {{ background: #fee2e2; color: #991b1b; }}
|
| 132 |
.badge-blue {{ background: #dbeafe; color: #1e40af; }}
|
| 133 |
|
| 134 |
-
/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
h2 {{
|
| 136 |
-
font-size:
|
| 137 |
font-weight: 700;
|
| 138 |
color: {_PRIMARY};
|
| 139 |
border-left: 4px solid {_SECONDARY};
|
| 140 |
padding-left: 10px;
|
| 141 |
-
margin:
|
| 142 |
text-transform: uppercase;
|
| 143 |
letter-spacing: 0.5px;
|
| 144 |
}}
|
| 145 |
h3 {{
|
| 146 |
-
font-size:
|
| 147 |
font-weight: 600;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
color: {_TEXT};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
margin: 14px 0 6px 0;
|
| 150 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
/* tables */
|
| 153 |
table {{
|
| 154 |
width: 100%;
|
| 155 |
border-collapse: collapse;
|
| 156 |
-
font-size:
|
| 157 |
-
margin: 8px 0
|
| 158 |
}}
|
| 159 |
-
|
| 160 |
background: {_PRIMARY};
|
| 161 |
color: #ffffff;
|
| 162 |
-
padding:
|
| 163 |
text-align: left;
|
| 164 |
font-weight: 600;
|
| 165 |
-
font-size:
|
| 166 |
text-transform: uppercase;
|
| 167 |
letter-spacing: 0.5px;
|
| 168 |
}}
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
padding:
|
| 173 |
border-bottom: 1px solid {_BORDER};
|
| 174 |
vertical-align: top;
|
| 175 |
}}
|
| 176 |
-
|
| 177 |
-
table tbody tr:hover {{ background: #f1f5f9; }}
|
| 178 |
.z-cell {{ font-weight: 600; font-variant-numeric: tabular-nums; }}
|
| 179 |
.z-pos {{ color: {_DANGER}; }}
|
| 180 |
.z-neg {{ color: {_SUCCESS}; }}
|
| 181 |
.z-typ {{ color: {_TEXT_MUTED}; }}
|
| 182 |
|
| 183 |
-
/*
|
| 184 |
-
.narrative {{
|
| 185 |
-
font-size: 11px;
|
| 186 |
-
line-height: 1.7;
|
| 187 |
-
white-space: pre-wrap;
|
| 188 |
-
color: {_TEXT};
|
| 189 |
-
}}
|
| 190 |
-
.narrative p {{ margin: 6px 0; }}
|
| 191 |
-
|
| 192 |
-
/* info box */
|
| 193 |
.info-box {{
|
| 194 |
background: #eff6ff;
|
| 195 |
border: 1px solid #bfdbfe;
|
| 196 |
border-radius: 6px;
|
| 197 |
-
padding:
|
| 198 |
-
font-size:
|
| 199 |
color: #1e40af;
|
| 200 |
-
margin:
|
| 201 |
}}
|
| 202 |
.warn-box {{
|
| 203 |
background: #fffbeb;
|
| 204 |
border: 1px solid #fde68a;
|
| 205 |
border-radius: 6px;
|
| 206 |
-
padding:
|
| 207 |
-
font-size:
|
| 208 |
color: #92400e;
|
| 209 |
-
margin:
|
| 210 |
-
}}
|
| 211 |
-
|
| 212 |
-
/* key findings highlight */
|
| 213 |
-
.highlight-row {{
|
| 214 |
-
display: flex;
|
| 215 |
-
gap: 10px;
|
| 216 |
-
margin: 10px 0;
|
| 217 |
-
}}
|
| 218 |
-
.highlight-card {{
|
| 219 |
-
flex: 1;
|
| 220 |
-
background: {_BG_LIGHT};
|
| 221 |
-
border: 1px solid {_BORDER};
|
| 222 |
-
border-radius: 6px;
|
| 223 |
-
padding: 12px;
|
| 224 |
-
text-align: center;
|
| 225 |
-
}}
|
| 226 |
-
.highlight-card .val {{
|
| 227 |
-
font-size: 20px;
|
| 228 |
-
font-weight: 700;
|
| 229 |
-
color: {_PRIMARY};
|
| 230 |
-
}}
|
| 231 |
-
.highlight-card .lbl {{
|
| 232 |
-
font-size: 8px;
|
| 233 |
-
text-transform: uppercase;
|
| 234 |
-
letter-spacing: 1px;
|
| 235 |
-
color: {_TEXT_MUTED};
|
| 236 |
-
margin-top: 4px;
|
| 237 |
}}
|
| 238 |
|
| 239 |
-
/* conversation style */
|
| 240 |
.chat-msg {{
|
| 241 |
-
margin:
|
| 242 |
padding: 8px 12px;
|
| 243 |
border-radius: 8px;
|
| 244 |
-
font-size:
|
| 245 |
-
line-height: 1.
|
| 246 |
page-break-inside: avoid;
|
| 247 |
}}
|
| 248 |
.chat-user {{
|
|
@@ -250,11 +312,10 @@ table tbody tr:hover {{ background: #f1f5f9; }}
|
|
| 250 |
border: 1px solid #93c5fd;
|
| 251 |
margin-left: 40px;
|
| 252 |
}}
|
| 253 |
-
.chat-user
|
| 254 |
-
content: "You";
|
| 255 |
font-weight: 700;
|
| 256 |
color: {_PRIMARY};
|
| 257 |
-
font-size:
|
| 258 |
text-transform: uppercase;
|
| 259 |
display: block;
|
| 260 |
margin-bottom: 2px;
|
|
@@ -264,29 +325,28 @@ table tbody tr:hover {{ background: #f1f5f9; }}
|
|
| 264 |
border: 1px solid {_BORDER};
|
| 265 |
margin-right: 40px;
|
| 266 |
}}
|
| 267 |
-
.chat-bot
|
| 268 |
-
content: "AI Assistant";
|
| 269 |
font-weight: 700;
|
| 270 |
color: {_SECONDARY};
|
| 271 |
-
font-size:
|
| 272 |
text-transform: uppercase;
|
| 273 |
display: block;
|
| 274 |
margin-bottom: 2px;
|
| 275 |
}}
|
| 276 |
|
| 277 |
-
/* footer */
|
| 278 |
.report-footer {{
|
| 279 |
-
margin-top:
|
| 280 |
-
padding-top:
|
| 281 |
border-top: 2px solid {_BORDER};
|
| 282 |
-
font-size:
|
| 283 |
color: {_TEXT_MUTED};
|
| 284 |
text-align: center;
|
| 285 |
-
line-height: 1.
|
| 286 |
}}
|
| 287 |
.report-footer .disclaimer {{
|
| 288 |
font-style: italic;
|
| 289 |
-
margin-top:
|
| 290 |
}}
|
| 291 |
"""
|
| 292 |
|
|
@@ -294,16 +354,18 @@ table tbody tr:hover {{ background: #f1f5f9; }}
|
|
| 294 |
def _header_html(report_title: str, report_type: str, now_str: str) -> str:
|
| 295 |
return f"""
|
| 296 |
<div class="report-header">
|
| 297 |
-
<
|
| 298 |
-
<
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
<
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
| 307 |
</div>"""
|
| 308 |
|
| 309 |
|
|
@@ -311,21 +373,23 @@ def _patient_box(ctx: dict, scan: Scan, bag_badge: str) -> str:
|
|
| 311 |
pred = f"{ctx['predicted_age']:.1f}" if ctx.get('predicted_age') else "N/A"
|
| 312 |
bag_val = ctx.get('brain_age_gap') or 0
|
| 313 |
bag_str = f"{bag_val:+.1f}" if ctx.get('brain_age_gap') is not None else "N/A"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
return f"""
|
| 315 |
-
<
|
| 316 |
-
<
|
| 317 |
-
<
|
| 318 |
-
|
| 319 |
-
<
|
| 320 |
-
<
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
<
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
<div class="cell-value">{scan.created_at.strftime('%Y-%m-%d') if scan.created_at else 'N/A'}</div></div>
|
| 328 |
-
</div>"""
|
| 329 |
|
| 330 |
|
| 331 |
def _highlight_cards(ctx: dict) -> str:
|
|
@@ -335,41 +399,23 @@ def _highlight_cards(ctx: dict) -> str:
|
|
| 335 |
n_regions = len(ctx.get('measurements', []))
|
| 336 |
abnormal = [r for r in ctx.get('regions', []) if abs(r.get('z_score', 0)) >= 2]
|
| 337 |
return f"""
|
| 338 |
-
<
|
| 339 |
-
<
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
</
|
| 343 |
-
<
|
| 344 |
-
|
| 345 |
-
<div class="lbl">Predicted Brain Age</div>
|
| 346 |
-
</div>
|
| 347 |
-
<div class="highlight-card">
|
| 348 |
-
<div class="val">{f'{bag:+.1f}' if bag is not None else '—'}y</div>
|
| 349 |
-
<div class="lbl">Brain Age Gap</div>
|
| 350 |
-
</div>
|
| 351 |
-
<div class="highlight-card">
|
| 352 |
-
<div class="val">{n_regions}</div>
|
| 353 |
-
<div class="lbl">Regions Analyzed</div>
|
| 354 |
-
</div>
|
| 355 |
-
<div class="highlight-card">
|
| 356 |
-
<div class="val">{len(abnormal)}</div>
|
| 357 |
-
<div class="lbl">Abnormal Regions</div>
|
| 358 |
-
</div>
|
| 359 |
-
</div>"""
|
| 360 |
|
| 361 |
|
| 362 |
def _region_table(ctx: dict) -> str:
|
| 363 |
"""Build an HTML table of top regions by |z-score|."""
|
| 364 |
-
out_dir = None
|
| 365 |
-
if ctx.get("meta") and ctx["meta"].get("n_regions"):
|
| 366 |
-
pass # we have regions from ctx
|
| 367 |
regions = ctx.get("regions", [])
|
| 368 |
if not regions:
|
| 369 |
return ""
|
| 370 |
sorted_r = sorted(regions, key=lambda r: abs(r.get("z_score", 0)), reverse=True)
|
| 371 |
rows_html = ""
|
| 372 |
-
for r in sorted_r[:
|
| 373 |
z = r.get("z_score", 0)
|
| 374 |
z_cls = "z-pos" if z > 1 else "z-neg" if z < -1 else "z-typ"
|
| 375 |
sev = "normal"
|
|
@@ -377,33 +423,42 @@ def _region_table(ctx: dict) -> str:
|
|
| 377 |
if az >= 3: sev = "severe"
|
| 378 |
elif az >= 2: sev = "moderate"
|
| 379 |
elif az >= 1: sev = "borderline"
|
| 380 |
-
pct = 50 + 50 * (2 / (1 + 2.718 ** (-z / 1.4)) - 1)
|
|
|
|
|
|
|
|
|
|
| 381 |
rows_html += f"""<tr>
|
| 382 |
-
<td>{r.get('region', '?')}</td>
|
| 383 |
-
<td style="text-align:right">{float(r.get('volume_mm3', 0)):.0f}</td>
|
|
|
|
| 384 |
<td class="z-cell {z_cls}" style="text-align:right">{z:+.2f}</td>
|
| 385 |
<td style="text-align:right">p{pct:.0f}</td>
|
| 386 |
-
<td><span class="badge
|
| 387 |
</tr>"""
|
| 388 |
return f"""
|
| 389 |
<h2>Regional Volumetric Analysis</h2>
|
| 390 |
<table>
|
| 391 |
<thead><tr>
|
| 392 |
-
<th>Region</th>
|
| 393 |
-
<th style="text-align:right">
|
|
|
|
|
|
|
|
|
|
| 394 |
<th>Severity</th>
|
| 395 |
</tr></thead>
|
| 396 |
<tbody>{rows_html}</tbody>
|
| 397 |
-
</table>
|
|
|
|
|
|
|
| 398 |
|
| 399 |
|
| 400 |
def _footer_html() -> str:
|
| 401 |
return """
|
| 402 |
<div class="report-footer">
|
| 403 |
-
<div>BrainAge AI • Neuroimaging Analytics Platform</div>
|
| 404 |
<div>Model: SFCN Dual-Branch • Trained on 6,050 healthy subjects • MAE: 2.5y (lifespan), 1.05y (pediatric)</div>
|
| 405 |
<div class="disclaimer">This report is AI-generated and intended to support, not replace, clinical decision-making.
|
| 406 |
-
All findings should be reviewed by a qualified healthcare professional.</div>
|
| 407 |
</div>"""
|
| 408 |
|
| 409 |
|
|
@@ -432,51 +487,66 @@ def _get_report_prompt(report_type: str, ctx: dict) -> str:
|
|
| 432 |
z = r.get("z_score", 0)
|
| 433 |
base_data += f" - {r.get('region')}: {float(r.get('volume_mm3',0)):.0f}mm³, z={z:+.2f}\n"
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
prompts = {
|
| 436 |
"comprehensive": (
|
| 437 |
-
base_data +
|
| 438 |
-
"
|
| 439 |
-
"
|
| 440 |
-
"
|
| 441 |
-
"
|
| 442 |
-
"
|
| 443 |
-
"
|
| 444 |
-
"
|
| 445 |
-
"
|
|
|
|
| 446 |
),
|
| 447 |
"progress": (
|
| 448 |
-
base_data +
|
| 449 |
-
"
|
| 450 |
-
"
|
| 451 |
-
"
|
| 452 |
-
"
|
| 453 |
-
"
|
| 454 |
-
"
|
| 455 |
-
"
|
|
|
|
|
|
|
| 456 |
),
|
| 457 |
"care_plan": (
|
| 458 |
-
base_data +
|
| 459 |
-
"
|
| 460 |
-
"
|
| 461 |
-
"
|
| 462 |
-
"
|
| 463 |
-
"
|
| 464 |
-
"
|
| 465 |
-
"
|
| 466 |
-
"
|
| 467 |
-
"
|
| 468 |
-
"
|
| 469 |
-
"
|
|
|
|
| 470 |
"Be evidence-based, specific, and actionable."
|
|
|
|
| 471 |
),
|
| 472 |
"executive": (
|
| 473 |
-
base_data +
|
| 474 |
-
"
|
| 475 |
-
"
|
| 476 |
-
"
|
| 477 |
-
"
|
| 478 |
-
"
|
|
|
|
| 479 |
"Keep everything extremely concise. This is for a busy clinician."
|
|
|
|
| 480 |
),
|
| 481 |
}
|
| 482 |
return prompts.get(report_type, prompts["comprehensive"])
|
|
@@ -514,7 +584,7 @@ def generate_report_pdf(db: Session, scan_id: int,
|
|
| 514 |
except Exception as e:
|
| 515 |
narrative = f"Report generation error: {e}"
|
| 516 |
|
| 517 |
-
narrative_html =
|
| 518 |
|
| 519 |
html = f"""<!DOCTYPE html>
|
| 520 |
<html><head><meta charset="utf-8">
|
|
@@ -553,8 +623,16 @@ def generate_conversation_pdf(db: Session, scan_id: int,
|
|
| 553 |
chat_html = ""
|
| 554 |
for m in msgs:
|
| 555 |
cls = "chat-user" if m.role == "user" else "chat-bot"
|
| 556 |
-
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
|
| 559 |
if not chat_html:
|
| 560 |
chat_html = '<div class="info-box">No messages in this conversation.</div>'
|
|
|
|
| 8 |
- conversation : Export of chat conversation
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
+
import csv, json, io, re
|
| 12 |
from datetime import datetime
|
| 13 |
from pathlib import Path
|
| 14 |
from sqlalchemy.orm import Session
|
|
|
|
| 17 |
from .medical_context import build_context
|
| 18 |
from ..models import Scan, Patient, ChatMessage
|
| 19 |
|
| 20 |
+
import markdown as _md
|
| 21 |
+
|
| 22 |
|
| 23 |
# ── colour palette ──────────────────────────────────────────────────────
|
| 24 |
_PRIMARY = "#1e40af"
|
|
|
|
| 33 |
_TEXT_MUTED = "#64748b"
|
| 34 |
|
| 35 |
|
| 36 |
+
def _md_to_html(text: str) -> str:
|
| 37 |
+
"""Convert LLM markdown output to proper HTML for PDF rendering."""
|
| 38 |
+
cleaned = text.replace("[REPORT_READY]", "").strip()
|
| 39 |
+
html = _md.markdown(cleaned, extensions=["tables", "fenced_code", "nl2br"])
|
| 40 |
+
return html
|
| 41 |
+
|
| 42 |
+
|
| 43 |
def _base_css() -> str:
|
| 44 |
return f"""
|
| 45 |
@page {{
|
| 46 |
size: A4;
|
| 47 |
+
margin: 18mm 16mm 22mm 16mm;
|
|
|
|
|
|
|
|
|
|
| 48 |
@bottom-center {{
|
| 49 |
content: counter(page) " of " counter(pages);
|
| 50 |
+
font-size: 8px;
|
| 51 |
color: {_TEXT_MUTED};
|
| 52 |
font-family: 'Helvetica Neue', Arial, sans-serif;
|
| 53 |
}}
|
|
|
|
| 57 |
font-family: 'Helvetica Neue', Arial, sans-serif;
|
| 58 |
color: {_TEXT};
|
| 59 |
line-height: 1.55;
|
| 60 |
+
font-size: 10.5px;
|
| 61 |
margin: 0;
|
| 62 |
padding: 0;
|
| 63 |
}}
|
| 64 |
|
| 65 |
+
/* ─── header band ─── */
|
| 66 |
.report-header {{
|
|
|
|
|
|
|
|
|
|
| 67 |
border-bottom: 3px solid {_PRIMARY};
|
| 68 |
+
padding-bottom: 12px;
|
| 69 |
+
margin-bottom: 14px;
|
| 70 |
}}
|
| 71 |
+
.report-header table {{ border: none; margin: 0; }}
|
| 72 |
+
.report-header td {{ border: none; padding: 0; vertical-align: top; background: transparent; }}
|
| 73 |
+
.brand {{
|
| 74 |
font-size: 22px;
|
| 75 |
font-weight: 700;
|
| 76 |
color: {_PRIMARY};
|
| 77 |
letter-spacing: -0.5px;
|
| 78 |
}}
|
| 79 |
+
.brand-sub {{
|
| 80 |
+
font-size: 8px;
|
| 81 |
color: {_TEXT_MUTED};
|
| 82 |
text-transform: uppercase;
|
| 83 |
letter-spacing: 1.5px;
|
| 84 |
margin-top: 2px;
|
| 85 |
}}
|
| 86 |
+
.doc-info {{
|
| 87 |
text-align: right;
|
| 88 |
font-size: 9px;
|
| 89 |
color: {_TEXT_MUTED};
|
| 90 |
line-height: 1.6;
|
| 91 |
}}
|
| 92 |
|
| 93 |
+
/* ─── patient info grid (table-based for WeasyPrint) ─── */
|
| 94 |
+
.patient-grid {{
|
| 95 |
+
width: 100%;
|
| 96 |
+
border-collapse: collapse;
|
| 97 |
+
margin-bottom: 14px;
|
|
|
|
| 98 |
border: 1px solid {_BORDER};
|
| 99 |
border-radius: 6px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
}}
|
| 101 |
+
.patient-grid td {{
|
| 102 |
+
padding: 8px 12px;
|
| 103 |
+
border: 1px solid {_BORDER};
|
| 104 |
+
background: {_BG_LIGHT};
|
| 105 |
+
vertical-align: top;
|
| 106 |
+
}}
|
| 107 |
+
.patient-grid .plabel {{
|
| 108 |
+
font-size: 7.5px;
|
| 109 |
text-transform: uppercase;
|
| 110 |
+
letter-spacing: 0.8px;
|
| 111 |
color: {_TEXT_MUTED};
|
| 112 |
+
margin-bottom: 2px;
|
| 113 |
+
display: block;
|
| 114 |
}}
|
| 115 |
+
.patient-grid .pval {{
|
| 116 |
+
font-size: 11.5px;
|
| 117 |
font-weight: 600;
|
| 118 |
color: {_TEXT};
|
| 119 |
+
display: block;
|
| 120 |
}}
|
| 121 |
|
| 122 |
+
/* ─── badges ─── */
|
| 123 |
.badge {{
|
| 124 |
display: inline-block;
|
| 125 |
padding: 2px 10px;
|
|
|
|
| 132 |
.badge-red {{ background: #fee2e2; color: #991b1b; }}
|
| 133 |
.badge-blue {{ background: #dbeafe; color: #1e40af; }}
|
| 134 |
|
| 135 |
+
/* ─── highlight cards (table-based) ─── */
|
| 136 |
+
.hl-table {{ width: 100%; border-collapse: separate; border-spacing: 6px; margin: 6px 0 14px 0; }}
|
| 137 |
+
.hl-table td {{
|
| 138 |
+
text-align: center;
|
| 139 |
+
background: {_BG_LIGHT};
|
| 140 |
+
border: 1px solid {_BORDER};
|
| 141 |
+
border-radius: 6px;
|
| 142 |
+
padding: 10px 6px;
|
| 143 |
+
width: 20%;
|
| 144 |
+
vertical-align: middle;
|
| 145 |
+
}}
|
| 146 |
+
.hl-val {{
|
| 147 |
+
font-size: 18px;
|
| 148 |
+
font-weight: 700;
|
| 149 |
+
color: {_PRIMARY};
|
| 150 |
+
display: block;
|
| 151 |
+
}}
|
| 152 |
+
.hl-lbl {{
|
| 153 |
+
font-size: 7px;
|
| 154 |
+
text-transform: uppercase;
|
| 155 |
+
letter-spacing: 0.8px;
|
| 156 |
+
color: {_TEXT_MUTED};
|
| 157 |
+
margin-top: 3px;
|
| 158 |
+
display: block;
|
| 159 |
+
}}
|
| 160 |
+
|
| 161 |
+
/* ─── section headings ─── */
|
| 162 |
h2 {{
|
| 163 |
+
font-size: 13px;
|
| 164 |
font-weight: 700;
|
| 165 |
color: {_PRIMARY};
|
| 166 |
border-left: 4px solid {_SECONDARY};
|
| 167 |
padding-left: 10px;
|
| 168 |
+
margin: 18px 0 8px 0;
|
| 169 |
text-transform: uppercase;
|
| 170 |
letter-spacing: 0.5px;
|
| 171 |
}}
|
| 172 |
h3 {{
|
| 173 |
+
font-size: 11.5px;
|
| 174 |
font-weight: 600;
|
| 175 |
+
color: {_PRIMARY};
|
| 176 |
+
margin: 12px 0 5px 0;
|
| 177 |
+
border-bottom: 1px solid {_BORDER};
|
| 178 |
+
padding-bottom: 3px;
|
| 179 |
+
}}
|
| 180 |
+
h4 {{
|
| 181 |
+
font-size: 10.5px;
|
| 182 |
+
font-weight: 600;
|
| 183 |
+
color: {_TEXT};
|
| 184 |
+
margin: 10px 0 4px 0;
|
| 185 |
+
}}
|
| 186 |
+
|
| 187 |
+
/* ─── narrative content (rendered from markdown) ─── */
|
| 188 |
+
.narrative {{
|
| 189 |
+
font-size: 10.5px;
|
| 190 |
+
line-height: 1.65;
|
| 191 |
color: {_TEXT};
|
| 192 |
+
}}
|
| 193 |
+
.narrative p {{
|
| 194 |
+
margin: 5px 0;
|
| 195 |
+
}}
|
| 196 |
+
.narrative strong {{
|
| 197 |
+
color: {_PRIMARY};
|
| 198 |
+
font-weight: 700;
|
| 199 |
+
}}
|
| 200 |
+
.narrative em {{
|
| 201 |
+
font-style: italic;
|
| 202 |
+
color: {_TEXT_MUTED};
|
| 203 |
+
}}
|
| 204 |
+
.narrative ul, .narrative ol {{
|
| 205 |
+
margin: 4px 0 8px 0;
|
| 206 |
+
padding-left: 20px;
|
| 207 |
+
}}
|
| 208 |
+
.narrative li {{
|
| 209 |
+
margin-bottom: 3px;
|
| 210 |
+
}}
|
| 211 |
+
.narrative h1 {{
|
| 212 |
+
font-size: 14px;
|
| 213 |
+
font-weight: 700;
|
| 214 |
+
color: {_PRIMARY};
|
| 215 |
+
border-left: 4px solid {_SECONDARY};
|
| 216 |
+
padding-left: 10px;
|
| 217 |
+
margin: 16px 0 8px 0;
|
| 218 |
+
text-transform: uppercase;
|
| 219 |
+
letter-spacing: 0.5px;
|
| 220 |
+
}}
|
| 221 |
+
.narrative h2 {{
|
| 222 |
+
font-size: 12px;
|
| 223 |
+
font-weight: 700;
|
| 224 |
+
color: {_PRIMARY};
|
| 225 |
+
border-left: 4px solid {_ACCENT};
|
| 226 |
+
padding-left: 8px;
|
| 227 |
margin: 14px 0 6px 0;
|
| 228 |
}}
|
| 229 |
+
.narrative h3 {{
|
| 230 |
+
font-size: 11px;
|
| 231 |
+
font-weight: 600;
|
| 232 |
+
color: {_TEXT};
|
| 233 |
+
margin: 10px 0 4px 0;
|
| 234 |
+
border-bottom: 1px dotted {_BORDER};
|
| 235 |
+
padding-bottom: 2px;
|
| 236 |
+
}}
|
| 237 |
+
.narrative hr {{
|
| 238 |
+
border: none;
|
| 239 |
+
border-top: 1px solid {_BORDER};
|
| 240 |
+
margin: 12px 0;
|
| 241 |
+
}}
|
| 242 |
+
.narrative blockquote {{
|
| 243 |
+
border-left: 3px solid {_ACCENT};
|
| 244 |
+
margin: 8px 0;
|
| 245 |
+
padding: 4px 12px;
|
| 246 |
+
background: #eff6ff;
|
| 247 |
+
color: {_PRIMARY};
|
| 248 |
+
font-size: 10px;
|
| 249 |
+
}}
|
| 250 |
|
| 251 |
+
/* ─── tables (data) ─── */
|
| 252 |
table {{
|
| 253 |
width: 100%;
|
| 254 |
border-collapse: collapse;
|
| 255 |
+
font-size: 9.5px;
|
| 256 |
+
margin: 8px 0 12px 0;
|
| 257 |
}}
|
| 258 |
+
thead th {{
|
| 259 |
background: {_PRIMARY};
|
| 260 |
color: #ffffff;
|
| 261 |
+
padding: 7px 8px;
|
| 262 |
text-align: left;
|
| 263 |
font-weight: 600;
|
| 264 |
+
font-size: 8.5px;
|
| 265 |
text-transform: uppercase;
|
| 266 |
letter-spacing: 0.5px;
|
| 267 |
}}
|
| 268 |
+
thead th:first-child {{ border-radius: 4px 0 0 0; }}
|
| 269 |
+
thead th:last-child {{ border-radius: 0 4px 0 0; }}
|
| 270 |
+
tbody td {{
|
| 271 |
+
padding: 5px 8px;
|
| 272 |
border-bottom: 1px solid {_BORDER};
|
| 273 |
vertical-align: top;
|
| 274 |
}}
|
| 275 |
+
tbody tr:nth-child(even) {{ background: {_BG_LIGHT}; }}
|
|
|
|
| 276 |
.z-cell {{ font-weight: 600; font-variant-numeric: tabular-nums; }}
|
| 277 |
.z-pos {{ color: {_DANGER}; }}
|
| 278 |
.z-neg {{ color: {_SUCCESS}; }}
|
| 279 |
.z-typ {{ color: {_TEXT_MUTED}; }}
|
| 280 |
|
| 281 |
+
/* ─── info / warning boxes ─── */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
.info-box {{
|
| 283 |
background: #eff6ff;
|
| 284 |
border: 1px solid #bfdbfe;
|
| 285 |
border-radius: 6px;
|
| 286 |
+
padding: 8px 12px;
|
| 287 |
+
font-size: 9.5px;
|
| 288 |
color: #1e40af;
|
| 289 |
+
margin: 8px 0;
|
| 290 |
}}
|
| 291 |
.warn-box {{
|
| 292 |
background: #fffbeb;
|
| 293 |
border: 1px solid #fde68a;
|
| 294 |
border-radius: 6px;
|
| 295 |
+
padding: 8px 12px;
|
| 296 |
+
font-size: 9.5px;
|
| 297 |
color: #92400e;
|
| 298 |
+
margin: 8px 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
}}
|
| 300 |
|
| 301 |
+
/* ─── conversation style ─── */
|
| 302 |
.chat-msg {{
|
| 303 |
+
margin: 6px 0;
|
| 304 |
padding: 8px 12px;
|
| 305 |
border-radius: 8px;
|
| 306 |
+
font-size: 10px;
|
| 307 |
+
line-height: 1.55;
|
| 308 |
page-break-inside: avoid;
|
| 309 |
}}
|
| 310 |
.chat-user {{
|
|
|
|
| 312 |
border: 1px solid #93c5fd;
|
| 313 |
margin-left: 40px;
|
| 314 |
}}
|
| 315 |
+
.chat-user-label {{
|
|
|
|
| 316 |
font-weight: 700;
|
| 317 |
color: {_PRIMARY};
|
| 318 |
+
font-size: 8px;
|
| 319 |
text-transform: uppercase;
|
| 320 |
display: block;
|
| 321 |
margin-bottom: 2px;
|
|
|
|
| 325 |
border: 1px solid {_BORDER};
|
| 326 |
margin-right: 40px;
|
| 327 |
}}
|
| 328 |
+
.chat-bot-label {{
|
|
|
|
| 329 |
font-weight: 700;
|
| 330 |
color: {_SECONDARY};
|
| 331 |
+
font-size: 8px;
|
| 332 |
text-transform: uppercase;
|
| 333 |
display: block;
|
| 334 |
margin-bottom: 2px;
|
| 335 |
}}
|
| 336 |
|
| 337 |
+
/* ─── footer ─── */
|
| 338 |
.report-footer {{
|
| 339 |
+
margin-top: 24px;
|
| 340 |
+
padding-top: 10px;
|
| 341 |
border-top: 2px solid {_BORDER};
|
| 342 |
+
font-size: 8px;
|
| 343 |
color: {_TEXT_MUTED};
|
| 344 |
text-align: center;
|
| 345 |
+
line-height: 1.5;
|
| 346 |
}}
|
| 347 |
.report-footer .disclaimer {{
|
| 348 |
font-style: italic;
|
| 349 |
+
margin-top: 3px;
|
| 350 |
}}
|
| 351 |
"""
|
| 352 |
|
|
|
|
| 354 |
def _header_html(report_title: str, report_type: str, now_str: str) -> str:
|
| 355 |
return f"""
|
| 356 |
<div class="report-header">
|
| 357 |
+
<table width="100%"><tr>
|
| 358 |
+
<td>
|
| 359 |
+
<div class="brand">BrainAge AI</div>
|
| 360 |
+
<div class="brand-sub">Neuroimaging Analytics Platform</div>
|
| 361 |
+
</td>
|
| 362 |
+
<td class="doc-info">
|
| 363 |
+
<strong>{report_title}</strong><br>
|
| 364 |
+
Report Type: {report_type}<br>
|
| 365 |
+
Generated: {now_str}<br>
|
| 366 |
+
Document ID: RPT-{datetime.now().strftime('%Y%m%d%H%M%S')}
|
| 367 |
+
</td>
|
| 368 |
+
</tr></table>
|
| 369 |
</div>"""
|
| 370 |
|
| 371 |
|
|
|
|
| 373 |
pred = f"{ctx['predicted_age']:.1f}" if ctx.get('predicted_age') else "N/A"
|
| 374 |
bag_val = ctx.get('brain_age_gap') or 0
|
| 375 |
bag_str = f"{bag_val:+.1f}" if ctx.get('brain_age_gap') is not None else "N/A"
|
| 376 |
+
age_display = ctx.get('patient_age', 'N/A')
|
| 377 |
+
if age_display is None:
|
| 378 |
+
age_display = "N/A"
|
| 379 |
+
scan_date = scan.created_at.strftime('%Y-%m-%d') if scan and scan.created_at else 'N/A'
|
| 380 |
return f"""
|
| 381 |
+
<table class="patient-grid">
|
| 382 |
+
<tr>
|
| 383 |
+
<td><span class="plabel">Patient Name</span><span class="pval">{ctx['patient_name']}</span></td>
|
| 384 |
+
<td><span class="plabel">Age</span><span class="pval">{age_display}y</span></td>
|
| 385 |
+
<td><span class="plabel">Sex</span><span class="pval">{'Male' if ctx['patient_sex']=='M' else 'Female' if ctx['patient_sex']=='F' else ctx['patient_sex']}</span></td>
|
| 386 |
+
</tr>
|
| 387 |
+
<tr>
|
| 388 |
+
<td><span class="plabel">Predicted Brain Age</span><span class="pval">{pred}y</span></td>
|
| 389 |
+
<td><span class="plabel">Brain Age Gap</span><span class="pval"><span class="badge {bag_badge}">{bag_str}y</span></span></td>
|
| 390 |
+
<td><span class="plabel">Scan Date</span><span class="pval">{scan_date}</span></td>
|
| 391 |
+
</tr>
|
| 392 |
+
</table>"""
|
|
|
|
|
|
|
| 393 |
|
| 394 |
|
| 395 |
def _highlight_cards(ctx: dict) -> str:
|
|
|
|
| 399 |
n_regions = len(ctx.get('measurements', []))
|
| 400 |
abnormal = [r for r in ctx.get('regions', []) if abs(r.get('z_score', 0)) >= 2]
|
| 401 |
return f"""
|
| 402 |
+
<table class="hl-table"><tr>
|
| 403 |
+
<td><span class="hl-val">{age if age else '—'}y</span><span class="hl-lbl">Chronological Age</span></td>
|
| 404 |
+
<td><span class="hl-val">{f'{pred:.1f}' if pred else '—'}y</span><span class="hl-lbl">Predicted Brain Age</span></td>
|
| 405 |
+
<td><span class="hl-val">{f'{bag:+.1f}' if bag is not None else '—'}y</span><span class="hl-lbl">Brain Age Gap</span></td>
|
| 406 |
+
<td><span class="hl-val">{n_regions}</span><span class="hl-lbl">Regions Analyzed</span></td>
|
| 407 |
+
<td><span class="hl-val">{len(abnormal)}</span><span class="hl-lbl">Abnormal Regions</span></td>
|
| 408 |
+
</tr></table>"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
|
| 411 |
def _region_table(ctx: dict) -> str:
|
| 412 |
"""Build an HTML table of top regions by |z-score|."""
|
|
|
|
|
|
|
|
|
|
| 413 |
regions = ctx.get("regions", [])
|
| 414 |
if not regions:
|
| 415 |
return ""
|
| 416 |
sorted_r = sorted(regions, key=lambda r: abs(r.get("z_score", 0)), reverse=True)
|
| 417 |
rows_html = ""
|
| 418 |
+
for i, r in enumerate(sorted_r[:20]):
|
| 419 |
z = r.get("z_score", 0)
|
| 420 |
z_cls = "z-pos" if z > 1 else "z-neg" if z < -1 else "z-typ"
|
| 421 |
sev = "normal"
|
|
|
|
| 423 |
if az >= 3: sev = "severe"
|
| 424 |
elif az >= 2: sev = "moderate"
|
| 425 |
elif az >= 1: sev = "borderline"
|
| 426 |
+
pct = 50 + 50 * (2 / (1 + 2.718 ** (-z / 1.4)) - 1)
|
| 427 |
+
badge_cls = "badge-red" if sev == "severe" else "badge-yellow" if sev in ("moderate", "borderline") else "badge-green"
|
| 428 |
+
exp = r.get("expected_mm3")
|
| 429 |
+
exp_str = f"{float(exp):,.0f}" if exp else "—"
|
| 430 |
rows_html += f"""<tr>
|
| 431 |
+
<td style="font-weight:{'600' if az>=2 else '400'}">{r.get('region', '?')}</td>
|
| 432 |
+
<td style="text-align:right">{float(r.get('volume_mm3', 0)):,.0f}</td>
|
| 433 |
+
<td style="text-align:right;color:{_TEXT_MUTED}">{exp_str}</td>
|
| 434 |
<td class="z-cell {z_cls}" style="text-align:right">{z:+.2f}</td>
|
| 435 |
<td style="text-align:right">p{pct:.0f}</td>
|
| 436 |
+
<td><span class="badge {badge_cls}">{sev}</span></td>
|
| 437 |
</tr>"""
|
| 438 |
return f"""
|
| 439 |
<h2>Regional Volumetric Analysis</h2>
|
| 440 |
<table>
|
| 441 |
<thead><tr>
|
| 442 |
+
<th>Region</th>
|
| 443 |
+
<th style="text-align:right">Volume (mm³)</th>
|
| 444 |
+
<th style="text-align:right">Expected (mm³)</th>
|
| 445 |
+
<th style="text-align:right">Z-Score</th>
|
| 446 |
+
<th style="text-align:right">Percentile</th>
|
| 447 |
<th>Severity</th>
|
| 448 |
</tr></thead>
|
| 449 |
<tbody>{rows_html}</tbody>
|
| 450 |
+
</table>
|
| 451 |
+
<div class="info-box">Showing top {min(20, len(sorted_r))} of {len(regions)} regions sorted by |z-score|.
|
| 452 |
+
Z-scores compare measured volumes against age-appropriate normative expectations.</div>"""
|
| 453 |
|
| 454 |
|
| 455 |
def _footer_html() -> str:
|
| 456 |
return """
|
| 457 |
<div class="report-footer">
|
| 458 |
+
<div><strong>BrainAge AI</strong> • Neuroimaging Analytics Platform</div>
|
| 459 |
<div>Model: SFCN Dual-Branch • Trained on 6,050 healthy subjects • MAE: 2.5y (lifespan), 1.05y (pediatric)</div>
|
| 460 |
<div class="disclaimer">This report is AI-generated and intended to support, not replace, clinical decision-making.
|
| 461 |
+
All findings should be reviewed and interpreted by a qualified healthcare professional.</div>
|
| 462 |
</div>"""
|
| 463 |
|
| 464 |
|
|
|
|
| 487 |
z = r.get("z_score", 0)
|
| 488 |
base_data += f" - {r.get('region')}: {float(r.get('volume_mm3',0)):.0f}mm³, z={z:+.2f}\n"
|
| 489 |
|
| 490 |
+
fmt_instruction = (
|
| 491 |
+
"\n\nIMPORTANT formatting rules for this report:\n"
|
| 492 |
+
"- Use markdown: ## for section headers, ### for sub-headers\n"
|
| 493 |
+
"- Use **bold** for important terms and values\n"
|
| 494 |
+
"- Use bullet lists with * for itemized content\n"
|
| 495 |
+
"- Do NOT include [REPORT_READY] or any metadata markers\n"
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
prompts = {
|
| 499 |
"comprehensive": (
|
| 500 |
+
base_data +
|
| 501 |
+
"\nGenerate a comprehensive clinical brain MRI analysis report with these sections:\n"
|
| 502 |
+
"## Executive Summary\n(2-3 sentences)\n"
|
| 503 |
+
"## Brain Age Analysis\n(interpretation of predicted vs chronological age)\n"
|
| 504 |
+
"## Volumetric Findings\n(key regional observations)\n"
|
| 505 |
+
"## Abnormal Regions\n(detail each significant deviation)\n"
|
| 506 |
+
"## Clinical Significance\n(what these findings mean)\n"
|
| 507 |
+
"## Recommendations\n(next steps, follow-up)\n"
|
| 508 |
+
"Be thorough but clinical."
|
| 509 |
+
+ fmt_instruction
|
| 510 |
),
|
| 511 |
"progress": (
|
| 512 |
+
base_data +
|
| 513 |
+
"\nGenerate a clinical progress report with these sections:\n"
|
| 514 |
+
"## Current Status Summary\n(brief overview of current brain health)\n"
|
| 515 |
+
"## Key Metrics\n(brain age gap, most significant regional findings)\n"
|
| 516 |
+
"## Changes Since Baseline\n(note this is the baseline scan if first visit)\n"
|
| 517 |
+
"## Areas of Concern\n(any regions requiring monitoring)\n"
|
| 518 |
+
"## Positive Indicators\n(regions within normal limits)\n"
|
| 519 |
+
"## Recommendations for Next Visit\n(timeline, focus areas)\n"
|
| 520 |
+
"Be concise and actionable."
|
| 521 |
+
+ fmt_instruction
|
| 522 |
),
|
| 523 |
"care_plan": (
|
| 524 |
+
base_data +
|
| 525 |
+
"\nGenerate a personalized brain health care plan with these sections:\n"
|
| 526 |
+
"## Patient Profile Summary\n(brief clinical overview)\n"
|
| 527 |
+
"## Brain Health Assessment\n(current status based on findings)\n"
|
| 528 |
+
"## Lifestyle Recommendations\n"
|
| 529 |
+
"### Cognitive Training Exercises\n"
|
| 530 |
+
"### Physical Activity Plan\n"
|
| 531 |
+
"### Nutrition Recommendations\n"
|
| 532 |
+
"### Sleep Hygiene\n"
|
| 533 |
+
"### Stress Management\n"
|
| 534 |
+
"## Monitoring Plan\n(what to track, when to re-scan)\n"
|
| 535 |
+
"## Risk Mitigation\n(specific to any abnormal regions)\n"
|
| 536 |
+
"## Goals & Timeline\n(3-month, 6-month, 12-month targets)\n"
|
| 537 |
"Be evidence-based, specific, and actionable."
|
| 538 |
+
+ fmt_instruction
|
| 539 |
),
|
| 540 |
"executive": (
|
| 541 |
+
base_data +
|
| 542 |
+
"\nGenerate a concise 1-page executive summary:\n"
|
| 543 |
+
"## Key Finding\n(one sentence headline)\n"
|
| 544 |
+
"## Brain Age Result\n(2 sentences)\n"
|
| 545 |
+
"## Notable Regions\n(bullet list, max 5)\n"
|
| 546 |
+
"## Risk Assessment\n(low/moderate/high with brief justification)\n"
|
| 547 |
+
"## Recommended Action\n(2-3 actionable items)\n"
|
| 548 |
"Keep everything extremely concise. This is for a busy clinician."
|
| 549 |
+
+ fmt_instruction
|
| 550 |
),
|
| 551 |
}
|
| 552 |
return prompts.get(report_type, prompts["comprehensive"])
|
|
|
|
| 584 |
except Exception as e:
|
| 585 |
narrative = f"Report generation error: {e}"
|
| 586 |
|
| 587 |
+
narrative_html = _md_to_html(narrative)
|
| 588 |
|
| 589 |
html = f"""<!DOCTYPE html>
|
| 590 |
<html><head><meta charset="utf-8">
|
|
|
|
| 623 |
chat_html = ""
|
| 624 |
for m in msgs:
|
| 625 |
cls = "chat-user" if m.role == "user" else "chat-bot"
|
| 626 |
+
label_cls = "chat-user-label" if m.role == "user" else "chat-bot-label"
|
| 627 |
+
label_txt = "You" if m.role == "user" else "AI Assistant"
|
| 628 |
+
content_md = (m.content or "").replace("[REPORT_READY]", "")
|
| 629 |
+
content_html = _md_to_html(content_md)
|
| 630 |
+
chat_html += (
|
| 631 |
+
f'<div class="chat-msg {cls}">'
|
| 632 |
+
f'<span class="{label_cls}">{label_txt}</span>'
|
| 633 |
+
f'{content_html}'
|
| 634 |
+
f'</div>\n'
|
| 635 |
+
)
|
| 636 |
|
| 637 |
if not chat_html:
|
| 638 |
chat_html = '<div class="info-box">No messages in this conversation.</div>'
|
requirements.txt
CHANGED
|
@@ -16,3 +16,4 @@ nilearn>=0.10
|
|
| 16 |
huggingface_hub>=0.23
|
| 17 |
weasyprint==60.2
|
| 18 |
pydyf==0.9.0
|
|
|
|
|
|
| 16 |
huggingface_hub>=0.23
|
| 17 |
weasyprint==60.2
|
| 18 |
pydyf==0.9.0
|
| 19 |
+
markdown>=3.5
|