bilalEthizo commited on
Commit
b4e8fa2
·
verified ·
1 Parent(s): f3ab364

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. app/services/pdf_generator.py +291 -213
  2. 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: 20mm 18mm 25mm 18mm;
39
- @top-center {{
40
- content: "";
41
- }}
42
  @bottom-center {{
43
  content: counter(page) " of " counter(pages);
44
- font-size: 9px;
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: 11px;
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: 14px;
66
- margin-bottom: 18px;
67
  }}
68
- .report-header .brand {{
 
 
69
  font-size: 22px;
70
  font-weight: 700;
71
  color: {_PRIMARY};
72
  letter-spacing: -0.5px;
73
  }}
74
- .report-header .brand-sub {{
75
- font-size: 9px;
76
  color: {_TEXT_MUTED};
77
  text-transform: uppercase;
78
  letter-spacing: 1.5px;
79
  margin-top: 2px;
80
  }}
81
- .report-header .doc-info {{
82
  text-align: right;
83
  font-size: 9px;
84
  color: {_TEXT_MUTED};
85
  line-height: 1.6;
86
  }}
87
 
88
- /* patient info box */
89
- .patient-box {{
90
- display: flex;
91
- flex-wrap: wrap;
92
- gap: 0;
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-box .cell:last-child {{ border-right: none; }}
108
- .patient-box .cell-label {{
109
- font-size: 8px;
 
 
 
 
 
110
  text-transform: uppercase;
111
- letter-spacing: 1px;
112
  color: {_TEXT_MUTED};
113
- margin-bottom: 3px;
 
114
  }}
115
- .patient-box .cell-value {{
116
- font-size: 12px;
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
- /* section headings */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  h2 {{
136
- font-size: 14px;
137
  font-weight: 700;
138
  color: {_PRIMARY};
139
  border-left: 4px solid {_SECONDARY};
140
  padding-left: 10px;
141
- margin: 20px 0 10px 0;
142
  text-transform: uppercase;
143
  letter-spacing: 0.5px;
144
  }}
145
  h3 {{
146
- font-size: 12px;
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: 10px;
157
- margin: 8px 0 14px 0;
158
  }}
159
- table thead th {{
160
  background: {_PRIMARY};
161
  color: #ffffff;
162
- padding: 8px 10px;
163
  text-align: left;
164
  font-weight: 600;
165
- font-size: 9px;
166
  text-transform: uppercase;
167
  letter-spacing: 0.5px;
168
  }}
169
- table thead th:first-child {{ border-radius: 4px 0 0 0; }}
170
- table thead th:last-child {{ border-radius: 0 4px 0 0; }}
171
- table tbody td {{
172
- padding: 6px 10px;
173
  border-bottom: 1px solid {_BORDER};
174
  vertical-align: top;
175
  }}
176
- table tbody tr:nth-child(even) {{ background: {_BG_LIGHT}; }}
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
- /* narrative text */
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: 10px 14px;
198
- font-size: 10px;
199
  color: #1e40af;
200
- margin: 10px 0;
201
  }}
202
  .warn-box {{
203
  background: #fffbeb;
204
  border: 1px solid #fde68a;
205
  border-radius: 6px;
206
- padding: 10px 14px;
207
- font-size: 10px;
208
  color: #92400e;
209
- margin: 10px 0;
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: 8px 0;
242
  padding: 8px 12px;
243
  border-radius: 8px;
244
- font-size: 11px;
245
- line-height: 1.6;
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::before {{
254
- content: "You";
255
  font-weight: 700;
256
  color: {_PRIMARY};
257
- font-size: 9px;
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::before {{
268
- content: "AI Assistant";
269
  font-weight: 700;
270
  color: {_SECONDARY};
271
- font-size: 9px;
272
  text-transform: uppercase;
273
  display: block;
274
  margin-bottom: 2px;
275
  }}
276
 
277
- /* footer */
278
  .report-footer {{
279
- margin-top: 30px;
280
- padding-top: 12px;
281
  border-top: 2px solid {_BORDER};
282
- font-size: 9px;
283
  color: {_TEXT_MUTED};
284
  text-align: center;
285
- line-height: 1.6;
286
  }}
287
  .report-footer .disclaimer {{
288
  font-style: italic;
289
- margin-top: 4px;
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
- <div>
298
- <div class="brand">BrainAge AI</div>
299
- <div class="brand-sub">Neuroimaging Analytics Platform</div>
300
- </div>
301
- <div class="doc-info">
302
- <strong>{report_title}</strong><br>
303
- Report Type: {report_type}<br>
304
- Generated: {now_str}<br>
305
- Document ID: RPT-{datetime.now().strftime('%Y%m%d%H%M%S')}
306
- </div>
 
 
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
- <div class="patient-box">
316
- <div class="cell"><div class="cell-label">Patient Name</div>
317
- <div class="cell-value">{ctx['patient_name']}</div></div>
318
- <div class="cell"><div class="cell-label">Age</div>
319
- <div class="cell-value">{ctx.get('patient_age', 'N/A')}y</div></div>
320
- <div class="cell"><div class="cell-label">Sex</div>
321
- <div class="cell-value">{ctx['patient_sex']}</div></div>
322
- <div class="cell"><div class="cell-label">Predicted Brain Age</div>
323
- <div class="cell-value">{pred}y</div></div>
324
- <div class="cell"><div class="cell-label">Brain Age Gap</div>
325
- <div class="cell-value"><span class="badge {bag_badge}">{bag_str}y</span></div></div>
326
- <div class="cell"><div class="cell-label">Scan Date</div>
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
- <div class="highlight-row">
339
- <div class="highlight-card">
340
- <div class="val">{age if age else '—'}y</div>
341
- <div class="lbl">Chronological Age</div>
342
- </div>
343
- <div class="highlight-card">
344
- <div class="val">{f'{pred:.1f}' if pred else '—'}y</div>
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[:15]:
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) # approx percentile
 
 
 
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 badge-{'red' if sev=='severe' else 'yellow' if sev in ('moderate','borderline') else 'green'}">{sev}</span></td>
387
  </tr>"""
388
  return f"""
389
  <h2>Regional Volumetric Analysis</h2>
390
  <table>
391
  <thead><tr>
392
- <th>Region</th><th style="text-align:right">Volume (mm³)</th>
393
- <th style="text-align:right">Z-Score</th><th style="text-align:right">Percentile</th>
 
 
 
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 &bull; Neuroimaging Analytics Platform</div>
404
  <div>Model: SFCN Dual-Branch &bull; Trained on 6,050 healthy subjects &bull; 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 + "\nGenerate a comprehensive clinical brain MRI analysis report with these sections:\n"
438
- "1. **Executive Summary** (2-3 sentences)\n"
439
- "2. **Brain Age Analysis** (interpretation of predicted vs chronological age)\n"
440
- "3. **Volumetric Findings** (key regional observations)\n"
441
- "4. **Abnormal Regions** (detail each significant deviation)\n"
442
- "5. **Tissue Composition** (GM/WM/CSF observations)\n"
443
- "6. **Clinical Significance** (what these findings mean)\n"
444
- "7. **Recommendations** (next steps, follow-up)\n"
445
- "Use clear section headers with **bold**. Be thorough but clinical."
 
446
  ),
447
  "progress": (
448
- base_data + "\nGenerate a clinical progress report with these sections:\n"
449
- "1. **Current Status Summary** (brief overview of current brain health)\n"
450
- "2. **Key Metrics** (brain age gap, most significant regional findings)\n"
451
- "3. **Changes Since Baseline** (note this is the baseline scan if first visit)\n"
452
- "4. **Areas of Concern** (any regions requiring monitoring)\n"
453
- "5. **Positive Indicators** (regions within normal limits)\n"
454
- "6. **Recommendations for Next Visit** (timeline, focus areas)\n"
455
- "Use clear section headers. Be concise and actionable."
 
 
456
  ),
457
  "care_plan": (
458
- base_data + "\nGenerate a personalized brain health care plan with these sections:\n"
459
- "1. **Patient Profile Summary** (brief clinical overview)\n"
460
- "2. **Brain Health Assessment** (current status based on findings)\n"
461
- "3. **Lifestyle Recommendations** (evidence-based interventions):\n"
462
- " - Cognitive training exercises\n"
463
- " - Physical activity plan\n"
464
- " - Nutrition recommendations\n"
465
- " - Sleep hygiene\n"
466
- " - Stress management\n"
467
- "4. **Monitoring Plan** (what to track, when to re-scan)\n"
468
- "5. **Risk Mitigation** (specific to any abnormal regions)\n"
469
- "6. **Goals & Timeline** (3-month, 6-month, 12-month targets)\n"
 
470
  "Be evidence-based, specific, and actionable."
 
471
  ),
472
  "executive": (
473
- base_data + "\nGenerate a concise 1-page executive summary:\n"
474
- "1. **Key Finding** (one sentence headline)\n"
475
- "2. **Brain Age Result** (2 sentences)\n"
476
- "3. **Notable Regions** (bullet list, max 5)\n"
477
- "4. **Risk Assessment** (low/moderate/high with brief justification)\n"
478
- "5. **Recommended Action** (2-3 actionable items)\n"
 
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 = narrative.replace("**", "").replace("\n", "<br>")
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
- content = (m.content or "").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br>")
557
- chat_html += f'<div class="chat-msg {cls}">{content}</div>\n'
 
 
 
 
 
 
 
 
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&sup3;)</th>
444
+ <th style="text-align:right">Expected (mm&sup3;)</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> &bull; Neuroimaging Analytics Platform</div>
459
  <div>Model: SFCN Dual-Branch &bull; Trained on 6,050 healthy subjects &bull; 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