r0mant1c Codex commited on
Commit
406250b
·
1 Parent(s): ae97f35

Add knowledge graph health report pipeline

Browse files

Co-authored-by: Codex <chatgpt-codex-connector[bot]@users.noreply.github.com>

README.md CHANGED
@@ -33,6 +33,16 @@ The long-term vision is to make medical paperwork less intimidating and help peo
33
 
34
  The first version focuses only on extraction: upload a lab report and convert it into structured raw values such as marker name, value, unit, reference range, status, source snippet, and confidence.
35
 
 
 
 
 
 
 
 
 
 
 
36
  Local setup:
37
 
38
  ```bash
 
33
 
34
  The first version focuses only on extraction: upload a lab report and convert it into structured raw values such as marker name, value, unit, reference range, status, source snippet, and confidence.
35
 
36
+ ## Current Pipeline
37
+
38
+ The app now runs extraction and deterministic knowledge-graph enrichment:
39
+
40
+ 1. The extractor reads an uploaded image, PDF, or text document and returns patient context plus raw lab values.
41
+ 2. `src.report_pipeline.build_health_report` resolves marker aliases against `kb/cbc_knowledge_graph.json`, selects age/sex-aware reference context, and merges marker explanations, importance, and food/exercise/supplement guidance.
42
+ 3. `app.py` renders the enriched report as the final health-report UI.
43
+
44
+ The knowledge graph is educational context, not diagnosis. The lab-provided reference range remains the primary comparison when it is available.
45
+
46
  Local setup:
47
 
48
  ```bash
app.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import os
 
4
  from html import escape
5
  from typing import Any
6
 
@@ -8,6 +9,7 @@ import gradio as gr
8
 
9
  from src.local_env import load_local_env
10
  from src.extraction import build_extractor
 
11
 
12
 
13
  load_local_env()
@@ -38,13 +40,27 @@ def extract_lab_values(
38
  gr.update(visible=True),
39
  )
40
 
41
- status_text = f"Extracted {len(result.tests)} lab values."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  if result.notes:
43
  status_text += " Notes: " + " ".join(result.notes[:3])
44
 
45
  return (
46
  _status_html("Extraction complete", status_text),
47
- report_html(result.tests, result.notes),
48
  gr.update(visible=True),
49
  )
50
 
@@ -60,7 +76,7 @@ def _status_html(title: str, detail: str, tone: str = "success") -> str:
60
 
61
  def show_processing() -> tuple[str, Any, str]:
62
  return (
63
- _status_html("Reading document", "Extracting markers, values, units, ranges, and confidence signals.", tone="loading"),
64
  gr.update(visible=True),
65
  loading_report_html(),
66
  )
@@ -91,7 +107,7 @@ def selected_document_html(filename: str | None = None) -> str:
91
  <div>
92
  <p class="bte-kicker">Document loaded</p>
93
  <h3>{escape(filename)}</h3>
94
- <p>Ready to extract markers, values, units, ranges, and confidence signals.</p>
95
  </div>
96
  </section>
97
  """
@@ -122,7 +138,7 @@ def loading_report_html() -> str:
122
  <div class="bte-loading-copy">
123
  <p class="bte-kicker">Extraction in progress</p>
124
  <h2>Reading your test results</h2>
125
- <p>The model is locating markers, values, units, reference ranges, and status flags. The full report will appear here when extraction is complete.</p>
126
  </div>
127
  <div class="bte-loading-stack" aria-hidden="true">
128
  <div><span></span><strong></strong></div>
@@ -346,65 +362,84 @@ def _ideal_marker_card(test: dict[str, str]) -> str:
346
  """
347
 
348
 
349
- def report_html(tests: list[dict[str, Any]], notes: list[str]) -> str:
350
- total = len(tests)
351
- high = _count_status(tests, "high")
352
- low = _count_status(tests, "low")
353
- abnormal = _count_status(tests, "abnormal")
354
- normal = _count_status(tests, "normal")
355
- needs_review = high + low + abnormal
356
 
357
- cards = "\n".join(_marker_card(test) for test in tests) or _empty_marker_card()
358
- notes_html = "".join(f"<li>{escape(note)}</li>" for note in notes[:6])
359
- notes_block = f"<ul>{notes_html}</ul>" if notes_html else "<p>No extraction notes returned.</p>"
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
  return f"""
362
- <section class="bte-report">
363
- <header class="bte-report-hero">
364
  <div>
365
- <p class="bte-kicker">Lab extraction report</p>
366
- <h2>{total} markers found</h2>
367
- <p>Review the extracted values before using them for interpretation. This draft is an extraction view only.</p>
368
- </div>
369
- <div class="bte-score">
370
- <span>{needs_review}</span>
371
- <small>need review</small>
372
  </div>
373
  </header>
374
 
375
- <div class="bte-metrics">
376
- {_metric("Total", total, "All extracted markers")}
377
- {_metric("Review", needs_review, "High, low, or abnormal")}
378
- {_metric("Normal", normal, "Marked normal")}
379
- {_metric("Unknown", max(total - needs_review - normal, 0), "Needs confirmation")}
380
- </div>
381
 
382
- <div class="bte-status-strip">
383
- {_pill("High", high, "high")}
384
- {_pill("Low", low, "low")}
385
- {_pill("Abnormal", abnormal, "abnormal")}
386
- {_pill("Normal", normal, "normal")}
 
 
 
 
 
 
 
 
 
 
 
 
387
  </div>
388
 
389
- <div class="bte-report-grid">
390
- <section class="bte-marker-list">
391
- {cards}
392
- </section>
393
- <aside class="bte-report-aside">
394
- <h3>Extraction notes</h3>
395
- {notes_block}
396
- <div class="bte-disclaimer">
397
- <strong>Draft only</strong>
398
- <span>These are raw extracted values, not medical advice. Confirm values against the original document.</span>
399
- </div>
400
- </aside>
401
  </div>
402
  </section>
403
  """
404
 
405
 
406
- def _count_status(tests: list[dict[str, Any]], status: str) -> int:
407
- return sum(1 for test in tests if str(test.get("status") or "").lower() == status)
 
 
 
 
 
 
 
 
 
408
 
409
 
410
  def _metric(label: str, value: int, caption: str) -> str:
@@ -422,13 +457,25 @@ def _pill(label: str, value: int, tone: str) -> str:
422
 
423
 
424
  def _marker_card(test: dict[str, Any]) -> str:
425
- marker = _text(test.get("marker"), "Unknown marker")
 
426
  value = _text(test.get("value"), "-")
427
  unit = _text(test.get("unit"), "")
428
- reference = _text(test.get("reference_range"), "Reference range not extracted")
429
  status = _text(test.get("status"), "unknown").lower()
430
- source = _text(test.get("source_text"), "No source snippet returned.")
431
  confidence = _confidence_percent(test.get("confidence"))
 
 
 
 
 
 
 
 
 
 
 
 
432
 
433
  return f"""
434
  <details class="bte-marker bte-marker--{escape(status)}" open>
@@ -444,17 +491,259 @@ def _marker_card(test: dict[str, Any]) -> str:
444
  <span class="bte-marker-status">{escape(status.title())}</span>
445
  </summary>
446
  <div class="bte-marker-body">
447
- <div>
448
- <span>Confidence</span>
449
  <div class="bte-confidence"><i style="width: {confidence}%"></i></div>
450
  <small>{confidence}%</small>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  </div>
452
- <blockquote>{escape(source)}</blockquote>
453
  </div>
454
  </details>
455
  """
456
 
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  def _empty_marker_card() -> str:
459
  return """
460
  <div class="bte-marker-empty">
@@ -463,6 +752,69 @@ def _empty_marker_card() -> str:
463
  """
464
 
465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  def _text(value: Any, fallback: str) -> str:
467
  if value is None:
468
  return fallback
@@ -818,7 +1170,9 @@ gradio-app,
818
  .bte-status-row,
819
  .bte-status-row > div,
820
  .bte-ideal-row,
821
- .bte-ideal-row > div {
 
 
822
  width: var(--bte-rail) !important;
823
  max-width: var(--bte-rail) !important;
824
  margin-left: auto !important;
@@ -834,7 +1188,10 @@ gradio-app,
834
  .bte-status-row .block,
835
  .bte-ideal-row .prose,
836
  .bte-ideal-row .html-container,
837
- .bte-ideal-row .block {
 
 
 
838
  padding: 0 !important;
839
  margin: 0 !important;
840
  background: transparent !important;
@@ -1775,6 +2132,78 @@ button.bte-action *,
1775
  color: var(--bte-muted);
1776
  }
1777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1778
  .bte-confidence {
1779
  height: 8px;
1780
  border-radius: 999px;
@@ -1815,6 +2244,39 @@ button.bte-action *,
1815
  color: var(--bte-muted);
1816
  }
1817
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1818
  .bte-disclaimer {
1819
  display: grid;
1820
  gap: 4px;
@@ -1840,6 +2302,12 @@ button.bte-action *,
1840
  background: transparent;
1841
  }
1842
 
 
 
 
 
 
 
1843
  .bte-ideal-hero {
1844
  display: flex;
1845
  align-items: center;
@@ -1920,7 +2388,11 @@ button.bte-action *,
1920
  .bte-ideal-doc:has(#bte-filter-total:checked) .bte-ideal-stat--total,
1921
  .bte-ideal-doc:has(#bte-filter-ideal:checked) .bte-ideal-stat--ideal,
1922
  .bte-ideal-doc:has(#bte-filter-normal:checked) .bte-ideal-stat--normal,
1923
- .bte-ideal-doc:has(#bte-filter-bad:checked) .bte-ideal-stat--bad {
 
 
 
 
1924
  border-color: transparent;
1925
  background:
1926
  linear-gradient(var(--bte-stat-bg), var(--bte-stat-bg)) padding-box,
@@ -1974,7 +2446,10 @@ button.bte-action *,
1974
 
1975
  .bte-ideal-doc:has(#bte-filter-ideal:checked) .bte-ideal-marker:not(.bte-ideal-marker--ideal),
1976
  .bte-ideal-doc:has(#bte-filter-normal:checked) .bte-ideal-marker:not(.bte-ideal-marker--normal),
1977
- .bte-ideal-doc:has(#bte-filter-bad:checked) .bte-ideal-marker:not(.bte-ideal-marker--bad) {
 
 
 
1978
  display: none;
1979
  }
1980
 
@@ -2281,10 +2756,18 @@ button.bte-action *,
2281
  .bte-hero-grid > :nth-child(3),
2282
  .bte-run-status,
2283
  .bte-report-anchor,
2284
- .bte-ideal-doc {
2285
  display: none !important;
2286
  }
2287
 
 
 
 
 
 
 
 
 
2288
  .bte-workflow-panel--upload,
2289
  .bte-workflow-panel--analysis {
2290
  min-width: 0 !important;
@@ -2406,6 +2889,11 @@ button.bte-action *,
2406
  grid-template-columns: 1fr;
2407
  }
2408
 
 
 
 
 
 
2409
  .bte-marker-value {
2410
  text-align: left;
2411
  }
@@ -2445,7 +2933,7 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
2445
  <div>
2446
  <p class="bte-kicker">Clinical clarity from raw documents</p>
2447
  <h1>Blood Test Explainer</h1>
2448
- <p>Upload a lab report and turn dense medical paperwork into a polished extraction report with raw markers, values, units, reference ranges, and confidence signals.</p>
2449
  </div>
2450
  """
2451
  )
@@ -2507,7 +2995,7 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
2507
  show_progress="hidden",
2508
  )
2509
 
2510
- with gr.Group(visible=False, elem_classes=["bte-report-panel"]) as report_panel:
2511
  report = gr.HTML(empty_report_html())
2512
 
2513
  run_button.click(
 
1
  from __future__ import annotations
2
 
3
  import os
4
+ import re
5
  from html import escape
6
  from typing import Any
7
 
 
9
 
10
  from src.local_env import load_local_env
11
  from src.extraction import build_extractor
12
+ from src.report_pipeline import build_health_report
13
 
14
 
15
  load_local_env()
 
40
  gr.update(visible=True),
41
  )
42
 
43
+ health_report = build_health_report(result)
44
+ summary = health_report["summary"]
45
+ patient = health_report["patient"]
46
+
47
+ status_text = (
48
+ f"Extracted {summary['total_markers']} lab values and enriched "
49
+ f"{summary['enriched_markers']} from the knowledge graph."
50
+ )
51
+ patient_bits = []
52
+ if patient.get("age"):
53
+ patient_bits.append(f"age {patient['age']}")
54
+ if patient.get("sex") and patient["sex"] != "unknown":
55
+ patient_bits.append(patient["sex"])
56
+ if patient_bits:
57
+ status_text += " Patient context: " + ", ".join(patient_bits) + "."
58
  if result.notes:
59
  status_text += " Notes: " + " ".join(result.notes[:3])
60
 
61
  return (
62
  _status_html("Extraction complete", status_text),
63
+ report_html(health_report),
64
  gr.update(visible=True),
65
  )
66
 
 
76
 
77
  def show_processing() -> tuple[str, Any, str]:
78
  return (
79
+ _status_html("Reading document", "Extracting patient context and markers, then matching them to the knowledge graph.", tone="loading"),
80
  gr.update(visible=True),
81
  loading_report_html(),
82
  )
 
107
  <div>
108
  <p class="bte-kicker">Document loaded</p>
109
  <h3>{escape(filename)}</h3>
110
+ <p>Ready to extract markers, patient context, values, units, ranges, and confidence signals.</p>
111
  </div>
112
  </section>
113
  """
 
138
  <div class="bte-loading-copy">
139
  <p class="bte-kicker">Extraction in progress</p>
140
  <h2>Reading your test results</h2>
141
+ <p>The model is locating patient context, markers, values, units, reference ranges, and status flags. The knowledge graph report will appear when enrichment is complete.</p>
142
  </div>
143
  <div class="bte-loading-stack" aria-hidden="true">
144
  <div><span></span><strong></strong></div>
 
362
  """
363
 
364
 
365
+ def report_html(report: dict[str, Any]) -> str:
366
+ markers = list(report.get("markers") or [])
367
+ summary = report.get("summary") or {}
368
+ patient = report.get("patient") or {}
 
 
 
369
 
370
+ total = int(summary.get("total_markers") or len(markers))
371
+ final_statuses = [_final_status_for_marker(marker) for marker in markers]
372
+ ideal = final_statuses.count("ideal")
373
+ normal = final_statuses.count("normal")
374
+ bad = final_statuses.count("bad")
375
+ patient_context = _final_patient_context(patient)
376
+
377
+ left_cards = "\n".join(
378
+ _final_marker_card(marker) for index, marker in enumerate(markers) if index % 2 == 0
379
+ )
380
+ right_cards = "\n".join(
381
+ _final_marker_card(marker) for index, marker in enumerate(markers) if index % 2 == 1
382
+ )
383
+ if not left_cards and not right_cards:
384
+ left_cards = _empty_final_marker_card()
385
 
386
  return f"""
387
+ <section class="bte-ideal-doc bte-final-report">
388
+ <header class="bte-ideal-hero">
389
  <div>
390
+ <p class="bte-kicker">Final health report</p>
391
+ <h2>Final health report reference</h2>
392
+ <p>Generated from the uploaded lab report, matched to the knowledge graph, and enriched with age and sex context. {escape(patient_context)}</p>
 
 
 
 
393
  </div>
394
  </header>
395
 
396
+ <input class="bte-ideal-filter" type="radio" name="bte-final-filter" id="bte-final-filter-total" checked>
397
+ <input class="bte-ideal-filter" type="radio" name="bte-final-filter" id="bte-final-filter-ideal">
398
+ <input class="bte-ideal-filter" type="radio" name="bte-final-filter" id="bte-final-filter-normal">
399
+ <input class="bte-ideal-filter" type="radio" name="bte-final-filter" id="bte-final-filter-bad">
 
 
400
 
401
+ <div class="bte-ideal-stats">
402
+ <label class="bte-ideal-stat bte-ideal-stat--total" for="bte-final-filter-total">
403
+ <span>{total}</span>
404
+ <strong>Total tests</strong>
405
+ </label>
406
+ <label class="bte-ideal-stat bte-ideal-stat--ideal" for="bte-final-filter-ideal">
407
+ <span>{ideal}</span>
408
+ <strong>Ideal</strong>
409
+ </label>
410
+ <label class="bte-ideal-stat bte-ideal-stat--normal" for="bte-final-filter-normal">
411
+ <span>{normal}</span>
412
+ <strong>Normal</strong>
413
+ </label>
414
+ <label class="bte-ideal-stat bte-ideal-stat--bad" for="bte-final-filter-bad">
415
+ <span>{bad}</span>
416
+ <strong>Bad</strong>
417
+ </label>
418
  </div>
419
 
420
+ <div class="bte-ideal-grid">
421
+ <div class="bte-ideal-column">
422
+ {left_cards}
423
+ </div>
424
+ <div class="bte-ideal-column">
425
+ {right_cards}
426
+ </div>
 
 
 
 
 
427
  </div>
428
  </section>
429
  """
430
 
431
 
432
+ def _patient_context_html(patient: dict[str, Any]) -> str:
433
+ age = _text(patient.get("age"), "Not extracted")
434
+ age_group = _text(patient.get("age_group"), "adult")
435
+ sex = _text(patient.get("sex"), "unknown")
436
+ return f"""
437
+ <dl class="bte-patient-context">
438
+ <div><dt>Age</dt><dd>{escape(age)}</dd></div>
439
+ <div><dt>Age group</dt><dd>{escape(age_group.title())}</dd></div>
440
+ <div><dt>Sex</dt><dd>{escape(sex.title())}</dd></div>
441
+ </dl>
442
+ """
443
 
444
 
445
  def _metric(label: str, value: int, caption: str) -> str:
 
457
 
458
 
459
  def _marker_card(test: dict[str, Any]) -> str:
460
+ marker = _preferred_marker_label(test)
461
+ raw_name = _text(test.get("raw_name"), marker)
462
  value = _text(test.get("value"), "-")
463
  unit = _text(test.get("unit"), "")
464
+ reference = _reference_label(test)
465
  status = _text(test.get("status"), "unknown").lower()
 
466
  confidence = _confidence_percent(test.get("confidence"))
467
+ comparison = test.get("comparison") or {}
468
+ range_position = escape(str(comparison.get("range_position", 50)))
469
+ knowledge = test.get("knowledge") or {}
470
+ source = _text(test.get("source_text"), "No source snippet returned.")
471
+ description = _text(knowledge.get("description"), "No knowledge graph description available for this marker.")
472
+ why = _text(knowledge.get("why_important"), "No knowledge graph importance note available for this marker.")
473
+ instructions = knowledge.get("instructions_to_improve") or {}
474
+ sex_context = knowledge.get("sex_significance") or {}
475
+ sex_summary = _text(sex_context.get("summary"), "No major sex-specific interpretation note is stored for this marker.")
476
+ stats_text = _statistics_text(test)
477
+ derived = _text(test.get("derived_status"), "unknown")
478
+ extracted = _text(test.get("extracted_status"), "unknown")
479
 
480
  return f"""
481
  <details class="bte-marker bte-marker--{escape(status)}" open>
 
491
  <span class="bte-marker-status">{escape(status.title())}</span>
492
  </summary>
493
  <div class="bte-marker-body">
494
+ <div class="bte-marker-evidence">
495
+ <span>Extraction confidence</span>
496
  <div class="bte-confidence"><i style="width: {confidence}%"></i></div>
497
  <small>{confidence}%</small>
498
+ <span>Raw marker</span>
499
+ <small>{escape(raw_name)}</small>
500
+ <span>Status check</span>
501
+ <small>Extracted: {escape(extracted.title())}. Calculated: {escape(derived.title())}.</small>
502
+ <blockquote>{escape(source)}</blockquote>
503
+ </div>
504
+ <div class="bte-marker-insights">
505
+ <div class="bte-range-scale bte-range-scale--report" style="--value-position: {range_position}%; --value-position-number: {range_position}">
506
+ <div class="bte-range-value">
507
+ <strong>{escape(value)}</strong>
508
+ <small>{escape(unit)}</small>
509
+ </div>
510
+ <div class="bte-range-track" aria-hidden="true">
511
+ <span>Low</span>
512
+ <span>Reference</span>
513
+ <span>High</span>
514
+ </div>
515
+ </div>
516
+ <div class="bte-insight-grid">
517
+ {_insight_block("Measures", description)}
518
+ {_insight_block("Why it matters", why)}
519
+ {_insight_block("Selected range", stats_text)}
520
+ {_insight_block("Sex context", sex_summary)}
521
+ </div>
522
+ <div class="bte-guidance">
523
+ {_guidance_column("Food", instructions.get("food"))}
524
+ {_guidance_column("Exercise", instructions.get("exercises"))}
525
+ {_guidance_column("Supplements", instructions.get("supplements"))}
526
+ </div>
527
  </div>
 
528
  </div>
529
  </details>
530
  """
531
 
532
 
533
+ def _final_marker_card(test: dict[str, Any]) -> str:
534
+ marker = _preferred_marker_label(test)
535
+ value = _text(test.get("value"), "-")
536
+ unit = _text(test.get("unit"), "")
537
+ status = _final_status_for_marker(test)
538
+ range_position = escape(str(_final_range_position(test)))
539
+ knowledge = test.get("knowledge") or {}
540
+ instructions = knowledge.get("instructions_to_improve") or {}
541
+ description = _text(knowledge.get("description"), "No knowledge graph description available for this marker.")
542
+ why = _text(knowledge.get("why_important"), "No knowledge graph importance note available for this marker.")
543
+ improve = _final_improvement_text(instructions)
544
+ context = _final_context_text(test)
545
+
546
+ return f"""
547
+ <article class="bte-ideal-marker bte-ideal-marker--{escape(status)}">
548
+ <div class="bte-ideal-marker-head">
549
+ <div class="bte-ideal-title-line">
550
+ <h3>{escape(marker)}</h3>
551
+ <span class="bte-ideal-status">{escape(status.title())}</span>
552
+ </div>
553
+ <div class="bte-range-scale" style="--value-position: {range_position}%; --value-position-number: {range_position}">
554
+ <div class="bte-range-value">
555
+ <strong>{escape(value)}</strong>
556
+ <small>{escape(unit)}</small>
557
+ </div>
558
+ <div class="bte-range-track" aria-hidden="true">
559
+ <span>Bad</span>
560
+ <span>Normal</span>
561
+ <span>Good</span>
562
+ </div>
563
+ </div>
564
+ </div>
565
+ <div class="bte-ideal-marker-body">
566
+ <div>
567
+ <span>Measures</span>
568
+ <p>{escape(description)}</p>
569
+ </div>
570
+ <div>
571
+ <span>Why it matters</span>
572
+ <p>{escape(why)}</p>
573
+ </div>
574
+ <div>
575
+ <span>How to improve</span>
576
+ <p>{escape(improve)}</p>
577
+ </div>
578
+ <div>
579
+ <span>Reference context</span>
580
+ <p>{escape(context)}</p>
581
+ </div>
582
+ </div>
583
+ </article>
584
+ """
585
+
586
+
587
+ def _final_status_for_marker(marker: dict[str, Any]) -> str:
588
+ quality = _final_quality(marker)
589
+ if quality is None:
590
+ status = _text(marker.get("status"), "unknown").lower()
591
+ if status in {"low", "high", "abnormal"}:
592
+ return "bad"
593
+ return "normal"
594
+ return quality["status"]
595
+
596
+
597
+ def _preferred_marker_label(marker: dict[str, Any]) -> str:
598
+ canonical = _text(marker.get("display_name"), "Unknown marker")
599
+ raw_name = _text(marker.get("raw_name"), "")
600
+ if _looks_like_marker_abbreviation(raw_name, canonical):
601
+ return raw_name
602
+ return canonical
603
+
604
+
605
+ def _looks_like_marker_abbreviation(raw_name: str, canonical: str) -> bool:
606
+ raw = raw_name.strip()
607
+ if not raw or raw.casefold() == canonical.strip().casefold():
608
+ return False
609
+ if len(raw) > 16:
610
+ return False
611
+
612
+ compact = re.sub(r"[\s._-]+", "", raw)
613
+ letters = [char for char in compact if char.isalpha()]
614
+ if not letters:
615
+ return False
616
+
617
+ if any(symbol in raw for symbol in ("%#",)):
618
+ return True
619
+ if "/" in raw and len(compact) <= 12:
620
+ return True
621
+
622
+ uppercase_ratio = sum(char.isupper() for char in letters) / len(letters)
623
+ if len(compact) <= 10 and uppercase_ratio >= 0.6:
624
+ return True
625
+
626
+ # Common lab shorthand is often title-cased, e.g. Hct or Plt.
627
+ canonical_is_descriptive = len(canonical) > len(raw) + 3 or " " in canonical
628
+ return canonical_is_descriptive and len(compact) <= 5 and raw[0].isupper()
629
+
630
+
631
+ def _final_range_position(marker: dict[str, Any]) -> int:
632
+ quality = _final_quality(marker)
633
+ if quality is not None:
634
+ return quality["position"]
635
+
636
+ return int((marker.get("comparison") or {}).get("range_position") or 50)
637
+
638
+
639
+ def _final_quality(marker: dict[str, Any]) -> dict[str, Any] | None:
640
+ values = _final_reference_values(marker)
641
+ numeric = _final_numeric_value(marker)
642
+ if values is None or numeric is None:
643
+ return None
644
+
645
+ low, normal, high = values
646
+ if high <= low:
647
+ return None
648
+
649
+ if numeric < low:
650
+ distance = (low - numeric) / max(normal - low, high - low, 1.0)
651
+ return {"status": "bad", "position": max(6, min(30, round(26 - distance * 18)))}
652
+ if numeric > high:
653
+ distance = (numeric - high) / max(high - normal, high - low, 1.0)
654
+ return {"status": "bad", "position": max(6, min(30, round(26 - distance * 18)))}
655
+
656
+ if numeric <= normal:
657
+ side_width = normal - low
658
+ else:
659
+ side_width = high - normal
660
+
661
+ if side_width <= 0:
662
+ closeness = 1.0
663
+ else:
664
+ closeness = 1 - abs(numeric - normal) / side_width
665
+ closeness = max(0.0, min(1.0, closeness))
666
+
667
+ if closeness >= 0.7:
668
+ # Good/ideal zone: the closer the patient is to the KG normal value, the fuller the bar.
669
+ position = 68 + (closeness - 0.7) / 0.3 * 26
670
+ return {"status": "ideal", "position": max(68, min(94, round(position)))}
671
+
672
+ # Normal zone: in range, but not close enough to the KG normal value to call it ideal.
673
+ position = 38 + closeness / 0.7 * 26
674
+ return {"status": "normal", "position": max(38, min(64, round(position)))}
675
+
676
+
677
+ def _final_reference_values(marker: dict[str, Any]) -> tuple[float, float, float] | None:
678
+ selection = marker.get("reference_selection") or {}
679
+ values = selection.get("values") or {}
680
+ try:
681
+ low = float(values["minimal_value"])
682
+ normal = float(values["normal_value"])
683
+ high = float(values["maximum_value"])
684
+ except (KeyError, TypeError, ValueError):
685
+ return None
686
+ if high <= low:
687
+ return None
688
+ return low, normal, high
689
+
690
+
691
+ def _final_numeric_value(marker: dict[str, Any]) -> float | None:
692
+ try:
693
+ return float(marker.get("numeric_value"))
694
+ except (TypeError, ValueError):
695
+ return None
696
+
697
+
698
+ def _final_improvement_text(instructions: dict[str, Any]) -> str:
699
+ parts = []
700
+ for label, key in (("Food", "food"), ("Exercise", "exercises"), ("Supplements", "supplements")):
701
+ items = instructions.get(key)
702
+ if isinstance(items, list) and items:
703
+ parts.append(f"{label}: {' '.join(str(item) for item in items[:2])}")
704
+ return " ".join(parts) or "No improvement guidance is stored for this marker."
705
+
706
+
707
+ def _final_context_text(marker: dict[str, Any]) -> str:
708
+ reference = _reference_label(marker)
709
+ sex_context = ((marker.get("knowledge") or {}).get("sex_significance") or {}).get("summary")
710
+ confidence = _confidence_percent(marker.get("confidence"))
711
+ source = _text(marker.get("source_text"), "")
712
+ parts = [reference, f"Extraction confidence: {confidence}%."]
713
+ if sex_context:
714
+ parts.append(str(sex_context))
715
+ if source:
716
+ parts.append(f"Source row: {source}")
717
+ return " ".join(parts)
718
+
719
+
720
+ def _final_patient_context(patient: dict[str, Any]) -> str:
721
+ age = _text(patient.get("age"), "")
722
+ sex = _text(patient.get("sex"), "")
723
+ age_group = _text(patient.get("age_group"), "")
724
+ parts = []
725
+ if age:
726
+ parts.append(f"Age: {age}")
727
+ if sex and sex != "unknown":
728
+ parts.append(f"Sex: {sex.title()}")
729
+ if age_group:
730
+ parts.append(f"Group: {age_group.title()}")
731
+ return " | ".join(parts)
732
+
733
+
734
+ def _empty_final_marker_card() -> str:
735
+ return """
736
+ <article class="bte-ideal-marker bte-ideal-marker--normal">
737
+ <div class="bte-ideal-marker-head">
738
+ <div class="bte-ideal-title-line">
739
+ <h3>No markers extracted yet</h3>
740
+ <span class="bte-ideal-status">Normal</span>
741
+ </div>
742
+ </div>
743
+ </article>
744
+ """
745
+
746
+
747
  def _empty_marker_card() -> str:
748
  return """
749
  <div class="bte-marker-empty">
 
752
  """
753
 
754
 
755
+ def _reference_label(test: dict[str, Any]) -> str:
756
+ lab_range = _text(test.get("lab_reference_range"), "")
757
+ if lab_range:
758
+ return f"Lab range: {lab_range}"
759
+ return _statistics_text(test)
760
+
761
+
762
+ def _statistics_text(test: dict[str, Any]) -> str:
763
+ selection = test.get("reference_selection") or {}
764
+ values = selection.get("values") or {}
765
+ low = values.get("minimal_value")
766
+ normal = values.get("normal_value")
767
+ high = values.get("maximum_value")
768
+ if low is None and high is None:
769
+ return "No knowledge graph range available."
770
+ age_group = _text(selection.get("age_group"), "adult").title()
771
+ sex = _text(selection.get("sex"), "not_applied").replace("_", " ").title()
772
+ unit = _text(test.get("unit"), "")
773
+ return f"{age_group}, {sex}: {low} min / {normal} typical / {high} max {unit}".strip()
774
+
775
+
776
+ def _insight_block(label: str, text: str) -> str:
777
+ return f"""
778
+ <div>
779
+ <span>{escape(label)}</span>
780
+ <p>{escape(text)}</p>
781
+ </div>
782
+ """
783
+
784
+
785
+ def _guidance_column(label: str, items: Any) -> str:
786
+ if not isinstance(items, list) or not items:
787
+ body = "<li>No guidance stored for this marker.</li>"
788
+ else:
789
+ body = "".join(f"<li>{escape(str(item))}</li>" for item in items[:3])
790
+ return f"""
791
+ <div>
792
+ <strong>{escape(label)}</strong>
793
+ <ul>{body}</ul>
794
+ </div>
795
+ """
796
+
797
+
798
+ def _source_links(markers: list[dict[str, Any]], sources: dict[str, str]) -> str:
799
+ source_ids: list[str] = []
800
+ for marker in markers:
801
+ knowledge = marker.get("knowledge") or {}
802
+ for source_id in knowledge.get("source_ids") or []:
803
+ if source_id not in source_ids:
804
+ source_ids.append(source_id)
805
+
806
+ links = []
807
+ for source_id in source_ids[:8]:
808
+ url = sources.get(source_id)
809
+ if not url:
810
+ continue
811
+ links.append(f'<li><a href="{escape(url)}" target="_blank" rel="noreferrer">{escape(source_id)}</a></li>')
812
+
813
+ if not links:
814
+ return "<p>No source links available for matched markers.</p>"
815
+ return f"<ul>{''.join(links)}</ul>"
816
+
817
+
818
  def _text(value: Any, fallback: str) -> str:
819
  if value is None:
820
  return fallback
 
1170
  .bte-status-row,
1171
  .bte-status-row > div,
1172
  .bte-ideal-row,
1173
+ .bte-ideal-row > div,
1174
+ .bte-final-row,
1175
+ .bte-final-row > div {
1176
  width: var(--bte-rail) !important;
1177
  max-width: var(--bte-rail) !important;
1178
  margin-left: auto !important;
 
1188
  .bte-status-row .block,
1189
  .bte-ideal-row .prose,
1190
  .bte-ideal-row .html-container,
1191
+ .bte-ideal-row .block,
1192
+ .bte-final-row .prose,
1193
+ .bte-final-row .html-container,
1194
+ .bte-final-row .block {
1195
  padding: 0 !important;
1196
  margin: 0 !important;
1197
  background: transparent !important;
 
2132
  color: var(--bte-muted);
2133
  }
2134
 
2135
+ .bte-marker-evidence {
2136
+ display: grid;
2137
+ align-content: start;
2138
+ gap: 8px;
2139
+ }
2140
+
2141
+ .bte-marker-evidence > span {
2142
+ color: var(--bte-ink);
2143
+ font-size: 12px;
2144
+ font-weight: 760;
2145
+ text-transform: uppercase;
2146
+ }
2147
+
2148
+ .bte-marker-insights {
2149
+ min-width: 0;
2150
+ display: grid;
2151
+ gap: 14px;
2152
+ }
2153
+
2154
+ .bte-range-scale--report {
2155
+ padding: 12px 0 4px;
2156
+ }
2157
+
2158
+ .bte-insight-grid {
2159
+ display: grid;
2160
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2161
+ gap: 10px;
2162
+ }
2163
+
2164
+ .bte-insight-grid div {
2165
+ min-width: 0;
2166
+ padding-top: 10px;
2167
+ border-top: 1px solid var(--bte-line);
2168
+ }
2169
+
2170
+ .bte-insight-grid span,
2171
+ .bte-guidance strong {
2172
+ display: block;
2173
+ margin-bottom: 6px;
2174
+ color: var(--bte-ink);
2175
+ font-size: 12px;
2176
+ font-weight: 760;
2177
+ text-transform: uppercase;
2178
+ }
2179
+
2180
+ .bte-insight-grid p {
2181
+ margin: 0;
2182
+ color: var(--bte-muted);
2183
+ font-size: 14px;
2184
+ line-height: 1.48;
2185
+ }
2186
+
2187
+ .bte-guidance {
2188
+ display: grid;
2189
+ grid-template-columns: repeat(3, minmax(0, 1fr));
2190
+ gap: 10px;
2191
+ }
2192
+
2193
+ .bte-guidance div {
2194
+ min-width: 0;
2195
+ padding-top: 10px;
2196
+ border-top: 1px solid var(--bte-line);
2197
+ }
2198
+
2199
+ .bte-guidance ul {
2200
+ margin: 0;
2201
+ padding-left: 18px;
2202
+ color: var(--bte-muted);
2203
+ font-size: 13px;
2204
+ line-height: 1.45;
2205
+ }
2206
+
2207
  .bte-confidence {
2208
  height: 8px;
2209
  border-radius: 999px;
 
2244
  color: var(--bte-muted);
2245
  }
2246
 
2247
+ .bte-report-aside a {
2248
+ color: var(--bte-blue);
2249
+ overflow-wrap: anywhere;
2250
+ }
2251
+
2252
+ .bte-patient-context {
2253
+ display: grid;
2254
+ gap: 8px;
2255
+ margin: 0 0 16px;
2256
+ }
2257
+
2258
+ .bte-patient-context div {
2259
+ display: flex;
2260
+ justify-content: space-between;
2261
+ gap: 12px;
2262
+ padding-bottom: 8px;
2263
+ border-bottom: 1px solid var(--bte-line);
2264
+ }
2265
+
2266
+ .bte-patient-context dt {
2267
+ color: var(--bte-muted);
2268
+ font-size: 12px;
2269
+ font-weight: 760;
2270
+ text-transform: uppercase;
2271
+ }
2272
+
2273
+ .bte-patient-context dd {
2274
+ margin: 0;
2275
+ color: var(--bte-ink);
2276
+ font-weight: 650;
2277
+ text-align: right;
2278
+ }
2279
+
2280
  .bte-disclaimer {
2281
  display: grid;
2282
  gap: 4px;
 
2302
  background: transparent;
2303
  }
2304
 
2305
+ .bte-final-report {
2306
+ width: var(--bte-rail) !important;
2307
+ max-width: var(--bte-rail) !important;
2308
+ margin: 0 auto !important;
2309
+ }
2310
+
2311
  .bte-ideal-hero {
2312
  display: flex;
2313
  align-items: center;
 
2388
  .bte-ideal-doc:has(#bte-filter-total:checked) .bte-ideal-stat--total,
2389
  .bte-ideal-doc:has(#bte-filter-ideal:checked) .bte-ideal-stat--ideal,
2390
  .bte-ideal-doc:has(#bte-filter-normal:checked) .bte-ideal-stat--normal,
2391
+ .bte-ideal-doc:has(#bte-filter-bad:checked) .bte-ideal-stat--bad,
2392
+ .bte-ideal-doc:has(#bte-final-filter-total:checked) .bte-ideal-stat--total,
2393
+ .bte-ideal-doc:has(#bte-final-filter-ideal:checked) .bte-ideal-stat--ideal,
2394
+ .bte-ideal-doc:has(#bte-final-filter-normal:checked) .bte-ideal-stat--normal,
2395
+ .bte-ideal-doc:has(#bte-final-filter-bad:checked) .bte-ideal-stat--bad {
2396
  border-color: transparent;
2397
  background:
2398
  linear-gradient(var(--bte-stat-bg), var(--bte-stat-bg)) padding-box,
 
2446
 
2447
  .bte-ideal-doc:has(#bte-filter-ideal:checked) .bte-ideal-marker:not(.bte-ideal-marker--ideal),
2448
  .bte-ideal-doc:has(#bte-filter-normal:checked) .bte-ideal-marker:not(.bte-ideal-marker--normal),
2449
+ .bte-ideal-doc:has(#bte-filter-bad:checked) .bte-ideal-marker:not(.bte-ideal-marker--bad),
2450
+ .bte-ideal-doc:has(#bte-final-filter-ideal:checked) .bte-ideal-marker:not(.bte-ideal-marker--ideal),
2451
+ .bte-ideal-doc:has(#bte-final-filter-normal:checked) .bte-ideal-marker:not(.bte-ideal-marker--normal),
2452
+ .bte-ideal-doc:has(#bte-final-filter-bad:checked) .bte-ideal-marker:not(.bte-ideal-marker--bad) {
2453
  display: none;
2454
  }
2455
 
 
2756
  .bte-hero-grid > :nth-child(3),
2757
  .bte-run-status,
2758
  .bte-report-anchor,
2759
+ .bte-ideal-row {
2760
  display: none !important;
2761
  }
2762
 
2763
+ .bte-final-report {
2764
+ display: grid !important;
2765
+ width: calc(100vw - 88px) !important;
2766
+ max-width: calc(100vw - 88px) !important;
2767
+ margin-left: 0 !important;
2768
+ margin-right: 0 !important;
2769
+ }
2770
+
2771
  .bte-workflow-panel--upload,
2772
  .bte-workflow-panel--analysis {
2773
  min-width: 0 !important;
 
2889
  grid-template-columns: 1fr;
2890
  }
2891
 
2892
+ .bte-insight-grid,
2893
+ .bte-guidance {
2894
+ grid-template-columns: 1fr;
2895
+ }
2896
+
2897
  .bte-marker-value {
2898
  text-align: left;
2899
  }
 
2933
  <div>
2934
  <p class="bte-kicker">Clinical clarity from raw documents</p>
2935
  <h1>Blood Test Explainer</h1>
2936
+ <p>Upload a lab report and turn dense medical paperwork into a polished health report with extracted values, age and sex context, and knowledge graph explanations.</p>
2937
  </div>
2938
  """
2939
  )
 
2995
  show_progress="hidden",
2996
  )
2997
 
2998
+ with gr.Group(visible=False, elem_classes=["bte-report-panel", "bte-final-row"]) as report_panel:
2999
  report = gr.HTML(empty_report_html())
3000
 
3001
  run_button.click(
kb/cbc_knowledge_graph.json ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": "1.1",
3
+ "title": "Complete Blood Count Knowledge Graph",
4
+ "purpose": "Educational knowledge graph for CBC markers mentioned in the GNU Health laboratory report sample. It is intended for explanation agents, not diagnosis or treatment.",
5
+ "medical_disclaimer": "Reference intervals vary by laboratory method, sex, pregnancy status, altitude, acute illness, medications, and clinical context. Abnormal values should be interpreted by a qualified clinician.",
6
+ "age_group_definitions": {
7
+ "child": "1-12 years, excluding neonates/infants when a source gave separate infant intervals",
8
+ "teenager": "13-17 years",
9
+ "adult": "18-64 years",
10
+ "elder": "65+ years; adult CBC intervals are reused unless a source provides an elder-specific or sex-specific threshold"
11
+ },
12
+ "sex_definitions": {
13
+ "male": "Patient sex category as reported by the lab or patient record. Do not infer from name.",
14
+ "female": "Patient sex category as reported by the lab or patient record. Pregnancy, menstruation, menopause, and hormone therapy can change interpretation.",
15
+ "unknown": "Use when sex is absent, unclear, nonbinary, or not safely mappable to a binary lab reference interval. Prefer the lab-provided reference range and show a caution."
16
+ },
17
+ "sex_significance_policy": "Agents should extract patient sex when present and pass it through the pipeline. For markers with sex_specific_statistics_per_group_age, prefer the sex-specific interval over the age-only fallback. If sex is unknown or the patient context does not fit binary lab intervals, use the lab's own reference range first and explain that sex-specific interpretation may require clinician review.",
18
+ "statistics_method": "For each age group, minimal_value and maximum_value represent the lower and upper educational reference interval endpoints. normal_value is the midpoint of that interval, rounded to 2 decimals where useful. Age-only statistics are fallback ranges; sex_specific_statistics_per_group_age is preferred when present and patient sex is known.",
19
+ "sources": {
20
+ "medlineplus_cbc": "https://medlineplus.gov/lab-tests/complete-blood-count-cbc/",
21
+ "medlineplus_rbc": "https://medlineplus.gov/lab-tests/red-blood-cell-rbc-count/",
22
+ "medlineplus_differential": "https://medlineplus.gov/lab-tests/blood-differential/",
23
+ "medlineplus_differential_encyclopedia": "https://medlineplus.gov/ency/article/003657.htm",
24
+ "medlineplus_esr": "https://medlineplus.gov/lab-tests/erythrocyte-sedimentation-rate-esr/",
25
+ "uiowa_cbc_reference": "https://www.healthcare.uiowa.edu/path_handbook/handbook/test299.html",
26
+ "uiowa_pediatric_reference": "https://www.healthcare.uiowa.edu/path_handbook/appendix/heme/pediatric_normals.html",
27
+ "seattle_childrens_hematocrit": "https://seattlechildrenslab.testcatalog.org/show/LAB289--1",
28
+ "seattle_childrens_platelet": "https://seattlechildrenslab.testcatalog.org/show/LAB301-1",
29
+ "seattle_childrens_esr": "https://seattlechildrenslab.testcatalog.org/show/LAB322-1",
30
+ "uchicago_cbc_diff": "https://uchicagomedlabs.testcatalog.org/show/CBCDIFF-1",
31
+ "uchicago_esr_reference": "https://uchicagomedlabs.testcatalog.org/show/ESR-1",
32
+ "cleveland_clinic_esr": "https://my.clevelandclinic.org/health/diagnostics/17747-sed-rate-erythrocyte-sedimentation-rate-or-esr-test",
33
+ "nih_ods_iron": "https://ods.od.nih.gov/factsheets/Iron-HealthProfessional/",
34
+ "nih_ods_b12": "https://ods.od.nih.gov/factsheets/VitaminB12-HealthProfessional/",
35
+ "nih_ods_folate": "https://ods.od.nih.gov/factsheets/Folate-HealthProfessional/",
36
+ "cdc_physical_activity": "https://www.cdc.gov/physical-activity-basics/guidelines/index.html"
37
+ },
38
+ "tests": [
39
+ {
40
+ "id": "hemoglobin",
41
+ "display_name": "Hemoglobin",
42
+ "aliases": ["HGB", "Hb", "Hgb"],
43
+ "category": "CBC red cell marker",
44
+ "unit": "g/dL",
45
+ "description": "Hemoglobin is the iron-containing protein inside red blood cells that carries oxygen from the lungs to body tissues.",
46
+ "why_important": "It is a core screen for anemia, blood loss, dehydration-related concentration changes, and disorders affecting oxygen delivery.",
47
+ "sex_significance": {
48
+ "level": "high",
49
+ "summary": "Adult and post-pubertal male reference intervals are typically higher than female intervals. Pregnancy and menstruation can further affect interpretation.",
50
+ "pipeline_guidance": "If patient sex is known, prefer sex_specific_statistics_per_group_age for comparison. If sex is unknown, nonbinary, pregnancy is possible, or hormone therapy is relevant, prefer the lab-provided reference range and include a clinician-context caution."
51
+ },
52
+ "sex_specific_statistics_per_group_age": {
53
+ "child": {
54
+ "male": {"minimal_value": 10.9, "normal_value": 12.95, "maximum_value": 15.0},
55
+ "female": {"minimal_value": 10.9, "normal_value": 12.95, "maximum_value": 15.0},
56
+ "unknown": {"minimal_value": 10.9, "normal_value": 12.95, "maximum_value": 15.0}
57
+ },
58
+ "teenager": {
59
+ "male": {"minimal_value": 13.2, "normal_value": 15.45, "maximum_value": 17.7},
60
+ "female": {"minimal_value": 11.9, "normal_value": 13.7, "maximum_value": 15.5},
61
+ "unknown": {"minimal_value": 11.9, "normal_value": 14.35, "maximum_value": 17.7}
62
+ },
63
+ "adult": {
64
+ "male": {"minimal_value": 13.2, "normal_value": 15.45, "maximum_value": 17.7},
65
+ "female": {"minimal_value": 11.9, "normal_value": 13.7, "maximum_value": 15.5},
66
+ "unknown": {"minimal_value": 11.9, "normal_value": 14.35, "maximum_value": 17.7}
67
+ },
68
+ "elder": {
69
+ "male": {"minimal_value": 13.2, "normal_value": 15.45, "maximum_value": 17.7},
70
+ "female": {"minimal_value": 11.9, "normal_value": 13.7, "maximum_value": 15.5},
71
+ "unknown": {"minimal_value": 11.9, "normal_value": 14.35, "maximum_value": 17.7}
72
+ }
73
+ },
74
+ "instructions_to_improve": {
75
+ "food": ["If low, emphasize iron-rich foods such as lean meat, fish, poultry, legumes, tofu, spinach, and iron-fortified grains.", "Pair plant iron with vitamin C foods such as citrus, berries, peppers, or tomatoes to improve absorption.", "Include folate and vitamin B12 sources such as leafy greens, beans, eggs, dairy, fish, and fortified foods."],
76
+ "exercises": ["Use moderate aerobic activity and strength training as tolerated to support cardiovascular fitness.", "Avoid unusually intense training until unexplained anemia, shortness of breath, dizziness, or fatigue has been evaluated."],
77
+ "supplements": ["Discuss iron, vitamin B12, or folate testing and supplementation with a clinician before starting.", "Avoid iron supplements unless deficiency or clinical need is confirmed, because excess iron can be harmful."]
78
+ },
79
+ "statistics_per_group_age": {
80
+ "child": {"minimal_value": 10.9, "normal_value": 12.95, "maximum_value": 15.0},
81
+ "teenager": {"minimal_value": 11.9, "normal_value": 14.35, "maximum_value": 17.7},
82
+ "adult": {"minimal_value": 11.9, "normal_value": 14.45, "maximum_value": 17.7},
83
+ "elder": {"minimal_value": 11.9, "normal_value": 14.45, "maximum_value": 17.7}
84
+ },
85
+ "related_tests": ["rbc", "hct", "mcv", "mch", "mchc", "rdw_cv", "rdw_sd"],
86
+ "source_ids": ["medlineplus_cbc", "uiowa_cbc_reference", "uiowa_pediatric_reference", "nih_ods_iron", "nih_ods_b12", "nih_ods_folate"]
87
+ },
88
+ {
89
+ "id": "rbc",
90
+ "display_name": "Red Blood Cell Count",
91
+ "aliases": ["RBC", "Erythrocyte count", "Red cell count"],
92
+ "category": "CBC red cell marker",
93
+ "unit": "10^6/uL",
94
+ "description": "RBC count measures the number of red blood cells in a volume of blood.",
95
+ "why_important": "Red blood cells carry hemoglobin and oxygen; low or high counts can point toward anemia, blood loss, dehydration, lung or heart disease, marrow disorders, or other conditions.",
96
+ "sex_significance": {
97
+ "level": "high",
98
+ "summary": "Adult and adolescent male RBC intervals are usually higher than female intervals.",
99
+ "pipeline_guidance": "Use sex_specific_statistics_per_group_age when sex is known. For unknown or context-sensitive sex data, compare against the lab-provided range and avoid overcalling status from a broad fallback interval."
100
+ },
101
+ "sex_specific_statistics_per_group_age": {
102
+ "child": {
103
+ "male": {"minimal_value": 3.8, "normal_value": 4.6, "maximum_value": 5.5},
104
+ "female": {"minimal_value": 3.8, "normal_value": 4.6, "maximum_value": 5.5},
105
+ "unknown": {"minimal_value": 3.8, "normal_value": 4.6, "maximum_value": 5.5}
106
+ },
107
+ "teenager": {
108
+ "male": {"minimal_value": 4.3, "normal_value": 4.95, "maximum_value": 5.6},
109
+ "female": {"minimal_value": 3.9, "normal_value": 4.55, "maximum_value": 5.1},
110
+ "unknown": {"minimal_value": 3.9, "normal_value": 4.75, "maximum_value": 5.6}
111
+ },
112
+ "adult": {
113
+ "male": {"minimal_value": 4.5, "normal_value": 5.35, "maximum_value": 6.2},
114
+ "female": {"minimal_value": 4.0, "normal_value": 4.6, "maximum_value": 5.2},
115
+ "unknown": {"minimal_value": 4.0, "normal_value": 5.1, "maximum_value": 6.2}
116
+ },
117
+ "elder": {
118
+ "male": {"minimal_value": 4.5, "normal_value": 5.35, "maximum_value": 6.2},
119
+ "female": {"minimal_value": 4.0, "normal_value": 4.6, "maximum_value": 5.2},
120
+ "unknown": {"minimal_value": 4.0, "normal_value": 5.1, "maximum_value": 6.2}
121
+ }
122
+ },
123
+ "instructions_to_improve": {
124
+ "food": ["Support red blood cell production with iron, protein, folate, and vitamin B12 containing foods.", "Hydrate regularly; dehydration can concentrate blood counts and make RBC appear higher.", "Limit heavy alcohol intake because it can interfere with nutrition and marrow function."],
125
+ "exercises": ["Maintain regular aerobic activity and resistance training if cleared for exercise.", "If RBC is high with headaches, dizziness, sleep apnea symptoms, or smoking history, seek medical evaluation rather than trying to lower it with exercise alone."],
126
+ "supplements": ["Use iron, B12, or folate only when deficiency is suspected or confirmed.", "Do not use performance-enhancing drugs or unsupervised erythropoietin-like products."]
127
+ },
128
+ "statistics_per_group_age": {
129
+ "child": {"minimal_value": 3.8, "normal_value": 4.6, "maximum_value": 5.5},
130
+ "teenager": {"minimal_value": 3.9, "normal_value": 4.75, "maximum_value": 5.6},
131
+ "adult": {"minimal_value": 4.0, "normal_value": 5.1, "maximum_value": 6.2},
132
+ "elder": {"minimal_value": 4.0, "normal_value": 5.1, "maximum_value": 6.2}
133
+ },
134
+ "related_tests": ["hemoglobin", "hct", "mcv"],
135
+ "source_ids": ["medlineplus_rbc", "uiowa_cbc_reference"]
136
+ },
137
+ {
138
+ "id": "hct",
139
+ "display_name": "Hematocrit",
140
+ "aliases": ["HCT", "PCV", "Packed cell volume"],
141
+ "category": "CBC red cell marker",
142
+ "unit": "%",
143
+ "description": "Hematocrit is the percentage of whole blood volume made up of red blood cells.",
144
+ "why_important": "It helps evaluate anemia, blood loss, dehydration, and conditions that change red cell concentration.",
145
+ "sex_significance": {
146
+ "level": "high",
147
+ "summary": "Male hematocrit reference intervals are typically higher than female intervals after puberty. Pregnancy and menstruation can affect interpretation.",
148
+ "pipeline_guidance": "Use sex_specific_statistics_per_group_age when sex is known. If sex is unknown or pregnancy/hormone context is relevant, show the lab reference range as primary and mark the KG range as educational."
149
+ },
150
+ "sex_specific_statistics_per_group_age": {
151
+ "child": {
152
+ "male": {"minimal_value": 31, "normal_value": 37.5, "maximum_value": 44},
153
+ "female": {"minimal_value": 31, "normal_value": 37.5, "maximum_value": 44},
154
+ "unknown": {"minimal_value": 31, "normal_value": 37.5, "maximum_value": 44}
155
+ },
156
+ "teenager": {
157
+ "male": {"minimal_value": 37, "normal_value": 43, "maximum_value": 49},
158
+ "female": {"minimal_value": 36, "normal_value": 41, "maximum_value": 46},
159
+ "unknown": {"minimal_value": 36, "normal_value": 42.5, "maximum_value": 49}
160
+ },
161
+ "adult": {
162
+ "male": {"minimal_value": 40, "normal_value": 46, "maximum_value": 52},
163
+ "female": {"minimal_value": 35, "normal_value": 41, "maximum_value": 47},
164
+ "unknown": {"minimal_value": 35, "normal_value": 43.5, "maximum_value": 52}
165
+ },
166
+ "elder": {
167
+ "male": {"minimal_value": 40, "normal_value": 46, "maximum_value": 52},
168
+ "female": {"minimal_value": 35, "normal_value": 41, "maximum_value": 47},
169
+ "unknown": {"minimal_value": 35, "normal_value": 43.5, "maximum_value": 52}
170
+ }
171
+ },
172
+ "instructions_to_improve": {
173
+ "food": ["For low values, support red cell production with iron, B12, folate, protein, and overall adequate calories.", "For high values, maintain hydration and avoid smoking exposure when possible.", "Ask a clinician about causes before making major diet changes."],
174
+ "exercises": ["Follow general activity guidelines if well; conditioning supports oxygen use but does not replace evaluation for anemia.", "Pause strenuous activity and seek care for chest pain, fainting, severe shortness of breath, or marked fatigue."],
175
+ "supplements": ["Discuss iron/B12/folate supplementation only when deficiency or risk is present.", "Avoid unsupervised iron if hematocrit is high."]
176
+ },
177
+ "statistics_per_group_age": {
178
+ "child": {"minimal_value": 31, "normal_value": 37.5, "maximum_value": 44},
179
+ "teenager": {"minimal_value": 34, "normal_value": 41, "maximum_value": 48},
180
+ "adult": {"minimal_value": 35, "normal_value": 43.5, "maximum_value": 52},
181
+ "elder": {"minimal_value": 35, "normal_value": 43.5, "maximum_value": 52}
182
+ },
183
+ "related_tests": ["hemoglobin", "rbc"],
184
+ "source_ids": ["medlineplus_cbc", "uiowa_cbc_reference", "uiowa_pediatric_reference", "seattle_childrens_hematocrit"]
185
+ },
186
+ {
187
+ "id": "mcv",
188
+ "display_name": "Mean Corpuscular Volume",
189
+ "aliases": ["MCV"],
190
+ "category": "CBC red cell index",
191
+ "unit": "fL",
192
+ "description": "MCV measures the average size of red blood cells.",
193
+ "why_important": "Small cells may suggest iron-related or thalassemia patterns; large cells may suggest B12, folate, alcohol, liver, thyroid, medication, or marrow-related patterns.",
194
+ "sex_significance": {
195
+ "level": "low",
196
+ "summary": "MCV is usually interpreted with age and lab method rather than sex. Sex still matters indirectly because hemoglobin, RBC, and hematocrit ranges differ by sex.",
197
+ "pipeline_guidance": "Use the age-group interval unless the report provides a sex-specific lab range. Keep nearby red-cell markers sex-aware."
198
+ },
199
+ "instructions_to_improve": {
200
+ "food": ["If low, ensure adequate iron intake and pair plant iron with vitamin C.", "If high, ensure adequate B12 and folate intake from animal foods, fortified foods, leafy greens, and legumes.", "Reduce heavy alcohol intake if relevant."],
201
+ "exercises": ["Exercise does not directly normalize MCV, but regular activity supports overall metabolic health.", "Avoid overtraining if anemia symptoms are present."],
202
+ "supplements": ["Discuss iron studies, B12, folate, thyroid, and liver evaluation before supplementing.", "Use B12 or folate supplements when dietary intake, absorption risk, or testing supports the need."]
203
+ },
204
+ "statistics_per_group_age": {
205
+ "child": {"minimal_value": 75, "normal_value": 82.5, "maximum_value": 90},
206
+ "teenager": {"minimal_value": 79, "normal_value": 87, "maximum_value": 95},
207
+ "adult": {"minimal_value": 82, "normal_value": 90.5, "maximum_value": 99},
208
+ "elder": {"minimal_value": 82, "normal_value": 90.5, "maximum_value": 99}
209
+ },
210
+ "related_tests": ["hemoglobin", "mch", "mchc", "rdw_cv", "rdw_sd"],
211
+ "source_ids": ["medlineplus_cbc", "uiowa_cbc_reference", "nih_ods_iron", "nih_ods_b12", "nih_ods_folate"]
212
+ },
213
+ {
214
+ "id": "mch",
215
+ "display_name": "Mean Corpuscular Hemoglobin",
216
+ "aliases": ["MCH"],
217
+ "category": "CBC red cell index",
218
+ "unit": "pg",
219
+ "description": "MCH estimates the average amount of hemoglobin in each red blood cell.",
220
+ "why_important": "It helps classify anemia patterns and is interpreted with hemoglobin, MCV, MCHC, and RDW.",
221
+ "sex_significance": {
222
+ "level": "low",
223
+ "summary": "MCH is generally not separated by sex in common CBC reference tables, but interpretation depends on sex-aware hemoglobin and RBC context.",
224
+ "pipeline_guidance": "Use the age-group interval unless the lab report includes a sex-specific range."
225
+ },
226
+ "instructions_to_improve": {
227
+ "food": ["Support hemoglobin production with iron-rich foods, protein, B12, and folate.", "Pair plant iron with vitamin C and avoid taking tea or coffee with iron-rich meals if iron deficiency is a concern.", "Maintain balanced meals rather than focusing on one nutrient only."],
228
+ "exercises": ["Use gentle-to-moderate activity if anemia symptoms are mild and cleared by a clinician.", "Delay intense endurance training when unexplained low red-cell indices are present."],
229
+ "supplements": ["Discuss iron, B12, and folate supplementation based on lab confirmation.", "Avoid stacking multiple blood-building supplements without clinician guidance."]
230
+ },
231
+ "statistics_per_group_age": {
232
+ "child": {"minimal_value": 23, "normal_value": 29, "maximum_value": 35},
233
+ "teenager": {"minimal_value": 25, "normal_value": 30, "maximum_value": 35},
234
+ "adult": {"minimal_value": 25, "normal_value": 30, "maximum_value": 35},
235
+ "elder": {"minimal_value": 25, "normal_value": 30, "maximum_value": 35}
236
+ },
237
+ "related_tests": ["mcv", "mchc", "hemoglobin"],
238
+ "source_ids": ["uiowa_cbc_reference", "uiowa_pediatric_reference", "nih_ods_iron"]
239
+ },
240
+ {
241
+ "id": "mchc",
242
+ "display_name": "Mean Corpuscular Hemoglobin Concentration",
243
+ "aliases": ["MCHC"],
244
+ "category": "CBC red cell index",
245
+ "unit": "g/dL",
246
+ "description": "MCHC estimates the concentration of hemoglobin within red blood cells.",
247
+ "why_important": "It helps characterize red-cell color/concentration patterns and can support anemia workups.",
248
+ "sex_significance": {
249
+ "level": "low",
250
+ "summary": "MCHC is typically interpreted using a shared reference interval across male and female patients.",
251
+ "pipeline_guidance": "Use the age-group interval unless the lab report provides a sex-specific range."
252
+ },
253
+ "instructions_to_improve": {
254
+ "food": ["For low values, focus on iron adequacy plus B12, folate, protein, and vitamin C-supported absorption.", "For high values, do not try to self-correct with diet; confirm with repeat testing and clinical review.", "Hydration and balanced nutrition support reliable results."],
255
+ "exercises": ["Exercise does not directly change MCHC; stay active within symptom limits.", "Seek care before strenuous exercise if anemia symptoms are significant."],
256
+ "supplements": ["Use iron only when iron deficiency is likely or confirmed.", "Discuss persistent abnormal MCHC with a clinician because it can reflect lab artifacts or specific red-cell disorders."]
257
+ },
258
+ "statistics_per_group_age": {
259
+ "child": {"minimal_value": 32, "normal_value": 34, "maximum_value": 36},
260
+ "teenager": {"minimal_value": 32, "normal_value": 34, "maximum_value": 36},
261
+ "adult": {"minimal_value": 32, "normal_value": 34, "maximum_value": 36},
262
+ "elder": {"minimal_value": 32, "normal_value": 34, "maximum_value": 36}
263
+ },
264
+ "related_tests": ["mch", "mcv", "hemoglobin"],
265
+ "source_ids": ["uiowa_cbc_reference", "uiowa_pediatric_reference"]
266
+ },
267
+ {
268
+ "id": "rdw_cv",
269
+ "display_name": "Red Cell Distribution Width - CV",
270
+ "aliases": ["RDW-CV", "RDWCV", "RDW"],
271
+ "category": "CBC red cell index",
272
+ "unit": "%",
273
+ "description": "RDW-CV describes variation in red blood cell size as a coefficient of variation.",
274
+ "why_important": "Higher RDW can help identify mixed red-cell populations and may support evaluation for iron, B12, folate, recent bleeding, or recovery from anemia.",
275
+ "sex_significance": {
276
+ "level": "low",
277
+ "summary": "RDW-CV is usually interpreted without a major sex split, but the causes of abnormal RDW can overlap with sex-relevant conditions such as menstruation, pregnancy, and iron deficiency.",
278
+ "pipeline_guidance": "Use age-group statistics as fallback and interpret alongside sex-aware hemoglobin, RBC, hematocrit, and iron-related context."
279
+ },
280
+ "instructions_to_improve": {
281
+ "food": ["Support steady red-cell production with iron, B12, folate, protein, and adequate calories.", "Include a mix of leafy greens, legumes, fortified grains, seafood, eggs, dairy, and lean meats as appropriate.", "Address restrictive diets with clinician or dietitian support."],
282
+ "exercises": ["Regular activity supports general health but does not directly normalize RDW.", "Avoid overtraining if iron deficiency or anemia is suspected."],
283
+ "supplements": ["Consider supplements only after identifying the relevant deficiency.", "Ask about iron studies, ferritin, B12, folate, and reticulocyte count when RDW is abnormal."]
284
+ },
285
+ "statistics_per_group_age": {
286
+ "child": {"minimal_value": 9.0, "normal_value": 11.75, "maximum_value": 14.5},
287
+ "teenager": {"minimal_value": 9.0, "normal_value": 11.75, "maximum_value": 14.5},
288
+ "adult": {"minimal_value": 9.0, "normal_value": 11.75, "maximum_value": 14.5},
289
+ "elder": {"minimal_value": 9.0, "normal_value": 11.75, "maximum_value": 14.5}
290
+ },
291
+ "related_tests": ["mcv", "hemoglobin", "rdw_sd"],
292
+ "source_ids": ["uiowa_cbc_reference", "nih_ods_iron", "nih_ods_b12", "nih_ods_folate"]
293
+ },
294
+ {
295
+ "id": "rdw_sd",
296
+ "display_name": "Red Cell Distribution Width - SD",
297
+ "aliases": ["RDW-SD", "RDWSD"],
298
+ "category": "CBC red cell index",
299
+ "unit": "fL",
300
+ "description": "RDW-SD measures the width of the red-cell size distribution in femtoliters.",
301
+ "why_important": "It complements RDW-CV and MCV by showing how varied red-cell sizes are.",
302
+ "sex_significance": {
303
+ "level": "low",
304
+ "summary": "RDW-SD is generally not interpreted with separate male and female thresholds, but abnormal results should be considered with sex-aware red-cell markers.",
305
+ "pipeline_guidance": "Use age-group statistics as fallback and defer to the lab reference range if it is sex-specific."
306
+ },
307
+ "instructions_to_improve": {
308
+ "food": ["Follow the same red-cell nutrition pattern used for RDW-CV: iron, B12, folate, protein, and balanced calories.", "Correcting the cause of abnormal red-cell production is more important than targeting RDW-SD directly.", "Maintain hydration before routine blood draws unless instructed otherwise."],
309
+ "exercises": ["Regular moderate activity is reasonable when symptoms allow.", "Avoid intense training until unexplained anemia, dizziness, or shortness of breath is reviewed."],
310
+ "supplements": ["Supplement only for documented or likely deficiency.", "Discuss persistent abnormalities with a clinician, especially when hemoglobin or MCV is also abnormal."]
311
+ },
312
+ "statistics_per_group_age": {
313
+ "child": {"minimal_value": 35.1, "normal_value": 40.75, "maximum_value": 46.3},
314
+ "teenager": {"minimal_value": 35.1, "normal_value": 40.75, "maximum_value": 46.3},
315
+ "adult": {"minimal_value": 35.1, "normal_value": 40.75, "maximum_value": 46.3},
316
+ "elder": {"minimal_value": 35.1, "normal_value": 40.75, "maximum_value": 46.3}
317
+ },
318
+ "related_tests": ["mcv", "rdw_cv", "hemoglobin"],
319
+ "source_ids": ["uiowa_cbc_reference"]
320
+ },
321
+ {
322
+ "id": "wbc",
323
+ "display_name": "White Blood Cell Count",
324
+ "aliases": ["WBC", "Leukocyte count", "White cell count"],
325
+ "category": "CBC white cell marker",
326
+ "unit": "10^3/uL",
327
+ "description": "WBC count measures the total number of white blood cells in blood.",
328
+ "why_important": "White blood cells are central to immune defense; high or low counts can reflect infection, inflammation, medication effects, immune disorders, marrow conditions, or treatment effects.",
329
+ "sex_significance": {
330
+ "level": "low",
331
+ "summary": "Common adult WBC reference intervals are often the same for male and female patients. Pregnancy and some medications can change interpretation.",
332
+ "pipeline_guidance": "Use the age-group interval unless the lab report gives a sex- or pregnancy-specific range."
333
+ },
334
+ "instructions_to_improve": {
335
+ "food": ["There is no food that reliably corrects WBC count by itself; prioritize adequate calories, protein, fruits, vegetables, and hydration.", "Food safety matters if WBC is very low or immune suppression is present; ask a clinician about precautions.", "Limit heavy alcohol intake because it may impair immune and marrow function."],
336
+ "exercises": ["Follow general activity guidelines when well; rest during fever or acute infection.", "Avoid strenuous exercise during significant illness or very abnormal counts until medically reviewed."],
337
+ "supplements": ["Do not use immune-boosting supplements as a substitute for evaluation.", "Review medications and supplements with a clinician if WBC is abnormal."]
338
+ },
339
+ "statistics_per_group_age": {
340
+ "child": {"minimal_value": 5.5, "normal_value": 11.0, "maximum_value": 17.0},
341
+ "teenager": {"minimal_value": 4.5, "normal_value": 8.25, "maximum_value": 11.0},
342
+ "adult": {"minimal_value": 3.7, "normal_value": 7.6, "maximum_value": 10.5},
343
+ "elder": {"minimal_value": 3.7, "normal_value": 7.6, "maximum_value": 10.5}
344
+ },
345
+ "related_tests": ["neu_percent", "lym_percent", "mon_percent", "eos_percent", "bas_percent", "lym_absolute", "gra_absolute"],
346
+ "source_ids": ["medlineplus_cbc", "medlineplus_differential", "uiowa_cbc_reference", "uiowa_pediatric_reference"]
347
+ },
348
+ {
349
+ "id": "neu_percent",
350
+ "display_name": "Neutrophils Percent",
351
+ "aliases": ["NEU%", "Neutrophil %", "Neutrophils"],
352
+ "category": "CBC differential",
353
+ "unit": "%",
354
+ "description": "Neutrophil percentage is the share of white blood cells that are neutrophils, the most common WBC type and a major defense against infection.",
355
+ "why_important": "It helps interpret infection, inflammation, stress responses, medication effects, and marrow function, especially when paired with absolute neutrophil count.",
356
+ "sex_significance": {
357
+ "level": "low",
358
+ "summary": "Neutrophil percentage is usually interpreted without separate male and female ranges. Pregnancy, acute stress, and medications can affect values.",
359
+ "pipeline_guidance": "Use the age-group interval and prioritize the lab-provided range if pregnancy or other sex-specific context is documented."
360
+ },
361
+ "instructions_to_improve": {
362
+ "food": ["Support immune health with adequate protein, fruits, vegetables, whole grains, and hydration.", "There is no diet that directly normalizes neutrophil percentage; treat the cause.", "Practice food safety if a clinician says neutrophils are dangerously low."],
363
+ "exercises": ["Rest during acute infection or fever.", "Resume moderate activity gradually after illness; intense exercise can transiently shift white-cell patterns."],
364
+ "supplements": ["Avoid self-treating abnormal neutrophils with supplements.", "Discuss medication effects, infections, and need for repeat CBC or absolute neutrophil count with a clinician."]
365
+ },
366
+ "statistics_per_group_age": {
367
+ "child": {"minimal_value": 40, "normal_value": 50, "maximum_value": 60},
368
+ "teenager": {"minimal_value": 40, "normal_value": 50, "maximum_value": 60},
369
+ "adult": {"minimal_value": 40, "normal_value": 50, "maximum_value": 60},
370
+ "elder": {"minimal_value": 40, "normal_value": 50, "maximum_value": 60}
371
+ },
372
+ "related_tests": ["wbc", "gra_absolute"],
373
+ "source_ids": ["medlineplus_differential", "medlineplus_differential_encyclopedia"]
374
+ },
375
+ {
376
+ "id": "lym_percent",
377
+ "display_name": "Lymphocytes Percent",
378
+ "aliases": ["LYM%", "Lymphocyte %", "Lymphocytes"],
379
+ "category": "CBC differential",
380
+ "unit": "%",
381
+ "description": "Lymphocyte percentage is the share of white blood cells that are lymphocytes, including B cells and T cells.",
382
+ "why_important": "It helps evaluate immune patterns such as viral infections, chronic inflammation, immune suppression, and some blood disorders.",
383
+ "sex_significance": {
384
+ "level": "low",
385
+ "summary": "Lymphocyte percentage is generally interpreted with shared reference intervals across sex categories.",
386
+ "pipeline_guidance": "Use the age-group interval unless the source report provides a sex-specific or pregnancy-specific range."
387
+ },
388
+ "instructions_to_improve": {
389
+ "food": ["Maintain adequate protein, micronutrients, and calories to support immune cell production.", "Use a varied dietary pattern rather than targeting lymphocyte percentage directly.", "Seek evaluation for persistent abnormal values instead of relying on diet alone."],
390
+ "exercises": ["Moderate regular activity supports immune resilience.", "Avoid heavy training during acute illness or unexplained low counts."],
391
+ "supplements": ["Do not use supplements to force lymphocyte changes.", "Discuss abnormal lymphocyte percentage with a clinician, especially if absolute lymphocyte count is also abnormal."]
392
+ },
393
+ "statistics_per_group_age": {
394
+ "child": {"minimal_value": 20, "normal_value": 30, "maximum_value": 40},
395
+ "teenager": {"minimal_value": 20, "normal_value": 30, "maximum_value": 40},
396
+ "adult": {"minimal_value": 20, "normal_value": 30, "maximum_value": 40},
397
+ "elder": {"minimal_value": 20, "normal_value": 30, "maximum_value": 40}
398
+ },
399
+ "related_tests": ["wbc", "lym_absolute"],
400
+ "source_ids": ["medlineplus_differential", "medlineplus_differential_encyclopedia"]
401
+ },
402
+ {
403
+ "id": "mon_percent",
404
+ "display_name": "Monocytes Percent",
405
+ "aliases": ["MON%", "Monocyte %", "Monocytes"],
406
+ "category": "CBC differential",
407
+ "unit": "%",
408
+ "description": "Monocyte percentage is the share of white blood cells that are monocytes, immune cells involved in clearing germs and dead cells and coordinating immune response.",
409
+ "why_important": "It can support evaluation of infections, chronic inflammation, recovery from infection, and some blood or marrow disorders.",
410
+ "sex_significance": {
411
+ "level": "low",
412
+ "summary": "Monocyte percentage is usually not split by sex in routine CBC differential interpretation.",
413
+ "pipeline_guidance": "Use the age-group interval unless the lab report gives a sex-specific range."
414
+ },
415
+ "instructions_to_improve": {
416
+ "food": ["Eat enough protein and a varied diet to support immune function.", "No specific food reliably lowers or raises monocyte percentage.", "Persistent abnormalities should prompt review for infection, inflammation, medications, or other causes."],
417
+ "exercises": ["Use regular moderate exercise for general immune and metabolic health.", "Rest when acutely ill or febrile."],
418
+ "supplements": ["Avoid immune supplements as a replacement for medical evaluation.", "Review supplement and medication use if monocytes are persistently abnormal."]
419
+ },
420
+ "statistics_per_group_age": {
421
+ "child": {"minimal_value": 2, "normal_value": 5, "maximum_value": 8},
422
+ "teenager": {"minimal_value": 2, "normal_value": 5, "maximum_value": 8},
423
+ "adult": {"minimal_value": 2, "normal_value": 5, "maximum_value": 8},
424
+ "elder": {"minimal_value": 2, "normal_value": 5, "maximum_value": 8}
425
+ },
426
+ "related_tests": ["wbc"],
427
+ "source_ids": ["medlineplus_differential", "medlineplus_differential_encyclopedia"]
428
+ },
429
+ {
430
+ "id": "eos_percent",
431
+ "display_name": "Eosinophils Percent",
432
+ "aliases": ["EOS%", "Eosinophil %", "Eosinophils"],
433
+ "category": "CBC differential",
434
+ "unit": "%",
435
+ "description": "Eosinophil percentage is the share of white blood cells that are eosinophils, cells involved in allergies, asthma-related inflammation, and parasite defense.",
436
+ "why_important": "It can point toward allergic disease, asthma activity, drug reactions, parasitic infection, or inflammatory conditions when interpreted with symptoms.",
437
+ "sex_significance": {
438
+ "level": "low",
439
+ "summary": "Eosinophil percentage is typically interpreted without separate male and female thresholds.",
440
+ "pipeline_guidance": "Use the age-group interval unless the report provides a sex-specific range."
441
+ },
442
+ "instructions_to_improve": {
443
+ "food": ["No food directly normalizes eosinophils; identify allergies or triggers when clinically relevant.", "Maintain an anti-inflammatory dietary pattern with fruits, vegetables, whole grains, and adequate protein.", "Avoid foods only when a true allergy or clinician-guided elimination plan exists."],
444
+ "exercises": ["Exercise according to tolerance; people with asthma symptoms should follow their asthma action plan.", "Avoid exercising through wheezing, severe allergy symptoms, or acute illness."],
445
+ "supplements": ["Do not self-treat elevated eosinophils with supplements.", "Discuss allergy, asthma, parasite exposure, and medication review with a clinician."]
446
+ },
447
+ "statistics_per_group_age": {
448
+ "child": {"minimal_value": 1, "normal_value": 2.5, "maximum_value": 4},
449
+ "teenager": {"minimal_value": 1, "normal_value": 2.5, "maximum_value": 4},
450
+ "adult": {"minimal_value": 1, "normal_value": 2.5, "maximum_value": 4},
451
+ "elder": {"minimal_value": 1, "normal_value": 2.5, "maximum_value": 4}
452
+ },
453
+ "related_tests": ["wbc", "bas_percent"],
454
+ "source_ids": ["medlineplus_differential", "medlineplus_differential_encyclopedia"]
455
+ },
456
+ {
457
+ "id": "bas_percent",
458
+ "display_name": "Basophils Percent",
459
+ "aliases": ["BAS%", "Basophil %", "Basophils"],
460
+ "category": "CBC differential",
461
+ "unit": "%",
462
+ "description": "Basophil percentage is the share of white blood cells that are basophils, cells that release mediators during allergic and asthma-related reactions.",
463
+ "why_important": "Basophils are usually a small fraction of WBCs; persistent elevation can be relevant in allergy, inflammation, or some marrow disorders.",
464
+ "sex_significance": {
465
+ "level": "low",
466
+ "summary": "Basophil percentage is typically interpreted without separate male and female thresholds.",
467
+ "pipeline_guidance": "Use the age-group interval unless the lab report provides a sex-specific range."
468
+ },
469
+ "instructions_to_improve": {
470
+ "food": ["No diet directly targets basophils.", "If allergies are relevant, avoid confirmed triggers and maintain balanced nutrition.", "Seek medical review for persistent elevation rather than self-treating."],
471
+ "exercises": ["Exercise as tolerated; avoid exposure-triggered activity if asthma or allergic symptoms are active.", "Rest during acute allergic or inflammatory episodes if symptoms are significant."],
472
+ "supplements": ["Avoid supplement-only management for abnormal basophils.", "Review medications, allergy history, and repeat testing with a clinician when needed."]
473
+ },
474
+ "statistics_per_group_age": {
475
+ "child": {"minimal_value": 0.5, "normal_value": 0.75, "maximum_value": 1},
476
+ "teenager": {"minimal_value": 0.5, "normal_value": 0.75, "maximum_value": 1},
477
+ "adult": {"minimal_value": 0.5, "normal_value": 0.75, "maximum_value": 1},
478
+ "elder": {"minimal_value": 0.5, "normal_value": 0.75, "maximum_value": 1}
479
+ },
480
+ "related_tests": ["wbc", "eos_percent"],
481
+ "source_ids": ["medlineplus_differential", "medlineplus_differential_encyclopedia"]
482
+ },
483
+ {
484
+ "id": "lym_absolute",
485
+ "display_name": "Absolute Lymphocyte Count",
486
+ "aliases": ["LYM#", "Lymphocyte #", "Absolute lymphocytes", "ALC"],
487
+ "category": "CBC differential absolute count",
488
+ "unit": "10^3/uL",
489
+ "description": "Absolute lymphocyte count is the number of lymphocytes in a volume of blood.",
490
+ "why_important": "It is often more clinically useful than lymphocyte percentage because percentages can shift when other WBC types rise or fall.",
491
+ "sex_significance": {
492
+ "level": "low",
493
+ "summary": "Absolute lymphocyte count is generally interpreted with shared male and female reference intervals.",
494
+ "pipeline_guidance": "Use the age-group interval unless the report gives a sex-specific range."
495
+ },
496
+ "instructions_to_improve": {
497
+ "food": ["Support immune cell production with adequate calories, protein, and micronutrient-rich foods.", "Food cannot reliably correct abnormal absolute lymphocytes by itself.", "If immunosuppressed, ask about food safety guidance."],
498
+ "exercises": ["Use moderate regular activity when well.", "Avoid intense exercise during acute infection, fever, or severe fatigue."],
499
+ "supplements": ["Do not use immune supplements to self-correct lymphocyte count.", "Discuss persistent low or high ALC with a clinician, especially with infections, weight loss, night sweats, or medication changes."]
500
+ },
501
+ "statistics_per_group_age": {
502
+ "child": {"minimal_value": 2.0, "normal_value": 5.75, "maximum_value": 9.5},
503
+ "teenager": {"minimal_value": 1.25, "normal_value": 3.63, "maximum_value": 7.0},
504
+ "adult": {"minimal_value": 0.875, "normal_value": 2.19, "maximum_value": 3.3},
505
+ "elder": {"minimal_value": 0.875, "normal_value": 2.19, "maximum_value": 3.3}
506
+ },
507
+ "related_tests": ["lym_percent", "wbc"],
508
+ "source_ids": ["uiowa_pediatric_reference", "uchicago_cbc_diff", "medlineplus_differential"]
509
+ },
510
+ {
511
+ "id": "gra_absolute",
512
+ "display_name": "Absolute Granulocyte Count",
513
+ "aliases": ["GRA#", "Granulocyte #", "Absolute granulocytes", "ANC when neutrophil-dominant"],
514
+ "category": "CBC differential absolute count",
515
+ "unit": "10^3/uL",
516
+ "description": "GRA# is a lab-reported absolute granulocyte count. Granulocytes include neutrophils, eosinophils, and basophils; in many CBC reports this value is mainly driven by neutrophils.",
517
+ "why_important": "It helps summarize infection-fighting granulocytes and may approximate neutrophil burden, but exact meaning depends on the analyzer and lab report.",
518
+ "sex_significance": {
519
+ "level": "low",
520
+ "summary": "Absolute granulocyte or neutrophil-dominant counts are usually interpreted without separate male and female thresholds. Pregnancy and acute stress can affect granulocyte counts.",
521
+ "pipeline_guidance": "Use the age-group interval and lab-provided reference range; do not infer sex-specific status unless the source range provides it."
522
+ },
523
+ "instructions_to_improve": {
524
+ "food": ["No food directly normalizes granulocyte count; support immune health with balanced nutrition and adequate protein.", "If counts are very low, ask about infection prevention and food safety precautions.", "Hydration and rest during illness can support recovery but do not replace evaluation."],
525
+ "exercises": ["Rest during fever or acute infection.", "Resume regular moderate activity after recovery and medical clearance if counts are significantly abnormal."],
526
+ "supplements": ["Avoid self-treatment with immune supplements.", "Ask whether the lab means granulocytes broadly or absolute neutrophil count, and whether repeat CBC/differential is needed."]
527
+ },
528
+ "statistics_per_group_age": {
529
+ "child": {"minimal_value": 1.5, "normal_value": 5.0, "maximum_value": 8.5},
530
+ "teenager": {"minimal_value": 1.7, "normal_value": 4.6, "maximum_value": 7.5},
531
+ "adult": {"minimal_value": 1.12, "normal_value": 3.36, "maximum_value": 6.72},
532
+ "elder": {"minimal_value": 1.12, "normal_value": 3.36, "maximum_value": 6.72}
533
+ },
534
+ "related_tests": ["neu_percent", "wbc", "eos_percent", "bas_percent"],
535
+ "source_ids": ["uiowa_pediatric_reference", "uchicago_cbc_diff", "medlineplus_differential"]
536
+ },
537
+ {
538
+ "id": "plt",
539
+ "display_name": "Platelet Count",
540
+ "aliases": ["PLT", "Platelets", "Thrombocytes"],
541
+ "category": "CBC platelet marker",
542
+ "unit": "10^3/uL",
543
+ "description": "Platelet count measures small blood cell fragments that help form clots and stop bleeding.",
544
+ "why_important": "Low platelets can increase bleeding risk; high platelets can occur with inflammation, iron deficiency, recovery from bleeding, or marrow disorders and may affect clotting risk.",
545
+ "sex_significance": {
546
+ "level": "low",
547
+ "summary": "Many adult CBC references use the same platelet interval for male and female patients. Pregnancy, menstruation-related iron deficiency, inflammation, and medications can affect interpretation.",
548
+ "pipeline_guidance": "Use the age-group interval unless the lab report provides a sex- or pregnancy-specific range. Surface pregnancy and bleeding-risk caveats when relevant."
549
+ },
550
+ "instructions_to_improve": {
551
+ "food": ["Eat a balanced pattern with adequate protein, iron, folate, and B12 to support marrow production.", "If platelets are high with low iron markers, iron-rich foods may matter, but the cause should be confirmed.", "Avoid heavy alcohol intake because it can lower platelet production."],
552
+ "exercises": ["If platelets are very low, avoid contact sports or high-impact activities until cleared.", "Use regular moderate activity when platelet count and symptoms allow."],
553
+ "supplements": ["Avoid aspirin-like or blood-thinning supplements unless clinician-approved, especially with low platelets or bleeding symptoms.", "Discuss iron, B12, or folate only when deficiency is suspected or confirmed."]
554
+ },
555
+ "statistics_per_group_age": {
556
+ "child": {"minimal_value": 155, "normal_value": 342.5, "maximum_value": 500},
557
+ "teenager": {"minimal_value": 140, "normal_value": 270, "maximum_value": 400},
558
+ "adult": {"minimal_value": 150, "normal_value": 275, "maximum_value": 400},
559
+ "elder": {"minimal_value": 150, "normal_value": 275, "maximum_value": 400}
560
+ },
561
+ "related_tests": ["wbc", "hemoglobin"],
562
+ "source_ids": ["medlineplus_cbc", "uiowa_cbc_reference", "seattle_childrens_platelet", "nih_ods_iron", "nih_ods_b12", "nih_ods_folate"]
563
+ },
564
+ {
565
+ "id": "esr",
566
+ "display_name": "Erythrocyte Sedimentation Rate",
567
+ "aliases": ["ESR", "Sed rate", "Westergren ESR"],
568
+ "category": "Inflammation marker",
569
+ "unit": "mm/hr",
570
+ "description": "ESR measures how quickly red blood cells settle in a tube over one hour.",
571
+ "why_important": "A faster sedimentation rate can be a nonspecific sign of inflammation, infection, autoimmune disease, some cancers, or blood disorders. ESR alone does not diagnose the cause.",
572
+ "sex_significance": {
573
+ "level": "high",
574
+ "summary": "ESR reference thresholds commonly differ by sex and increase with age; female thresholds are often higher than male thresholds.",
575
+ "pipeline_guidance": "Use sex_specific_statistics_per_group_age when sex is known. ESR is nonspecific, so the UI should avoid diagnosis and should mention that anemia, pregnancy, age, and other conditions can affect results."
576
+ },
577
+ "sex_specific_statistics_per_group_age": {
578
+ "child": {
579
+ "male": {"minimal_value": 0, "normal_value": 6.5, "maximum_value": 13},
580
+ "female": {"minimal_value": 0, "normal_value": 6.5, "maximum_value": 13},
581
+ "unknown": {"minimal_value": 0, "normal_value": 6.5, "maximum_value": 13}
582
+ },
583
+ "teenager": {
584
+ "male": {"minimal_value": 0, "normal_value": 7.5, "maximum_value": 15},
585
+ "female": {"minimal_value": 0, "normal_value": 10, "maximum_value": 20},
586
+ "unknown": {"minimal_value": 0, "normal_value": 10, "maximum_value": 20}
587
+ },
588
+ "adult": {
589
+ "male": {"minimal_value": 0, "normal_value": 7.5, "maximum_value": 15},
590
+ "female": {"minimal_value": 0, "normal_value": 10, "maximum_value": 20},
591
+ "unknown": {"minimal_value": 0, "normal_value": 10, "maximum_value": 20}
592
+ },
593
+ "elder": {
594
+ "male": {"minimal_value": 0, "normal_value": 10, "maximum_value": 20},
595
+ "female": {"minimal_value": 0, "normal_value": 15, "maximum_value": 30},
596
+ "unknown": {"minimal_value": 0, "normal_value": 15, "maximum_value": 30}
597
+ }
598
+ },
599
+ "instructions_to_improve": {
600
+ "food": ["Use a balanced dietary pattern emphasizing vegetables, fruits, whole grains, legumes, nuts, fish, and adequate protein.", "Reduce heavy alcohol intake and ultra-processed foods if they are major parts of the diet.", "Address the medical cause of inflammation; diet alone may not normalize ESR."],
601
+ "exercises": ["Regular moderate activity can support inflammatory and cardiovascular health when appropriate.", "Avoid strenuous exercise during acute illness, fever, or unexplained inflammatory symptoms."],
602
+ "supplements": ["Do not use supplements to hide or self-treat unexplained inflammation.", "Discuss whether CRP, repeat ESR, or condition-specific testing is appropriate; review all supplements and medications because some can affect results."]
603
+ },
604
+ "statistics_per_group_age": {
605
+ "child": {"minimal_value": 0, "normal_value": 5, "maximum_value": 10},
606
+ "teenager": {"minimal_value": 0, "normal_value": 10, "maximum_value": 20},
607
+ "adult": {"minimal_value": 0, "normal_value": 10, "maximum_value": 20},
608
+ "elder": {"minimal_value": 0, "normal_value": 15, "maximum_value": 30}
609
+ },
610
+ "related_tests": ["wbc", "hemoglobin"],
611
+ "source_ids": ["medlineplus_esr", "seattle_childrens_esr", "uchicago_esr_reference", "cleveland_clinic_esr"]
612
+ }
613
+ ]
614
+ }
src/extraction/local_minicpmv.py CHANGED
@@ -32,6 +32,7 @@ from src.openbmb_client import (
32
  EXTRACTION_PROMPT,
33
  ExtractionResult,
34
  _normalize_notes,
 
35
  _normalize_tests,
36
  )
37
 
@@ -91,6 +92,7 @@ class LocalMiniCPMVExtractor:
91
  parsed = {}
92
 
93
  return ExtractionResult(
 
94
  tests=_normalize_tests(parsed.get("tests", [])),
95
  notes=_normalize_notes(parsed.get("notes", [])),
96
  raw_response=raw,
 
32
  EXTRACTION_PROMPT,
33
  ExtractionResult,
34
  _normalize_notes,
35
+ _normalize_patient,
36
  _normalize_tests,
37
  )
38
 
 
92
  parsed = {}
93
 
94
  return ExtractionResult(
95
+ patient=_normalize_patient(parsed.get("patient", {})),
96
  tests=_normalize_tests(parsed.get("tests", [])),
97
  notes=_normalize_notes(parsed.get("notes", [])),
98
  raw_response=raw,
src/extraction/local_server.py CHANGED
@@ -32,6 +32,7 @@ from src.openbmb_client import (
32
  EXTRACTION_PROMPT,
33
  ExtractionResult,
34
  _normalize_notes,
 
35
  _normalize_tests,
36
  _parse_json_response,
37
  )
@@ -81,6 +82,7 @@ class LocalServerExtractor:
81
  raw = _message_content(response.json())
82
  parsed = _parse_json_response(raw)
83
  return ExtractionResult(
 
84
  tests=_normalize_tests(parsed.get("tests", [])),
85
  notes=_normalize_notes(parsed.get("notes", [])),
86
  raw_response=raw,
 
32
  EXTRACTION_PROMPT,
33
  ExtractionResult,
34
  _normalize_notes,
35
+ _normalize_patient,
36
  _normalize_tests,
37
  _parse_json_response,
38
  )
 
82
  raw = _message_content(response.json())
83
  parsed = _parse_json_response(raw)
84
  return ExtractionResult(
85
+ patient=_normalize_patient(parsed.get("patient", {})),
86
  tests=_normalize_tests(parsed.get("tests", [])),
87
  notes=_normalize_notes(parsed.get("notes", [])),
88
  raw_response=raw,
src/grammar.py CHANGED
@@ -1,7 +1,7 @@
1
  """GBNF grammar for the extraction schema.
2
 
3
  Grammar-constrained decoding makes the local model **physically unable** to emit anything but
4
- a valid `{tests:[...], notes:[...]}` object in our exact schema. For a small model this is the
5
  single biggest reliability lever: no parse failures, no stray prose, no hallucinated keys.
6
  Passed to llama.cpp via `LlamaGrammar.from_string(...)`.
7
  """
@@ -9,7 +9,13 @@ Passed to llama.cpp via `LlamaGrammar.from_string(...)`.
9
  from __future__ import annotations
10
 
11
  EXTRACTION_GRAMMAR = r"""
12
- root ::= "{" ws "\"tests\"" ws ":" ws tests ws "," ws "\"notes\"" ws ":" ws notes ws "}"
 
 
 
 
 
 
13
 
14
  tests ::= "[" ws ( test ( ws "," ws test )* )? ws "]"
15
  test ::= "{" ws
@@ -25,7 +31,9 @@ test ::= "{" ws
25
  notes ::= "[" ws ( string ( ws "," ws string )* )? ws "]"
26
 
27
  status ::= "\"low\"" | "\"normal\"" | "\"high\"" | "\"abnormal\"" | "\"unknown\""
 
28
  strornull ::= string | "null"
 
29
  string ::= "\"" char* "\""
30
  char ::= [^"\\] | "\\" ["\\/bfnrt]
31
  number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)?
 
1
  """GBNF grammar for the extraction schema.
2
 
3
  Grammar-constrained decoding makes the local model **physically unable** to emit anything but
4
+ a valid `{patient:{...}, tests:[...], notes:[...]}` object in our exact schema. For a small model this is the
5
  single biggest reliability lever: no parse failures, no stray prose, no hallucinated keys.
6
  Passed to llama.cpp via `LlamaGrammar.from_string(...)`.
7
  """
 
9
  from __future__ import annotations
10
 
11
  EXTRACTION_GRAMMAR = r"""
12
+ root ::= "{" ws "\"patient\"" ws ":" ws patient ws "," ws "\"tests\"" ws ":" ws tests ws "," ws "\"notes\"" ws ":" ws notes ws "}"
13
+
14
+ patient ::= "{" ws
15
+ "\"age\"" ws ":" ws strornull ws "," ws
16
+ "\"age_years\"" ws ":" ws numberornull ws "," ws
17
+ "\"sex\"" ws ":" ws sex ws
18
+ "}"
19
 
20
  tests ::= "[" ws ( test ( ws "," ws test )* )? ws "]"
21
  test ::= "{" ws
 
31
  notes ::= "[" ws ( string ( ws "," ws string )* )? ws "]"
32
 
33
  status ::= "\"low\"" | "\"normal\"" | "\"high\"" | "\"abnormal\"" | "\"unknown\""
34
+ sex ::= "\"male\"" | "\"female\"" | "\"unknown\""
35
  strornull ::= string | "null"
36
+ numberornull ::= number | "null"
37
  string ::= "\"" char* "\""
38
  char ::= [^"\\] | "\\" ["\\/bfnrt]
39
  number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)?
src/knowledge_graph.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Knowledge-graph lookup and reference selection for lab markers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ DEFAULT_KNOWLEDGE_GRAPH_PATH = ROOT / "kb" / "cbc_knowledge_graph.json"
14
+
15
+
16
+ class LabKnowledgeGraph:
17
+ """Small deterministic lookup layer over the JSON knowledge graph."""
18
+
19
+ def __init__(self, payload: dict[str, Any]) -> None:
20
+ self.payload = payload
21
+ self.tests: list[dict[str, Any]] = list(payload.get("tests", []))
22
+ self._by_id = {str(test.get("id", "")).casefold(): test for test in self.tests}
23
+ self._alias_index = self._build_alias_index()
24
+
25
+ @classmethod
26
+ def load(cls, path: str | Path = DEFAULT_KNOWLEDGE_GRAPH_PATH) -> "LabKnowledgeGraph":
27
+ graph_path = Path(path)
28
+ return cls(json.loads(graph_path.read_text(encoding="utf-8")))
29
+
30
+ def resolve(self, marker_name: str | None) -> dict[str, Any] | None:
31
+ """Return the graph node matching a raw marker name or alias."""
32
+ for key in _candidate_keys(marker_name):
33
+ match = self._alias_index.get(key)
34
+ if match is not None:
35
+ return match
36
+ return None
37
+
38
+ def get(self, marker_id: str | None) -> dict[str, Any] | None:
39
+ if not marker_id:
40
+ return None
41
+ return self._by_id.get(str(marker_id).casefold())
42
+
43
+ def select_statistics(
44
+ self,
45
+ node: dict[str, Any],
46
+ age_group: str,
47
+ sex: str,
48
+ ) -> dict[str, Any] | None:
49
+ """Select the best statistics block for age/sex context.
50
+
51
+ Sex-specific ranges are preferred when available. The JSON keeps an
52
+ `unknown` sex bucket for high-impact markers, and age-only statistics
53
+ remain the compatibility fallback for every marker.
54
+ """
55
+ normalized_sex = sex if sex in {"male", "female"} else "unknown"
56
+ sex_stats = node.get("sex_specific_statistics_per_group_age")
57
+ if isinstance(sex_stats, dict):
58
+ group_stats = sex_stats.get(age_group)
59
+ if isinstance(group_stats, dict):
60
+ values = group_stats.get(normalized_sex) or group_stats.get("unknown")
61
+ if isinstance(values, dict):
62
+ return {
63
+ "basis": "sex_specific_statistics_per_group_age",
64
+ "age_group": age_group,
65
+ "sex": normalized_sex,
66
+ "values": values,
67
+ }
68
+
69
+ age_stats = node.get("statistics_per_group_age", {})
70
+ values = age_stats.get(age_group)
71
+ if isinstance(values, dict):
72
+ return {
73
+ "basis": "statistics_per_group_age",
74
+ "age_group": age_group,
75
+ "sex": "not_applied",
76
+ "values": values,
77
+ }
78
+ return None
79
+
80
+ def _build_alias_index(self) -> dict[str, dict[str, Any]]:
81
+ index: dict[str, dict[str, Any]] = {}
82
+ for test in self.tests:
83
+ names = [
84
+ test.get("id"),
85
+ test.get("display_name"),
86
+ *(test.get("aliases") or []),
87
+ ]
88
+ for name in names:
89
+ for key in _candidate_keys(name):
90
+ index.setdefault(key, test)
91
+ return index
92
+
93
+
94
+ @lru_cache(maxsize=1)
95
+ def default_knowledge_graph() -> LabKnowledgeGraph:
96
+ return LabKnowledgeGraph.load()
97
+
98
+
99
+ def _candidate_keys(value: str | None) -> list[str]:
100
+ if value is None:
101
+ return []
102
+
103
+ text = str(value).strip()
104
+ if not text:
105
+ return []
106
+
107
+ pieces = {text}
108
+ pieces.add(re.sub(r"\([^)]*\)", "", text).strip())
109
+
110
+ for inner in re.findall(r"\(([^)]*)\)", text):
111
+ pieces.add(inner)
112
+ pieces.update(part.strip() for part in re.split(r"[/,;]", inner))
113
+
114
+ keys: list[str] = []
115
+ for piece in pieces:
116
+ key = _marker_key(piece)
117
+ if key and key not in keys:
118
+ keys.append(key)
119
+ return keys
120
+
121
+
122
+ def _marker_key(value: str) -> str:
123
+ normalized = value.casefold()
124
+ normalized = normalized.replace("µ", "u").replace("μ", "u")
125
+ normalized = normalized.replace("percent", "%").replace("number", "#")
126
+ return re.sub(r"[^a-z0-9%#]+", "", normalized)
src/openbmb_client.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
  import json
4
  import os
5
  import re
6
- from dataclasses import dataclass
7
  from typing import Any
8
 
9
  import requests
@@ -25,6 +25,11 @@ You are extracting laboratory test results from a medical document.
25
 
26
  Return only valid JSON with this exact shape:
27
  {
 
 
 
 
 
28
  "tests": [
29
  {
30
  "marker": "string",
@@ -42,6 +47,9 @@ Return only valid JSON with this exact shape:
42
  Rules:
43
  - Extract pure lab values only.
44
  - Do not diagnose, interpret, recommend food, supplements, or exercise.
 
 
 
45
  - Do not invent missing values.
46
  - Preserve the units and reference ranges exactly as shown when possible.
47
  - If a marker is unreadable, omit it or add a short note.
@@ -56,6 +64,7 @@ class ExtractionResult:
56
  notes: list[str]
57
  raw_response: str
58
  request_summary: dict[str, Any]
 
59
 
60
 
61
  class OpenBMBExtractor:
@@ -119,6 +128,7 @@ class OpenBMBExtractor:
119
  parsed = _parse_json_response(raw_response)
120
 
121
  return ExtractionResult(
 
122
  tests=_normalize_tests(parsed.get("tests", [])),
123
  notes=_normalize_notes(parsed.get("notes", [])),
124
  raw_response=raw_response,
@@ -237,6 +247,20 @@ def _normalize_notes(value: Any) -> list[str]:
237
  return [str(note).strip() for note in value if str(note).strip()]
238
 
239
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  def _optional_string(value: Any) -> str | None:
241
  if value is None:
242
  return None
@@ -244,6 +268,24 @@ def _optional_string(value: Any) -> str | None:
244
  return text or None
245
 
246
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  def _confidence(value: Any) -> float:
248
  try:
249
  score = float(value)
 
3
  import json
4
  import os
5
  import re
6
+ from dataclasses import dataclass, field
7
  from typing import Any
8
 
9
  import requests
 
25
 
26
  Return only valid JSON with this exact shape:
27
  {
28
+ "patient": {
29
+ "age": "string or null",
30
+ "age_years": 0.0,
31
+ "sex": "male | female | unknown"
32
+ },
33
  "tests": [
34
  {
35
  "marker": "string",
 
47
  Rules:
48
  - Extract pure lab values only.
49
  - Do not diagnose, interpret, recommend food, supplements, or exercise.
50
+ - Extract patient age and sex only when visibly present in the document.
51
+ - Normalize sex to "male", "female", or "unknown"; do not infer sex from the patient's name.
52
+ - Use null for age and age_years when age is missing.
53
  - Do not invent missing values.
54
  - Preserve the units and reference ranges exactly as shown when possible.
55
  - If a marker is unreadable, omit it or add a short note.
 
64
  notes: list[str]
65
  raw_response: str
66
  request_summary: dict[str, Any]
67
+ patient: dict[str, Any] = field(default_factory=dict)
68
 
69
 
70
  class OpenBMBExtractor:
 
128
  parsed = _parse_json_response(raw_response)
129
 
130
  return ExtractionResult(
131
+ patient=_normalize_patient(parsed.get("patient", {})),
132
  tests=_normalize_tests(parsed.get("tests", [])),
133
  notes=_normalize_notes(parsed.get("notes", [])),
134
  raw_response=raw_response,
 
247
  return [str(note).strip() for note in value if str(note).strip()]
248
 
249
 
250
+ def _normalize_patient(value: Any) -> dict[str, Any]:
251
+ if not isinstance(value, dict):
252
+ return {"age": None, "age_years": None, "sex": "unknown"}
253
+
254
+ age = _optional_string(value.get("age") or value.get("age_text") or value.get("patient_age"))
255
+ age_years = _optional_float(value.get("age_years"))
256
+ sex = _normalize_sex(value.get("sex") or value.get("patient_sex") or value.get("gender"))
257
+ return {
258
+ "age": age,
259
+ "age_years": age_years,
260
+ "sex": sex,
261
+ }
262
+
263
+
264
  def _optional_string(value: Any) -> str | None:
265
  if value is None:
266
  return None
 
268
  return text or None
269
 
270
 
271
+ def _optional_float(value: Any) -> float | None:
272
+ if value is None or value == "":
273
+ return None
274
+ try:
275
+ return float(value)
276
+ except (TypeError, ValueError):
277
+ return None
278
+
279
+
280
+ def _normalize_sex(value: Any) -> str:
281
+ text = str(value or "").strip().casefold()
282
+ if text in {"m", "male"}:
283
+ return "male"
284
+ if text in {"f", "female"}:
285
+ return "female"
286
+ return "unknown"
287
+
288
+
289
  def _confidence(value: Any) -> float:
290
  try:
291
  score = float(value)
src/report_pipeline.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extraction-to-health-report pipeline.
2
+
3
+ This module keeps the agentic part focused on reading the document. Everything after that is
4
+ deterministic: marker resolution, age/sex reference selection, status comparison, and shaping the
5
+ object consumed by the UI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ from src.knowledge_graph import LabKnowledgeGraph, default_knowledge_graph
14
+ from src.openbmb_client import ExtractionResult
15
+
16
+
17
+ AGE_GROUPS = ("child", "teenager", "adult", "elder")
18
+ KNOWN_STATUSES = {"low", "normal", "high", "abnormal", "unknown"}
19
+
20
+
21
+ def build_health_report(
22
+ extraction: ExtractionResult,
23
+ knowledge_graph: LabKnowledgeGraph | None = None,
24
+ ) -> dict[str, Any]:
25
+ """Merge extracted lab values with knowledge-graph context for rendering."""
26
+ graph = knowledge_graph or default_knowledge_graph()
27
+ patient = normalize_patient(getattr(extraction, "patient", {}))
28
+ markers = [
29
+ enrich_marker(test, patient=patient, knowledge_graph=graph)
30
+ for test in extraction.tests
31
+ ]
32
+
33
+ status_counts = _status_counts(markers)
34
+ enriched_count = sum(1 for marker in markers if marker.get("knowledge") is not None)
35
+ unmatched = [
36
+ marker["raw_name"]
37
+ for marker in markers
38
+ if marker.get("knowledge") is None
39
+ ]
40
+
41
+ return {
42
+ "patient": patient,
43
+ "markers": markers,
44
+ "notes": list(extraction.notes),
45
+ "summary": {
46
+ "total_markers": len(markers),
47
+ "enriched_markers": enriched_count,
48
+ "unmatched_markers": unmatched,
49
+ "status_counts": status_counts,
50
+ "needs_review": (
51
+ status_counts.get("high", 0)
52
+ + status_counts.get("low", 0)
53
+ + status_counts.get("abnormal", 0)
54
+ ),
55
+ },
56
+ "knowledge_graph": {
57
+ "schema_version": graph.payload.get("schema_version"),
58
+ "title": graph.payload.get("title"),
59
+ "medical_disclaimer": graph.payload.get("medical_disclaimer"),
60
+ "sex_significance_policy": graph.payload.get("sex_significance_policy"),
61
+ "sources": graph.payload.get("sources", {}),
62
+ },
63
+ "request_summary": extraction.request_summary,
64
+ "raw_response": extraction.raw_response,
65
+ }
66
+
67
+
68
+ def enrich_marker(
69
+ extracted: dict[str, Any],
70
+ patient: dict[str, Any],
71
+ knowledge_graph: LabKnowledgeGraph,
72
+ ) -> dict[str, Any]:
73
+ raw_name = _text(extracted.get("marker"), "Unknown marker")
74
+ node = knowledge_graph.resolve(raw_name)
75
+ numeric_value = parse_numeric_value(extracted.get("value"))
76
+ extracted_status = normalize_status(extracted.get("status"))
77
+ lab_interval = parse_reference_interval(extracted.get("reference_range"))
78
+ kg_selection = (
79
+ knowledge_graph.select_statistics(node, patient["age_group"], patient["sex"])
80
+ if node is not None
81
+ else None
82
+ )
83
+ kg_interval = _interval_from_statistics(kg_selection)
84
+
85
+ comparison_interval = lab_interval or kg_interval
86
+ reference_basis = "lab_reference_range" if lab_interval else "knowledge_graph"
87
+ derived_status = status_from_interval(numeric_value, comparison_interval)
88
+ final_status = extracted_status if extracted_status != "unknown" else (derived_status or "unknown")
89
+
90
+ return {
91
+ "raw_name": raw_name,
92
+ "canonical_id": node.get("id") if node else None,
93
+ "display_name": node.get("display_name") if node else raw_name,
94
+ "value": _text(extracted.get("value"), "-"),
95
+ "numeric_value": numeric_value,
96
+ "unit": _text(extracted.get("unit"), node.get("unit", "") if node else ""),
97
+ "lab_reference_range": _optional_text(extracted.get("reference_range")),
98
+ "status": final_status,
99
+ "extracted_status": extracted_status,
100
+ "derived_status": derived_status or "unknown",
101
+ "confidence": _confidence(extracted.get("confidence")),
102
+ "source_text": _optional_text(extracted.get("source_text")),
103
+ "comparison": {
104
+ "basis": reference_basis,
105
+ "interval": comparison_interval,
106
+ "range_position": range_position(numeric_value, comparison_interval),
107
+ },
108
+ "reference_selection": kg_selection,
109
+ "knowledge": _knowledge_payload(node),
110
+ }
111
+
112
+
113
+ def normalize_patient(value: Any) -> dict[str, Any]:
114
+ source = value if isinstance(value, dict) else {}
115
+ raw_age = (
116
+ source.get("age")
117
+ or source.get("age_text")
118
+ or source.get("age_years")
119
+ or source.get("patient_age")
120
+ )
121
+ age_years = parse_age_years(source.get("age_years"))
122
+ if age_years is None:
123
+ age_years = parse_age_years(raw_age)
124
+
125
+ sex = normalize_sex(source.get("sex") or source.get("patient_sex") or source.get("gender"))
126
+ return {
127
+ "age": _optional_text(raw_age),
128
+ "age_years": age_years,
129
+ "age_group": age_group_for(age_years),
130
+ "sex": sex,
131
+ "raw": source,
132
+ }
133
+
134
+
135
+ def parse_age_years(value: Any) -> float | None:
136
+ if value is None:
137
+ return None
138
+ if isinstance(value, (int, float)):
139
+ return float(value) if value >= 0 else None
140
+
141
+ text = str(value).strip().casefold()
142
+ if not text:
143
+ return None
144
+
145
+ # Common report format: "25y 10m 26d".
146
+ years = _first_number_before(text, ("y", "yr", "yrs", "year", "years"))
147
+ months = _first_number_before(text, ("mo", "mos", "month", "months", "m"))
148
+ days = _first_number_before(text, ("d", "day", "days"))
149
+ if years is not None or months is not None or days is not None:
150
+ return round((years or 0.0) + (months or 0.0) / 12 + (days or 0.0) / 365.25, 2)
151
+
152
+ match = re.search(r"\d+(?:\.\d+)?", text)
153
+ if match:
154
+ parsed = float(match.group(0))
155
+ return parsed if parsed >= 0 else None
156
+ return None
157
+
158
+
159
+ def normalize_sex(value: Any) -> str:
160
+ if value is None:
161
+ return "unknown"
162
+ text = str(value).strip().casefold()
163
+ if text in {"m", "male", "man", "boy"}:
164
+ return "male"
165
+ if text in {"f", "female", "woman", "girl"}:
166
+ return "female"
167
+ return "unknown"
168
+
169
+
170
+ def age_group_for(age_years: float | None) -> str:
171
+ if age_years is None:
172
+ return "adult"
173
+ if age_years < 13:
174
+ return "child"
175
+ if age_years < 18:
176
+ return "teenager"
177
+ if age_years < 65:
178
+ return "adult"
179
+ return "elder"
180
+
181
+
182
+ def parse_numeric_value(value: Any) -> float | None:
183
+ if value is None:
184
+ return None
185
+ if isinstance(value, (int, float)):
186
+ return float(value)
187
+ match = re.search(r"-?\d+(?:,\d{3})*(?:\.\d+)?", str(value))
188
+ if not match:
189
+ return None
190
+ try:
191
+ return float(match.group(0).replace(",", ""))
192
+ except ValueError:
193
+ return None
194
+
195
+
196
+ def parse_reference_interval(value: Any) -> dict[str, float | None] | None:
197
+ text = _optional_text(value)
198
+ if not text:
199
+ return None
200
+
201
+ cleaned = text.casefold().replace("–", "-").replace("—", "-")
202
+ numbers = [float(match.replace(",", "")) for match in re.findall(r"\d+(?:,\d{3})*(?:\.\d+)?", cleaned)]
203
+ if len(numbers) >= 2 and re.search(r"\d\s*-\s*\d", cleaned):
204
+ low, high = numbers[0], numbers[1]
205
+ return {"low": min(low, high), "high": max(low, high)}
206
+
207
+ if numbers and re.search(r"(up to|less than|<|<=|≤|below)", cleaned):
208
+ return {"low": None, "high": numbers[0]}
209
+
210
+ if numbers and re.search(r"(greater than|>|>=|≥|above|at least)", cleaned):
211
+ return {"low": numbers[0], "high": None}
212
+
213
+ return None
214
+
215
+
216
+ def status_from_interval(
217
+ value: float | None,
218
+ interval: dict[str, float | None] | None,
219
+ ) -> str | None:
220
+ if value is None or not interval:
221
+ return None
222
+ low = interval.get("low")
223
+ high = interval.get("high")
224
+ if low is not None and value < low:
225
+ return "low"
226
+ if high is not None and value > high:
227
+ return "high"
228
+ return "normal"
229
+
230
+
231
+ def range_position(
232
+ value: float | None,
233
+ interval: dict[str, float | None] | None,
234
+ ) -> int:
235
+ if value is None or not interval:
236
+ return 50
237
+ low = interval.get("low")
238
+ high = interval.get("high")
239
+ if low is not None and high is not None and high > low:
240
+ return _clamp_percent((value - low) / (high - low) * 100)
241
+ if high is not None and high > 0:
242
+ return _clamp_percent(value / high * 100)
243
+ if low is not None and low > 0:
244
+ return _clamp_percent(value / low * 100)
245
+ return 50
246
+
247
+
248
+ def normalize_status(value: Any) -> str:
249
+ status = str(value or "unknown").strip().casefold()
250
+ if status in {"l", "lo"}:
251
+ return "low"
252
+ if status in {"h", "hi"}:
253
+ return "high"
254
+ if status in {"ok", "within range", "in range"}:
255
+ return "normal"
256
+ return status if status in KNOWN_STATUSES else "unknown"
257
+
258
+
259
+ def _knowledge_payload(node: dict[str, Any] | None) -> dict[str, Any] | None:
260
+ if node is None:
261
+ return None
262
+ return {
263
+ "description": node.get("description"),
264
+ "why_important": node.get("why_important"),
265
+ "instructions_to_improve": node.get("instructions_to_improve") or {},
266
+ "sex_significance": node.get("sex_significance") or {},
267
+ "related_tests": node.get("related_tests") or [],
268
+ "source_ids": node.get("source_ids") or [],
269
+ "category": node.get("category"),
270
+ "unit": node.get("unit"),
271
+ }
272
+
273
+
274
+ def _interval_from_statistics(selection: dict[str, Any] | None) -> dict[str, float | None] | None:
275
+ if not selection:
276
+ return None
277
+ values = selection.get("values") or {}
278
+ low = values.get("minimal_value")
279
+ high = values.get("maximum_value")
280
+ if low is None and high is None:
281
+ return None
282
+ return {"low": float(low) if low is not None else None, "high": float(high) if high is not None else None}
283
+
284
+
285
+ def _first_number_before(text: str, suffixes: tuple[str, ...]) -> float | None:
286
+ suffix_pattern = "|".join(re.escape(suffix) for suffix in suffixes)
287
+ match = re.search(rf"(\d+(?:\.\d+)?)\s*(?:{suffix_pattern})\b", text)
288
+ return float(match.group(1)) if match else None
289
+
290
+
291
+ def _status_counts(markers: list[dict[str, Any]]) -> dict[str, int]:
292
+ counts = {status: 0 for status in sorted(KNOWN_STATUSES)}
293
+ for marker in markers:
294
+ status = normalize_status(marker.get("status"))
295
+ counts[status] = counts.get(status, 0) + 1
296
+ return counts
297
+
298
+
299
+ def _text(value: Any, fallback: str) -> str:
300
+ text = _optional_text(value)
301
+ return text if text is not None else fallback
302
+
303
+
304
+ def _optional_text(value: Any) -> str | None:
305
+ if value is None:
306
+ return None
307
+ text = str(value).strip()
308
+ return text or None
309
+
310
+
311
+ def _confidence(value: Any) -> float:
312
+ try:
313
+ score = float(value)
314
+ except (TypeError, ValueError):
315
+ return 0.0
316
+ return max(0.0, min(1.0, score))
317
+
318
+
319
+ def _clamp_percent(value: float) -> int:
320
+ return max(0, min(100, round(value)))
tests/test_report_pipeline.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
5
+
6
+ from src.knowledge_graph import LabKnowledgeGraph # noqa: E402
7
+ from src.openbmb_client import ExtractionResult # noqa: E402
8
+ from src.report_pipeline import build_health_report, normalize_patient, parse_reference_interval # noqa: E402
9
+ from app import _final_range_position, _final_status_for_marker, _preferred_marker_label # noqa: E402
10
+
11
+
12
+ def _result(tests, patient=None):
13
+ return ExtractionResult(
14
+ patient=patient or {},
15
+ tests=tests,
16
+ notes=[],
17
+ raw_response="{}",
18
+ request_summary={},
19
+ )
20
+
21
+
22
+ def test_patient_age_and_sex_are_normalized():
23
+ patient = normalize_patient({"age": "25y 10m 26d", "sex": "Female"})
24
+ assert patient["age_group"] == "adult"
25
+ assert patient["sex"] == "female"
26
+ assert patient["age_years"] and patient["age_years"] > 25
27
+
28
+
29
+ def test_reference_interval_parser_handles_common_lab_formats():
30
+ assert parse_reference_interval("3.5-5.50") == {"low": 3.5, "high": 5.5}
31
+ assert parse_reference_interval("Up to 15") == {"low": None, "high": 15.0}
32
+ assert parse_reference_interval("< 20") == {"low": None, "high": 20.0}
33
+
34
+
35
+ def test_report_enriches_extracted_marker_with_knowledge_graph():
36
+ report = build_health_report(
37
+ _result(
38
+ [
39
+ {
40
+ "marker": "RBC",
41
+ "value": "3.3",
42
+ "unit": "10^6/uL",
43
+ "reference_range": "3.5-5.50",
44
+ "status": "low",
45
+ "source_text": "RBC 3.3 3.5-5.50",
46
+ "confidence": 0.91,
47
+ }
48
+ ],
49
+ patient={"age": "25y 10m 26d", "sex": "Female"},
50
+ )
51
+ )
52
+ marker = report["markers"][0]
53
+ assert marker["canonical_id"] == "rbc"
54
+ assert marker["knowledge"]["description"]
55
+ assert marker["comparison"]["basis"] == "lab_reference_range"
56
+ assert marker["status"] == "low"
57
+ assert marker["reference_selection"]["sex"] == "female"
58
+ assert marker["reference_selection"]["values"]["maximum_value"] == 5.2
59
+
60
+
61
+ def test_report_uses_sex_specific_kg_range_when_lab_range_is_missing():
62
+ report = build_health_report(
63
+ _result(
64
+ [
65
+ {
66
+ "marker": "Hemoglobin",
67
+ "value": "12.7",
68
+ "unit": "g/dL",
69
+ "reference_range": None,
70
+ "status": "unknown",
71
+ "confidence": 1,
72
+ }
73
+ ],
74
+ patient={"age_years": 42, "sex": "male"},
75
+ )
76
+ )
77
+ marker = report["markers"][0]
78
+ assert marker["comparison"]["basis"] == "knowledge_graph"
79
+ assert marker["derived_status"] == "low"
80
+ assert marker["status"] == "low"
81
+ assert marker["reference_selection"]["sex"] == "male"
82
+
83
+
84
+ def test_knowledge_graph_has_sex_guidance_for_every_marker():
85
+ graph = LabKnowledgeGraph.load()
86
+ assert graph.tests
87
+ assert all("sex_significance" in test for test in graph.tests)
88
+ high = [test for test in graph.tests if test["sex_significance"]["level"] == "high"]
89
+ assert {test["id"] for test in high} == {"hemoglobin", "rbc", "hct", "esr"}
90
+ assert all("sex_specific_statistics_per_group_age" in test for test in high)
91
+
92
+
93
+ def test_final_report_bar_uses_kg_min_normal_and_max_values():
94
+ report = build_health_report(
95
+ _result(
96
+ [
97
+ {"marker": "RBC", "value": "3.3", "unit": "10^6/uL", "status": "normal", "confidence": 1},
98
+ {"marker": "WBC", "value": "6.7", "unit": "10^3/uL", "status": "normal", "confidence": 1},
99
+ ],
100
+ patient={"age_years": 25, "sex": "female"},
101
+ )
102
+ )
103
+ rbc, wbc = report["markers"]
104
+ assert _final_status_for_marker(rbc) == "bad"
105
+ assert 6 <= _final_range_position(rbc) <= 30
106
+ assert _final_status_for_marker(wbc) == "ideal"
107
+ assert 68 <= _final_range_position(wbc) <= 94
108
+
109
+
110
+ def test_final_report_prefers_lab_abbreviations_when_extracted():
111
+ assert _preferred_marker_label(
112
+ {"raw_name": "RBC", "display_name": "Red Blood Cell Count"}
113
+ ) == "RBC"
114
+ assert _preferred_marker_label(
115
+ {"raw_name": "Hct", "display_name": "Hematocrit"}
116
+ ) == "Hct"
117
+ assert _preferred_marker_label(
118
+ {"raw_name": "NEU%", "display_name": "Neutrophils Percent"}
119
+ ) == "NEU%"
120
+ assert _preferred_marker_label(
121
+ {"raw_name": "Hemoglobin", "display_name": "Hemoglobin"}
122
+ ) == "Hemoglobin"