r0mant1c Codex Codex commited on
Commit
354b37e
·
1 Parent(s): 14e8913

Add agent trace panel, vision extraction, and local Transformers backend.

Browse files

Replace chat preview with a scrollable pipeline trace, restore PDF/image
vision intake, default to local Transformers extraction, expand the marker
knowledge base, and track hackathon logo assets for deployment. Codex
collaborated on the trace UI, vision pipeline, and deployment workflow.

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

.gitattributes CHANGED
@@ -34,3 +34,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.gguf filter=lfs diff=lfs merge=lfs -text
 
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.gguf filter=lfs diff=lfs merge=lfs -text
37
+ assets/logos/*.png filter=lfs diff=lfs merge=lfs -text
38
+ assets/logos/*.webp filter=lfs diff=lfs merge=lfs -text
app.py CHANGED
@@ -15,6 +15,13 @@ import gradio as gr
15
  from src.extraction import build_extractor
16
  from src.interpretation_render import patterns_html
17
  from src.local_env import load_local_env
 
 
 
 
 
 
 
18
  from src.report_pipeline import build_health_report
19
 
20
 
@@ -26,21 +33,21 @@ def _boot_log(message: str) -> None:
26
  elapsed = time.perf_counter() - _BOOT_T0
27
  print(f"[Blood Test Explainer][{elapsed:0.2f}s] {message}", flush=True)
28
 
29
- # The hosted API key field is only relevant when the API backend is active. The current Space
30
- # path is ZeroGPU, so users should not see model/API configuration controls.
31
- _API_MODE = os.getenv("EXTRACTOR_BACKEND", "auto").strip().lower() == "api"
32
  _boot_log("environment loaded")
33
 
34
 
35
  def extract_lab_values(
36
  uploaded_file: str | None,
37
- ) -> tuple[str, str, Any, str]:
38
  if not uploaded_file:
39
  return (
40
  _status_html("Waiting for a document", "Upload a lab report to begin extraction."),
41
  empty_report_html("No document uploaded", "Choose a file first, then run extraction again."),
42
  gr.update(visible=True),
43
  workflow_phase_html("ready"),
 
44
  )
45
 
46
  extractor = build_extractor()
@@ -54,11 +61,13 @@ def extract_lab_values(
54
  empty_report_html("Extraction failed", detail),
55
  gr.update(visible=True),
56
  workflow_phase_html("ready"),
 
57
  )
58
 
59
  health_report = build_health_report(result)
60
  summary = health_report["summary"]
61
  patient = health_report["patient"]
 
62
 
63
  status_text = (
64
  f"Extracted {summary['total_markers']} lab values and enriched "
@@ -76,11 +85,10 @@ def extract_lab_values(
76
 
77
  return (
78
  _status_html("Extraction complete", status_text),
79
- # Per-marker insight comes from the knowledge-graph report; append the cross-marker
80
- # patterns (anemia picture, liver cluster, lipid risk) which the per-marker report omits.
81
  report_html(health_report) + patterns_html(result.tests),
82
  gr.update(visible=True),
83
  workflow_phase_html("done"),
 
84
  )
85
 
86
 
@@ -101,11 +109,10 @@ def _format_extraction_error(error: Exception) -> str:
101
  "The llama.cpp backend could not load the GGUF model. That points to a model/runtime "
102
  "compatibility issue, not a background worker problem."
103
  )
 
 
104
  if "401" in lowered or "unauthorized" in lowered:
105
- return (
106
- "The OpenBMB endpoint rejected the request. Check the API key or switch to the local "
107
- "ZeroGPU path."
108
- )
109
  if "could not be converted into a report" in lowered:
110
  return "The model produced output, but it could not be parsed into the extraction schema."
111
  return primary
@@ -135,26 +142,152 @@ def _display_status_label(status: str) -> str:
135
  return normalized.title() if normalized else "Unknown"
136
 
137
 
138
- def hero_attribution_html() -> str:
139
- items = [
140
- ("Codex", "Build with Codex", "CDX", "aria-label=\"Codex logo\""),
141
- ("OpenBMB", "Enabled with OpenBMB", "OB", "aria-label=\"OpenBMB logo\""),
142
- ("Modal", "Finetuned with Modal", "M", "aria-label=\"Modal logo\""),
143
- ("ACG", "Created by researchers at ACG", "ACG", "aria-label=\"ACG logo\""),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  ]
145
- badges = "\n".join(
146
  f"""
147
- <li class=\"bte-hero-badge bte-hero-badge--{escape(slug.lower())}\">
148
- <span class=\"bte-hero-badge-mark\" {attrs}>{escape(mark)}</span>
149
- <span class=\"bte-hero-badge-text\">{escape(label)}</span>
 
 
 
150
  </li>
151
  """
152
- for slug, label, mark, attrs in items
153
  )
154
  return f"""
155
- <ul class=\"bte-hero-attribution\" aria-label=\"Project attributions\">
156
- {badges}
157
- </ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  """
159
 
160
 
@@ -195,22 +328,24 @@ def workflow_arrow_html(kind: str) -> str:
195
  """
196
 
197
 
198
- def show_processing() -> tuple[str, Any, str, str]:
199
  return (
200
  _status_html("Reading document", "Extracting patient context and markers, then matching them to the knowledge graph.", tone="loading"),
201
  gr.update(visible=False),
202
  "",
203
  workflow_phase_html("processing"),
 
204
  )
205
 
206
 
207
- def upload_state(uploaded_file: str | None) -> tuple[Any, Any]:
208
  if not uploaded_file:
209
  return (
210
  gr.update(visible=True),
211
- gr.update(value='<p class="bte-upload-hint">Supported formats: PDF</p>', visible=True),
212
  gr.update(visible=False, value=selected_document_html()),
213
  workflow_phase_html("ready"),
 
214
  )
215
 
216
  preview_data_url = _uploaded_file_preview_data_url(uploaded_file)
@@ -219,6 +354,7 @@ def upload_state(uploaded_file: str | None) -> tuple[Any, Any]:
219
  gr.update(value="", visible=False),
220
  gr.update(visible=True, value=selected_document_html(preview_data_url=preview_data_url)),
221
  workflow_phase_html("processing"),
 
222
  )
223
 
224
 
@@ -266,7 +402,7 @@ def _uploaded_file_preview_data_url(uploaded_file: str) -> str | None:
266
  if document.page_count == 0:
267
  return None
268
  page = document.load_page(0)
269
- pixmap = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
270
  encoded = base64.b64encode(pixmap.tobytes("png")).decode("ascii")
271
  return f"data:image/png;base64,{encoded}"
272
 
@@ -334,38 +470,6 @@ def analysis_animation_html() -> str:
334
  """
335
 
336
 
337
- def result_preview_html() -> str:
338
- return """
339
- <section class="bte-formation bte-formation--result" aria-label="Clear lab results preview">
340
- <div class="bte-formation-stage bte-formation-stage--result">
341
- <div class="bte-smart-report">
342
- <div class="bte-report-window">
343
- <div class="bte-report-header">
344
- <strong>12 markers</strong>
345
- <small>ready to review</small>
346
- </div>
347
- <div class="bte-mini-card bte-mini-card--green">
348
- <span>Hemoglobin</span>
349
- <strong>Normal</strong>
350
- </div>
351
- <div class="bte-mini-card bte-mini-card--red">
352
- <span>Vitamin D</span>
353
- <strong>Low</strong>
354
- </div>
355
- <div class="bte-mini-chart">
356
- <span style="height: 34%"></span>
357
- <span style="height: 56%"></span>
358
- <span style="height: 42%"></span>
359
- <span style="height: 74%"></span>
360
- <span style="height: 61%"></span>
361
- </div>
362
- </div>
363
- </div>
364
- </div>
365
- </section>
366
- """
367
-
368
-
369
  def _ideal_marker_card(test: dict[str, str]) -> str:
370
  status = test["status"]
371
  range_position_value = test.get("range_position", "50")
@@ -899,6 +1003,7 @@ CUSTOM_CSS = """
899
  --bte-radius: 22px;
900
  --bte-shadow: 0 14px 34px rgba(17, 24, 39, 0.055);
901
  --bte-shadow-strong: 0 18px 44px rgba(17, 24, 39, 0.07);
 
902
  --bte-rail: min(94vw, 1240px);
903
  }
904
 
@@ -1038,15 +1143,15 @@ gradio-app,
1038
  width: var(--bte-rail) !important;
1039
  max-width: var(--bte-rail) !important;
1040
  margin: 0 auto 18px !important;
1041
- padding: 30px 28px 28px;
1042
  display: grid;
1043
- grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
1044
- gap: 32px;
1045
- align-items: center;
1046
  border: 1px solid rgba(255, 255, 255, 0.42);
1047
  border-radius: var(--bte-radius);
1048
  background:
1049
- linear-gradient(120deg, rgba(18, 128, 92, 0.98) 0%, rgba(37, 99, 235, 0.95) 58%, rgba(191, 52, 52, 0.82) 100%),
1050
  #12805c;
1051
  box-shadow: var(--bte-shadow-strong);
1052
  }
@@ -1064,17 +1169,268 @@ gradio-app,
1064
  color: rgba(255, 255, 255, 0.88);
1065
  -webkit-text-fill-color: rgba(255, 255, 255, 0.88) !important;
1066
  font-size: 16px;
1067
- max-width: 820px;
1068
  margin: 0;
 
 
 
 
 
 
 
 
 
 
 
 
1069
  }
1070
 
1071
  .bte-title-copy {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
  min-width: 0;
1073
  }
1074
 
1075
  .bte-title-attribution-wrap {
1076
  min-width: 0;
1077
- justify-self: end;
1078
  }
1079
 
1080
  .bte-hero-attribution {
@@ -1086,53 +1442,93 @@ gradio-app,
1086
  }
1087
 
1088
  .bte-hero-badge {
1089
- display: grid;
1090
- grid-template-columns: 34px minmax(0, 1fr);
1091
- gap: 12px;
1092
- align-items: center;
 
 
1093
  padding: 10px 12px;
1094
  border-radius: 14px;
1095
  background: rgba(255, 255, 255, 0.12);
1096
  border: 1px solid rgba(255, 255, 255, 0.18);
1097
  backdrop-filter: blur(6px);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1098
  }
1099
 
1100
  .bte-hero-badge-mark {
1101
- width: 34px;
1102
  height: 34px;
1103
- border-radius: 11px;
1104
  display: grid;
1105
  place-items: center;
1106
- color: #fff;
1107
  font-size: 11px;
1108
  font-weight: 800;
1109
  letter-spacing: 0;
1110
- background: linear-gradient(135deg, rgba(255, 255, 255, 0.26), rgba(255, 255, 255, 0.08));
1111
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
 
 
 
 
 
 
 
 
 
1112
  }
1113
 
1114
  .bte-hero-badge-text {
 
 
1115
  color: #ffffff !important;
1116
  -webkit-text-fill-color: #ffffff !important;
1117
  font-size: 13px;
1118
- line-height: 1.2;
1119
  font-weight: 700;
1120
  }
1121
 
1122
- .bte-hero-badge--codex .bte-hero-badge-mark {
1123
- background: linear-gradient(135deg, rgba(37, 99, 235, 0.95), rgba(18, 128, 92, 0.92));
1124
- }
1125
-
1126
- .bte-hero-badge--openbmb .bte-hero-badge-mark {
1127
- background: linear-gradient(135deg, rgba(18, 128, 92, 0.95), rgba(37, 99, 235, 0.92));
1128
  }
1129
 
1130
- .bte-hero-badge--modal .bte-hero-badge-mark {
1131
- background: linear-gradient(135deg, rgba(191, 52, 52, 0.95), rgba(37, 99, 235, 0.9));
 
1132
  }
1133
 
1134
- .bte-hero-badge--acg .bte-hero-badge-mark {
1135
- background: linear-gradient(135deg, rgba(90, 99, 214, 0.95), rgba(18, 128, 92, 0.9));
 
1136
  }
1137
 
1138
  .bte-title .bte-kicker,
@@ -1153,6 +1549,11 @@ gradio-app,
1153
  .bte-title h1 {
1154
  font-size: clamp(38px, 5vw, 56px) !important;
1155
  line-height: 1.04 !important;
 
 
 
 
 
1156
  }
1157
 
1158
  .bte-title > div,
@@ -1203,17 +1604,8 @@ gradio-app,
1203
  padding: 0 !important;
1204
  }
1205
 
1206
- .bte-hero-grid .bte-upload-card {
1207
- border: 1px solid var(--bte-line) !important;
1208
- border-radius: var(--bte-radius) !important;
1209
- padding: 18px !important;
1210
- background: var(--bte-page) !important;
1211
- box-shadow: var(--bte-shadow) !important;
1212
- overflow: hidden !important;
1213
- }
1214
-
1215
- .bte-hero-grid .block:has(.bte-upload-card),
1216
- .bte-hero-grid div:has(> .bte-upload-card) {
1217
  height: 430px !important;
1218
  min-height: 430px !important;
1219
  border: 1px solid var(--bte-line) !important;
@@ -1222,15 +1614,33 @@ gradio-app,
1222
  background: var(--bte-page) !important;
1223
  box-shadow: var(--bte-shadow) !important;
1224
  overflow: hidden !important;
 
 
1225
  }
1226
 
1227
- .bte-hero-grid .block:has(.bte-upload-card) .bte-upload-card,
1228
- .bte-hero-grid div:has(> .bte-upload-card) > .bte-upload-card {
 
 
1229
  height: 100% !important;
1230
  min-height: 0 !important;
 
 
 
1231
  border: 0 !important;
1232
  padding: 0 !important;
1233
  box-shadow: none !important;
 
 
 
 
 
 
 
 
 
 
 
1234
  }
1235
 
1236
  .bte-workflow-panel {
@@ -1378,117 +1788,538 @@ gradio-app,
1378
  transition: opacity 220ms ease, filter 220ms ease, box-shadow 220ms ease, transform 220ms ease, border-color 220ms ease, background 220ms ease;
1379
  }
1380
 
1381
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--upload,
1382
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--analysis,
1383
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--report {
1384
- opacity: 1;
1385
- filter: saturate(1);
1386
- transform: translateY(-1px);
1387
- border-color: rgba(37, 99, 235, 0.32);
1388
- background: linear-gradient(180deg, rgba(37, 99, 235, 0.08), rgba(255, 255, 255, 0.98));
1389
- box-shadow: 0 16px 34px rgba(37, 99, 235, 0.1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1390
  }
1391
 
1392
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--analysis,
1393
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--report,
1394
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--upload,
1395
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--report,
1396
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--upload,
1397
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--analysis {
1398
- opacity: 0.38;
1399
- filter: saturate(0.45);
1400
- transform: none;
1401
- background: var(--bte-surface);
1402
- box-shadow: var(--bte-shadow);
1403
- border-color: rgba(216, 226, 238, 0.92);
1404
  }
1405
 
1406
- .bte-step-heading span {
1407
- width: 34px;
1408
- min-width: 34px;
1409
- aspect-ratio: 1;
1410
- display: grid;
1411
- place-items: center;
1412
- border-radius: 50%;
1413
- color: #ffffff !important;
1414
- -webkit-text-fill-color: #ffffff !important;
1415
- background: linear-gradient(135deg, var(--bte-green), var(--bte-blue));
1416
- font-size: 15px;
1417
- font-weight: 780;
1418
  }
1419
 
1420
- .bte-step-heading span,
1421
- .bte-step-heading span * {
1422
- color: #ffffff !important;
1423
- -webkit-text-fill-color: #ffffff !important;
 
 
1424
  }
1425
 
1426
- .bte-step-heading h2 {
1427
- margin: 0 !important;
1428
- color: var(--bte-ink) !important;
1429
- font-size: clamp(18px, 2.1vw, 24px) !important;
1430
- line-height: 1.18 !important;
1431
- letter-spacing: 0 !important;
1432
- text-align: left !important;
1433
  }
1434
 
1435
- .bte-panel-upload .bte-upload-card,
1436
- .bte-panel-analysis .bte-formation,
1437
- .bte-panel-result .bte-formation,
1438
- .bte-final-row .bte-report {
1439
- transition: opacity 220ms ease, filter 220ms ease, box-shadow 220ms ease, transform 220ms ease, border-color 220ms ease, background 220ms ease;
1440
  }
1441
 
1442
- .bte-step-heading--report {
1443
- margin-top: 0;
1444
- min-height: 112px;
1445
- padding: 18px;
1446
  }
1447
 
1448
- .bte-upload-card {
1449
- height: 430px !important;
1450
- display: flex;
1451
- flex-direction: column;
1452
- justify-content: space-between;
1453
- min-height: 430px;
1454
- overflow: visible !important;
1455
  }
1456
 
1457
- .bte-formation {
1458
- width: 100% !important;
1459
- max-width: 100% !important;
1460
- height: 430px !important;
1461
- min-height: 430px;
1462
- border: 1px solid var(--bte-line);
1463
- border-radius: var(--bte-radius);
1464
- padding: 22px;
1465
- background: var(--bte-surface);
1466
- box-shadow: var(--bte-shadow);
1467
- overflow: hidden;
1468
  }
1469
 
1470
- .bte-formation-stage {
1471
- height: 100%;
1472
- min-height: 382px;
1473
- display: grid;
1474
- grid-template-columns: minmax(0, 1fr);
1475
- justify-items: center;
1476
- align-items: center;
1477
- gap: 14px;
1478
  }
1479
 
1480
- .bte-formation-stage--analysis .bte-source-doc,
1481
- .bte-formation-stage--result .bte-smart-report,
1482
- .bte-formation-stage--result .bte-report-window {
1483
- width: 100%;
 
 
1484
  }
1485
 
1486
- .bte-panel-analysis .bte-formation--analysis,
1487
- .bte-panel-result .bte-formation--result {
1488
- overflow: visible;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1489
  }
1490
 
1491
- .bte-panel-result .bte-smart-report,
1492
  .bte-panel-result .bte-mini-card,
1493
  .bte-panel-result .bte-mini-chart span {
1494
  animation-play-state: paused !important;
@@ -1503,39 +2334,58 @@ gradio-app,
1503
  }
1504
 
1505
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
1506
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
1507
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
1508
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
1509
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
1510
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis {
1511
  opacity: 0.42;
1512
  filter: saturate(0.5);
1513
  }
1514
 
1515
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
 
1516
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
1517
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result {
 
1518
  opacity: 1;
1519
- filter: saturate(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1520
  }
1521
 
1522
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
1523
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
1524
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
1525
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
1526
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
1527
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis {
1528
  animation-play-state: paused !important;
1529
  }
1530
 
1531
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
1532
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result {
1533
- animation-play-state: running !important;
1534
- }
1535
-
1536
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-smart-report,
1537
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-mini-card,
1538
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-mini-chart span {
1539
  animation-play-state: running !important;
1540
  }
1541
 
@@ -1547,7 +2397,6 @@ gradio-app,
1547
  animation-play-state: running !important;
1548
  }
1549
 
1550
- .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result .bte-smart-report,
1551
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card {
1552
  animation-play-state: paused !important;
1553
  }
@@ -1823,12 +2672,121 @@ gradio-app,
1823
  box-shadow: none !important;
1824
  }
1825
 
1826
- .bte-upload-hint {
1827
- margin: 0 0 10px !important;
1828
- color: var(--bte-muted) !important;
1829
- font-size: 13px !important;
1830
- font-weight: 650 !important;
1831
- text-align: center !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1832
  }
1833
 
1834
  .bte-shell .file-preview,
@@ -1841,51 +2799,90 @@ gradio-app,
1841
  .bte-shell [class*="drop"],
1842
  .bte-shell [class*="upload"] {
1843
  background: var(--bte-page) !important;
1844
- border-color: #d8e2ee !important;
1845
  border-radius: 18px !important;
1846
  color: var(--bte-ink) !important;
1847
- }
1848
-
1849
- .bte-uploader [class*="drop"],
1850
- .bte-uploader [class*="upload"] {
1851
- min-height: 250px !important;
 
 
 
 
 
 
 
 
 
 
 
 
1852
  }
1853
 
1854
  .bte-selected-document {
1855
  display: grid;
1856
  grid-template-columns: minmax(0, 1fr);
1857
- gap: 14px;
1858
  align-items: stretch;
1859
- min-height: 220px;
1860
- border: 1px solid #d8e2ee;
1861
- border-radius: 18px;
1862
- padding: 22px;
1863
- background: var(--bte-page);
 
 
 
 
 
 
 
 
 
1864
  }
1865
 
1866
  .bte-selected-preview {
1867
  position: relative;
1868
- min-height: 260px;
1869
- border-radius: 22px;
1870
- border: 1px solid rgba(216, 226, 238, 0.9);
 
1871
  background: var(--bte-page);
1872
  overflow: hidden;
 
 
 
 
 
 
 
 
1873
  }
1874
 
1875
  .bte-upload-preview-image,
1876
  .bte-upload-preview-placeholder {
1877
  position: absolute;
1878
- inset: 18px;
1879
- border-radius: 20px;
1880
  }
1881
 
1882
  .bte-upload-preview-image {
1883
- width: calc(100% - 36px);
1884
- height: calc(100% - 36px);
1885
- object-fit: cover;
1886
- filter: blur(1.8px) saturate(0.78) contrast(0.92);
1887
- transform: scale(1.03);
1888
- box-shadow: 0 16px 35px rgba(18, 32, 56, 0.08);
 
 
 
 
 
 
 
 
 
1889
  }
1890
 
1891
  .bte-upload-preview-placeholder {
@@ -1958,9 +2955,8 @@ gradio-app,
1958
  position: absolute;
1959
  inset: 0;
1960
  background:
1961
- linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.04)),
1962
- radial-gradient(circle at 50% 46%, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 40%);
1963
- backdrop-filter: blur(1.8px);
1964
  }
1965
 
1966
  .bte-selected-document p:last-child {
@@ -2007,16 +3003,16 @@ gradio-app,
2007
  -webkit-text-fill-color: var(--bte-ink) !important;
2008
  }
2009
 
2010
- .bte-shell [class*="drop"] {
2011
- border-style: dashed !important;
2012
- border-width: 2px !important;
2013
- }
2014
-
2015
  .bte-shell svg,
2016
  .bte-shell .icon-wrap {
2017
  color: var(--bte-blue) !important;
2018
  }
2019
 
 
 
 
 
 
2020
  button.bte-action,
2021
  button.bte-action *,
2022
  .bte-action button,
@@ -2608,12 +3604,19 @@ button.bte-action *,
2608
  }
2609
 
2610
  .bte-final-report {
 
2611
  width: var(--bte-rail) !important;
2612
  max-width: var(--bte-rail) !important;
2613
  margin: 0 auto !important;
2614
  background: rgb(248, 249, 252) !important;
2615
  align-content: start;
2616
- gap: 12px;
 
 
 
 
 
 
2617
  }
2618
 
2619
  .bte-final-report .bte-ideal-marker {
@@ -2630,12 +3633,13 @@ button.bte-action *,
2630
  align-items: center;
2631
  justify-content: space-between;
2632
  gap: 22px;
2633
- padding: 24px 28px 14px;
 
2634
  border: 1px solid rgba(255, 255, 255, 0.42);
2635
  border-radius: var(--bte-radius);
2636
  color: #ffffff;
2637
  background:
2638
- linear-gradient(120deg, rgba(18, 128, 92, 0.98) 0%, rgba(37, 99, 235, 0.95) 58%, rgba(191, 52, 52, 0.82) 100%),
2639
  #12805c;
2640
  box-shadow: 0 6px 16px rgba(17, 24, 39, 0.045);
2641
  }
@@ -2661,7 +3665,7 @@ button.bte-action *,
2661
  .bte-ideal-stats {
2662
  display: grid;
2663
  grid-template-columns: repeat(4, minmax(0, 1fr));
2664
- gap: 12px;
2665
  margin: 0;
2666
  }
2667
 
@@ -2748,14 +3752,14 @@ button.bte-action *,
2748
  display: grid;
2749
  grid-template-columns: repeat(2, minmax(0, 1fr));
2750
  align-items: start;
2751
- gap: 12px;
2752
- margin-top: 14px;
2753
  }
2754
 
2755
  .bte-ideal-column {
2756
  display: grid;
2757
  align-content: start;
2758
- gap: 12px;
2759
  }
2760
 
2761
  .bte-ideal-doc:has(#bte-filter-ideal:checked) .bte-ideal-marker:not(.bte-ideal-marker--ideal),
@@ -3042,6 +4046,36 @@ button.bte-action *,
3042
  gap: 18px;
3043
  }
3044
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3045
  .bte-title-attribution-wrap {
3046
  justify-self: start;
3047
  width: 100%;
@@ -3157,6 +4191,11 @@ button.bte-action *,
3157
  height: 80px;
3158
  }
3159
 
 
 
 
 
 
3160
  .bte-uploader [class*="drop"],
3161
  .bte-uploader [class*="upload"] {
3162
  min-height: 210px !important;
@@ -3250,7 +4289,7 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
3250
  "border:0 !important;box-shadow:none !important;padding:0 !important;}</style>"
3251
  )
3252
  with gr.Row(equal_height=True, elem_classes=["bte-title"]):
3253
- with gr.Column(scale=1, min_width=420, elem_classes=["bte-title-copy"]):
3254
  gr.HTML(
3255
  """
3256
  <div>
@@ -3260,7 +4299,9 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
3260
  </div>
3261
  """
3262
  )
3263
- with gr.Column(scale=0, min_width=300, elem_classes=["bte-title-attribution-wrap"]):
 
 
3264
  gr.HTML(hero_attribution_html())
3265
 
3266
  workflow_phase = gr.HTML(
@@ -3281,7 +4322,7 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
3281
  </div>
3282
  <div class="bte-step-heading bte-step-heading--report">
3283
  <span>3</span>
3284
- <h2>Get your blood test results in the clearest possible format</h2>
3285
  </div>
3286
  </div>
3287
  """,
@@ -3292,10 +4333,10 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
3292
  with gr.Column(scale=4, min_width=320, elem_classes=["bte-workflow-panel", "bte-panel-upload"]):
3293
  with gr.Group(elem_classes=["bte-shell", "bte-upload-card"]):
3294
  upload_hint = gr.HTML(
3295
- '<p class="bte-upload-hint">Supported formats: PDF</p>',
3296
  elem_classes=["bte-upload-hint-wrap"],
3297
  )
3298
- with gr.Group() as upload_dropzone:
3299
  uploaded = gr.File(
3300
  label="Upload medical test document",
3301
  file_count="single",
@@ -3303,13 +4344,21 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
3303
  type="filepath",
3304
  elem_classes=["bte-uploader"],
3305
  )
3306
- selected_document = gr.HTML(selected_document_html(), visible=False)
 
 
 
 
3307
 
3308
  with gr.Column(scale=4, min_width=300, elem_classes=["bte-workflow-panel", "bte-panel-analysis"]):
3309
  gr.HTML(analysis_animation_html())
3310
 
3311
- with gr.Column(scale=4, min_width=300, elem_classes=["bte-workflow-panel", "bte-panel-result"]):
3312
- gr.HTML(result_preview_html())
 
 
 
 
3313
 
3314
  status = gr.HTML(
3315
  _status_html("Ready", "Upload a lab report to create the first interactive extraction draft."),
@@ -3324,17 +4373,17 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
3324
  uploaded.change(
3325
  upload_state,
3326
  inputs=[uploaded],
3327
- outputs=[upload_dropzone, upload_hint, selected_document, workflow_phase],
3328
  show_progress="hidden",
3329
  ).then(
3330
  show_processing,
3331
- outputs=[status, report_panel, report, workflow_phase],
3332
  scroll_to_output=True,
3333
  show_progress="hidden",
3334
  ).then(
3335
  extract_lab_values,
3336
  inputs=[uploaded],
3337
- outputs=[status, report, report_panel, workflow_phase],
3338
  scroll_to_output=True,
3339
  show_progress="hidden",
3340
  )
 
15
  from src.extraction import build_extractor
16
  from src.interpretation_render import patterns_html
17
  from src.local_env import load_local_env
18
+ from src.pipeline_trace import (
19
+ build_pipeline_trace,
20
+ empty_trace_html,
21
+ error_trace_html,
22
+ processing_trace_html,
23
+ trace_to_html,
24
+ )
25
  from src.report_pipeline import build_health_report
26
 
27
 
 
33
  elapsed = time.perf_counter() - _BOOT_T0
34
  print(f"[Blood Test Explainer][{elapsed:0.2f}s] {message}", flush=True)
35
 
36
+ _APP_ROOT = Path(__file__).resolve().parent
37
+ _LOGO_DIR = _APP_ROOT / "assets" / "logos"
 
38
  _boot_log("environment loaded")
39
 
40
 
41
  def extract_lab_values(
42
  uploaded_file: str | None,
43
+ ) -> tuple[str, str, Any, str, str]:
44
  if not uploaded_file:
45
  return (
46
  _status_html("Waiting for a document", "Upload a lab report to begin extraction."),
47
  empty_report_html("No document uploaded", "Choose a file first, then run extraction again."),
48
  gr.update(visible=True),
49
  workflow_phase_html("ready"),
50
+ empty_trace_html(),
51
  )
52
 
53
  extractor = build_extractor()
 
61
  empty_report_html("Extraction failed", detail),
62
  gr.update(visible=True),
63
  workflow_phase_html("ready"),
64
+ error_trace_html(detail),
65
  )
66
 
67
  health_report = build_health_report(result)
68
  summary = health_report["summary"]
69
  patient = health_report["patient"]
70
+ steps = build_pipeline_trace(result, health_report, source_path=uploaded_file)
71
 
72
  status_text = (
73
  f"Extracted {summary['total_markers']} lab values and enriched "
 
85
 
86
  return (
87
  _status_html("Extraction complete", status_text),
 
 
88
  report_html(health_report) + patterns_html(result.tests),
89
  gr.update(visible=True),
90
  workflow_phase_html("done"),
91
+ trace_to_html(steps),
92
  )
93
 
94
 
 
109
  "The llama.cpp backend could not load the GGUF model. That points to a model/runtime "
110
  "compatibility issue, not a background worker problem."
111
  )
112
+ if "hosted openbmb api backend is disabled" in lowered:
113
+ return "Hosted API extraction is disabled. The app uses local Transformers only."
114
  if "401" in lowered or "unauthorized" in lowered:
115
+ return "Authentication failed for the configured backend."
 
 
 
116
  if "could not be converted into a report" in lowered:
117
  return "The model produced output, but it could not be parsed into the extraction schema."
118
  return primary
 
142
  return normalized.title() if normalized else "Unknown"
143
 
144
 
145
+ def _logo_data_uri(filename: str) -> str | None:
146
+ path = _LOGO_DIR / filename
147
+ if not path.exists():
148
+ return None
149
+ mime_type = {
150
+ ".svg": "image/svg+xml",
151
+ ".png": "image/png",
152
+ ".jpg": "image/jpeg",
153
+ ".jpeg": "image/jpeg",
154
+ ".webp": "image/webp",
155
+ }.get(path.suffix.lower(), "application/octet-stream")
156
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
157
+ return f"data:{mime_type};base64,{encoded}"
158
+
159
+
160
+ def _hero_badge_mark_html(slug: str, mark: str, logo_file: str) -> str:
161
+ logo_uri = _logo_data_uri(logo_file)
162
+ if not logo_uri:
163
+ return escape(mark)
164
+ return (
165
+ f'<img class="bte-hero-badge-logo" src="{logo_uri}" '
166
+ f'alt="{escape(slug)} logo" loading="lazy" />'
167
+ )
168
+
169
+
170
+ def hero_hackathon_panel_html() -> str:
171
+ hf_logo_uri = _logo_data_uri("HF.webp")
172
+ hf_logo_inner = (
173
+ f'<img class="bte-title-hf-logo" src="{hf_logo_uri}" alt="Hugging Face logo" loading="lazy" />'
174
+ if hf_logo_uri
175
+ else '<span class="bte-title-hf-logo-fallback" aria-hidden="true">HF</span>'
176
+ )
177
+ hf_logo_html = f'<span class="bte-title-hf-logo-wrap">{hf_logo_inner}</span>'
178
+ badges = [
179
+ (
180
+ "🔌",
181
+ "Off the Grid",
182
+ "Extraction runs on-device through llama.cpp or ZeroGPU with no external inference API.",
183
+ ),
184
+ (
185
+ "🎯",
186
+ "Well-Tuned",
187
+ "MiniCPM-V was fine-tuned on Modal and published on Hugging Face for lab report extraction.",
188
+ ),
189
+ (
190
+ "🎨",
191
+ "Off-Brand",
192
+ "Custom CSS, HTML reports, and workflow panels push past the default Gradio look.",
193
+ ),
194
+ (
195
+ "🦙",
196
+ "Llama Champion",
197
+ "GGUF models run through the llama.cpp runtime on CPU and ZeroGPU paths.",
198
+ ),
199
+ (
200
+ "📡",
201
+ "Sharing is Caring",
202
+ "Agent traces, eval artifacts, and model cards are shared on the Hugging Face Hub.",
203
+ ),
204
+ (
205
+ "📓",
206
+ "Field Notes",
207
+ "Build notes, runbooks, and deployment logs document what we built and learned.",
208
+ ),
209
  ]
210
+ badge_items = "\n".join(
211
  f"""
212
+ <li class="bte-hack-badge" tabindex="0">
213
+ <div class="bte-hack-badge-row">
214
+ <span class="bte-hack-badge-icon" aria-hidden="true">{emoji}</span>
215
+ <span class="bte-hack-badge-name">{escape(name)}</span>
216
+ </div>
217
+ <p class="bte-expand-detail">{escape(detail)}</p>
218
  </li>
219
  """
220
+ for emoji, name, detail in badges
221
  )
222
  return f"""
223
+ <div class="bte-title-hackathon-panel">
224
+ <section class="bte-title-hf" aria-label="Hackathon project">
225
+ {hf_logo_html}
226
+ <p class="bte-title-hf-copy">Project for Build Small Hackathon</p>
227
+ </section>
228
+ <div class="bte-title-section-divider" aria-hidden="true"></div>
229
+ <section class="bte-hack-badges">
230
+ <p class="bte-title-side-label">Badges Collected</p>
231
+ <ul class="bte-hack-badges-grid" aria-label="Hackathon badges collected">
232
+ {badge_items}
233
+ </ul>
234
+ </section>
235
+ </div>
236
+ """
237
+
238
+
239
+ def hero_attribution_html() -> str:
240
+ items = [
241
+ (
242
+ "Codex",
243
+ "Build with Codex",
244
+ "CDX",
245
+ "codex.png",
246
+ "Codex helped build the app UI, extraction pipeline, deployment scripts, and iteration workflow.",
247
+ ),
248
+ (
249
+ "OpenBMB",
250
+ "Enabled with OpenBMB",
251
+ "OB",
252
+ "openbmb.png",
253
+ "MiniCPM-V-4.6 reads uploaded lab reports and extracts marker values, units, and status flags.",
254
+ ),
255
+ (
256
+ "Modal",
257
+ "Finetuned with Modal",
258
+ "M",
259
+ "modal.png",
260
+ "Modal runs LoRA fine-tuning and evaluation jobs that produced the published extraction model.",
261
+ ),
262
+ (
263
+ "ACG",
264
+ "Created by researchers at ACG",
265
+ "ACG",
266
+ "acg.png",
267
+ "Developed at The American College of Greece for the Hugging Face Build Small Hackathon.",
268
+ ),
269
+ ]
270
+ badge_chunks = []
271
+ for slug, label, mark, logo_file, detail in items:
272
+ mark_html = _hero_badge_mark_html(slug, mark, logo_file)
273
+ badge_chunks.append(
274
+ f"""
275
+ <li class="bte-hero-badge bte-hero-badge--{escape(slug.lower())}" tabindex="0">
276
+ <div class="bte-hero-badge-row">
277
+ <span class="bte-hero-badge-mark">{mark_html}</span>
278
+ <span class="bte-hero-badge-text">{escape(label)}</span>
279
+ </div>
280
+ <p class="bte-expand-detail">{escape(detail)}</p>
281
+ </li>
282
+ """
283
+ )
284
+ badges = "\n".join(badge_chunks)
285
+ return f"""
286
+ <div class="bte-hero-credits">
287
+ <ul class="bte-hero-attribution" aria-label="Project attributions">
288
+ {badges}
289
+ </ul>
290
+ </div>
291
  """
292
 
293
 
 
328
  """
329
 
330
 
331
+ def show_processing() -> tuple[str, Any, str, str, str]:
332
  return (
333
  _status_html("Reading document", "Extracting patient context and markers, then matching them to the knowledge graph.", tone="loading"),
334
  gr.update(visible=False),
335
  "",
336
  workflow_phase_html("processing"),
337
+ processing_trace_html(),
338
  )
339
 
340
 
341
+ def upload_state(uploaded_file: str | None) -> tuple[Any, Any, Any, str, str]:
342
  if not uploaded_file:
343
  return (
344
  gr.update(visible=True),
345
+ gr.update(value='<p class="bte-upload-hint">Supported formats: PDF, PNG, JPEG, WebP</p>', visible=True),
346
  gr.update(visible=False, value=selected_document_html()),
347
  workflow_phase_html("ready"),
348
+ empty_trace_html(),
349
  )
350
 
351
  preview_data_url = _uploaded_file_preview_data_url(uploaded_file)
 
354
  gr.update(value="", visible=False),
355
  gr.update(visible=True, value=selected_document_html(preview_data_url=preview_data_url)),
356
  workflow_phase_html("processing"),
357
+ processing_trace_html(),
358
  )
359
 
360
 
 
402
  if document.page_count == 0:
403
  return None
404
  page = document.load_page(0)
405
+ pixmap = page.get_pixmap(matrix=fitz.Matrix(2.8, 2.8), alpha=False)
406
  encoded = base64.b64encode(pixmap.tobytes("png")).decode("ascii")
407
  return f"data:image/png;base64,{encoded}"
408
 
 
470
  """
471
 
472
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
473
  def _ideal_marker_card(test: dict[str, str]) -> str:
474
  status = test["status"]
475
  range_position_value = test.get("range_position", "50")
 
1003
  --bte-radius: 22px;
1004
  --bte-shadow: 0 14px 34px rgba(17, 24, 39, 0.055);
1005
  --bte-shadow-strong: 0 18px 44px rgba(17, 24, 39, 0.07);
1006
+ --bte-active-ring: linear-gradient(120deg, var(--bte-green), var(--bte-blue), var(--bte-red));
1007
  --bte-rail: min(94vw, 1240px);
1008
  }
1009
 
 
1143
  width: var(--bte-rail) !important;
1144
  max-width: var(--bte-rail) !important;
1145
  margin: 0 auto 18px !important;
1146
+ padding: 28px 28px 26px;
1147
  display: grid;
1148
+ grid-template-columns: minmax(0, 1.05fr) minmax(250px, 0.95fr) minmax(250px, 0.9fr);
1149
+ gap: 24px 28px;
1150
+ align-items: stretch;
1151
  border: 1px solid rgba(255, 255, 255, 0.42);
1152
  border-radius: var(--bte-radius);
1153
  background:
1154
+ linear-gradient(120deg, rgba(191, 52, 52, 0.82) 0%, rgba(37, 99, 235, 0.95) 58%, rgba(18, 128, 92, 0.98) 100%),
1155
  #12805c;
1156
  box-shadow: var(--bte-shadow-strong);
1157
  }
 
1169
  color: rgba(255, 255, 255, 0.88);
1170
  -webkit-text-fill-color: rgba(255, 255, 255, 0.88) !important;
1171
  font-size: 16px;
1172
+ max-width: none;
1173
  margin: 0;
1174
+ text-align: left;
1175
+ }
1176
+
1177
+ .bte-title-copy,
1178
+ .bte-title-hackathon-wrap,
1179
+ .bte-title-credits-wrap,
1180
+ .bte-title .bte-title-copy,
1181
+ .bte-title .bte-title-hackathon-wrap,
1182
+ .bte-title .bte-title-credits-wrap {
1183
+ position: relative;
1184
+ align-self: stretch;
1185
+ min-width: 0;
1186
  }
1187
 
1188
  .bte-title-copy {
1189
+ text-align: left;
1190
+ justify-self: stretch;
1191
+ padding-right: 24px;
1192
+ }
1193
+
1194
+ .bte-title-hackathon-wrap {
1195
+ padding-right: 24px;
1196
+ }
1197
+
1198
+ .bte-title-credits-wrap {
1199
+ padding-left: 4px;
1200
+ }
1201
+
1202
+ .bte-title-copy::after,
1203
+ .bte-title-hackathon-wrap::after,
1204
+ .bte-title .bte-title-copy::after,
1205
+ .bte-title .bte-title-hackathon-wrap::after {
1206
+ content: "";
1207
+ position: absolute;
1208
+ top: 0;
1209
+ right: 0;
1210
+ bottom: 0;
1211
+ width: 1px;
1212
+ background: rgba(255, 255, 255, 0.42);
1213
+ pointer-events: none;
1214
+ }
1215
+
1216
+ .bte-title-hackathon-panel {
1217
+ display: grid;
1218
+ gap: 0;
1219
+ align-content: start;
1220
+ }
1221
+
1222
+ .bte-title-hf {
1223
+ display: flex;
1224
+ flex-direction: row;
1225
+ align-items: center;
1226
+ justify-content: flex-start;
1227
+ gap: 14px;
1228
+ padding-bottom: 18px;
1229
+ text-align: left;
1230
+ }
1231
+
1232
+ .bte-title-hf-logo-wrap {
1233
+ flex: 0 0 52px;
1234
+ width: 52px;
1235
+ height: 52px;
1236
+ display: grid;
1237
+ place-items: center;
1238
+ overflow: hidden;
1239
+ border-radius: 23%;
1240
+ background: rgba(255, 255, 255, 0.96);
1241
+ box-shadow: 0 4px 14px rgba(17, 24, 39, 0.14);
1242
+ }
1243
+
1244
+ .bte-title-hf-logo {
1245
+ display: block;
1246
+ width: 100%;
1247
+ height: 100%;
1248
+ object-fit: cover;
1249
+ }
1250
+
1251
+ .bte-title-hf-logo-fallback {
1252
+ display: grid;
1253
+ place-items: center;
1254
+ width: 100%;
1255
+ height: 100%;
1256
+ color: #111827;
1257
+ font-size: 18px;
1258
+ font-weight: 800;
1259
+ }
1260
+
1261
+ .bte-title-hf-copy {
1262
+ margin: 0;
1263
+ flex: 1;
1264
+ min-width: 0;
1265
+ font-size: 10px;
1266
+ font-weight: 800;
1267
+ letter-spacing: 0.07em;
1268
+ line-height: 1.35;
1269
+ text-transform: uppercase;
1270
+ color: rgba(255, 255, 255, 0.88) !important;
1271
+ -webkit-text-fill-color: rgba(255, 255, 255, 0.88) !important;
1272
+ }
1273
+
1274
+ .bte-title-section-divider {
1275
+ height: 1px;
1276
+ background: rgba(255, 255, 255, 0.34);
1277
+ margin-bottom: 18px;
1278
+ }
1279
+
1280
+ .bte-title-side-label {
1281
+ margin: 0 0 10px;
1282
+ font-size: 11px;
1283
+ font-weight: 800;
1284
+ letter-spacing: 0.08em;
1285
+ text-transform: uppercase;
1286
+ color: rgba(255, 255, 255, 0.72) !important;
1287
+ -webkit-text-fill-color: rgba(255, 255, 255, 0.72) !important;
1288
+ }
1289
+
1290
+ .bte-hack-badges,
1291
+ .bte-hack-badges-grid,
1292
+ .bte-hero-credits,
1293
+ .bte-hero-attribution {
1294
+ overflow: visible;
1295
+ }
1296
+
1297
+ .bte-hack-badges-grid {
1298
+ list-style: none;
1299
+ margin: 0;
1300
+ padding: 0;
1301
+ display: grid;
1302
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1303
+ grid-auto-rows: minmax(36px, auto);
1304
+ align-content: start;
1305
+ gap: 8px 10px;
1306
+ }
1307
+
1308
+ .bte-hack-badge {
1309
+ display: flex;
1310
+ flex-direction: column;
1311
+ align-items: stretch;
1312
+ gap: 0;
1313
+ width: 100%;
1314
+ max-width: 100%;
1315
+ min-height: 36px;
1316
+ max-height: 36px;
1317
+ min-width: 0;
1318
+ box-sizing: border-box;
1319
+ padding: 8px;
1320
+ border-radius: 12px;
1321
+ background: rgba(255, 255, 255, 0.1);
1322
+ border: 1px solid rgba(255, 255, 255, 0.16);
1323
+ overflow: hidden;
1324
+ cursor: pointer;
1325
+ position: relative;
1326
+ transition:
1327
+ max-height 260ms ease,
1328
+ background 180ms ease,
1329
+ border-color 180ms ease,
1330
+ box-shadow 180ms ease;
1331
+ }
1332
+
1333
+ .bte-hack-badge:hover,
1334
+ .bte-hack-badge:focus-within {
1335
+ max-height: 500px;
1336
+ overflow: visible;
1337
+ z-index: 3;
1338
+ background: rgba(255, 255, 255, 0.18);
1339
+ border-color: rgba(255, 255, 255, 0.32);
1340
+ box-shadow: 0 10px 24px rgba(17, 24, 39, 0.16);
1341
+ outline: none;
1342
+ }
1343
+
1344
+ .bte-hack-badge-row {
1345
+ display: flex;
1346
+ align-items: center;
1347
+ gap: 7px;
1348
+ min-width: 0;
1349
+ min-height: 18px;
1350
+ }
1351
+
1352
+ .bte-hack-badge-icon {
1353
+ flex: 0 0 18px;
1354
+ width: 18px;
1355
+ height: 18px;
1356
+ display: grid;
1357
+ place-items: center;
1358
+ font-size: 14px;
1359
+ line-height: 1;
1360
+ }
1361
+
1362
+ .bte-hack-badge-name {
1363
+ flex: 1 1 auto;
1364
+ min-width: 0;
1365
+ overflow: hidden;
1366
+ text-overflow: ellipsis;
1367
+ white-space: nowrap;
1368
+ color: #ffffff !important;
1369
+ -webkit-text-fill-color: #ffffff !important;
1370
+ font-size: 10px;
1371
+ line-height: 1.2;
1372
+ font-weight: 700;
1373
+ }
1374
+
1375
+ .bte-expand-detail {
1376
+ margin: 0;
1377
+ max-height: 0;
1378
+ opacity: 0;
1379
+ overflow: hidden;
1380
+ color: rgba(255, 255, 255, 0.76) !important;
1381
+ -webkit-text-fill-color: rgba(255, 255, 255, 0.76) !important;
1382
+ font-size: 10px !important;
1383
+ line-height: 1.4;
1384
+ font-weight: 400 !important;
1385
+ white-space: normal;
1386
+ transition:
1387
+ max-height 260ms ease,
1388
+ opacity 180ms ease,
1389
+ margin-top 180ms ease;
1390
+ }
1391
+
1392
+ .bte-title .bte-expand-detail,
1393
+ .bte-title .bte-expand-detail * {
1394
+ font-size: 10px !important;
1395
+ font-weight: 400 !important;
1396
+ line-height: 1.4 !important;
1397
+ }
1398
+
1399
+ .bte-hack-badge:hover .bte-hack-badge-name,
1400
+ .bte-hack-badge:focus-within .bte-hack-badge-name,
1401
+ .bte-hero-badge:hover .bte-hero-badge-text,
1402
+ .bte-hero-badge:focus-within .bte-hero-badge-text {
1403
+ white-space: normal;
1404
+ overflow: visible;
1405
+ text-overflow: clip;
1406
+ }
1407
+
1408
+ .bte-hack-badge:hover .bte-expand-detail,
1409
+ .bte-hack-badge:focus-within .bte-expand-detail {
1410
+ padding-left: 25px;
1411
+ }
1412
+
1413
+ .bte-hero-badge:hover .bte-expand-detail,
1414
+ .bte-hero-badge:focus-within .bte-expand-detail {
1415
+ padding-left: 58px;
1416
+ }
1417
+
1418
+ .bte-hack-badge:hover .bte-expand-detail,
1419
+ .bte-hack-badge:focus-within .bte-expand-detail,
1420
+ .bte-hero-badge:hover .bte-expand-detail,
1421
+ .bte-hero-badge:focus-within .bte-expand-detail {
1422
+ max-height: 400px;
1423
+ opacity: 1;
1424
+ margin-top: 6px;
1425
+ overflow: visible;
1426
+ }
1427
+
1428
+ .bte-hero-credits {
1429
  min-width: 0;
1430
  }
1431
 
1432
  .bte-title-attribution-wrap {
1433
  min-width: 0;
 
1434
  }
1435
 
1436
  .bte-hero-attribution {
 
1442
  }
1443
 
1444
  .bte-hero-badge {
1445
+ display: flex;
1446
+ flex-direction: column;
1447
+ align-items: stretch;
1448
+ gap: 0;
1449
+ min-height: 54px;
1450
+ max-height: 54px;
1451
  padding: 10px 12px;
1452
  border-radius: 14px;
1453
  background: rgba(255, 255, 255, 0.12);
1454
  border: 1px solid rgba(255, 255, 255, 0.18);
1455
  backdrop-filter: blur(6px);
1456
+ overflow: hidden;
1457
+ cursor: pointer;
1458
+ position: relative;
1459
+ transition:
1460
+ max-height 260ms ease,
1461
+ background 180ms ease,
1462
+ border-color 180ms ease,
1463
+ box-shadow 180ms ease;
1464
+ }
1465
+
1466
+ .bte-hero-badge:hover,
1467
+ .bte-hero-badge:focus-within {
1468
+ max-height: 500px;
1469
+ overflow: visible;
1470
+ z-index: 3;
1471
+ background: rgba(255, 255, 255, 0.18);
1472
+ border-color: rgba(255, 255, 255, 0.32);
1473
+ box-shadow: 0 10px 24px rgba(17, 24, 39, 0.16);
1474
+ outline: none;
1475
+ }
1476
+
1477
+ .bte-hero-badge-row {
1478
+ display: flex;
1479
+ align-items: center;
1480
+ gap: 12px;
1481
+ min-width: 0;
1482
+ min-height: 34px;
1483
  }
1484
 
1485
  .bte-hero-badge-mark {
1486
+ width: 46px;
1487
  height: 34px;
1488
+ flex: 0 0 46px;
1489
  display: grid;
1490
  place-items: center;
1491
+ color: #ffffff;
1492
  font-size: 11px;
1493
  font-weight: 800;
1494
  letter-spacing: 0;
1495
+ background: transparent;
1496
+ box-shadow: none;
1497
+ overflow: visible;
1498
+ }
1499
+
1500
+ .bte-hero-badge-logo {
1501
+ display: block;
1502
+ width: 34px;
1503
+ max-width: 38px;
1504
+ max-height: 24px;
1505
+ object-fit: contain;
1506
  }
1507
 
1508
  .bte-hero-badge-text {
1509
+ flex: 1 1 auto;
1510
+ min-width: 0;
1511
  color: #ffffff !important;
1512
  -webkit-text-fill-color: #ffffff !important;
1513
  font-size: 13px;
1514
+ line-height: 1.3;
1515
  font-weight: 700;
1516
  }
1517
 
1518
+ .bte-hero-badge--openbmb .bte-hero-badge-logo {
1519
+ width: auto;
1520
+ max-width: 44px;
1521
+ max-height: 22px;
 
 
1522
  }
1523
 
1524
+ .bte-hero-badge--modal .bte-hero-badge-logo {
1525
+ width: 38px;
1526
+ max-width: 40px;
1527
  }
1528
 
1529
+ .bte-hero-badge--acg .bte-hero-badge-logo {
1530
+ width: 32px;
1531
+ max-height: 32px;
1532
  }
1533
 
1534
  .bte-title .bte-kicker,
 
1549
  .bte-title h1 {
1550
  font-size: clamp(38px, 5vw, 56px) !important;
1551
  line-height: 1.04 !important;
1552
+ text-align: left !important;
1553
+ }
1554
+
1555
+ .bte-title .bte-kicker {
1556
+ text-align: left !important;
1557
  }
1558
 
1559
  .bte-title > div,
 
1604
  padding: 0 !important;
1605
  }
1606
 
1607
+ .bte-hero-grid .bte-panel-upload .block:has(.bte-upload-card),
1608
+ .bte-hero-grid .bte-panel-upload div:has(> .bte-upload-card) {
 
 
 
 
 
 
 
 
 
1609
  height: 430px !important;
1610
  min-height: 430px !important;
1611
  border: 1px solid var(--bte-line) !important;
 
1614
  background: var(--bte-page) !important;
1615
  box-shadow: var(--bte-shadow) !important;
1616
  overflow: hidden !important;
1617
+ display: flex !important;
1618
+ flex-direction: column !important;
1619
  }
1620
 
1621
+ .bte-hero-grid .bte-panel-upload .block:has(.bte-upload-card) .bte-shell,
1622
+ .bte-hero-grid .bte-panel-upload div:has(> .bte-upload-card) .bte-shell,
1623
+ .bte-hero-grid .bte-panel-upload .block:has(.bte-upload-card) .bte-upload-card,
1624
+ .bte-hero-grid .bte-panel-upload div:has(> .bte-upload-card) > .bte-upload-card {
1625
  height: 100% !important;
1626
  min-height: 0 !important;
1627
+ flex: 1 1 auto !important;
1628
+ display: flex !important;
1629
+ flex-direction: column !important;
1630
  border: 0 !important;
1631
  padding: 0 !important;
1632
  box-shadow: none !important;
1633
+ background: transparent !important;
1634
+ overflow: hidden !important;
1635
+ }
1636
+
1637
+ .bte-hero-grid .bte-upload-card:not(.bte-panel-upload .block:has(.bte-upload-card) .bte-upload-card) {
1638
+ border: 1px solid var(--bte-line) !important;
1639
+ border-radius: var(--bte-radius) !important;
1640
+ padding: 18px !important;
1641
+ background: var(--bte-page) !important;
1642
+ box-shadow: var(--bte-shadow) !important;
1643
+ overflow: hidden !important;
1644
  }
1645
 
1646
  .bte-workflow-panel {
 
1788
  transition: opacity 220ms ease, filter 220ms ease, box-shadow 220ms ease, transform 220ms ease, border-color 220ms ease, background 220ms ease;
1789
  }
1790
 
1791
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--upload,
1792
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--analysis,
1793
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--report {
1794
+ opacity: 1;
1795
+ filter: saturate(1.08);
1796
+ transform: translateY(-1px);
1797
+ border: 2px solid transparent;
1798
+ background:
1799
+ linear-gradient(var(--bte-surface), var(--bte-surface)) padding-box,
1800
+ var(--bte-active-ring) border-box;
1801
+ box-shadow: var(--bte-shadow-strong);
1802
+ }
1803
+
1804
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--analysis,
1805
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--report,
1806
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--upload,
1807
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--report,
1808
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--upload,
1809
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--analysis {
1810
+ opacity: 0.38;
1811
+ filter: saturate(0.45);
1812
+ transform: none;
1813
+ background: var(--bte-surface);
1814
+ box-shadow: var(--bte-shadow);
1815
+ border-color: rgba(216, 226, 238, 0.92);
1816
+ }
1817
+
1818
+ .bte-step-heading span {
1819
+ width: 34px;
1820
+ min-width: 34px;
1821
+ aspect-ratio: 1;
1822
+ display: grid;
1823
+ place-items: center;
1824
+ border-radius: 50%;
1825
+ color: #ffffff !important;
1826
+ -webkit-text-fill-color: #ffffff !important;
1827
+ background: linear-gradient(135deg, var(--bte-green), var(--bte-blue));
1828
+ font-size: 15px;
1829
+ font-weight: 780;
1830
+ }
1831
+
1832
+ .bte-step-heading span,
1833
+ .bte-step-heading span * {
1834
+ color: #ffffff !important;
1835
+ -webkit-text-fill-color: #ffffff !important;
1836
+ }
1837
+
1838
+ .bte-step-heading h2 {
1839
+ margin: 0 !important;
1840
+ color: var(--bte-ink) !important;
1841
+ font-size: clamp(18px, 2.1vw, 24px) !important;
1842
+ line-height: 1.18 !important;
1843
+ letter-spacing: 0 !important;
1844
+ text-align: left !important;
1845
+ }
1846
+
1847
+ .bte-panel-upload .bte-upload-card,
1848
+ .bte-panel-analysis .bte-formation,
1849
+ .bte-panel-result .bte-agent-panel,
1850
+ .bte-final-row .bte-report {
1851
+ transition: opacity 220ms ease, filter 220ms ease, box-shadow 220ms ease, transform 220ms ease, border-color 220ms ease, background 220ms ease;
1852
+ }
1853
+
1854
+ .bte-step-heading--report {
1855
+ margin-top: 0;
1856
+ min-height: 112px;
1857
+ padding: 18px;
1858
+ }
1859
+
1860
+ .bte-upload-card {
1861
+ height: 100% !important;
1862
+ display: flex;
1863
+ flex-direction: column;
1864
+ justify-content: flex-start;
1865
+ min-height: 0 !important;
1866
+ overflow: hidden !important;
1867
+ position: relative !important;
1868
+ }
1869
+
1870
+ .bte-panel-upload .bte-upload-dropzone,
1871
+ .bte-panel-upload .bte-upload-card > .block:has(.bte-upload-dropzone),
1872
+ .bte-panel-upload .bte-upload-card > .form:has(.bte-upload-dropzone) {
1873
+ position: absolute !important;
1874
+ inset: 0 !important;
1875
+ flex: 1 1 auto !important;
1876
+ min-height: 0 !important;
1877
+ display: flex !important;
1878
+ flex-direction: column !important;
1879
+ overflow: hidden !important;
1880
+ border: 0 !important;
1881
+ padding: 0 !important;
1882
+ background: transparent !important;
1883
+ box-shadow: none !important;
1884
+ z-index: 1 !important;
1885
+ }
1886
+
1887
+ .bte-upload-card:has(.bte-selected-document) .bte-upload-dropzone,
1888
+ .bte-upload-card:has(.bte-selected-document) .bte-upload-hint-wrap,
1889
+ .bte-upload-card:has(.bte-selected-document) > .block:has(.bte-upload-dropzone),
1890
+ .bte-upload-card:has(.bte-selected-document) > .form:has(.bte-upload-dropzone) {
1891
+ display: none !important;
1892
+ }
1893
+
1894
+ .bte-upload-card:has(.bte-selected-document) .block:has(.bte-selected-document),
1895
+ .bte-upload-card:has(.bte-selected-document) .html-container:has(.bte-selected-document) {
1896
+ position: absolute !important;
1897
+ inset: 0 !important;
1898
+ z-index: 5 !important;
1899
+ width: 100% !important;
1900
+ height: 100% !important;
1901
+ margin: 0 !important;
1902
+ padding: 0 !important;
1903
+ border: 0 !important;
1904
+ background: transparent !important;
1905
+ box-shadow: none !important;
1906
+ overflow: hidden !important;
1907
+ }
1908
+
1909
+ .bte-upload-card:has(.bte-selected-document) .prose.bte-selected-document-wrap,
1910
+ .bte-upload-card:has(.bte-selected-document) .html-container:has(.bte-selected-document) > *,
1911
+ .bte-upload-card:has(.bte-selected-document) .bte-selected-document,
1912
+ .bte-upload-card:has(.bte-selected-document) .bte-selected-preview {
1913
+ height: 100% !important;
1914
+ min-height: 100% !important;
1915
+ }
1916
+
1917
+ .bte-upload-card:has(.bte-selected-document) .prose:has(.bte-selected-document) {
1918
+ padding: 0 !important;
1919
+ margin: 0 !important;
1920
+ max-width: none !important;
1921
+ }
1922
+
1923
+ .bte-panel-upload .bte-upload-dropzone > .block,
1924
+ .bte-panel-upload .bte-upload-dropzone > .form,
1925
+ .bte-panel-upload .bte-upload-dropzone > div {
1926
+ flex: 1 1 auto !important;
1927
+ min-height: 0 !important;
1928
+ display: flex !important;
1929
+ flex-direction: column !important;
1930
+ overflow: hidden !important;
1931
+ }
1932
+
1933
+ .bte-panel-upload .bte-upload-hint-wrap {
1934
+ flex: 0 0 auto;
1935
+ position: relative !important;
1936
+ z-index: 2 !important;
1937
+ pointer-events: none !important;
1938
+ }
1939
+
1940
+ .bte-upload-hint {
1941
+ margin: 0 0 12px !important;
1942
+ color: var(--bte-ink) !important;
1943
+ font-size: 18px !important;
1944
+ font-weight: 700 !important;
1945
+ text-align: center !important;
1946
+ }
1947
+
1948
+ .bte-panel-upload .bte-upload-card .block:has(.bte-uploader),
1949
+ .bte-panel-upload .bte-upload-card .form:has(.bte-uploader),
1950
+ .bte-panel-upload .bte-shell > .block:has(.bte-uploader),
1951
+ .bte-panel-upload .bte-shell > .form:has(.bte-uploader),
1952
+ .bte-panel-upload .bte-upload-card > .block:has(.bte-uploader) {
1953
+ flex: 1 1 auto !important;
1954
+ min-height: 0 !important;
1955
+ display: flex !important;
1956
+ flex-direction: column !important;
1957
+ overflow: hidden !important;
1958
+ }
1959
+
1960
+ .bte-panel-upload .bte-uploader,
1961
+ .bte-panel-upload .bte-uploader > div,
1962
+ .bte-panel-upload .bte-uploader > div > div,
1963
+ .bte-panel-upload .bte-uploader .wrap {
1964
+ flex: 1 1 auto !important;
1965
+ min-height: 0 !important;
1966
+ height: 100% !important;
1967
+ display: flex !important;
1968
+ flex-direction: column !important;
1969
+ overflow: hidden !important;
1970
+ }
1971
+
1972
+ .bte-formation {
1973
+ width: 100% !important;
1974
+ max-width: 100% !important;
1975
+ height: 430px !important;
1976
+ min-height: 430px;
1977
+ border: 1px solid var(--bte-line);
1978
+ border-radius: var(--bte-radius);
1979
+ padding: 22px;
1980
+ background: var(--bte-surface);
1981
+ box-shadow: var(--bte-shadow);
1982
+ overflow: hidden;
1983
+ }
1984
+
1985
+ .bte-formation-stage {
1986
+ height: 100%;
1987
+ min-height: 382px;
1988
+ display: grid;
1989
+ grid-template-columns: minmax(0, 1fr);
1990
+ justify-items: center;
1991
+ align-items: center;
1992
+ gap: 14px;
1993
+ }
1994
+
1995
+ .bte-formation-stage--analysis .bte-source-doc,
1996
+ .bte-formation-stage--result .bte-smart-report,
1997
+ .bte-formation-stage--result .bte-report-window {
1998
+ width: 100%;
1999
+ }
2000
+
2001
+ .bte-panel-analysis .bte-formation--analysis,
2002
+ .bte-panel-result .bte-agent-panel {
2003
+ overflow: hidden;
2004
+ }
2005
+
2006
+ .bte-hero-grid .bte-panel-trace .block:has(.bte-agent-panel),
2007
+ .bte-hero-grid .bte-panel-trace div:has(> .bte-agent-panel) {
2008
+ height: 430px !important;
2009
+ min-height: 430px !important;
2010
+ max-height: 430px !important;
2011
+ border: 1px solid var(--bte-line) !important;
2012
+ border-radius: var(--bte-radius) !important;
2013
+ padding: 16px !important;
2014
+ background: var(--bte-page) !important;
2015
+ box-shadow: var(--bte-shadow) !important;
2016
+ overflow: hidden !important;
2017
+ display: flex !important;
2018
+ flex-direction: column !important;
2019
+ box-sizing: border-box !important;
2020
+ }
2021
+
2022
+ .bte-hero-grid .bte-panel-trace .block:has(.bte-agent-panel) .bte-shell,
2023
+ .bte-hero-grid .bte-panel-trace div:has(> .bte-agent-panel) .bte-shell,
2024
+ .bte-hero-grid .bte-panel-trace .block:has(.bte-agent-panel) .bte-agent-panel,
2025
+ .bte-hero-grid .bte-panel-trace div:has(> .bte-agent-panel) > .bte-agent-panel {
2026
+ height: 100% !important;
2027
+ min-height: 0 !important;
2028
+ flex: 1 1 auto !important;
2029
+ display: flex !important;
2030
+ flex-direction: column !important;
2031
+ border: 0 !important;
2032
+ padding: 0 !important;
2033
+ box-shadow: none !important;
2034
+ background: transparent !important;
2035
+ overflow: hidden !important;
2036
+ gap: 0 !important;
2037
+ }
2038
+
2039
+ .bte-agent-panel,
2040
+ .bte-agent-panel > div,
2041
+ .bte-agent-panel > .block,
2042
+ .bte-agent-panel > .form {
2043
+ display: flex !important;
2044
+ flex-direction: column !important;
2045
+ flex: 1 1 auto !important;
2046
+ min-height: 0 !important;
2047
+ width: 100% !important;
2048
+ }
2049
+
2050
+ .bte-agent-panel .block:has(.bte-agent-trace),
2051
+ .bte-agent-panel .block:has(.bte-trace-panel),
2052
+ .bte-agent-panel .html-container:has(.bte-trace-panel),
2053
+ .bte-panel-trace .bte-agent-panel .block,
2054
+ .bte-panel-trace .bte-agent-panel .form,
2055
+ .bte-panel-trace .bte-agent-panel .wrap,
2056
+ .bte-panel-trace .bte-agent-panel .html-container,
2057
+ .bte-panel-trace .bte-agent-panel .prose {
2058
+ flex: 1 1 auto !important;
2059
+ min-height: 0 !important;
2060
+ height: 100% !important;
2061
+ max-height: 100% !important;
2062
+ overflow: hidden !important;
2063
+ margin: 0 !important;
2064
+ padding: 0 !important;
2065
+ border: 0 !important;
2066
+ background: transparent !important;
2067
+ box-shadow: none !important;
2068
+ box-sizing: border-box !important;
2069
+ display: flex !important;
2070
+ flex-direction: column !important;
2071
+ }
2072
+
2073
+ .bte-panel-trace .bte-agent-panel .html-container:has(.bte-trace-panel),
2074
+ .bte-panel-trace .bte-agent-panel .prose:has(.bte-trace-panel) {
2075
+ width: 100% !important;
2076
+ }
2077
+
2078
+ .bte-trace-panel {
2079
+ flex: 1 1 auto !important;
2080
+ height: 100% !important;
2081
+ max-height: 100% !important;
2082
+ min-height: 0 !important;
2083
+ display: flex;
2084
+ flex-direction: column;
2085
+ overflow: hidden;
2086
+ padding: 4px 10px 0;
2087
+ box-sizing: border-box;
2088
+ }
2089
+
2090
+ .bte-trace-panel-header {
2091
+ flex: 0 0 auto;
2092
+ padding: 0 4px 10px;
2093
+ border-bottom: 1px solid var(--bte-line);
2094
+ margin-bottom: 10px;
2095
+ }
2096
+
2097
+ .bte-trace-panel-header strong {
2098
+ display: block;
2099
+ color: var(--bte-ink);
2100
+ font-size: 18px;
2101
+ line-height: 1.25;
2102
+ padding: 0 2px;
2103
+ overflow: visible;
2104
+ word-break: normal;
2105
+ }
2106
+
2107
+ .bte-trace-subtitle {
2108
+ margin: 6px 0 0;
2109
+ padding: 0 2px;
2110
+ color: var(--bte-muted);
2111
+ font-size: 13px;
2112
+ line-height: 1.45;
2113
+ }
2114
+
2115
+ .bte-trace-steps {
2116
+ flex: 1 1 auto;
2117
+ min-height: 0;
2118
+ max-height: calc(430px - 32px - 92px);
2119
+ overflow-x: hidden;
2120
+ overflow-y: auto !important;
2121
+ overscroll-behavior: contain;
2122
+ -webkit-overflow-scrolling: touch;
2123
+ padding: 0 2px 8px 0;
2124
+ scrollbar-gutter: stable;
2125
+ }
2126
+
2127
+ .bte-trace-steps::-webkit-scrollbar {
2128
+ width: 8px;
2129
+ }
2130
+
2131
+ .bte-trace-steps::-webkit-scrollbar-thumb {
2132
+ background: #cbd5e1;
2133
+ border-radius: 999px;
2134
+ }
2135
+
2136
+ .bte-trace-steps::-webkit-scrollbar-track {
2137
+ background: transparent;
2138
+ }
2139
+
2140
+ .bte-trace-empty {
2141
+ margin: 0;
2142
+ color: var(--bte-muted);
2143
+ font-size: 14px;
2144
+ line-height: 1.5;
2145
+ }
2146
+
2147
+ .bte-trace-empty--active {
2148
+ color: var(--bte-ink);
2149
+ }
2150
+
2151
+ .bte-trace-empty--error {
2152
+ color: #b42318;
2153
+ }
2154
+
2155
+ .bte-trace-status {
2156
+ display: inline-flex;
2157
+ align-items: center;
2158
+ padding: 2px 8px;
2159
+ border-radius: 999px;
2160
+ font-size: 11px;
2161
+ font-weight: 700;
2162
+ letter-spacing: 0.02em;
2163
+ text-transform: uppercase;
2164
+ }
2165
+
2166
+ .bte-trace-status--complete {
2167
+ color: #067647;
2168
+ background: #ecfdf3;
2169
+ border: 1px solid #abefc6;
2170
+ }
2171
+
2172
+ .bte-trace-status--running {
2173
+ color: #175cd3;
2174
+ background: #eff8ff;
2175
+ border: 1px solid #b2ddff;
2176
+ }
2177
+
2178
+ .bte-trace-status--failed {
2179
+ color: #b42318;
2180
+ background: #fef3f2;
2181
+ border: 1px solid #fecdca;
2182
+ }
2183
+
2184
+ .bte-trace-status--unknown {
2185
+ color: #344054;
2186
+ background: #f2f4f7;
2187
+ border: 1px solid #eaecf0;
2188
+ }
2189
+
2190
+ .bte-trace-step-summary {
2191
+ display: grid;
2192
+ grid-template-columns: minmax(0, 1fr);
2193
+ gap: 4px;
2194
+ padding: 10px 12px;
2195
+ cursor: pointer;
2196
+ list-style: none;
2197
+ }
2198
+
2199
+ .bte-trace-step-heading {
2200
+ display: flex;
2201
+ align-items: center;
2202
+ justify-content: space-between;
2203
+ gap: 8px;
2204
+ }
2205
+
2206
+ .bte-trace-step-meta {
2207
+ color: #475467;
2208
+ font-size: 11px;
2209
+ font-weight: 600;
2210
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
2211
+ }
2212
+
2213
+ .bte-trace-meta {
2214
+ display: grid;
2215
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2216
+ gap: 8px 12px;
2217
+ margin: 0 0 10px;
2218
+ padding: 10px;
2219
+ border: 1px solid #eef2f7;
2220
+ border-radius: 10px;
2221
+ background: #f8fafc;
2222
+ }
2223
+
2224
+ .bte-trace-meta dt {
2225
+ margin: 0;
2226
+ color: #667085;
2227
+ font-size: 11px;
2228
+ font-weight: 600;
2229
+ text-transform: uppercase;
2230
+ letter-spacing: 0.03em;
2231
  }
2232
 
2233
+ .bte-trace-meta dd {
2234
+ margin: 2px 0 0;
2235
+ color: #101828;
2236
+ font-size: 12px;
2237
+ line-height: 1.4;
2238
+ white-space: pre-wrap;
2239
+ word-break: break-word;
 
 
 
 
 
2240
  }
2241
 
2242
+ .bte-trace-step-summary::-webkit-details-marker {
2243
+ display: none;
 
 
 
 
 
 
 
 
 
 
2244
  }
2245
 
2246
+ .bte-trace-step {
2247
+ border: 1px solid #e5e7eb;
2248
+ border-radius: 12px;
2249
+ margin-bottom: 8px;
2250
+ background: #fff;
2251
+ overflow: hidden;
2252
  }
2253
 
2254
+ .bte-trace-step:last-child {
2255
+ margin-bottom: 0;
 
 
 
 
 
2256
  }
2257
 
2258
+ .bte-trace-step-title {
2259
+ color: var(--bte-ink);
2260
+ font-size: 14px;
2261
+ font-weight: 700;
 
2262
  }
2263
 
2264
+ .bte-trace-step-teaser {
2265
+ color: var(--bte-muted);
2266
+ font-size: 12px;
2267
+ line-height: 1.4;
2268
  }
2269
 
2270
+ .bte-trace-step[open] .bte-trace-step-summary {
2271
+ border-bottom: 1px solid #eef2f7;
2272
+ background: #f8fbff;
 
 
 
 
2273
  }
2274
 
2275
+ .bte-trace-step-body {
2276
+ padding: 10px 12px 12px;
 
 
 
 
 
 
 
 
 
2277
  }
2278
 
2279
+ .bte-trace-summary {
2280
+ margin: 0 0 8px;
2281
+ color: #334155;
2282
+ font-size: 13px;
2283
+ line-height: 1.5;
2284
+ white-space: pre-wrap;
 
 
2285
  }
2286
 
2287
+ .bte-trace-subdetails {
2288
+ margin-top: 8px;
2289
+ border: 1px solid #e5e7eb;
2290
+ border-radius: 10px;
2291
+ padding: 8px 10px;
2292
+ background: #f9fafb;
2293
  }
2294
 
2295
+ .bte-trace-subdetails summary {
2296
+ cursor: pointer;
2297
+ font-size: 12px;
2298
+ font-weight: 600;
2299
+ color: #334155;
2300
+ }
2301
+
2302
+ .bte-trace-subdetails pre {
2303
+ margin: 8px 0 0;
2304
+ padding: 8px;
2305
+ border-radius: 8px;
2306
+ background: #fff;
2307
+ border: 1px solid #e5e7eb;
2308
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
2309
+ font-size: 11px;
2310
+ line-height: 1.45;
2311
+ white-space: pre-wrap;
2312
+ word-break: break-word;
2313
+ max-height: 220px;
2314
+ overflow: auto;
2315
+ }
2316
+
2317
+ .bte-panel-trace {
2318
+ display: flex;
2319
+ flex-direction: column;
2320
+ min-height: 0;
2321
  }
2322
 
 
2323
  .bte-panel-result .bte-mini-card,
2324
  .bte-panel-result .bte-mini-chart span {
2325
  animation-play-state: paused !important;
 
2334
  }
2335
 
2336
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
2337
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-result .bte-agent-panel,
2338
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
2339
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-result .bte-agent-panel,
2340
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
2341
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis {
2342
  opacity: 0.42;
2343
  filter: saturate(0.5);
2344
  }
2345
 
2346
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .block:has(.bte-upload-card),
2347
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload div:has(> .bte-upload-card),
2348
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
2349
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-trace .block:has(.bte-agent-panel),
2350
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-trace div:has(> .bte-agent-panel) {
2351
  opacity: 1;
2352
+ filter: saturate(1.08);
2353
+ transform: translateY(-1px);
2354
+ border: 2px solid transparent !important;
2355
+ background:
2356
+ linear-gradient(var(--bte-page), var(--bte-page)) padding-box,
2357
+ var(--bte-active-ring) border-box !important;
2358
+ box-shadow: var(--bte-shadow-strong) !important;
2359
+ }
2360
+
2361
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .block:has(.bte-upload-card) .bte-shell,
2362
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
2363
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .block:has(.bte-upload-card) .bte-upload-card {
2364
+ border: 0 !important;
2365
+ background: transparent !important;
2366
+ box-shadow: none !important;
2367
+ }
2368
+
2369
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
2370
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-trace .block:has(.bte-agent-panel),
2371
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-trace div:has(> .bte-agent-panel) {
2372
+ background:
2373
+ linear-gradient(var(--bte-surface), var(--bte-surface)) padding-box,
2374
+ var(--bte-active-ring) border-box !important;
2375
  }
2376
 
2377
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
2378
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
2379
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-result .bte-agent-panel,
2380
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
2381
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-result .bte-agent-panel,
2382
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis {
2383
  animation-play-state: paused !important;
2384
  }
2385
 
2386
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
2387
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-trace .block:has(.bte-agent-panel),
2388
+ .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-trace div:has(> .bte-agent-panel) {
 
 
 
 
 
2389
  animation-play-state: running !important;
2390
  }
2391
 
 
2397
  animation-play-state: running !important;
2398
  }
2399
 
 
2400
  .bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card {
2401
  animation-play-state: paused !important;
2402
  }
 
2672
  box-shadow: none !important;
2673
  }
2674
 
2675
+ .bte-panel-upload .bte-uploader [class*="drop"],
2676
+ .bte-panel-upload .bte-uploader [class*="upload"] {
2677
+ flex: 1 1 auto !important;
2678
+ min-height: 0 !important;
2679
+ max-height: 100% !important;
2680
+ display: flex !important;
2681
+ flex-direction: column !important;
2682
+ align-items: center !important;
2683
+ justify-content: center !important;
2684
+ gap: 0 !important;
2685
+ padding: 0 !important;
2686
+ overflow: hidden !important;
2687
+ box-sizing: border-box !important;
2688
+ }
2689
+
2690
+ .bte-panel-upload .bte-uploader [data-testid="block-label"],
2691
+ .bte-panel-upload .bte-uploader [data-testid="status-tracker"],
2692
+ .bte-panel-upload .bte-uploader .icon-button-wrapper,
2693
+ .bte-panel-upload .bte-uploader .file-preview-holder {
2694
+ display: none !important;
2695
+ }
2696
+
2697
+ .bte-panel-upload .bte-uploader > button,
2698
+ .bte-panel-upload .bte-uploader button[class*="center"] {
2699
+ flex: 1 1 auto !important;
2700
+ width: 100% !important;
2701
+ height: 100% !important;
2702
+ min-height: 0 !important;
2703
+ margin: 0 !important;
2704
+ padding: 0 !important;
2705
+ border: 0 !important;
2706
+ background: transparent !important;
2707
+ box-shadow: none !important;
2708
+ display: flex !important;
2709
+ align-items: center !important;
2710
+ justify-content: center !important;
2711
+ cursor: pointer !important;
2712
+ }
2713
+
2714
+ .bte-panel-upload .bte-uploader button .wrap:not(:has(.uploading)) {
2715
+ font-size: 0 !important;
2716
+ line-height: 0 !important;
2717
+ color: transparent !important;
2718
+ display: inline-flex !important;
2719
+ align-items: center !important;
2720
+ justify-content: center !important;
2721
+ gap: 0 !important;
2722
+ }
2723
+
2724
+ .bte-panel-upload .bte-uploader button .or {
2725
+ display: none !important;
2726
+ }
2727
+
2728
+ .bte-panel-upload .bte-uploader .wrap:has(.uploading),
2729
+ .bte-panel-upload .bte-uploader .wrap:has(.progress-bar) {
2730
+ flex: 1 1 auto !important;
2731
+ width: 100% !important;
2732
+ height: 100% !important;
2733
+ min-height: 0 !important;
2734
+ display: flex !important;
2735
+ align-items: center !important;
2736
+ justify-content: center !important;
2737
+ position: relative !important;
2738
+ font-size: 0 !important;
2739
+ color: transparent !important;
2740
+ }
2741
+
2742
+ .bte-panel-upload .bte-uploader .wrap:has(.uploading) > *,
2743
+ .bte-panel-upload .bte-uploader .wrap:has(.progress-bar) > * {
2744
+ display: none !important;
2745
+ }
2746
+
2747
+ .bte-panel-upload .bte-uploader .wrap:has(.uploading)::before,
2748
+ .bte-panel-upload .bte-uploader .wrap:has(.progress-bar)::before {
2749
+ content: "";
2750
+ width: 72px;
2751
+ height: 72px;
2752
+ border-radius: 50%;
2753
+ flex: 0 0 auto;
2754
+ background:
2755
+ radial-gradient(circle at 50% 50%, var(--bte-page) 0 56%, transparent 57%),
2756
+ conic-gradient(from 0deg, var(--bte-green), var(--bte-blue), var(--bte-red), var(--bte-green));
2757
+ animation: bte-spin 1.05s linear infinite;
2758
+ box-shadow: 0 12px 30px rgba(17, 24, 39, 0.08);
2759
+ }
2760
+
2761
+ .bte-uploader [class*="drop"],
2762
+ .bte-uploader [class*="upload"] {
2763
+ min-height: 220px !important;
2764
+ }
2765
+
2766
+ .bte-panel-upload .bte-uploader svg,
2767
+ .bte-panel-upload .bte-shell .icon-wrap,
2768
+ .bte-panel-upload .bte-shell .icon-wrap svg {
2769
+ width: 72px !important;
2770
+ height: 72px !important;
2771
+ flex: 0 0 auto !important;
2772
+ }
2773
+
2774
+ .bte-panel-upload .bte-uploader button .icon-wrap {
2775
+ background: var(--bte-active-ring) !important;
2776
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' y1='3' x2='12' y2='15'/%3E%3C/svg%3E") !important;
2777
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' y1='3' x2='12' y2='15'/%3E%3C/svg%3E") !important;
2778
+ -webkit-mask-repeat: no-repeat !important;
2779
+ mask-repeat: no-repeat !important;
2780
+ -webkit-mask-position: center !important;
2781
+ mask-position: center !important;
2782
+ -webkit-mask-size: contain !important;
2783
+ mask-size: contain !important;
2784
+ }
2785
+
2786
+ .bte-panel-upload .bte-uploader button .icon-wrap svg {
2787
+ opacity: 0 !important;
2788
+ visibility: hidden !important;
2789
+ pointer-events: none !important;
2790
  }
2791
 
2792
  .bte-shell .file-preview,
 
2799
  .bte-shell [class*="drop"],
2800
  .bte-shell [class*="upload"] {
2801
  background: var(--bte-page) !important;
2802
+ border: 0 !important;
2803
  border-radius: 18px !important;
2804
  color: var(--bte-ink) !important;
2805
+ box-shadow: none !important;
2806
+ outline: none !important;
2807
+ }
2808
+
2809
+ .bte-panel-upload .bte-shell .block,
2810
+ .bte-panel-upload .bte-shell .form,
2811
+ .bte-panel-upload .bte-shell .html-container,
2812
+ .bte-panel-upload .bte-uploader,
2813
+ .bte-panel-upload .bte-uploader > div,
2814
+ .bte-panel-upload .bte-uploader > div > div,
2815
+ .bte-panel-upload .bte-uploader [class*="drop"],
2816
+ .bte-panel-upload .bte-uploader [class*="upload"],
2817
+ .bte-panel-upload .bte-shell [class*="drop"],
2818
+ .bte-panel-upload .bte-shell [class*="upload"] {
2819
+ border: 0 !important;
2820
+ outline: none !important;
2821
+ box-shadow: none !important;
2822
  }
2823
 
2824
  .bte-selected-document {
2825
  display: grid;
2826
  grid-template-columns: minmax(0, 1fr);
2827
+ gap: 0;
2828
  align-items: stretch;
2829
+ height: 100%;
2830
+ min-height: 100%;
2831
+ border: 0;
2832
+ border-radius: 0;
2833
+ padding: 0;
2834
+ background: transparent;
2835
+ }
2836
+
2837
+ .bte-upload-card:has(.bte-selected-document) .bte-selected-document {
2838
+ height: 100% !important;
2839
+ min-height: 100% !important;
2840
+ padding: 0 !important;
2841
+ border: 0 !important;
2842
+ background: transparent !important;
2843
  }
2844
 
2845
  .bte-selected-preview {
2846
  position: relative;
2847
+ height: 100%;
2848
+ min-height: 100%;
2849
+ border-radius: 14px;
2850
+ border: 0;
2851
  background: var(--bte-page);
2852
  overflow: hidden;
2853
+ display: block;
2854
+ }
2855
+
2856
+ .bte-upload-card:has(.bte-selected-document) .bte-selected-preview {
2857
+ height: 100% !important;
2858
+ min-height: 100% !important;
2859
+ border: 0 !important;
2860
+ border-radius: 12px !important;
2861
  }
2862
 
2863
  .bte-upload-preview-image,
2864
  .bte-upload-preview-placeholder {
2865
  position: absolute;
2866
+ inset: 0;
2867
+ border-radius: 12px;
2868
  }
2869
 
2870
  .bte-upload-preview-image {
2871
+ width: 100%;
2872
+ height: 100%;
2873
+ object-fit: contain;
2874
+ object-position: center center;
2875
+ filter: saturate(0.98) contrast(1.03);
2876
+ transform: none;
2877
+ box-shadow: none;
2878
+ }
2879
+
2880
+ .bte-upload-card:has(.bte-selected-document) .bte-upload-preview-image {
2881
+ inset: 0 !important;
2882
+ width: 100% !important;
2883
+ height: 100% !important;
2884
+ object-fit: contain !important;
2885
+ object-position: center center !important;
2886
  }
2887
 
2888
  .bte-upload-preview-placeholder {
 
2955
  position: absolute;
2956
  inset: 0;
2957
  background:
2958
+ linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0));
2959
+ pointer-events: none;
 
2960
  }
2961
 
2962
  .bte-selected-document p:last-child {
 
3003
  -webkit-text-fill-color: var(--bte-ink) !important;
3004
  }
3005
 
 
 
 
 
 
3006
  .bte-shell svg,
3007
  .bte-shell .icon-wrap {
3008
  color: var(--bte-blue) !important;
3009
  }
3010
 
3011
+ .bte-panel-upload .bte-uploader button .icon-wrap {
3012
+ color: transparent !important;
3013
+ -webkit-text-fill-color: transparent !important;
3014
+ }
3015
+
3016
  button.bte-action,
3017
  button.bte-action *,
3018
  .bte-action button,
 
3604
  }
3605
 
3606
  .bte-final-report {
3607
+ --bte-report-stack-gap: 12px;
3608
  width: var(--bte-rail) !important;
3609
  max-width: var(--bte-rail) !important;
3610
  margin: 0 auto !important;
3611
  background: rgb(248, 249, 252) !important;
3612
  align-content: start;
3613
+ gap: var(--bte-report-stack-gap);
3614
+ }
3615
+
3616
+ .bte-final-report > .bte-ideal-hero,
3617
+ .bte-final-report > .bte-ideal-stats,
3618
+ .bte-final-report > .bte-ideal-grid {
3619
+ margin: 0;
3620
  }
3621
 
3622
  .bte-final-report .bte-ideal-marker {
 
3633
  align-items: center;
3634
  justify-content: space-between;
3635
  gap: 22px;
3636
+ padding: 24px 28px;
3637
+ margin: 0;
3638
  border: 1px solid rgba(255, 255, 255, 0.42);
3639
  border-radius: var(--bte-radius);
3640
  color: #ffffff;
3641
  background:
3642
+ linear-gradient(120deg, rgba(191, 52, 52, 0.82) 0%, rgba(37, 99, 235, 0.95) 58%, rgba(18, 128, 92, 0.98) 100%),
3643
  #12805c;
3644
  box-shadow: 0 6px 16px rgba(17, 24, 39, 0.045);
3645
  }
 
3665
  .bte-ideal-stats {
3666
  display: grid;
3667
  grid-template-columns: repeat(4, minmax(0, 1fr));
3668
+ gap: var(--bte-report-stack-gap, 12px);
3669
  margin: 0;
3670
  }
3671
 
 
3752
  display: grid;
3753
  grid-template-columns: repeat(2, minmax(0, 1fr));
3754
  align-items: start;
3755
+ gap: var(--bte-report-stack-gap, 12px);
3756
+ margin: 0;
3757
  }
3758
 
3759
  .bte-ideal-column {
3760
  display: grid;
3761
  align-content: start;
3762
+ gap: var(--bte-report-stack-gap, 12px);
3763
  }
3764
 
3765
  .bte-ideal-doc:has(#bte-filter-ideal:checked) .bte-ideal-marker:not(.bte-ideal-marker--ideal),
 
4046
  gap: 18px;
4047
  }
4048
 
4049
+ .bte-title-copy {
4050
+ padding-right: 0;
4051
+ padding-bottom: 18px;
4052
+ }
4053
+
4054
+ .bte-title-copy::after,
4055
+ .bte-title-hackathon-wrap::after {
4056
+ display: none;
4057
+ }
4058
+
4059
+ .bte-title-hackathon-wrap {
4060
+ padding-right: 0;
4061
+ padding-bottom: 18px;
4062
+ }
4063
+
4064
+ .bte-title-copy,
4065
+ .bte-title-hackathon-wrap,
4066
+ .bte-title-credits-wrap {
4067
+ border-bottom: 1px solid rgba(255, 255, 255, 0.42);
4068
+ }
4069
+
4070
+ .bte-title-credits-wrap {
4071
+ padding-left: 0;
4072
+ border-bottom: 0;
4073
+ }
4074
+
4075
+ .bte-hack-badges-grid {
4076
+ grid-template-columns: 1fr;
4077
+ }
4078
+
4079
  .bte-title-attribution-wrap {
4080
  justify-self: start;
4081
  width: 100%;
 
4191
  height: 80px;
4192
  }
4193
 
4194
+ .bte-panel-upload .bte-uploader [class*="drop"],
4195
+ .bte-panel-upload .bte-uploader [class*="upload"] {
4196
+ min-height: 0 !important;
4197
+ }
4198
+
4199
  .bte-uploader [class*="drop"],
4200
  .bte-uploader [class*="upload"] {
4201
  min-height: 210px !important;
 
4289
  "border:0 !important;box-shadow:none !important;padding:0 !important;}</style>"
4290
  )
4291
  with gr.Row(equal_height=True, elem_classes=["bte-title"]):
4292
+ with gr.Column(scale=2, min_width=260, elem_classes=["bte-title-copy"]):
4293
  gr.HTML(
4294
  """
4295
  <div>
 
4299
  </div>
4300
  """
4301
  )
4302
+ with gr.Column(scale=2, min_width=250, elem_classes=["bte-title-hackathon-wrap"]):
4303
+ gr.HTML(hero_hackathon_panel_html())
4304
+ with gr.Column(scale=0, min_width=260, elem_classes=["bte-title-credits-wrap"]):
4305
  gr.HTML(hero_attribution_html())
4306
 
4307
  workflow_phase = gr.HTML(
 
4322
  </div>
4323
  <div class="bte-step-heading bte-step-heading--report">
4324
  <span>3</span>
4325
+ <h2>Review the agent pipeline steps for your blood tests</h2>
4326
  </div>
4327
  </div>
4328
  """,
 
4333
  with gr.Column(scale=4, min_width=320, elem_classes=["bte-workflow-panel", "bte-panel-upload"]):
4334
  with gr.Group(elem_classes=["bte-shell", "bte-upload-card"]):
4335
  upload_hint = gr.HTML(
4336
+ '<p class="bte-upload-hint">Supported formats: PDF, PNG, JPEG, WebP</p>',
4337
  elem_classes=["bte-upload-hint-wrap"],
4338
  )
4339
+ with gr.Group(elem_classes=["bte-upload-dropzone"]) as upload_dropzone:
4340
  uploaded = gr.File(
4341
  label="Upload medical test document",
4342
  file_count="single",
 
4344
  type="filepath",
4345
  elem_classes=["bte-uploader"],
4346
  )
4347
+ selected_document = gr.HTML(
4348
+ selected_document_html(),
4349
+ visible=False,
4350
+ elem_classes=["bte-selected-document-wrap"],
4351
+ )
4352
 
4353
  with gr.Column(scale=4, min_width=300, elem_classes=["bte-workflow-panel", "bte-panel-analysis"]):
4354
  gr.HTML(analysis_animation_html())
4355
 
4356
+ with gr.Column(scale=4, min_width=300, elem_classes=["bte-workflow-panel", "bte-panel-result", "bte-panel-trace"]):
4357
+ with gr.Group(elem_classes=["bte-shell", "bte-agent-panel"]):
4358
+ agent_trace = gr.HTML(
4359
+ empty_trace_html(),
4360
+ elem_classes=["bte-agent-trace"],
4361
+ )
4362
 
4363
  status = gr.HTML(
4364
  _status_html("Ready", "Upload a lab report to create the first interactive extraction draft."),
 
4373
  uploaded.change(
4374
  upload_state,
4375
  inputs=[uploaded],
4376
+ outputs=[upload_dropzone, upload_hint, selected_document, workflow_phase, agent_trace],
4377
  show_progress="hidden",
4378
  ).then(
4379
  show_processing,
4380
+ outputs=[status, report_panel, report, workflow_phase, agent_trace],
4381
  scroll_to_output=True,
4382
  show_progress="hidden",
4383
  ).then(
4384
  extract_lab_values,
4385
  inputs=[uploaded],
4386
+ outputs=[status, report, report_panel, workflow_phase, agent_trace],
4387
  scroll_to_output=True,
4388
  show_progress="hidden",
4389
  )
assets/logos/HF.webp ADDED

Git LFS Details

  • SHA256: 5d94e4e4068508426360f1fa334b7c1a411e867399811ce8215bfa3897bc85f9
  • Pointer size: 129 Bytes
  • Size of remote file: 6.82 kB
assets/logos/acg.png ADDED

Git LFS Details

  • SHA256: 3696b23f335e27bed147c7eb876d2c4b751f9bb0a4a14742282d115ef7ac70ae
  • Pointer size: 130 Bytes
  • Size of remote file: 45.7 kB
assets/logos/codex.png ADDED

Git LFS Details

  • SHA256: fcea9ddbaafdca236a8380cef2ecd3342ecd9914a7b080873873cf45f415686d
  • Pointer size: 129 Bytes
  • Size of remote file: 7.58 kB
assets/logos/modal.png ADDED

Git LFS Details

  • SHA256: 133494dbbca027c787e18c59825564e0f032c646e2873f7bae3c5a221cf12431
  • Pointer size: 131 Bytes
  • Size of remote file: 162 kB
assets/logos/openbmb.png ADDED

Git LFS Details

  • SHA256: 7a6bbf3422c38b524d51ceee8d3aaa7526638eb17293f36aedca4b0f5a167729
  • Pointer size: 129 Bytes
  • Size of remote file: 1.44 kB
kb/cbc_knowledge_graph.json CHANGED
@@ -39,7 +39,11 @@
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.",
@@ -51,44 +55,137 @@
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.",
@@ -100,44 +197,129 @@
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.",
@@ -149,44 +331,128 @@
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.",
@@ -197,23 +463,63 @@
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.",
@@ -224,23 +530,59 @@
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.",
@@ -251,23 +593,60 @@
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.",
@@ -278,23 +657,61 @@
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.",
@@ -305,23 +722,59 @@
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.",
@@ -332,23 +785,66 @@
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.",
@@ -359,23 +855,59 @@
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.",
@@ -386,23 +918,59 @@
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.",
@@ -413,23 +981,58 @@
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.",
@@ -440,23 +1043,59 @@
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.",
@@ -467,23 +1106,60 @@
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.",
@@ -494,23 +1170,61 @@
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.",
@@ -521,23 +1235,62 @@
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.",
@@ -548,23 +1301,63 @@
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.",
@@ -576,39 +1369,121 @@
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
  }
 
39
  {
40
  "id": "hemoglobin",
41
  "display_name": "Hemoglobin",
42
+ "aliases": [
43
+ "HGB",
44
+ "Hb",
45
+ "Hgb"
46
+ ],
47
  "category": "CBC red cell marker",
48
  "unit": "g/dL",
49
  "description": "Hemoglobin is the iron-containing protein inside red blood cells that carries oxygen from the lungs to body tissues.",
 
55
  },
56
  "sex_specific_statistics_per_group_age": {
57
  "child": {
58
+ "male": {
59
+ "minimal_value": 10.9,
60
+ "normal_value": 12.95,
61
+ "maximum_value": 15.0
62
+ },
63
+ "female": {
64
+ "minimal_value": 10.9,
65
+ "normal_value": 12.95,
66
+ "maximum_value": 15.0
67
+ },
68
+ "unknown": {
69
+ "minimal_value": 10.9,
70
+ "normal_value": 12.95,
71
+ "maximum_value": 15.0
72
+ }
73
  },
74
  "teenager": {
75
+ "male": {
76
+ "minimal_value": 13.2,
77
+ "normal_value": 15.45,
78
+ "maximum_value": 17.7
79
+ },
80
+ "female": {
81
+ "minimal_value": 11.9,
82
+ "normal_value": 13.7,
83
+ "maximum_value": 15.5
84
+ },
85
+ "unknown": {
86
+ "minimal_value": 11.9,
87
+ "normal_value": 14.8,
88
+ "maximum_value": 17.7
89
+ }
90
  },
91
  "adult": {
92
+ "male": {
93
+ "minimal_value": 13.2,
94
+ "normal_value": 15.45,
95
+ "maximum_value": 17.7
96
+ },
97
+ "female": {
98
+ "minimal_value": 11.9,
99
+ "normal_value": 13.7,
100
+ "maximum_value": 15.5
101
+ },
102
+ "unknown": {
103
+ "minimal_value": 11.9,
104
+ "normal_value": 14.8,
105
+ "maximum_value": 17.7
106
+ }
107
  },
108
  "elder": {
109
+ "male": {
110
+ "minimal_value": 13.2,
111
+ "normal_value": 15.45,
112
+ "maximum_value": 17.7
113
+ },
114
+ "female": {
115
+ "minimal_value": 11.9,
116
+ "normal_value": 13.7,
117
+ "maximum_value": 15.5
118
+ },
119
+ "unknown": {
120
+ "minimal_value": 11.9,
121
+ "normal_value": 14.8,
122
+ "maximum_value": 17.7
123
+ }
124
  }
125
  },
126
  "instructions_to_improve": {
127
+ "food": [
128
+ "If low, emphasize iron-rich foods such as lean meat, fish, poultry, legumes, tofu, spinach, and iron-fortified grains.",
129
+ "Pair plant iron with vitamin C foods such as citrus, berries, peppers, or tomatoes to improve absorption.",
130
+ "Include folate and vitamin B12 sources such as leafy greens, beans, eggs, dairy, fish, and fortified foods."
131
+ ],
132
+ "exercises": [
133
+ "Use moderate aerobic activity and strength training as tolerated to support cardiovascular fitness.",
134
+ "Avoid unusually intense training until unexplained anemia, shortness of breath, dizziness, or fatigue has been evaluated."
135
+ ],
136
+ "supplements": [
137
+ "Discuss iron, vitamin B12, or folate testing and supplementation with a clinician before starting.",
138
+ "Avoid iron supplements unless deficiency or clinical need is confirmed, because excess iron can be harmful."
139
+ ]
140
  },
141
  "statistics_per_group_age": {
142
+ "child": {
143
+ "minimal_value": 10.9,
144
+ "normal_value": 12.95,
145
+ "maximum_value": 15.0
146
+ },
147
+ "teenager": {
148
+ "minimal_value": 11.9,
149
+ "normal_value": 14.8,
150
+ "maximum_value": 17.7
151
+ },
152
+ "adult": {
153
+ "minimal_value": 11.9,
154
+ "normal_value": 14.8,
155
+ "maximum_value": 17.7
156
+ },
157
+ "elder": {
158
+ "minimal_value": 11.9,
159
+ "normal_value": 14.8,
160
+ "maximum_value": 17.7
161
+ }
162
  },
163
+ "related_tests": [
164
+ "rbc",
165
+ "hct",
166
+ "mcv",
167
+ "mch",
168
+ "mchc",
169
+ "rdw_cv",
170
+ "rdw_sd"
171
+ ],
172
+ "source_ids": [
173
+ "medlineplus_cbc",
174
+ "uiowa_cbc_reference",
175
+ "uiowa_pediatric_reference",
176
+ "nih_ods_iron",
177
+ "nih_ods_b12",
178
+ "nih_ods_folate"
179
+ ]
180
  },
181
  {
182
  "id": "rbc",
183
  "display_name": "Red Blood Cell Count",
184
+ "aliases": [
185
+ "RBC",
186
+ "Erythrocyte count",
187
+ "Red cell count"
188
+ ],
189
  "category": "CBC red cell marker",
190
  "unit": "10^6/uL",
191
  "description": "RBC count measures the number of red blood cells in a volume of blood.",
 
197
  },
198
  "sex_specific_statistics_per_group_age": {
199
  "child": {
200
+ "male": {
201
+ "minimal_value": 3.8,
202
+ "normal_value": 4.65,
203
+ "maximum_value": 5.5
204
+ },
205
+ "female": {
206
+ "minimal_value": 3.8,
207
+ "normal_value": 4.65,
208
+ "maximum_value": 5.5
209
+ },
210
+ "unknown": {
211
+ "minimal_value": 3.8,
212
+ "normal_value": 4.65,
213
+ "maximum_value": 5.5
214
+ }
215
  },
216
  "teenager": {
217
+ "male": {
218
+ "minimal_value": 4.3,
219
+ "normal_value": 4.95,
220
+ "maximum_value": 5.6
221
+ },
222
+ "female": {
223
+ "minimal_value": 3.9,
224
+ "normal_value": 4.5,
225
+ "maximum_value": 5.1
226
+ },
227
+ "unknown": {
228
+ "minimal_value": 3.9,
229
+ "normal_value": 4.75,
230
+ "maximum_value": 5.6
231
+ }
232
  },
233
  "adult": {
234
+ "male": {
235
+ "minimal_value": 4.5,
236
+ "normal_value": 5.35,
237
+ "maximum_value": 6.2
238
+ },
239
+ "female": {
240
+ "minimal_value": 4.0,
241
+ "normal_value": 4.6,
242
+ "maximum_value": 5.2
243
+ },
244
+ "unknown": {
245
+ "minimal_value": 4.0,
246
+ "normal_value": 5.1,
247
+ "maximum_value": 6.2
248
+ }
249
  },
250
  "elder": {
251
+ "male": {
252
+ "minimal_value": 4.5,
253
+ "normal_value": 5.35,
254
+ "maximum_value": 6.2
255
+ },
256
+ "female": {
257
+ "minimal_value": 4.0,
258
+ "normal_value": 4.6,
259
+ "maximum_value": 5.2
260
+ },
261
+ "unknown": {
262
+ "minimal_value": 4.0,
263
+ "normal_value": 5.1,
264
+ "maximum_value": 6.2
265
+ }
266
  }
267
  },
268
  "instructions_to_improve": {
269
+ "food": [
270
+ "Support red blood cell production with iron, protein, folate, and vitamin B12 containing foods.",
271
+ "Hydrate regularly; dehydration can concentrate blood counts and make RBC appear higher.",
272
+ "Limit heavy alcohol intake because it can interfere with nutrition and marrow function."
273
+ ],
274
+ "exercises": [
275
+ "Maintain regular aerobic activity and resistance training if cleared for exercise.",
276
+ "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."
277
+ ],
278
+ "supplements": [
279
+ "Use iron, B12, or folate only when deficiency is suspected or confirmed.",
280
+ "Do not use performance-enhancing drugs or unsupervised erythropoietin-like products."
281
+ ]
282
  },
283
  "statistics_per_group_age": {
284
+ "child": {
285
+ "minimal_value": 3.8,
286
+ "normal_value": 4.65,
287
+ "maximum_value": 5.5
288
+ },
289
+ "teenager": {
290
+ "minimal_value": 3.9,
291
+ "normal_value": 4.75,
292
+ "maximum_value": 5.6
293
+ },
294
+ "adult": {
295
+ "minimal_value": 4.0,
296
+ "normal_value": 5.1,
297
+ "maximum_value": 6.2
298
+ },
299
+ "elder": {
300
+ "minimal_value": 4.0,
301
+ "normal_value": 5.1,
302
+ "maximum_value": 6.2
303
+ }
304
  },
305
+ "related_tests": [
306
+ "hemoglobin",
307
+ "hct",
308
+ "mcv"
309
+ ],
310
+ "source_ids": [
311
+ "medlineplus_rbc",
312
+ "uiowa_cbc_reference"
313
+ ]
314
  },
315
  {
316
  "id": "hct",
317
  "display_name": "Hematocrit",
318
+ "aliases": [
319
+ "HCT",
320
+ "PCV",
321
+ "Packed cell volume"
322
+ ],
323
  "category": "CBC red cell marker",
324
  "unit": "%",
325
  "description": "Hematocrit is the percentage of whole blood volume made up of red blood cells.",
 
331
  },
332
  "sex_specific_statistics_per_group_age": {
333
  "child": {
334
+ "male": {
335
+ "minimal_value": 31,
336
+ "normal_value": 37.5,
337
+ "maximum_value": 44
338
+ },
339
+ "female": {
340
+ "minimal_value": 31,
341
+ "normal_value": 37.5,
342
+ "maximum_value": 44
343
+ },
344
+ "unknown": {
345
+ "minimal_value": 31,
346
+ "normal_value": 37.5,
347
+ "maximum_value": 44
348
+ }
349
  },
350
  "teenager": {
351
+ "male": {
352
+ "minimal_value": 37,
353
+ "normal_value": 43.0,
354
+ "maximum_value": 49
355
+ },
356
+ "female": {
357
+ "minimal_value": 36,
358
+ "normal_value": 41.0,
359
+ "maximum_value": 46
360
+ },
361
+ "unknown": {
362
+ "minimal_value": 36,
363
+ "normal_value": 42.5,
364
+ "maximum_value": 49
365
+ }
366
  },
367
  "adult": {
368
+ "male": {
369
+ "minimal_value": 40,
370
+ "normal_value": 46.0,
371
+ "maximum_value": 52
372
+ },
373
+ "female": {
374
+ "minimal_value": 35,
375
+ "normal_value": 41.0,
376
+ "maximum_value": 47
377
+ },
378
+ "unknown": {
379
+ "minimal_value": 35,
380
+ "normal_value": 43.5,
381
+ "maximum_value": 52
382
+ }
383
  },
384
  "elder": {
385
+ "male": {
386
+ "minimal_value": 40,
387
+ "normal_value": 46.0,
388
+ "maximum_value": 52
389
+ },
390
+ "female": {
391
+ "minimal_value": 35,
392
+ "normal_value": 41.0,
393
+ "maximum_value": 47
394
+ },
395
+ "unknown": {
396
+ "minimal_value": 35,
397
+ "normal_value": 43.5,
398
+ "maximum_value": 52
399
+ }
400
  }
401
  },
402
  "instructions_to_improve": {
403
+ "food": [
404
+ "For low values, support red cell production with iron, B12, folate, protein, and overall adequate calories.",
405
+ "For high values, maintain hydration and avoid smoking exposure when possible.",
406
+ "Ask a clinician about causes before making major diet changes."
407
+ ],
408
+ "exercises": [
409
+ "Follow general activity guidelines if well; conditioning supports oxygen use but does not replace evaluation for anemia.",
410
+ "Pause strenuous activity and seek care for chest pain, fainting, severe shortness of breath, or marked fatigue."
411
+ ],
412
+ "supplements": [
413
+ "Discuss iron/B12/folate supplementation only when deficiency or risk is present.",
414
+ "Avoid unsupervised iron if hematocrit is high."
415
+ ]
416
  },
417
  "statistics_per_group_age": {
418
+ "child": {
419
+ "minimal_value": 31,
420
+ "normal_value": 37.5,
421
+ "maximum_value": 44
422
+ },
423
+ "teenager": {
424
+ "minimal_value": 34,
425
+ "normal_value": 41.0,
426
+ "maximum_value": 48
427
+ },
428
+ "adult": {
429
+ "minimal_value": 35,
430
+ "normal_value": 43.5,
431
+ "maximum_value": 52
432
+ },
433
+ "elder": {
434
+ "minimal_value": 35,
435
+ "normal_value": 43.5,
436
+ "maximum_value": 52
437
+ }
438
  },
439
+ "related_tests": [
440
+ "hemoglobin",
441
+ "rbc"
442
+ ],
443
+ "source_ids": [
444
+ "medlineplus_cbc",
445
+ "uiowa_cbc_reference",
446
+ "uiowa_pediatric_reference",
447
+ "seattle_childrens_hematocrit"
448
+ ]
449
  },
450
  {
451
  "id": "mcv",
452
  "display_name": "Mean Corpuscular Volume",
453
+ "aliases": [
454
+ "MCV"
455
+ ],
456
  "category": "CBC red cell index",
457
  "unit": "fL",
458
  "description": "MCV measures the average size of red blood cells.",
 
463
  "pipeline_guidance": "Use the age-group interval unless the report provides a sex-specific lab range. Keep nearby red-cell markers sex-aware."
464
  },
465
  "instructions_to_improve": {
466
+ "food": [
467
+ "If low, ensure adequate iron intake and pair plant iron with vitamin C.",
468
+ "If high, ensure adequate B12 and folate intake from animal foods, fortified foods, leafy greens, and legumes.",
469
+ "Reduce heavy alcohol intake if relevant."
470
+ ],
471
+ "exercises": [
472
+ "Exercise does not directly normalize MCV, but regular activity supports overall metabolic health.",
473
+ "Avoid overtraining if anemia symptoms are present."
474
+ ],
475
+ "supplements": [
476
+ "Discuss iron studies, B12, folate, thyroid, and liver evaluation before supplementing.",
477
+ "Use B12 or folate supplements when dietary intake, absorption risk, or testing supports the need."
478
+ ]
479
  },
480
  "statistics_per_group_age": {
481
+ "child": {
482
+ "minimal_value": 75,
483
+ "normal_value": 82.5,
484
+ "maximum_value": 90
485
+ },
486
+ "teenager": {
487
+ "minimal_value": 79,
488
+ "normal_value": 87.0,
489
+ "maximum_value": 95
490
+ },
491
+ "adult": {
492
+ "minimal_value": 82,
493
+ "normal_value": 90.5,
494
+ "maximum_value": 99
495
+ },
496
+ "elder": {
497
+ "minimal_value": 82,
498
+ "normal_value": 90.5,
499
+ "maximum_value": 99
500
+ }
501
  },
502
+ "related_tests": [
503
+ "hemoglobin",
504
+ "mch",
505
+ "mchc",
506
+ "rdw_cv",
507
+ "rdw_sd"
508
+ ],
509
+ "source_ids": [
510
+ "medlineplus_cbc",
511
+ "uiowa_cbc_reference",
512
+ "nih_ods_iron",
513
+ "nih_ods_b12",
514
+ "nih_ods_folate"
515
+ ]
516
  },
517
  {
518
  "id": "mch",
519
  "display_name": "Mean Corpuscular Hemoglobin",
520
+ "aliases": [
521
+ "MCH"
522
+ ],
523
  "category": "CBC red cell index",
524
  "unit": "pg",
525
  "description": "MCH estimates the average amount of hemoglobin in each red blood cell.",
 
530
  "pipeline_guidance": "Use the age-group interval unless the lab report includes a sex-specific range."
531
  },
532
  "instructions_to_improve": {
533
+ "food": [
534
+ "Support hemoglobin production with iron-rich foods, protein, B12, and folate.",
535
+ "Pair plant iron with vitamin C and avoid taking tea or coffee with iron-rich meals if iron deficiency is a concern.",
536
+ "Maintain balanced meals rather than focusing on one nutrient only."
537
+ ],
538
+ "exercises": [
539
+ "Use gentle-to-moderate activity if anemia symptoms are mild and cleared by a clinician.",
540
+ "Delay intense endurance training when unexplained low red-cell indices are present."
541
+ ],
542
+ "supplements": [
543
+ "Discuss iron, B12, and folate supplementation based on lab confirmation.",
544
+ "Avoid stacking multiple blood-building supplements without clinician guidance."
545
+ ]
546
  },
547
  "statistics_per_group_age": {
548
+ "child": {
549
+ "minimal_value": 23,
550
+ "normal_value": 29.0,
551
+ "maximum_value": 35
552
+ },
553
+ "teenager": {
554
+ "minimal_value": 25,
555
+ "normal_value": 30.0,
556
+ "maximum_value": 35
557
+ },
558
+ "adult": {
559
+ "minimal_value": 25,
560
+ "normal_value": 30.0,
561
+ "maximum_value": 35
562
+ },
563
+ "elder": {
564
+ "minimal_value": 25,
565
+ "normal_value": 30.0,
566
+ "maximum_value": 35
567
+ }
568
  },
569
+ "related_tests": [
570
+ "mcv",
571
+ "mchc",
572
+ "hemoglobin"
573
+ ],
574
+ "source_ids": [
575
+ "uiowa_cbc_reference",
576
+ "uiowa_pediatric_reference",
577
+ "nih_ods_iron"
578
+ ]
579
  },
580
  {
581
  "id": "mchc",
582
  "display_name": "Mean Corpuscular Hemoglobin Concentration",
583
+ "aliases": [
584
+ "MCHC"
585
+ ],
586
  "category": "CBC red cell index",
587
  "unit": "g/dL",
588
  "description": "MCHC estimates the concentration of hemoglobin within red blood cells.",
 
593
  "pipeline_guidance": "Use the age-group interval unless the lab report provides a sex-specific range."
594
  },
595
  "instructions_to_improve": {
596
+ "food": [
597
+ "For low values, focus on iron adequacy plus B12, folate, protein, and vitamin C-supported absorption.",
598
+ "For high values, do not try to self-correct with diet; confirm with repeat testing and clinical review.",
599
+ "Hydration and balanced nutrition support reliable results."
600
+ ],
601
+ "exercises": [
602
+ "Exercise does not directly change MCHC; stay active within symptom limits.",
603
+ "Seek care before strenuous exercise if anemia symptoms are significant."
604
+ ],
605
+ "supplements": [
606
+ "Use iron only when iron deficiency is likely or confirmed.",
607
+ "Discuss persistent abnormal MCHC with a clinician because it can reflect lab artifacts or specific red-cell disorders."
608
+ ]
609
  },
610
  "statistics_per_group_age": {
611
+ "child": {
612
+ "minimal_value": 32,
613
+ "normal_value": 34.0,
614
+ "maximum_value": 36
615
+ },
616
+ "teenager": {
617
+ "minimal_value": 32,
618
+ "normal_value": 34.0,
619
+ "maximum_value": 36
620
+ },
621
+ "adult": {
622
+ "minimal_value": 32,
623
+ "normal_value": 34.0,
624
+ "maximum_value": 36
625
+ },
626
+ "elder": {
627
+ "minimal_value": 32,
628
+ "normal_value": 34.0,
629
+ "maximum_value": 36
630
+ }
631
  },
632
+ "related_tests": [
633
+ "mch",
634
+ "mcv",
635
+ "hemoglobin"
636
+ ],
637
+ "source_ids": [
638
+ "uiowa_cbc_reference",
639
+ "uiowa_pediatric_reference"
640
+ ]
641
  },
642
  {
643
  "id": "rdw_cv",
644
  "display_name": "Red Cell Distribution Width - CV",
645
+ "aliases": [
646
+ "RDW-CV",
647
+ "RDWCV",
648
+ "RDW"
649
+ ],
650
  "category": "CBC red cell index",
651
  "unit": "%",
652
  "description": "RDW-CV describes variation in red blood cell size as a coefficient of variation.",
 
657
  "pipeline_guidance": "Use age-group statistics as fallback and interpret alongside sex-aware hemoglobin, RBC, hematocrit, and iron-related context."
658
  },
659
  "instructions_to_improve": {
660
+ "food": [
661
+ "Support steady red-cell production with iron, B12, folate, protein, and adequate calories.",
662
+ "Include a mix of leafy greens, legumes, fortified grains, seafood, eggs, dairy, and lean meats as appropriate.",
663
+ "Address restrictive diets with clinician or dietitian support."
664
+ ],
665
+ "exercises": [
666
+ "Regular activity supports general health but does not directly normalize RDW.",
667
+ "Avoid overtraining if iron deficiency or anemia is suspected."
668
+ ],
669
+ "supplements": [
670
+ "Consider supplements only after identifying the relevant deficiency.",
671
+ "Ask about iron studies, ferritin, B12, folate, and reticulocyte count when RDW is abnormal."
672
+ ]
673
  },
674
  "statistics_per_group_age": {
675
+ "child": {
676
+ "minimal_value": 9.0,
677
+ "normal_value": 11.75,
678
+ "maximum_value": 14.5
679
+ },
680
+ "teenager": {
681
+ "minimal_value": 9.0,
682
+ "normal_value": 11.75,
683
+ "maximum_value": 14.5
684
+ },
685
+ "adult": {
686
+ "minimal_value": 9.0,
687
+ "normal_value": 11.75,
688
+ "maximum_value": 14.5
689
+ },
690
+ "elder": {
691
+ "minimal_value": 9.0,
692
+ "normal_value": 11.75,
693
+ "maximum_value": 14.5
694
+ }
695
  },
696
+ "related_tests": [
697
+ "mcv",
698
+ "hemoglobin",
699
+ "rdw_sd"
700
+ ],
701
+ "source_ids": [
702
+ "uiowa_cbc_reference",
703
+ "nih_ods_iron",
704
+ "nih_ods_b12",
705
+ "nih_ods_folate"
706
+ ]
707
  },
708
  {
709
  "id": "rdw_sd",
710
  "display_name": "Red Cell Distribution Width - SD",
711
+ "aliases": [
712
+ "RDW-SD",
713
+ "RDWSD"
714
+ ],
715
  "category": "CBC red cell index",
716
  "unit": "fL",
717
  "description": "RDW-SD measures the width of the red-cell size distribution in femtoliters.",
 
722
  "pipeline_guidance": "Use age-group statistics as fallback and defer to the lab reference range if it is sex-specific."
723
  },
724
  "instructions_to_improve": {
725
+ "food": [
726
+ "Follow the same red-cell nutrition pattern used for RDW-CV: iron, B12, folate, protein, and balanced calories.",
727
+ "Correcting the cause of abnormal red-cell production is more important than targeting RDW-SD directly.",
728
+ "Maintain hydration before routine blood draws unless instructed otherwise."
729
+ ],
730
+ "exercises": [
731
+ "Regular moderate activity is reasonable when symptoms allow.",
732
+ "Avoid intense training until unexplained anemia, dizziness, or shortness of breath is reviewed."
733
+ ],
734
+ "supplements": [
735
+ "Supplement only for documented or likely deficiency.",
736
+ "Discuss persistent abnormalities with a clinician, especially when hemoglobin or MCV is also abnormal."
737
+ ]
738
  },
739
  "statistics_per_group_age": {
740
+ "child": {
741
+ "minimal_value": 35.1,
742
+ "normal_value": 40.7,
743
+ "maximum_value": 46.3
744
+ },
745
+ "teenager": {
746
+ "minimal_value": 35.1,
747
+ "normal_value": 40.7,
748
+ "maximum_value": 46.3
749
+ },
750
+ "adult": {
751
+ "minimal_value": 35.1,
752
+ "normal_value": 40.7,
753
+ "maximum_value": 46.3
754
+ },
755
+ "elder": {
756
+ "minimal_value": 35.1,
757
+ "normal_value": 40.7,
758
+ "maximum_value": 46.3
759
+ }
760
  },
761
+ "related_tests": [
762
+ "mcv",
763
+ "rdw_cv",
764
+ "hemoglobin"
765
+ ],
766
+ "source_ids": [
767
+ "uiowa_cbc_reference"
768
+ ]
769
  },
770
  {
771
  "id": "wbc",
772
  "display_name": "White Blood Cell Count",
773
+ "aliases": [
774
+ "WBC",
775
+ "Leukocyte count",
776
+ "White cell count"
777
+ ],
778
  "category": "CBC white cell marker",
779
  "unit": "10^3/uL",
780
  "description": "WBC count measures the total number of white blood cells in blood.",
 
785
  "pipeline_guidance": "Use the age-group interval unless the lab report gives a sex- or pregnancy-specific range."
786
  },
787
  "instructions_to_improve": {
788
+ "food": [
789
+ "There is no food that reliably corrects WBC count by itself; prioritize adequate calories, protein, fruits, vegetables, and hydration.",
790
+ "Food safety matters if WBC is very low or immune suppression is present; ask a clinician about precautions.",
791
+ "Limit heavy alcohol intake because it may impair immune and marrow function."
792
+ ],
793
+ "exercises": [
794
+ "Follow general activity guidelines when well; rest during fever or acute infection.",
795
+ "Avoid strenuous exercise during significant illness or very abnormal counts until medically reviewed."
796
+ ],
797
+ "supplements": [
798
+ "Do not use immune-boosting supplements as a substitute for evaluation.",
799
+ "Review medications and supplements with a clinician if WBC is abnormal."
800
+ ]
801
  },
802
  "statistics_per_group_age": {
803
+ "child": {
804
+ "minimal_value": 5.5,
805
+ "normal_value": 11.25,
806
+ "maximum_value": 17.0
807
+ },
808
+ "teenager": {
809
+ "minimal_value": 4.5,
810
+ "normal_value": 7.75,
811
+ "maximum_value": 11.0
812
+ },
813
+ "adult": {
814
+ "minimal_value": 3.7,
815
+ "normal_value": 7.1,
816
+ "maximum_value": 10.5
817
+ },
818
+ "elder": {
819
+ "minimal_value": 3.7,
820
+ "normal_value": 7.1,
821
+ "maximum_value": 10.5
822
+ }
823
  },
824
+ "related_tests": [
825
+ "neu_percent",
826
+ "lym_percent",
827
+ "mon_percent",
828
+ "eos_percent",
829
+ "bas_percent",
830
+ "lym_absolute",
831
+ "gra_absolute"
832
+ ],
833
+ "source_ids": [
834
+ "medlineplus_cbc",
835
+ "medlineplus_differential",
836
+ "uiowa_cbc_reference",
837
+ "uiowa_pediatric_reference"
838
+ ]
839
  },
840
  {
841
  "id": "neu_percent",
842
  "display_name": "Neutrophils Percent",
843
+ "aliases": [
844
+ "NEU%",
845
+ "Neutrophil %",
846
+ "Neutrophils"
847
+ ],
848
  "category": "CBC differential",
849
  "unit": "%",
850
  "description": "Neutrophil percentage is the share of white blood cells that are neutrophils, the most common WBC type and a major defense against infection.",
 
855
  "pipeline_guidance": "Use the age-group interval and prioritize the lab-provided range if pregnancy or other sex-specific context is documented."
856
  },
857
  "instructions_to_improve": {
858
+ "food": [
859
+ "Support immune health with adequate protein, fruits, vegetables, whole grains, and hydration.",
860
+ "There is no diet that directly normalizes neutrophil percentage; treat the cause.",
861
+ "Practice food safety if a clinician says neutrophils are dangerously low."
862
+ ],
863
+ "exercises": [
864
+ "Rest during acute infection or fever.",
865
+ "Resume moderate activity gradually after illness; intense exercise can transiently shift white-cell patterns."
866
+ ],
867
+ "supplements": [
868
+ "Avoid self-treating abnormal neutrophils with supplements.",
869
+ "Discuss medication effects, infections, and need for repeat CBC or absolute neutrophil count with a clinician."
870
+ ]
871
  },
872
  "statistics_per_group_age": {
873
+ "child": {
874
+ "minimal_value": 40,
875
+ "normal_value": 55.0,
876
+ "maximum_value": 70
877
+ },
878
+ "teenager": {
879
+ "minimal_value": 40,
880
+ "normal_value": 55.0,
881
+ "maximum_value": 70
882
+ },
883
+ "adult": {
884
+ "minimal_value": 40,
885
+ "normal_value": 55.0,
886
+ "maximum_value": 70
887
+ },
888
+ "elder": {
889
+ "minimal_value": 40,
890
+ "normal_value": 55.0,
891
+ "maximum_value": 70
892
+ }
893
  },
894
+ "related_tests": [
895
+ "wbc",
896
+ "gra_absolute"
897
+ ],
898
+ "source_ids": [
899
+ "medlineplus_differential",
900
+ "medlineplus_differential_encyclopedia"
901
+ ]
902
  },
903
  {
904
  "id": "lym_percent",
905
  "display_name": "Lymphocytes Percent",
906
+ "aliases": [
907
+ "LYM%",
908
+ "Lymphocyte %",
909
+ "Lymphocytes"
910
+ ],
911
  "category": "CBC differential",
912
  "unit": "%",
913
  "description": "Lymphocyte percentage is the share of white blood cells that are lymphocytes, including B cells and T cells.",
 
918
  "pipeline_guidance": "Use the age-group interval unless the source report provides a sex-specific or pregnancy-specific range."
919
  },
920
  "instructions_to_improve": {
921
+ "food": [
922
+ "Maintain adequate protein, micronutrients, and calories to support immune cell production.",
923
+ "Use a varied dietary pattern rather than targeting lymphocyte percentage directly.",
924
+ "Seek evaluation for persistent abnormal values instead of relying on diet alone."
925
+ ],
926
+ "exercises": [
927
+ "Moderate regular activity supports immune resilience.",
928
+ "Avoid heavy training during acute illness or unexplained low counts."
929
+ ],
930
+ "supplements": [
931
+ "Do not use supplements to force lymphocyte changes.",
932
+ "Discuss abnormal lymphocyte percentage with a clinician, especially if absolute lymphocyte count is also abnormal."
933
+ ]
934
  },
935
  "statistics_per_group_age": {
936
+ "child": {
937
+ "minimal_value": 20,
938
+ "normal_value": 30.0,
939
+ "maximum_value": 40
940
+ },
941
+ "teenager": {
942
+ "minimal_value": 20,
943
+ "normal_value": 30.0,
944
+ "maximum_value": 40
945
+ },
946
+ "adult": {
947
+ "minimal_value": 20,
948
+ "normal_value": 30.0,
949
+ "maximum_value": 40
950
+ },
951
+ "elder": {
952
+ "minimal_value": 20,
953
+ "normal_value": 30.0,
954
+ "maximum_value": 40
955
+ }
956
  },
957
+ "related_tests": [
958
+ "wbc",
959
+ "lym_absolute"
960
+ ],
961
+ "source_ids": [
962
+ "medlineplus_differential",
963
+ "medlineplus_differential_encyclopedia"
964
+ ]
965
  },
966
  {
967
  "id": "mon_percent",
968
  "display_name": "Monocytes Percent",
969
+ "aliases": [
970
+ "MON%",
971
+ "Monocyte %",
972
+ "Monocytes"
973
+ ],
974
  "category": "CBC differential",
975
  "unit": "%",
976
  "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.",
 
981
  "pipeline_guidance": "Use the age-group interval unless the lab report gives a sex-specific range."
982
  },
983
  "instructions_to_improve": {
984
+ "food": [
985
+ "Eat enough protein and a varied diet to support immune function.",
986
+ "No specific food reliably lowers or raises monocyte percentage.",
987
+ "Persistent abnormalities should prompt review for infection, inflammation, medications, or other causes."
988
+ ],
989
+ "exercises": [
990
+ "Use regular moderate exercise for general immune and metabolic health.",
991
+ "Rest when acutely ill or febrile."
992
+ ],
993
+ "supplements": [
994
+ "Avoid immune supplements as a replacement for medical evaluation.",
995
+ "Review supplement and medication use if monocytes are persistently abnormal."
996
+ ]
997
  },
998
  "statistics_per_group_age": {
999
+ "child": {
1000
+ "minimal_value": 2,
1001
+ "normal_value": 5.0,
1002
+ "maximum_value": 8
1003
+ },
1004
+ "teenager": {
1005
+ "minimal_value": 2,
1006
+ "normal_value": 5.0,
1007
+ "maximum_value": 8
1008
+ },
1009
+ "adult": {
1010
+ "minimal_value": 2,
1011
+ "normal_value": 5.0,
1012
+ "maximum_value": 8
1013
+ },
1014
+ "elder": {
1015
+ "minimal_value": 2,
1016
+ "normal_value": 5.0,
1017
+ "maximum_value": 8
1018
+ }
1019
  },
1020
+ "related_tests": [
1021
+ "wbc"
1022
+ ],
1023
+ "source_ids": [
1024
+ "medlineplus_differential",
1025
+ "medlineplus_differential_encyclopedia"
1026
+ ]
1027
  },
1028
  {
1029
  "id": "eos_percent",
1030
  "display_name": "Eosinophils Percent",
1031
+ "aliases": [
1032
+ "EOS%",
1033
+ "Eosinophil %",
1034
+ "Eosinophils"
1035
+ ],
1036
  "category": "CBC differential",
1037
  "unit": "%",
1038
  "description": "Eosinophil percentage is the share of white blood cells that are eosinophils, cells involved in allergies, asthma-related inflammation, and parasite defense.",
 
1043
  "pipeline_guidance": "Use the age-group interval unless the report provides a sex-specific range."
1044
  },
1045
  "instructions_to_improve": {
1046
+ "food": [
1047
+ "No food directly normalizes eosinophils; identify allergies or triggers when clinically relevant.",
1048
+ "Maintain an anti-inflammatory dietary pattern with fruits, vegetables, whole grains, and adequate protein.",
1049
+ "Avoid foods only when a true allergy or clinician-guided elimination plan exists."
1050
+ ],
1051
+ "exercises": [
1052
+ "Exercise according to tolerance; people with asthma symptoms should follow their asthma action plan.",
1053
+ "Avoid exercising through wheezing, severe allergy symptoms, or acute illness."
1054
+ ],
1055
+ "supplements": [
1056
+ "Do not self-treat elevated eosinophils with supplements.",
1057
+ "Discuss allergy, asthma, parasite exposure, and medication review with a clinician."
1058
+ ]
1059
  },
1060
  "statistics_per_group_age": {
1061
+ "child": {
1062
+ "minimal_value": 1,
1063
+ "normal_value": 2.5,
1064
+ "maximum_value": 4
1065
+ },
1066
+ "teenager": {
1067
+ "minimal_value": 1,
1068
+ "normal_value": 2.5,
1069
+ "maximum_value": 4
1070
+ },
1071
+ "adult": {
1072
+ "minimal_value": 1,
1073
+ "normal_value": 2.5,
1074
+ "maximum_value": 4
1075
+ },
1076
+ "elder": {
1077
+ "minimal_value": 1,
1078
+ "normal_value": 2.5,
1079
+ "maximum_value": 4
1080
+ }
1081
  },
1082
+ "related_tests": [
1083
+ "wbc",
1084
+ "bas_percent"
1085
+ ],
1086
+ "source_ids": [
1087
+ "medlineplus_differential",
1088
+ "medlineplus_differential_encyclopedia"
1089
+ ]
1090
  },
1091
  {
1092
  "id": "bas_percent",
1093
  "display_name": "Basophils Percent",
1094
+ "aliases": [
1095
+ "BAS%",
1096
+ "Basophil %",
1097
+ "Basophils"
1098
+ ],
1099
  "category": "CBC differential",
1100
  "unit": "%",
1101
  "description": "Basophil percentage is the share of white blood cells that are basophils, cells that release mediators during allergic and asthma-related reactions.",
 
1106
  "pipeline_guidance": "Use the age-group interval unless the lab report provides a sex-specific range."
1107
  },
1108
  "instructions_to_improve": {
1109
+ "food": [
1110
+ "No diet directly targets basophils.",
1111
+ "If allergies are relevant, avoid confirmed triggers and maintain balanced nutrition.",
1112
+ "Seek medical review for persistent elevation rather than self-treating."
1113
+ ],
1114
+ "exercises": [
1115
+ "Exercise as tolerated; avoid exposure-triggered activity if asthma or allergic symptoms are active.",
1116
+ "Rest during acute allergic or inflammatory episodes if symptoms are significant."
1117
+ ],
1118
+ "supplements": [
1119
+ "Avoid supplement-only management for abnormal basophils.",
1120
+ "Review medications, allergy history, and repeat testing with a clinician when needed."
1121
+ ]
1122
  },
1123
  "statistics_per_group_age": {
1124
+ "child": {
1125
+ "minimal_value": 0.5,
1126
+ "normal_value": 0.75,
1127
+ "maximum_value": 1
1128
+ },
1129
+ "teenager": {
1130
+ "minimal_value": 0.5,
1131
+ "normal_value": 0.75,
1132
+ "maximum_value": 1
1133
+ },
1134
+ "adult": {
1135
+ "minimal_value": 0.5,
1136
+ "normal_value": 0.75,
1137
+ "maximum_value": 1
1138
+ },
1139
+ "elder": {
1140
+ "minimal_value": 0.5,
1141
+ "normal_value": 0.75,
1142
+ "maximum_value": 1
1143
+ }
1144
  },
1145
+ "related_tests": [
1146
+ "wbc",
1147
+ "eos_percent"
1148
+ ],
1149
+ "source_ids": [
1150
+ "medlineplus_differential",
1151
+ "medlineplus_differential_encyclopedia"
1152
+ ]
1153
  },
1154
  {
1155
  "id": "lym_absolute",
1156
  "display_name": "Absolute Lymphocyte Count",
1157
+ "aliases": [
1158
+ "LYM#",
1159
+ "Lymphocyte #",
1160
+ "Absolute lymphocytes",
1161
+ "ALC"
1162
+ ],
1163
  "category": "CBC differential absolute count",
1164
  "unit": "10^3/uL",
1165
  "description": "Absolute lymphocyte count is the number of lymphocytes in a volume of blood.",
 
1170
  "pipeline_guidance": "Use the age-group interval unless the report gives a sex-specific range."
1171
  },
1172
  "instructions_to_improve": {
1173
+ "food": [
1174
+ "Support immune cell production with adequate calories, protein, and micronutrient-rich foods.",
1175
+ "Food cannot reliably correct abnormal absolute lymphocytes by itself.",
1176
+ "If immunosuppressed, ask about food safety guidance."
1177
+ ],
1178
+ "exercises": [
1179
+ "Use moderate regular activity when well.",
1180
+ "Avoid intense exercise during acute infection, fever, or severe fatigue."
1181
+ ],
1182
+ "supplements": [
1183
+ "Do not use immune supplements to self-correct lymphocyte count.",
1184
+ "Discuss persistent low or high ALC with a clinician, especially with infections, weight loss, night sweats, or medication changes."
1185
+ ]
1186
  },
1187
  "statistics_per_group_age": {
1188
+ "child": {
1189
+ "minimal_value": 2.0,
1190
+ "normal_value": 5.75,
1191
+ "maximum_value": 9.5
1192
+ },
1193
+ "teenager": {
1194
+ "minimal_value": 1.25,
1195
+ "normal_value": 4.12,
1196
+ "maximum_value": 7.0
1197
+ },
1198
+ "adult": {
1199
+ "minimal_value": 0.875,
1200
+ "normal_value": 2.84,
1201
+ "maximum_value": 4.8
1202
+ },
1203
+ "elder": {
1204
+ "minimal_value": 0.875,
1205
+ "normal_value": 2.84,
1206
+ "maximum_value": 4.8
1207
+ }
1208
  },
1209
+ "related_tests": [
1210
+ "lym_percent",
1211
+ "wbc"
1212
+ ],
1213
+ "source_ids": [
1214
+ "uiowa_pediatric_reference",
1215
+ "uchicago_cbc_diff",
1216
+ "medlineplus_differential"
1217
+ ]
1218
  },
1219
  {
1220
  "id": "gra_absolute",
1221
  "display_name": "Absolute Granulocyte Count",
1222
+ "aliases": [
1223
+ "GRA#",
1224
+ "Granulocyte #",
1225
+ "Absolute granulocytes",
1226
+ "ANC when neutrophil-dominant"
1227
+ ],
1228
  "category": "CBC differential absolute count",
1229
  "unit": "10^3/uL",
1230
  "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.",
 
1235
  "pipeline_guidance": "Use the age-group interval and lab-provided reference range; do not infer sex-specific status unless the source range provides it."
1236
  },
1237
  "instructions_to_improve": {
1238
+ "food": [
1239
+ "No food directly normalizes granulocyte count; support immune health with balanced nutrition and adequate protein.",
1240
+ "If counts are very low, ask about infection prevention and food safety precautions.",
1241
+ "Hydration and rest during illness can support recovery but do not replace evaluation."
1242
+ ],
1243
+ "exercises": [
1244
+ "Rest during fever or acute infection.",
1245
+ "Resume regular moderate activity after recovery and medical clearance if counts are significantly abnormal."
1246
+ ],
1247
+ "supplements": [
1248
+ "Avoid self-treatment with immune supplements.",
1249
+ "Ask whether the lab means granulocytes broadly or absolute neutrophil count, and whether repeat CBC/differential is needed."
1250
+ ]
1251
  },
1252
  "statistics_per_group_age": {
1253
+ "child": {
1254
+ "minimal_value": 1.5,
1255
+ "normal_value": 5.0,
1256
+ "maximum_value": 8.5
1257
+ },
1258
+ "teenager": {
1259
+ "minimal_value": 1.7,
1260
+ "normal_value": 4.6,
1261
+ "maximum_value": 7.5
1262
+ },
1263
+ "adult": {
1264
+ "minimal_value": 1.12,
1265
+ "normal_value": 3.92,
1266
+ "maximum_value": 6.72
1267
+ },
1268
+ "elder": {
1269
+ "minimal_value": 1.12,
1270
+ "normal_value": 3.92,
1271
+ "maximum_value": 6.72
1272
+ }
1273
  },
1274
+ "related_tests": [
1275
+ "neu_percent",
1276
+ "wbc",
1277
+ "eos_percent",
1278
+ "bas_percent"
1279
+ ],
1280
+ "source_ids": [
1281
+ "uiowa_pediatric_reference",
1282
+ "uchicago_cbc_diff",
1283
+ "medlineplus_differential"
1284
+ ]
1285
  },
1286
  {
1287
  "id": "plt",
1288
  "display_name": "Platelet Count",
1289
+ "aliases": [
1290
+ "PLT",
1291
+ "Platelets",
1292
+ "Thrombocytes"
1293
+ ],
1294
  "category": "CBC platelet marker",
1295
  "unit": "10^3/uL",
1296
  "description": "Platelet count measures small blood cell fragments that help form clots and stop bleeding.",
 
1301
  "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."
1302
  },
1303
  "instructions_to_improve": {
1304
+ "food": [
1305
+ "Eat a balanced pattern with adequate protein, iron, folate, and B12 to support marrow production.",
1306
+ "If platelets are high with low iron markers, iron-rich foods may matter, but the cause should be confirmed.",
1307
+ "Avoid heavy alcohol intake because it can lower platelet production."
1308
+ ],
1309
+ "exercises": [
1310
+ "If platelets are very low, avoid contact sports or high-impact activities until cleared.",
1311
+ "Use regular moderate activity when platelet count and symptoms allow."
1312
+ ],
1313
+ "supplements": [
1314
+ "Avoid aspirin-like or blood-thinning supplements unless clinician-approved, especially with low platelets or bleeding symptoms.",
1315
+ "Discuss iron, B12, or folate only when deficiency is suspected or confirmed."
1316
+ ]
1317
  },
1318
  "statistics_per_group_age": {
1319
+ "child": {
1320
+ "minimal_value": 155,
1321
+ "normal_value": 327.5,
1322
+ "maximum_value": 500
1323
+ },
1324
+ "teenager": {
1325
+ "minimal_value": 140,
1326
+ "normal_value": 270.0,
1327
+ "maximum_value": 400
1328
+ },
1329
+ "adult": {
1330
+ "minimal_value": 150,
1331
+ "normal_value": 275.0,
1332
+ "maximum_value": 400
1333
+ },
1334
+ "elder": {
1335
+ "minimal_value": 150,
1336
+ "normal_value": 275.0,
1337
+ "maximum_value": 400
1338
+ }
1339
  },
1340
+ "related_tests": [
1341
+ "wbc",
1342
+ "hemoglobin"
1343
+ ],
1344
+ "source_ids": [
1345
+ "medlineplus_cbc",
1346
+ "uiowa_cbc_reference",
1347
+ "seattle_childrens_platelet",
1348
+ "nih_ods_iron",
1349
+ "nih_ods_b12",
1350
+ "nih_ods_folate"
1351
+ ]
1352
  },
1353
  {
1354
  "id": "esr",
1355
  "display_name": "Erythrocyte Sedimentation Rate",
1356
+ "aliases": [
1357
+ "ESR",
1358
+ "Sed rate",
1359
+ "Westergren ESR"
1360
+ ],
1361
  "category": "Inflammation marker",
1362
  "unit": "mm/hr",
1363
  "description": "ESR measures how quickly red blood cells settle in a tube over one hour.",
 
1369
  },
1370
  "sex_specific_statistics_per_group_age": {
1371
  "child": {
1372
+ "male": {
1373
+ "minimal_value": 0,
1374
+ "normal_value": 6.5,
1375
+ "maximum_value": 13
1376
+ },
1377
+ "female": {
1378
+ "minimal_value": 0,
1379
+ "normal_value": 6.5,
1380
+ "maximum_value": 13
1381
+ },
1382
+ "unknown": {
1383
+ "minimal_value": 0,
1384
+ "normal_value": 6.5,
1385
+ "maximum_value": 13
1386
+ }
1387
  },
1388
  "teenager": {
1389
+ "male": {
1390
+ "minimal_value": 0,
1391
+ "normal_value": 7.5,
1392
+ "maximum_value": 15
1393
+ },
1394
+ "female": {
1395
+ "minimal_value": 0,
1396
+ "normal_value": 10.0,
1397
+ "maximum_value": 20
1398
+ },
1399
+ "unknown": {
1400
+ "minimal_value": 0,
1401
+ "normal_value": 10.0,
1402
+ "maximum_value": 20
1403
+ }
1404
  },
1405
  "adult": {
1406
+ "male": {
1407
+ "minimal_value": 0,
1408
+ "normal_value": 7.5,
1409
+ "maximum_value": 15
1410
+ },
1411
+ "female": {
1412
+ "minimal_value": 0,
1413
+ "normal_value": 10.0,
1414
+ "maximum_value": 20
1415
+ },
1416
+ "unknown": {
1417
+ "minimal_value": 0,
1418
+ "normal_value": 10.0,
1419
+ "maximum_value": 20
1420
+ }
1421
  },
1422
  "elder": {
1423
+ "male": {
1424
+ "minimal_value": 0,
1425
+ "normal_value": 10.0,
1426
+ "maximum_value": 20
1427
+ },
1428
+ "female": {
1429
+ "minimal_value": 0,
1430
+ "normal_value": 15.0,
1431
+ "maximum_value": 30
1432
+ },
1433
+ "unknown": {
1434
+ "minimal_value": 0,
1435
+ "normal_value": 15.0,
1436
+ "maximum_value": 30
1437
+ }
1438
  }
1439
  },
1440
  "instructions_to_improve": {
1441
+ "food": [
1442
+ "Use a balanced dietary pattern emphasizing vegetables, fruits, whole grains, legumes, nuts, fish, and adequate protein.",
1443
+ "Reduce heavy alcohol intake and ultra-processed foods if they are major parts of the diet.",
1444
+ "Address the medical cause of inflammation; diet alone may not normalize ESR."
1445
+ ],
1446
+ "exercises": [
1447
+ "Regular moderate activity can support inflammatory and cardiovascular health when appropriate.",
1448
+ "Avoid strenuous exercise during acute illness, fever, or unexplained inflammatory symptoms."
1449
+ ],
1450
+ "supplements": [
1451
+ "Do not use supplements to hide or self-treat unexplained inflammation.",
1452
+ "Discuss whether CRP, repeat ESR, or condition-specific testing is appropriate; review all supplements and medications because some can affect results."
1453
+ ]
1454
  },
1455
  "statistics_per_group_age": {
1456
+ "child": {
1457
+ "minimal_value": 0,
1458
+ "normal_value": 5.0,
1459
+ "maximum_value": 10
1460
+ },
1461
+ "teenager": {
1462
+ "minimal_value": 0,
1463
+ "normal_value": 10.0,
1464
+ "maximum_value": 20
1465
+ },
1466
+ "adult": {
1467
+ "minimal_value": 0,
1468
+ "normal_value": 10.0,
1469
+ "maximum_value": 20
1470
+ },
1471
+ "elder": {
1472
+ "minimal_value": 0,
1473
+ "normal_value": 15.0,
1474
+ "maximum_value": 30
1475
+ }
1476
  },
1477
+ "related_tests": [
1478
+ "wbc",
1479
+ "hemoglobin"
1480
+ ],
1481
+ "source_ids": [
1482
+ "medlineplus_esr",
1483
+ "seattle_childrens_esr",
1484
+ "uchicago_esr_reference",
1485
+ "cleveland_clinic_esr"
1486
+ ]
1487
  }
1488
  ]
1489
  }
kb/knowledge_base.py CHANGED
@@ -65,6 +65,56 @@ KB: dict[str, MarkerKB] = {
65
  low="A low MCV (small red cells) is classically associated with iron deficiency or thalassemia.",
66
  questions=("Given my MCV, should we look for an iron or a B12 cause?",),
67
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  # --- Metabolic panel ---
69
  "Glucose": MarkerKB(
70
  high="An elevated fasting glucose can indicate prediabetes or diabetes, or simply that the sample was not fasting.",
@@ -116,6 +166,91 @@ KB: dict[str, MarkerKB] = {
116
  low="May reflect nutrition, liver, or kidney factors.",
117
  questions=("Does this fit with my albumin level?",),
118
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  # --- Liver enzymes ---
120
  "ALT": MarkerKB(
121
  high="ALT is fairly liver-specific; elevations can follow fatty liver, alcohol, medications, or viral hepatitis.",
@@ -142,6 +277,21 @@ KB: dict[str, MarkerKB] = {
142
  low="A low bilirubin is not a concern.",
143
  questions=("Is this mild and stable, or does it need follow-up?",),
144
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  # --- Lipid panel ---
146
  "Total Cholesterol": MarkerKB(
147
  high="A high total cholesterol contributes to cardiovascular risk and is best read alongside LDL, HDL, and your overall risk.",
@@ -163,6 +313,26 @@ KB: dict[str, MarkerKB] = {
163
  low="A low triglyceride level is generally not a concern.",
164
  questions=("Was this fasting?", "Would diet changes help?"),
165
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  # --- Thyroid ---
167
  "TSH": MarkerKB(
168
  high="A high TSH usually signals an underactive thyroid (the body asking for more hormone).",
@@ -174,6 +344,26 @@ KB: dict[str, MarkerKB] = {
174
  low="A low Free T4 supports an underactive thyroid picture.",
175
  questions=("How does this fit with my TSH?",),
176
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  # --- Vitamins / iron ---
178
  "Vitamin D": MarkerKB(
179
  high="A very high vitamin D is uncommon and usually from supplements.",
@@ -195,6 +385,166 @@ KB: dict[str, MarkerKB] = {
195
  low="A low HbA1c is generally not a concern.",
196
  questions=("Am I in the prediabetes range?", "What changes would lower this?"),
197
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  }
199
 
200
 
@@ -249,6 +599,36 @@ PATTERNS: tuple[Pattern, ...] = (
249
  "high Glucose with high HbA1c",
250
  "A high spot glucose backed by a high HbA1c is a stronger signal of impaired blood-sugar control than either alone.",
251
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  )
253
 
254
 
 
65
  low="A low MCV (small red cells) is classically associated with iron deficiency or thalassemia.",
66
  questions=("Given my MCV, should we look for an iron or a B12 cause?",),
67
  ),
68
+ "MCH": MarkerKB(
69
+ high="A high MCH usually tracks with large red cells (high MCV) and similar causes such as B12 or folate deficiency.",
70
+ low="A low MCH usually tracks with small red cells (low MCV) and points toward iron deficiency.",
71
+ questions=("Does my MCH fit with my MCV and hemoglobin?",),
72
+ ),
73
+ "MCHC": MarkerKB(
74
+ high="A high MCHC may reflect spherocytosis or dehydration-related concentration.",
75
+ low="A low MCHC is common in iron-deficiency anemia where cells carry less hemoglobin.",
76
+ questions=("Could iron studies explain my low MCHC?",),
77
+ ),
78
+ "RDW": MarkerKB(
79
+ high="A high RDW means red cells vary more in size; it often appears early in iron, B12, or folate deficiency.",
80
+ low="A low RDW is usually not clinically significant.",
81
+ questions=("Does a high RDW suggest a mixed or early deficiency?",),
82
+ ),
83
+ "MPV": MarkerKB(
84
+ high="A high MPV means larger platelets, which can appear when the marrow is making new platelets quickly.",
85
+ low="A low MPV is usually not a concern on its own.",
86
+ questions=("Does my MPV fit with my platelet count?",),
87
+ ),
88
+ "Absolute Neutrophil Count": MarkerKB(
89
+ high="A high neutrophil count often accompanies bacterial infection, inflammation, or physical stress.",
90
+ low="A low neutrophil count raises infection risk and may follow viruses, medications, or bone-marrow issues.",
91
+ questions=("Could an infection explain my neutrophil count?", "If low, do I need extra precautions?"),
92
+ ),
93
+ "Absolute Lymphocyte Count": MarkerKB(
94
+ high="A high lymphocyte count may follow viral infections or certain immune conditions.",
95
+ low="A low lymphocyte count can follow stress, steroids, or immune conditions.",
96
+ questions=("Was this drawn during or after an illness?",),
97
+ ),
98
+ "Absolute Monocyte Count": MarkerKB(
99
+ high="A high monocyte count may appear during recovery from infection or with chronic inflammation.",
100
+ low="A low monocyte count is rarely significant on its own.",
101
+ questions=("Does this fit with a recent or ongoing infection?",),
102
+ ),
103
+ "Absolute Eosinophil Count": MarkerKB(
104
+ high="A high eosinophil count is classically linked to allergies, asthma, or parasitic infection.",
105
+ low="A low eosinophil count is usually not a concern.",
106
+ questions=("Could allergies or asthma explain this?",),
107
+ ),
108
+ "Absolute Basophil Count": MarkerKB(
109
+ high="A high basophil count is uncommon and may relate to allergy or certain blood disorders.",
110
+ low="A low basophil count is usually not significant.",
111
+ questions=("Is this a persistent finding worth rechecking?",),
112
+ ),
113
+ "Reticulocyte Count": MarkerKB(
114
+ high="A high reticulocyte count means the marrow is making extra red cells, often after blood loss or hemolysis.",
115
+ low="A low reticulocyte count in anemia suggests the marrow is not keeping up with red-cell need.",
116
+ questions=("Is my body replacing red cells appropriately?",),
117
+ ),
118
  # --- Metabolic panel ---
119
  "Glucose": MarkerKB(
120
  high="An elevated fasting glucose can indicate prediabetes or diabetes, or simply that the sample was not fasting.",
 
166
  low="May reflect nutrition, liver, or kidney factors.",
167
  questions=("Does this fit with my albumin level?",),
168
  ),
169
+ "Globulin": MarkerKB(
170
+ high="A high globulin may reflect increased antibodies from infection, inflammation, or immune conditions.",
171
+ low="A low globulin may reflect reduced antibody production or liver disease.",
172
+ questions=("Does the albumin/globulin ratio need follow-up?",),
173
+ ),
174
+ "Bicarbonate": MarkerKB(
175
+ high="A high bicarbonate may reflect metabolic alkalosis or compensation for lung issues.",
176
+ low="A low bicarbonate may reflect metabolic acidosis or severe diarrhea.",
177
+ questions=("Does this fit with my other electrolytes and symptoms?",),
178
+ ),
179
+ "Anion Gap": MarkerKB(
180
+ high="A high anion gap often points to acid buildup from ketones, lactate, toxins, or kidney failure.",
181
+ low="A low anion gap is uncommon and usually less urgent.",
182
+ questions=("Could this relate to dehydration, diabetes, or kidney function?",),
183
+ ),
184
+ "Magnesium": MarkerKB(
185
+ high="A high magnesium is uncommon outside supplements or severe kidney impairment.",
186
+ low="A low magnesium can cause cramps, tremor, or heart rhythm issues and often accompanies low potassium.",
187
+ questions=("Should we recheck magnesium and potassium together?",),
188
+ ),
189
+ "Phosphate": MarkerKB(
190
+ high="A high phosphate may relate to kidney disease, low parathyroid activity, or cell breakdown.",
191
+ low="A low phosphate may relate to malnutrition, alcohol, or overcorrection of vitamin D.",
192
+ questions=("Does this fit with my calcium and kidney results?",),
193
+ ),
194
+ "Uric Acid": MarkerKB(
195
+ high="A high uric acid is associated with gout and kidney stones and may rise with diet, alcohol, or kidney disease.",
196
+ low="A low uric acid is usually not a concern.",
197
+ questions=("Could diet or medications be contributing?",),
198
+ ),
199
+ "Serum Iron": MarkerKB(
200
+ high="A high serum iron may reflect supplements, hemochromatosis, or recent infusion.",
201
+ low="A low serum iron often accompanies iron-deficiency anemia but varies with recent meals and inflammation.",
202
+ questions=("Should we interpret this with ferritin and TIBC?",),
203
+ ),
204
+ "TIBC": MarkerKB(
205
+ high="A high TIBC often means the body is trying to bind more iron during iron deficiency.",
206
+ low="A low TIBC may appear with inflammation or iron overload.",
207
+ questions=("Does TIBC fit with my ferritin and transferrin saturation?",),
208
+ ),
209
+ "Transferrin Saturation": MarkerKB(
210
+ high="A high saturation may suggest iron overload or excess intake.",
211
+ low="A low saturation is common in iron deficiency.",
212
+ questions=("Is this consistent with my ferritin and hemoglobin?",),
213
+ ),
214
+ "LDH": MarkerKB(
215
+ high="LDH is a nonspecific marker of cell damage from hemolysis, liver injury, muscle injury, or malignancy.",
216
+ low="A low LDH is not typically a concern.",
217
+ questions=("What might be causing cell turnover or damage?",),
218
+ ),
219
+ "Osmolality": MarkerKB(
220
+ high="A high osmolality may reflect dehydration, high blood sugar, or excess sodium.",
221
+ low="A low osmolality may reflect overhydration or low sodium.",
222
+ questions=("Does this match my sodium and glucose?",),
223
+ ),
224
+ "Ammonia": MarkerKB(
225
+ high="A high ammonia may relate to liver disease and can affect mental status.",
226
+ low="A low ammonia is not typically significant.",
227
+ questions=("Should liver function be evaluated if ammonia is high?",),
228
+ ),
229
+ "Lactate": MarkerKB(
230
+ high="A high lactate may reflect poor tissue oxygen delivery, sepsis, or strenuous exercise at draw time.",
231
+ low="A low lactate is not a concern.",
232
+ questions=("Was the sample handled promptly?", "Could infection or low blood pressure explain this?"),
233
+ ),
234
+ "Homocysteine": MarkerKB(
235
+ high="A high homocysteine may relate to low B vitamins and is linked to vascular risk in research.",
236
+ low="A low homocysteine is generally favorable.",
237
+ questions=("Should we check B12 and folate?",),
238
+ ),
239
+ "Cystatin C": MarkerKB(
240
+ high="A high cystatin C suggests reduced kidney filtration, similar to creatinine but less muscle-dependent.",
241
+ low="A low cystatin C is usually not a concern.",
242
+ questions=("How does this compare with my creatinine and eGFR?",),
243
+ ),
244
+ "Prealbumin": MarkerKB(
245
+ high="A high prealbumin is uncommon and may reflect steroids or kidney loss of protein.",
246
+ low="A low prealbumin may reflect recent poor nutrition or inflammation.",
247
+ questions=("Could nutrition or inflammation be affecting this?",),
248
+ ),
249
+ "Beta-2 Microglobulin": MarkerKB(
250
+ high="A high beta-2 microglobulin may reflect increased cell turnover, kidney impairment, or certain blood conditions.",
251
+ low="A low beta-2 microglobulin is not typically significant.",
252
+ questions=("Should this be interpreted with kidney function?",),
253
+ ),
254
  # --- Liver enzymes ---
255
  "ALT": MarkerKB(
256
  high="ALT is fairly liver-specific; elevations can follow fatty liver, alcohol, medications, or viral hepatitis.",
 
277
  low="A low bilirubin is not a concern.",
278
  questions=("Is this mild and stable, or does it need follow-up?",),
279
  ),
280
+ "Direct Bilirubin": MarkerKB(
281
+ high="A high direct bilirubin suggests the liver or bile ducts are not processing bilirubin normally.",
282
+ low="A low direct bilirubin is not a concern.",
283
+ questions=("Does this point to a liver or bile-duct issue?",),
284
+ ),
285
+ "Lipase": MarkerKB(
286
+ high="A high lipase commonly points to pancreatic inflammation but can rise with other abdominal conditions.",
287
+ low="A low lipase is not typically significant.",
288
+ questions=("Could abdominal pain relate to this lipase?",),
289
+ ),
290
+ "Amylase": MarkerKB(
291
+ high="A high amylase may reflect pancreatic or salivary-gland inflammation.",
292
+ low="A low amylase is rarely significant.",
293
+ questions=("Should lipase be checked alongside amylase?",),
294
+ ),
295
  # --- Lipid panel ---
296
  "Total Cholesterol": MarkerKB(
297
  high="A high total cholesterol contributes to cardiovascular risk and is best read alongside LDL, HDL, and your overall risk.",
 
313
  low="A low triglyceride level is generally not a concern.",
314
  questions=("Was this fasting?", "Would diet changes help?"),
315
  ),
316
+ "Non-HDL Cholesterol": MarkerKB(
317
+ high="Non-HDL cholesterol captures all atherogenic particles and adds to cardiovascular risk.",
318
+ low="A low non-HDL cholesterol is generally favorable.",
319
+ questions=("What non-HDL target fits my overall risk?",),
320
+ ),
321
+ "Apolipoprotein B": MarkerKB(
322
+ high="Apo B reflects the number of LDL-like particles and is linked to plaque risk.",
323
+ low="A low Apo B is generally favorable.",
324
+ questions=("How does Apo B compare with my LDL?",),
325
+ ),
326
+ "Apolipoprotein A-1": MarkerKB(
327
+ high="A higher Apo A-1 is generally associated with more HDL and lower cardiovascular risk.",
328
+ low="A low Apo A-1 may accompany low HDL and higher risk.",
329
+ questions=("Would exercise or not smoking help raise HDL/Apo A-1?",),
330
+ ),
331
+ "Lipoprotein(a)": MarkerKB(
332
+ high="Lp(a) is largely genetic and adds cardiovascular risk independent of LDL.",
333
+ low="A low Lp(a) is generally favorable.",
334
+ questions=("Does my family history fit with a high Lp(a)?",),
335
+ ),
336
  # --- Thyroid ---
337
  "TSH": MarkerKB(
338
  high="A high TSH usually signals an underactive thyroid (the body asking for more hormone).",
 
344
  low="A low Free T4 supports an underactive thyroid picture.",
345
  questions=("How does this fit with my TSH?",),
346
  ),
347
+ "Free T3": MarkerKB(
348
+ high="A high Free T3 supports an overactive thyroid picture.",
349
+ low="A low Free T3 may appear in underactive thyroid or severe illness.",
350
+ questions=("How does Free T3 fit with my TSH and Free T4?",),
351
+ ),
352
+ "Total T4": MarkerKB(
353
+ high="A high Total T4 may reflect hyperthyroidism or high thyroid-binding proteins.",
354
+ low="A low Total T4 may reflect hypothyroidism or binding-protein changes.",
355
+ questions=("Should Free T4 be used for interpretation?",),
356
+ ),
357
+ "Total T3": MarkerKB(
358
+ high="A high Total T3 may reflect hyperthyroidism.",
359
+ low="A low Total T3 may appear in hypothyroidism or non-thyroidal illness.",
360
+ questions=("Does this match my TSH and Free T3?",),
361
+ ),
362
+ "Anti-TPO Antibodies": MarkerKB(
363
+ high="Anti-TPO antibodies suggest autoimmune thyroid disease such as Hashimoto's.",
364
+ low="A negative or low Anti-TPO is expected in most people without autoimmune thyroid disease.",
365
+ questions=("Could this explain my thyroid symptoms or TSH changes?",),
366
+ ),
367
  # --- Vitamins / iron ---
368
  "Vitamin D": MarkerKB(
369
  high="A very high vitamin D is uncommon and usually from supplements.",
 
385
  low="A low HbA1c is generally not a concern.",
386
  questions=("Am I in the prediabetes range?", "What changes would lower this?"),
387
  ),
388
+ # --- Coagulation ---
389
+ "Prothrombin Time": MarkerKB(
390
+ high="A prolonged PT means clotting takes longer and may relate to warfarin, liver disease, or clotting-factor deficiency.",
391
+ low="A shorter PT is usually not clinically flagged.",
392
+ questions=("Am I on blood thinners?", "Should INR be checked instead?"),
393
+ ),
394
+ "INR": MarkerKB(
395
+ high="A high INR means slower clotting; it is expected on warfarin but dangerous if unintentionally high.",
396
+ low="A low INR on warfarin may mean under-anticoagulation.",
397
+ questions=("What INR range am I aiming for?",),
398
+ ),
399
+ "aPTT": MarkerKB(
400
+ high="A prolonged aPTT may relate to heparin, lupus anticoagulant, or clotting-factor deficiency.",
401
+ low="A shorter aPTT is rarely flagged alone.",
402
+ questions=("Am I on heparin or do I bruise easily?",),
403
+ ),
404
+ "Fibrinogen": MarkerKB(
405
+ high="A high fibrinogen may reflect inflammation or increased clotting tendency.",
406
+ low="A low fibrinogen may increase bleeding risk.",
407
+ questions=("Could inflammation explain a high fibrinogen?",),
408
+ ),
409
+ "D-Dimer": MarkerKB(
410
+ high="A high D-dimer suggests active clot breakdown but is nonspecific and rises with infection, surgery, or pregnancy.",
411
+ low="A low D-dimer makes significant clotting less likely in the right clinical context.",
412
+ questions=("Was this ordered because of leg pain or shortness of breath?",),
413
+ ),
414
+ # --- Inflammation / immune ---
415
+ "C-Reactive Protein": MarkerKB(
416
+ high="A high CRP indicates inflammation from infection, autoimmune disease, or tissue injury.",
417
+ low="A low CRP is expected in the absence of significant inflammation.",
418
+ questions=("Could a recent infection explain this?",),
419
+ ),
420
+ "hs-CRP": MarkerKB(
421
+ high="An elevated hs-CRP adds to cardiovascular risk even when general CRP is low-grade.",
422
+ low="A low hs-CRP is generally favorable for heart risk.",
423
+ questions=("What lifestyle changes would lower my cardiovascular risk?",),
424
+ ),
425
+ "ESR": MarkerKB(
426
+ high="A high ESR is a nonspecific sign of inflammation, infection, or autoimmune activity.",
427
+ low="A low ESR is usually not a concern.",
428
+ questions=("Does this fit with my symptoms or other inflammatory markers?",),
429
+ ),
430
+ "Procalcitonin": MarkerKB(
431
+ high="A high procalcitonin more specifically suggests bacterial infection.",
432
+ low="A low procalcitonin makes serious bacterial infection less likely.",
433
+ questions=("Was this drawn during a fever or suspected infection?",),
434
+ ),
435
+ "Complement C3": MarkerKB(
436
+ high="A high C3 may appear during acute inflammation.",
437
+ low="A low C3 may appear in active autoimmune disease or complement consumption.",
438
+ questions=("Should this be read with other immune tests?",),
439
+ ),
440
+ "Complement C4": MarkerKB(
441
+ high="A high C4 is less commonly flagged than low values.",
442
+ low="A low C4 may appear in autoimmune conditions such as lupus.",
443
+ questions=("Do my symptoms fit an autoimmune pattern?",),
444
+ ),
445
+ "Rheumatoid Factor": MarkerKB(
446
+ high="A positive rheumatoid factor may appear in rheumatoid arthritis and other conditions.",
447
+ low="A negative rheumatoid factor does not rule out arthritis.",
448
+ questions=("Do my joints hurt or swell, especially in the morning?",),
449
+ ),
450
+ # --- Cardiac ---
451
+ "BNP": MarkerKB(
452
+ high="A high BNP suggests the heart is under strain, as in heart failure or fluid overload.",
453
+ low="A low BNP makes significant heart failure less likely.",
454
+ questions=("Do I have shortness of breath or leg swelling?",),
455
+ ),
456
+ "Troponin I": MarkerKB(
457
+ high="A high troponin indicates heart-muscle injury and needs urgent clinical evaluation.",
458
+ low="A low troponin is expected when there is no heart injury.",
459
+ questions=("Was chest pain or pressure present when this was drawn?",),
460
+ ),
461
+ "Creatine Kinase": MarkerKB(
462
+ high="A high CK may reflect muscle injury from exercise, trauma, statins, or heart muscle damage.",
463
+ low="A low CK is not typically significant.",
464
+ questions=("Did I exercise heavily before the blood draw?", "Am I on a statin?"),
465
+ ),
466
+ "CK-MB": MarkerKB(
467
+ high="A high CK-MB raises concern for heart-muscle injury when troponin is also elevated.",
468
+ low="A low CK-MB is expected without heart injury.",
469
+ questions=("Was this checked because of chest symptoms?",),
470
+ ),
471
+ # --- Hormones ---
472
+ "Cortisol": MarkerKB(
473
+ high="A high cortisol may reflect stress, steroids, Cushing syndrome, or lab timing.",
474
+ low="A low cortisol may relate to adrenal insufficiency and can cause fatigue or low blood pressure.",
475
+ questions=("Was this a morning sample?", "Am I on steroid medications?"),
476
+ ),
477
+ "Insulin": MarkerKB(
478
+ high="A high insulin may reflect insulin resistance even when glucose is still normal.",
479
+ low="A low insulin may appear in type 1 diabetes or long-standing type 2 diabetes.",
480
+ questions=("Should we assess insulin resistance or diabetes risk?",),
481
+ ),
482
+ "Testosterone": MarkerKB(
483
+ high="A high testosterone may relate to supplements, tumors, or polycystic ovary syndrome in women.",
484
+ low="A low testosterone may cause fatigue, low libido, or muscle loss in men.",
485
+ questions=("Could symptoms match my testosterone level?",),
486
+ ),
487
+ "Estradiol": MarkerKB(
488
+ high="A high estradiol may relate to ovarian function, obesity, or hormone therapy.",
489
+ low="A low estradiol may relate to menopause, ovarian failure, or low body weight.",
490
+ questions=("Where am I in my cycle or menopause status?",),
491
+ ),
492
+ "Prolactin": MarkerKB(
493
+ high="A high prolactin may cause menstrual changes or milk production and can come from pituitary issues or medications.",
494
+ low="A low prolactin is usually not a concern.",
495
+ questions=("Am I on medications that raise prolactin?",),
496
+ ),
497
+ "FSH": MarkerKB(
498
+ high="A high FSH in women often suggests reduced ovarian reserve or menopause; in men it may suggest testicular failure.",
499
+ low="A low FSH may relate to pituitary or hypothalamic issues.",
500
+ questions=("Are fertility or menopause questions relevant?",),
501
+ ),
502
+ "LH": MarkerKB(
503
+ high="A high LH may appear at menopause or with polycystic ovary syndrome depending on context.",
504
+ low="A low LH may relate to pituitary or hypothalamic causes of low sex hormones.",
505
+ questions=("Should LH be read with FSH and estradiol or testosterone?",),
506
+ ),
507
+ "Progesterone": MarkerKB(
508
+ high="A high progesterone may reflect the luteal phase, pregnancy, or supplementation.",
509
+ low="A low progesterone may relate to anovulation or luteal-phase deficiency.",
510
+ questions=("What day of my cycle was this drawn?",),
511
+ ),
512
+ "Parathyroid Hormone": MarkerKB(
513
+ high="A high PTH may drive calcium up in primary hyperparathyroidism or rise appropriately when calcium is low.",
514
+ low="A low PTH may appear after parathyroid surgery or with high calcium from other causes.",
515
+ questions=("How does PTH fit with my calcium and vitamin D?",),
516
+ ),
517
+ "ACTH": MarkerKB(
518
+ high="A high ACTH may appear when the adrenal glands are underactive or in certain tumors.",
519
+ low="A low ACTH may appear with steroid use or pituitary causes of low cortisol.",
520
+ questions=("Should ACTH be read with cortisol?",),
521
+ ),
522
+ "SHBG": MarkerKB(
523
+ high="A high SHBG binds more testosterone and estrogen, lowering their free fractions.",
524
+ low="A low SHBG is linked to insulin resistance and higher free androgen activity.",
525
+ questions=("Should free testosterone be calculated?",),
526
+ ),
527
+ "IGF-1": MarkerKB(
528
+ high="A high IGF-1 may reflect excess growth hormone.",
529
+ low="A low IGF-1 may reflect growth-hormone deficiency or malnutrition.",
530
+ questions=("Are height, hands, or jaw changes relevant?",),
531
+ ),
532
+ # --- Oncology / screening ---
533
+ "PSA": MarkerKB(
534
+ high="A high PSA may come from prostate enlargement, infection, recent procedures, or less commonly cancer.",
535
+ low="A low PSA is expected and does not rule out all prostate conditions.",
536
+ questions=("Could recent exercise or infection have raised PSA?", "Is repeat testing planned?"),
537
+ ),
538
+ "Folate": MarkerKB(
539
+ high="A high folate is usually from supplements or fortified foods.",
540
+ low="A low folate can cause anemia similar to B12 deficiency and affects DNA synthesis.",
541
+ questions=("Could low folate explain my MCV or anemia?",),
542
+ ),
543
+ "Vitamin A": MarkerKB(
544
+ high="A very high vitamin A is usually from supplements and can be toxic.",
545
+ low="A low vitamin A may affect vision and immunity and relates to diet or malabsorption.",
546
+ questions=("Am I taking vitamin A supplements?",),
547
+ ),
548
  }
549
 
550
 
 
599
  "high Glucose with high HbA1c",
600
  "A high spot glucose backed by a high HbA1c is a stronger signal of impaired blood-sugar control than either alone.",
601
  ),
602
+ Pattern(
603
+ "Iron studies pattern",
604
+ "low Ferritin with low Serum Iron and low Transferrin Saturation",
605
+ "Low iron stores with low circulating iron and saturation strongly supports iron deficiency as a cause of anemia.",
606
+ ),
607
+ Pattern(
608
+ "Infection / inflammation cluster",
609
+ "high White Blood Cell Count with high C-Reactive Protein or high Procalcitonin",
610
+ "Together these suggest an active inflammatory or infectious process worth clinical correlation.",
611
+ ),
612
+ Pattern(
613
+ "Coagulation concern",
614
+ "prolonged Prothrombin Time or high INR with prolonged aPTT",
615
+ "Multiple clotting tests abnormal together raise bleeding risk and medication or liver causes should be reviewed.",
616
+ ),
617
+ Pattern(
618
+ "Cardiac strain pattern",
619
+ "high BNP with high Troponin I",
620
+ "Elevated heart-strain and injury markers together warrant urgent clinical assessment.",
621
+ ),
622
+ Pattern(
623
+ "Pancreatic enzyme pattern",
624
+ "high Lipase with high Amylase",
625
+ "Both pancreatic enzymes elevated together more strongly suggest pancreatic inflammation than either alone.",
626
+ ),
627
+ Pattern(
628
+ "Autoimmune thyroid pattern",
629
+ "high Anti-TPO Antibodies with abnormal TSH",
630
+ "Thyroid autoantibodies plus abnormal TSH suggest autoimmune thyroid disease rather than a transient lab variation.",
631
+ ),
632
  )
633
 
634
 
requirements.txt CHANGED
@@ -4,13 +4,12 @@ requests==2.32.5
4
  pillow==12.0.0
5
  pymupdf==1.26.6
6
  json-repair==0.60.1
7
- # ZeroGPU/CUDA path for EXTRACTOR_BACKEND=auto: the app uses the official OpenBMB
8
- # Transformers pipeline when ACCELERATOR is ZeroGPU, ZERO_GPU=TRUE, or CUDA is visible.
9
- torch==2.9.1 ; sys_platform == "linux" and platform_machine == "x86_64"
10
  transformers[torch]==5.7.0
11
  accelerate==1.12.0
12
  bitsandbytes==0.48.2 ; sys_platform == "linux" and platform_machine == "x86_64"
13
- torchvision==0.24.1 ; sys_platform == "linux" and platform_machine == "x86_64"
14
  av==16.0.1 ; sys_platform == "linux" and platform_machine == "x86_64"
15
  # CPU fallback path: install the prebuilt manylinux wheel directly to avoid a source build on Spaces.
16
  llama-cpp-python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.28/llama_cpp_python-0.3.28-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl ; sys_platform == "linux" and platform_machine == "x86_64"
 
4
  pillow==12.0.0
5
  pymupdf==1.26.6
6
  json-repair==0.60.1
7
+ # Local Transformers vision path (Mac + Linux GPU). Default EXTRACTOR_BACKEND=transformers.
8
+ torch==2.9.1 ; sys_platform == "darwin" or (sys_platform == "linux" and platform_machine == "x86_64")
 
9
  transformers[torch]==5.7.0
10
  accelerate==1.12.0
11
  bitsandbytes==0.48.2 ; sys_platform == "linux" and platform_machine == "x86_64"
12
+ torchvision==0.24.1 ; sys_platform == "darwin" or (sys_platform == "linux" and platform_machine == "x86_64")
13
  av==16.0.1 ; sys_platform == "linux" and platform_machine == "x86_64"
14
  # CPU fallback path: install the prebuilt manylinux wheel directly to avoid a source build on Spaces.
15
  llama-cpp-python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.28/llama_cpp_python-0.3.28-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl ; sys_platform == "linux" and platform_machine == "x86_64"
src/document_processing.py CHANGED
@@ -1,12 +1,18 @@
1
  from __future__ import annotations
2
 
 
 
3
  from pathlib import Path
4
 
5
  import fitz
6
 
7
-
8
  SUPPORTED_TEXT_EXTENSIONS = {".txt", ".csv"}
9
- SUPPORTED_EXTENSIONS = SUPPORTED_TEXT_EXTENSIONS | {".pdf"}
 
 
 
 
 
10
 
11
 
12
  def validate_upload(path: str) -> Path:
@@ -22,12 +28,31 @@ def validate_upload(path: str) -> Path:
22
  return file_path
23
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  def document_to_payload_parts(path: str, max_pages: int | None = None) -> list[dict]:
 
26
  file_path = validate_upload(path)
27
  extension = file_path.suffix.lower()
 
28
 
29
  if extension == ".pdf":
30
- return [{"type": "text", "text": _pdf_to_text(file_path, max_pages=max_pages)}]
 
 
 
31
 
32
  if extension in SUPPORTED_TEXT_EXTENSIONS:
33
  return [{"type": "text", "text": _read_text_file(file_path)}]
@@ -35,26 +60,41 @@ def document_to_payload_parts(path: str, max_pages: int | None = None) -> list[d
35
  raise ValueError(f"Unsupported file type `{extension}`.")
36
 
37
 
38
- def _pdf_to_text(file_path: Path, max_pages: int | None) -> str:
39
- chunks: list[str] = []
40
  with fitz.open(file_path) as document:
41
  if document.page_count == 0:
42
  raise ValueError("The uploaded PDF does not contain any pages.")
43
 
44
- pages_to_read = document.page_count if max_pages is None else min(document.page_count, max_pages)
45
- for page_index in range(pages_to_read):
46
  page = document.load_page(page_index)
47
- text = page.get_text("text").strip()
48
- if text:
49
- chunks.append(f"[Page {page_index + 1}]\n{text}")
50
-
51
- text = "\n\n".join(chunks).strip()
52
- if not text:
53
- raise ValueError(
54
- "The uploaded PDF does not contain extractable text. "
55
- "Please use a text-based PDF rather than a scanned image."
56
- )
57
- return text[:30000]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  def _read_text_file(file_path: Path) -> str:
 
1
  from __future__ import annotations
2
 
3
+ import base64
4
+ import io
5
  from pathlib import Path
6
 
7
  import fitz
8
 
 
9
  SUPPORTED_TEXT_EXTENSIONS = {".txt", ".csv"}
10
+ SUPPORTED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".tif", ".tiff"}
11
+ SUPPORTED_EXTENSIONS = SUPPORTED_TEXT_EXTENSIONS | SUPPORTED_IMAGE_EXTENSIONS | {".pdf"}
12
+
13
+ _DEFAULT_MAX_PAGES = 3
14
+ _PDF_RENDER_MATRIX = fitz.Matrix(2.5, 2.5)
15
+ _MAX_IMAGE_EDGE = 2048
16
 
17
 
18
  def validate_upload(path: str) -> Path:
 
28
  return file_path
29
 
30
 
31
+ def document_intake_metadata(path: str, parts: list[dict]) -> dict[str, object]:
32
+ """Lightweight intake stats for traces (no base64 payloads)."""
33
+ extension = Path(path).suffix.lower()
34
+ image_count = sum(1 for part in parts if part.get("type") == "image_url")
35
+ text_characters = sum(len(str(part.get("text") or "")) for part in parts if part.get("type") == "text")
36
+ return {
37
+ "source_extension": extension,
38
+ "input_modality": "vision" if image_count else "text",
39
+ "pages_rendered": image_count if extension == ".pdf" else None,
40
+ "image_count": image_count,
41
+ "text_characters": text_characters,
42
+ }
43
+
44
+
45
  def document_to_payload_parts(path: str, max_pages: int | None = None) -> list[dict]:
46
+ """Build OpenAI-compatible message parts for vision extraction."""
47
  file_path = validate_upload(path)
48
  extension = file_path.suffix.lower()
49
+ page_limit = _DEFAULT_MAX_PAGES if max_pages is None else max_pages
50
 
51
  if extension == ".pdf":
52
+ return _pdf_to_image_parts(file_path, max_pages=page_limit)
53
+
54
+ if extension in SUPPORTED_IMAGE_EXTENSIONS:
55
+ return [_image_part(file_path)]
56
 
57
  if extension in SUPPORTED_TEXT_EXTENSIONS:
58
  return [{"type": "text", "text": _read_text_file(file_path)}]
 
60
  raise ValueError(f"Unsupported file type `{extension}`.")
61
 
62
 
63
+ def _pdf_to_image_parts(file_path: Path, max_pages: int) -> list[dict]:
64
+ parts: list[dict] = []
65
  with fitz.open(file_path) as document:
66
  if document.page_count == 0:
67
  raise ValueError("The uploaded PDF does not contain any pages.")
68
 
69
+ pages_to_render = min(document.page_count, max(1, max_pages))
70
+ for page_index in range(pages_to_render):
71
  page = document.load_page(page_index)
72
+ pixmap = page.get_pixmap(matrix=_PDF_RENDER_MATRIX, alpha=False)
73
+ encoded = base64.b64encode(pixmap.tobytes("png")).decode("ascii")
74
+ parts.append(
75
+ {
76
+ "type": "image_url",
77
+ "image_url": {"url": f"data:image/png;base64,{encoded}"},
78
+ }
79
+ )
80
+
81
+ return parts
82
+
83
+
84
+ def _image_part(file_path: Path) -> dict:
85
+ from PIL import Image, ImageOps
86
+
87
+ with Image.open(file_path) as image:
88
+ image = ImageOps.exif_transpose(image).convert("RGB")
89
+ image.thumbnail((_MAX_IMAGE_EDGE, _MAX_IMAGE_EDGE))
90
+ buffer = io.BytesIO()
91
+ image.save(buffer, format="JPEG", quality=90, optimize=True)
92
+ encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
93
+
94
+ return {
95
+ "type": "image_url",
96
+ "image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
97
+ }
98
 
99
 
100
  def _read_text_file(file_path: Path) -> str:
src/extraction/__init__.py CHANGED
@@ -1,13 +1,12 @@
1
  """Extraction backends behind one interface.
2
 
3
  `build_extractor()` returns the right backend for the environment:
4
- - **auto**: Transformers on ZeroGPU/CUDA; CPU llama.cpp otherwise.
5
- - **zerogpu** / **transformers**: force official OpenBMB MiniCPM-V through Transformers.
6
  - **llamacpp-gpu** / **llama-champion**: force GGUF through llama.cpp.
7
  - **local**: local llama-server / llama.cpp backends for local experimentation.
8
- - **api**: the original OpenBMB hosted endpoint, kept as a dev fallback only.
9
 
10
- Default is `auto`.
11
  """
12
 
13
  from src.extraction.base import Extractor, ExtractionResult
 
1
  """Extraction backends behind one interface.
2
 
3
  `build_extractor()` returns the right backend for the environment:
4
+ - **transformers** (default): local OpenBMB MiniCPM-V through Transformers.
5
+ - **auto**: same as transformers.
6
  - **llamacpp-gpu** / **llama-champion**: force GGUF through llama.cpp.
7
  - **local**: local llama-server / llama.cpp backends for local experimentation.
 
8
 
9
+ The hosted OpenBMB HTTP API is disabled.
10
  """
11
 
12
  from src.extraction.base import Extractor, ExtractionResult
src/extraction/auto.py CHANGED
@@ -5,85 +5,33 @@ from __future__ import annotations
5
  import os
6
 
7
  from src.extraction.base import Extractor
8
- from src.extraction.llamacpp_gpu import LlamaCppGPUExtractor
9
  from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor
10
 
11
 
12
  class AutoExtractor:
13
- """Use Transformers on ZeroGPU/CUDA, otherwise use CPU llama.cpp."""
14
 
15
  def __init__(self, model_id: str | None = None) -> None:
16
  self.model_id = model_id
17
  self._selected: Extractor | None = None
18
 
19
  def extract(self, file_path: str, max_pages: int = 3):
20
- backend = self._backend()
21
- try:
22
- return backend.extract(file_path, max_pages=max_pages)
23
- except Exception as exc:
24
- if not isinstance(backend, ZeroGPUTransformersExtractor) or not _fallback_enabled():
25
- raise
26
 
 
 
 
 
 
27
  print(
28
- "[Blood Test Explainer] CUDA Transformers backend failed; "
29
- f"falling back to CPU llama.cpp. Inner error: {type(exc).__name__}: {exc}",
30
  flush=True,
31
  )
32
- self._selected = LlamaCppGPUExtractor()
33
- return self._selected.extract(file_path, max_pages=max_pages)
34
-
35
- def _backend(self) -> Extractor:
36
- if self._selected is None:
37
- target = runtime_target()
38
- print(f"[Blood Test Explainer] auto extractor selected {target}", flush=True)
39
- if target == "transformers":
40
- self._selected = ZeroGPUTransformersExtractor(model_id=self.model_id)
41
- else:
42
- self._selected = LlamaCppGPUExtractor()
43
  return self._selected
44
 
45
 
46
  def runtime_target() -> str:
47
- """Return `transformers` for ZeroGPU/CUDA and `llamacpp` for CPU-only runtime."""
48
- if zerogpu_runtime_requested() or cuda_available():
49
- return "transformers"
50
- return "llamacpp"
51
-
52
-
53
- def zerogpu_runtime_requested() -> bool:
54
- """Detect HF ZeroGPU from explicit Space/runtime environment flags.
55
-
56
- ZeroGPU exposes CUDA only inside a `@spaces.GPU` worker, so checking
57
- `torch.cuda.is_available()` in normal Gradio app code is not enough.
58
- """
59
- boolean_flags = ("ZERO_GPU", "SPACES_ZERO_GPU", "HF_ZERO_GPU", "BTE_ZERO_GPU")
60
- for name in boolean_flags:
61
- value = os.getenv(name, "").strip().lower()
62
- if value in {"1", "true", "yes", "on", "zerogpu", "zero-gpu"}:
63
- return True
64
-
65
- hardware_flags = ("ACCELERATOR", "BTE_RUNTIME", "BTE_HARDWARE", "SPACE_HARDWARE", "HF_SPACE_HARDWARE")
66
- for name in hardware_flags:
67
- value = os.getenv(name, "").strip().lower()
68
- if "zero" in value and "gpu" in value:
69
- return True
70
- if value.startswith("zero-"):
71
- return True
72
-
73
- return False
74
-
75
-
76
- def cuda_available() -> bool:
77
- try:
78
- import torch
79
- except Exception:
80
- return False
81
-
82
- try:
83
- return bool(torch.cuda.is_available())
84
- except Exception:
85
- return False
86
-
87
-
88
- def _fallback_enabled() -> bool:
89
- return os.getenv("AUTO_FALLBACK_TO_LLAMACPP", "0").strip().lower() in {"1", "true", "yes", "on"}
 
5
  import os
6
 
7
  from src.extraction.base import Extractor
 
8
  from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor
9
 
10
 
11
  class AutoExtractor:
12
+ """Use the local Transformers MiniCPM-V path."""
13
 
14
  def __init__(self, model_id: str | None = None) -> None:
15
  self.model_id = model_id
16
  self._selected: Extractor | None = None
17
 
18
  def extract(self, file_path: str, max_pages: int = 3):
19
+ return self._backend().extract(file_path, max_pages=max_pages)
 
 
 
 
 
20
 
21
+ def _backend(self) -> Extractor:
22
+ if self._selected is None:
23
+ from src.model_paths import resolve_transformers_model_source
24
+
25
+ source = resolve_transformers_model_source(self.model_id)
26
  print(
27
+ "[Blood Test Explainer] using Transformers extractor "
28
+ f"(origin={source.origin}, model={source.model_id})",
29
  flush=True,
30
  )
31
+ self._selected = ZeroGPUTransformersExtractor(model_id=self.model_id)
 
 
 
 
 
 
 
 
 
 
32
  return self._selected
33
 
34
 
35
  def runtime_target() -> str:
36
+ """Local app always runs through Transformers."""
37
+ return "transformers"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/extraction/factory.py CHANGED
@@ -1,12 +1,14 @@
1
  """Backend selection.
2
 
3
  `EXTRACTOR_BACKEND` env:
4
- - `auto`: Transformers on ZeroGPU/CUDA, CPU llama.cpp otherwise.
 
 
5
  - `llamacpp-gpu` / `llama-champion`: llama.cpp GGUF badge path.
6
- - `zerogpu` / `transformers`: official OpenBMB Transformers backend.
7
- - `api`: hosted OpenBMB endpoint (dev fallback only).
8
  - `local` / `server`: local llama-server backend for local development.
9
  - `llamacpp`: in-process llama-cpp-python backend for local development.
 
 
10
  """
11
 
12
  from __future__ import annotations
@@ -19,40 +21,26 @@ from src.extraction.llamacpp_gpu import LlamaCppGPUExtractor
19
  from src.extraction.local_minicpmv import LocalMiniCPMVExtractor
20
  from src.extraction.local_server import LocalServerExtractor
21
  from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor
22
- from src.openbmb_client import OpenBMBExtractor
 
 
23
 
24
 
25
- def build_extractor(
26
- api_url: str | None = None,
27
- model: str | None = None,
28
- api_key: str | None = None,
29
- ) -> Extractor:
30
- backend = os.getenv("EXTRACTOR_BACKEND", "auto").strip().lower()
31
 
32
- if backend == "auto":
 
 
 
 
 
 
33
  return AutoExtractor(model_id=model)
34
  if backend in ("llamacpp-gpu", "gpu-llamacpp", "llama-champion"):
35
  return LlamaCppGPUExtractor()
36
- if backend in ("zerogpu", "zero-gpu", "transformers"):
37
- return ZeroGPUTransformersExtractor(model_id=model)
38
- if backend == "api":
39
- return OpenBMBExtractor(api_url=api_url, model=model, api_key=api_key)
40
  if backend in ("local", "server", "local-server"):
41
  return LocalServerExtractor()
42
  if backend == "llamacpp":
43
  return LocalMiniCPMVExtractor()
44
  raise ValueError(f"Unknown EXTRACTOR_BACKEND: {backend}")
45
-
46
-
47
- def _llamacpp_available() -> bool:
48
- if not (os.getenv("LOCAL_MODEL_PATH") and os.getenv("LOCAL_MMPROJ_PATH")):
49
- return False
50
- try:
51
- import llama_cpp # noqa: F401
52
- except ImportError:
53
- return False
54
- return True
55
-
56
-
57
- def _in_process_local_configured() -> bool:
58
- return bool(os.getenv("LOCAL_MODEL_PATH") and os.getenv("LOCAL_MMPROJ_PATH"))
 
1
  """Backend selection.
2
 
3
  `EXTRACTOR_BACKEND` env:
4
+ - `transformers` (default): local OpenBMB MiniCPM-V through Transformers.
5
+ - `auto`: same as `transformers`.
6
+ - `zerogpu` / `zero-gpu`: alias for `transformers`.
7
  - `llamacpp-gpu` / `llama-champion`: llama.cpp GGUF badge path.
 
 
8
  - `local` / `server`: local llama-server backend for local development.
9
  - `llamacpp`: in-process llama-cpp-python backend for local development.
10
+
11
+ The hosted OpenBMB HTTP API is disabled in this project.
12
  """
13
 
14
  from __future__ import annotations
 
21
  from src.extraction.local_minicpmv import LocalMiniCPMVExtractor
22
  from src.extraction.local_server import LocalServerExtractor
23
  from src.extraction.zerogpu_transformers import ZeroGPUTransformersExtractor
24
+
25
+ _DEFAULT_BACKEND = "transformers"
26
+ _DISABLED_BACKENDS = {"api", "openbmb", "hosted"}
27
 
28
 
29
+ def build_extractor(model: str | None = None) -> Extractor:
30
+ backend = os.getenv("EXTRACTOR_BACKEND", _DEFAULT_BACKEND).strip().lower()
 
 
 
 
31
 
32
+ if backend in _DISABLED_BACKENDS:
33
+ raise ValueError(
34
+ "The hosted OpenBMB API backend is disabled. "
35
+ "Use EXTRACTOR_BACKEND=transformers for local MiniCPM-V extraction."
36
+ )
37
+
38
+ if backend in ("auto", "zerogpu", "zero-gpu", "transformers"):
39
  return AutoExtractor(model_id=model)
40
  if backend in ("llamacpp-gpu", "gpu-llamacpp", "llama-champion"):
41
  return LlamaCppGPUExtractor()
 
 
 
 
42
  if backend in ("local", "server", "local-server"):
43
  return LocalServerExtractor()
44
  if backend == "llamacpp":
45
  return LocalMiniCPMVExtractor()
46
  raise ValueError(f"Unknown EXTRACTOR_BACKEND: {backend}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/extraction/llamacpp_gpu.py CHANGED
@@ -19,10 +19,11 @@ Config (env):
19
  from __future__ import annotations
20
 
21
  import os
 
22
  from functools import lru_cache
23
  from typing import Any
24
 
25
- from src.document_processing import document_to_payload_parts
26
  from src.openbmb_client import (
27
  EXTRACTION_PROMPT,
28
  ExtractionResult,
@@ -30,6 +31,7 @@ from src.openbmb_client import (
30
  _normalize_patient,
31
  _normalize_tests,
32
  _parse_json_response,
 
33
  )
34
 
35
  DEFAULT_GGUF_REPO = "openbmb/MiniCPM-V-4.6-gguf"
@@ -62,6 +64,7 @@ class LlamaCppGPUExtractor:
62
  def extract(self, file_path: str, max_pages: int = 3) -> ExtractionResult:
63
  parts = document_to_payload_parts(file_path, max_pages=max_pages)
64
  prompt_text = _compose_prompt(parts)
 
65
  raw = _run_llamacpp_generation(
66
  prompt_text=prompt_text,
67
  repo=self.repo,
@@ -70,6 +73,7 @@ class LlamaCppGPUExtractor:
70
  n_ctx=self.n_ctx,
71
  n_gpu_layers=self.n_gpu_layers,
72
  )
 
73
  parsed = _parse_json_response(raw)
74
  return ExtractionResult(
75
  patient=_normalize_patient(parsed.get("patient", {})),
@@ -79,8 +83,15 @@ class LlamaCppGPUExtractor:
79
  request_summary={
80
  "backend": "llamacpp-gpu",
81
  "repo": self.repo,
 
82
  "document_parts": len(parts),
83
  "max_pages": max_pages,
 
 
 
 
 
 
84
  },
85
  )
86
 
@@ -153,11 +164,60 @@ def _run_llamacpp_generation(
153
  ) from exc
154
 
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  def _compose_prompt(parts: list[dict[str, Any]]) -> str:
157
  text_parts: list[str] = [EXTRACTION_PROMPT]
 
158
  for part in parts:
159
  if part.get("type") == "text":
160
  text = str(part.get("text", "")).strip()
161
  if text:
162
  text_parts.append(text)
 
 
 
 
 
 
 
 
 
163
  return "\n\n".join(text_parts)
 
19
  from __future__ import annotations
20
 
21
  import os
22
+ import time
23
  from functools import lru_cache
24
  from typing import Any
25
 
26
+ from src.document_processing import document_intake_metadata, document_to_payload_parts
27
  from src.openbmb_client import (
28
  EXTRACTION_PROMPT,
29
  ExtractionResult,
 
31
  _normalize_patient,
32
  _normalize_tests,
33
  _parse_json_response,
34
+ summarize_document_parts,
35
  )
36
 
37
  DEFAULT_GGUF_REPO = "openbmb/MiniCPM-V-4.6-gguf"
 
64
  def extract(self, file_path: str, max_pages: int = 3) -> ExtractionResult:
65
  parts = document_to_payload_parts(file_path, max_pages=max_pages)
66
  prompt_text = _compose_prompt(parts)
67
+ started = time.perf_counter()
68
  raw = _run_llamacpp_generation(
69
  prompt_text=prompt_text,
70
  repo=self.repo,
 
73
  n_ctx=self.n_ctx,
74
  n_gpu_layers=self.n_gpu_layers,
75
  )
76
+ duration_ms = int((time.perf_counter() - started) * 1000)
77
  parsed = _parse_json_response(raw)
78
  return ExtractionResult(
79
  patient=_normalize_patient(parsed.get("patient", {})),
 
83
  request_summary={
84
  "backend": "llamacpp-gpu",
85
  "repo": self.repo,
86
+ "model": self.model_file,
87
  "document_parts": len(parts),
88
  "max_pages": max_pages,
89
+ "extraction_prompt": EXTRACTION_PROMPT,
90
+ "user_message_preview": summarize_document_parts(parts),
91
+ **document_intake_metadata(file_path, parts),
92
+ "composed_prompt": prompt_text,
93
+ "return_code": 0,
94
+ "duration_ms": duration_ms,
95
  },
96
  )
97
 
 
164
  ) from exc
165
 
166
 
167
+ @spaces.GPU(duration=120)
168
+ def _run_llamacpp_chat(
169
+ messages: list[dict[str, str]],
170
+ repo: str,
171
+ model_file: str,
172
+ max_tokens: int,
173
+ n_ctx: int,
174
+ n_gpu_layers: int,
175
+ ) -> str:
176
+ try:
177
+ model_path = _download(repo, model_file)
178
+ except Exception as exc:
179
+ raise RuntimeError(
180
+ "llama.cpp download failed while preparing the GGUF model: "
181
+ f"{type(exc).__name__}: {exc}"
182
+ ) from exc
183
+
184
+ try:
185
+ llm = _load(model_path, n_ctx, n_gpu_layers)
186
+ except Exception as exc:
187
+ raise RuntimeError(
188
+ "The llama.cpp backend could not load the text-only GGUF model for chat. "
189
+ f"Inner error: {type(exc).__name__}: {exc}"
190
+ ) from exc
191
+
192
+ try:
193
+ response = llm.create_chat_completion(
194
+ messages=messages,
195
+ temperature=0.2,
196
+ max_tokens=max_tokens,
197
+ )
198
+ return str(response["choices"][0]["message"].get("content") or "").strip()
199
+ except Exception as exc:
200
+ raise RuntimeError(
201
+ "llama.cpp chat generation failed. "
202
+ f"Inner error: {type(exc).__name__}: {exc}"
203
+ ) from exc
204
+
205
+
206
  def _compose_prompt(parts: list[dict[str, Any]]) -> str:
207
  text_parts: list[str] = [EXTRACTION_PROMPT]
208
+ image_count = 0
209
  for part in parts:
210
  if part.get("type") == "text":
211
  text = str(part.get("text", "")).strip()
212
  if text:
213
  text_parts.append(text)
214
+ elif part.get("type") == "image_url":
215
+ image_count += 1
216
+
217
+ if image_count and len(text_parts) == 1:
218
+ raise RuntimeError(
219
+ "The CPU llama.cpp backend cannot analyze image-based documents. "
220
+ "Use EXTRACTOR_BACKEND=transformers for local vision extraction."
221
+ )
222
+
223
  return "\n\n".join(text_parts)
src/extraction/local_minicpmv.py CHANGED
@@ -24,9 +24,10 @@ from __future__ import annotations
24
 
25
  import json
26
  import os
 
27
  from functools import lru_cache
28
 
29
- from src.document_processing import document_to_payload_parts
30
  from src.grammar import extraction_grammar
31
  from src.openbmb_client import (
32
  EXTRACTION_PROMPT,
@@ -34,6 +35,7 @@ from src.openbmb_client import (
34
  _normalize_notes,
35
  _normalize_patient,
36
  _normalize_tests,
 
37
  )
38
 
39
 
@@ -78,12 +80,14 @@ class LocalMiniCPMVExtractor:
78
  )
79
  parts = document_to_payload_parts(file_path, max_pages=max_pages)
80
 
 
81
  response = llm.create_chat_completion(
82
  messages=[{"role": "user", "content": [{"type": "text", "text": EXTRACTION_PROMPT}, *parts]}],
83
  grammar=_grammar(),
84
  temperature=0.0,
85
  max_tokens=2048,
86
  )
 
87
  raw = response["choices"][0]["message"]["content"] or "{}"
88
  # GBNF guarantees valid JSON, but never trust a single parse.
89
  try:
@@ -101,6 +105,10 @@ class LocalMiniCPMVExtractor:
101
  "model_path": os.path.basename(self.model_path),
102
  "document_parts": len(parts),
103
  "max_pages": max_pages,
 
 
 
 
104
  },
105
  )
106
 
 
24
 
25
  import json
26
  import os
27
+ import time
28
  from functools import lru_cache
29
 
30
+ from src.document_processing import document_intake_metadata, document_to_payload_parts
31
  from src.grammar import extraction_grammar
32
  from src.openbmb_client import (
33
  EXTRACTION_PROMPT,
 
35
  _normalize_notes,
36
  _normalize_patient,
37
  _normalize_tests,
38
+ summarize_document_parts,
39
  )
40
 
41
 
 
80
  )
81
  parts = document_to_payload_parts(file_path, max_pages=max_pages)
82
 
83
+ started = time.perf_counter()
84
  response = llm.create_chat_completion(
85
  messages=[{"role": "user", "content": [{"type": "text", "text": EXTRACTION_PROMPT}, *parts]}],
86
  grammar=_grammar(),
87
  temperature=0.0,
88
  max_tokens=2048,
89
  )
90
+ duration_ms = int((time.perf_counter() - started) * 1000)
91
  raw = response["choices"][0]["message"]["content"] or "{}"
92
  # GBNF guarantees valid JSON, but never trust a single parse.
93
  try:
 
105
  "model_path": os.path.basename(self.model_path),
106
  "document_parts": len(parts),
107
  "max_pages": max_pages,
108
+ "user_message_preview": summarize_document_parts(parts),
109
+ **document_intake_metadata(file_path, parts),
110
+ "return_code": 0,
111
+ "duration_ms": duration_ms,
112
  },
113
  )
114
 
src/extraction/local_server.py CHANGED
@@ -23,10 +23,11 @@ Config (env):
23
  from __future__ import annotations
24
 
25
  import os
 
26
 
27
  import requests
28
 
29
- from src.document_processing import document_to_payload_parts
30
  from src.grammar import extraction_grammar
31
  from src.openbmb_client import (
32
  EXTRACTION_PROMPT,
@@ -35,6 +36,7 @@ from src.openbmb_client import (
35
  _normalize_patient,
36
  _normalize_tests,
37
  _parse_json_response,
 
38
  )
39
 
40
  DEFAULT_SERVER_URL = "http://127.0.0.1:8080/v1/chat/completions"
@@ -71,12 +73,14 @@ class LocalServerExtractor:
71
  # Grammar-constrained decoding: output can only be our {tests, notes} schema.
72
  payload["grammar"] = extraction_grammar()
73
 
 
74
  response = requests.post(
75
  self.url,
76
  json=payload,
77
  headers={"Content-Type": "application/json"},
78
  timeout=self.timeout_seconds,
79
  )
 
80
  response.raise_for_status()
81
 
82
  raw = _message_content(response.json())
@@ -89,9 +93,15 @@ class LocalServerExtractor:
89
  request_summary={
90
  "backend": "local-server",
91
  "url": self.url,
 
92
  "document_parts": len(parts),
93
  "max_pages": max_pages,
94
  "grammar": self.use_grammar,
 
 
 
 
 
95
  },
96
  )
97
 
 
23
  from __future__ import annotations
24
 
25
  import os
26
+ import time
27
 
28
  import requests
29
 
30
+ from src.document_processing import document_intake_metadata, document_to_payload_parts
31
  from src.grammar import extraction_grammar
32
  from src.openbmb_client import (
33
  EXTRACTION_PROMPT,
 
36
  _normalize_patient,
37
  _normalize_tests,
38
  _parse_json_response,
39
+ summarize_document_parts,
40
  )
41
 
42
  DEFAULT_SERVER_URL = "http://127.0.0.1:8080/v1/chat/completions"
 
73
  # Grammar-constrained decoding: output can only be our {tests, notes} schema.
74
  payload["grammar"] = extraction_grammar()
75
 
76
+ started = time.perf_counter()
77
  response = requests.post(
78
  self.url,
79
  json=payload,
80
  headers={"Content-Type": "application/json"},
81
  timeout=self.timeout_seconds,
82
  )
83
+ duration_ms = int((time.perf_counter() - started) * 1000)
84
  response.raise_for_status()
85
 
86
  raw = _message_content(response.json())
 
93
  request_summary={
94
  "backend": "local-server",
95
  "url": self.url,
96
+ "model": self.model,
97
  "document_parts": len(parts),
98
  "max_pages": max_pages,
99
  "grammar": self.use_grammar,
100
+ "user_message_preview": summarize_document_parts(parts),
101
+ **document_intake_metadata(file_path, parts),
102
+ "http_status": response.status_code,
103
+ "return_code": 0,
104
+ "duration_ms": duration_ms,
105
  },
106
  )
107
 
src/extraction/text_generation.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared text-generation helpers for chat (mirrors EXTRACTOR_BACKEND)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from src.extraction.llamacpp_gpu import DEFAULT_GGUF_REPO, DEFAULT_MODEL_FILE
8
+ from src.local_env import load_local_env
9
+
10
+ load_local_env()
11
+
12
+
13
+ def generate_text_chat(messages: list[dict[str, str]], max_tokens: int | None = None) -> str:
14
+ """Run a text-only chat completion using the configured extraction backend family."""
15
+ backend = os.getenv("EXTRACTOR_BACKEND", "transformers").strip().lower()
16
+ token_limit = max_tokens or int(os.getenv("CHAT_MAX_TOKENS", "1024"))
17
+
18
+ if backend in {"api", "openbmb", "hosted"}:
19
+ raise RuntimeError(
20
+ "Chat via the hosted OpenBMB API is disabled. Use EXTRACTOR_BACKEND=transformers."
21
+ )
22
+ if backend in {"auto", "zerogpu", "zero-gpu", "transformers"}:
23
+ return _transformers_chat(messages, token_limit)
24
+ if backend in {"llamacpp-gpu", "gpu-llamacpp", "llama-champion", "llamacpp"}:
25
+ return _llamacpp_chat(messages, token_limit)
26
+
27
+ raise RuntimeError(f"Chat is not configured for EXTRACTOR_BACKEND={backend!r}.")
28
+
29
+
30
+ def _transformers_chat(messages: list[dict[str, str]], max_tokens: int) -> str:
31
+ from src.extraction.zerogpu_transformers import _run_zerogpu_generation
32
+ from src.model_paths import resolve_transformers_model_source
33
+
34
+ model_source = resolve_transformers_model_source(os.getenv("ZEROGPU_MODEL_ID"))
35
+ downsample_mode = (os.getenv("ZEROGPU_DOWNSAMPLE_MODE") or "16x").strip()
36
+ structured = [{"role": m["role"], "content": m["content"]} for m in messages]
37
+ return _run_zerogpu_generation(
38
+ messages=structured,
39
+ model_source=model_source,
40
+ max_new_tokens=max_tokens,
41
+ downsample_mode=downsample_mode,
42
+ )
43
+
44
+
45
+ def _llamacpp_chat(messages: list[dict[str, str]], max_tokens: int) -> str:
46
+ from src.extraction.llamacpp_gpu import _run_llamacpp_chat
47
+
48
+ repo = os.getenv("LLAMACPP_GGUF_REPO", DEFAULT_GGUF_REPO).strip()
49
+ model_file = os.getenv("LLAMACPP_MODEL_FILE", DEFAULT_MODEL_FILE).strip()
50
+ n_ctx = int(os.getenv("LLAMACPP_N_CTX", "8192"))
51
+ n_gpu_layers = int(os.getenv("LLAMACPP_N_GPU_LAYERS", "0"))
52
+ return _run_llamacpp_chat(
53
+ messages=messages,
54
+ repo=repo,
55
+ model_file=model_file,
56
+ max_tokens=max_tokens,
57
+ n_ctx=n_ctx,
58
+ n_gpu_layers=n_gpu_layers,
59
+ )
src/extraction/zerogpu_transformers.py CHANGED
@@ -5,7 +5,7 @@ from __future__ import annotations
5
  import os
6
  from typing import Any
7
 
8
- from src.document_processing import document_to_payload_parts
9
  from src.openbmb_client import (
10
  EXTRACTION_PROMPT,
11
  ExtractionResult,
@@ -13,13 +13,16 @@ from src.openbmb_client import (
13
  _normalize_patient,
14
  _normalize_tests,
15
  _parse_json_response,
 
16
  )
17
 
 
 
18
  DEFAULT_ZEROGPU_MODEL = "openbmb/MiniCPM-V-4.6"
19
 
20
 
21
  class ZeroGPUTransformersExtractor:
22
- """Extractor backed by HF ZeroGPU and the OpenBMB Transformers implementation."""
23
 
24
  def __init__(
25
  self,
@@ -27,7 +30,8 @@ class ZeroGPUTransformersExtractor:
27
  max_new_tokens: int = 2048,
28
  downsample_mode: str = "16x",
29
  ) -> None:
30
- self.model_id = (model_id or os.getenv("ZEROGPU_MODEL_ID") or DEFAULT_ZEROGPU_MODEL).strip()
 
31
  self.max_new_tokens = int(os.getenv("ZEROGPU_MAX_NEW_TOKENS", str(max_new_tokens)))
32
  self.downsample_mode = (os.getenv("ZEROGPU_DOWNSAMPLE_MODE") or downsample_mode).strip()
33
 
@@ -37,14 +41,14 @@ class ZeroGPUTransformersExtractor:
37
  {
38
  "role": "user",
39
  "content": [
40
- *_to_transformers_content(parts),
41
  {"type": "text", "text": EXTRACTION_PROMPT},
 
42
  ],
43
  }
44
  ]
45
  raw = _run_zerogpu_generation(
46
  messages=messages,
47
- model_id=self.model_id,
48
  max_new_tokens=self.max_new_tokens,
49
  downsample_mode=self.downsample_mode,
50
  )
@@ -55,11 +59,17 @@ class ZeroGPUTransformersExtractor:
55
  notes=_normalize_notes(parsed.get("notes", [])),
56
  raw_response=raw,
57
  request_summary={
58
- "backend": "zerogpu-transformers",
59
  "model": self.model_id,
 
 
60
  "document_parts": len(parts),
61
  "max_pages": max_pages,
62
  "downsample_mode": self.downsample_mode,
 
 
 
 
63
  },
64
  )
65
 
@@ -83,17 +93,58 @@ def _to_transformers_content(parts: list[dict[str, Any]]) -> list[dict[str, str]
83
  return content
84
 
85
 
86
- def _load_model(model_id: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  import torch
88
  from transformers import AutoModelForImageTextToText, AutoProcessor
89
 
90
- processor = AutoProcessor.from_pretrained(model_id)
 
 
 
 
 
 
 
 
 
91
 
92
- # 4-bit (NF4) quantization on GPU: earns the quantization badge and roughly quarters the
93
- # GPU memory footprint (helps stay within ZeroGPU limits). Set ZEROGPU_QUANTIZE=0 to fall
94
- # back to bf16 full precision if bitsandbytes ever misbehaves on the runtime.
95
  use_4bit = os.getenv("ZEROGPU_QUANTIZE", "1") != "0" and torch.cuda.is_available()
96
- load_kwargs: dict[str, Any] = {"device_map": "auto"}
 
 
97
  if use_4bit:
98
  from transformers import BitsAndBytesConfig
99
 
@@ -103,10 +154,14 @@ def _load_model(model_id: str):
103
  bnb_4bit_compute_dtype=torch.bfloat16,
104
  bnb_4bit_use_double_quant=True,
105
  )
 
 
 
 
106
  else:
107
- load_kwargs["torch_dtype"] = torch.bfloat16 if torch.cuda.is_available() else "auto"
108
 
109
- model = AutoModelForImageTextToText.from_pretrained(model_id, **load_kwargs)
110
  model.eval()
111
  return processor, model
112
 
@@ -114,10 +169,25 @@ def _load_model(model_id: str):
114
  _MODEL_CACHE: dict[str, tuple[Any, Any]] = {}
115
 
116
 
117
- def _get_model(model_id: str) -> tuple[Any, Any]:
118
- if model_id not in _MODEL_CACHE:
119
- _MODEL_CACHE[model_id] = _load_model(model_id)
120
- return _MODEL_CACHE[model_id]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
 
123
  try:
@@ -137,14 +207,14 @@ except ImportError: # Local development without the HF Spaces package.
137
  @spaces.GPU(duration=120)
138
  def _run_zerogpu_generation(
139
  messages: list[dict[str, Any]],
140
- model_id: str,
141
  max_new_tokens: int,
142
  downsample_mode: str,
143
  ) -> str:
144
  import torch
145
 
146
  try:
147
- processor, model = _get_model(model_id)
148
  inputs = processor.apply_chat_template(
149
  messages,
150
  tokenize=True,
@@ -174,6 +244,6 @@ def _run_zerogpu_generation(
174
  return str(output_text[0]).strip() if output_text else ""
175
  except Exception as exc:
176
  raise RuntimeError(
177
- "OpenBMB Transformers generation failed on the CUDA/ZeroGPU lane. "
178
  f"Inner error: {type(exc).__name__}: {exc}"
179
  ) from exc
 
5
  import os
6
  from typing import Any
7
 
8
+ from src.document_processing import document_intake_metadata, document_to_payload_parts
9
  from src.openbmb_client import (
10
  EXTRACTION_PROMPT,
11
  ExtractionResult,
 
13
  _normalize_patient,
14
  _normalize_tests,
15
  _parse_json_response,
16
+ summarize_document_parts,
17
  )
18
 
19
+ from src.model_paths import TransformersModelSource, resolve_transformers_model_source
20
+
21
  DEFAULT_ZEROGPU_MODEL = "openbmb/MiniCPM-V-4.6"
22
 
23
 
24
  class ZeroGPUTransformersExtractor:
25
+ """Extractor backed by local or Hub MiniCPM-V Transformers weights."""
26
 
27
  def __init__(
28
  self,
 
30
  max_new_tokens: int = 2048,
31
  downsample_mode: str = "16x",
32
  ) -> None:
33
+ self.model_source = resolve_transformers_model_source(model_id)
34
+ self.model_id = self.model_source.model_id
35
  self.max_new_tokens = int(os.getenv("ZEROGPU_MAX_NEW_TOKENS", str(max_new_tokens)))
36
  self.downsample_mode = (os.getenv("ZEROGPU_DOWNSAMPLE_MODE") or downsample_mode).strip()
37
 
 
41
  {
42
  "role": "user",
43
  "content": [
 
44
  {"type": "text", "text": EXTRACTION_PROMPT},
45
+ *_to_transformers_content(parts),
46
  ],
47
  }
48
  ]
49
  raw = _run_zerogpu_generation(
50
  messages=messages,
51
+ model_source=self.model_source,
52
  max_new_tokens=self.max_new_tokens,
53
  downsample_mode=self.downsample_mode,
54
  )
 
59
  notes=_normalize_notes(parsed.get("notes", [])),
60
  raw_response=raw,
61
  request_summary={
62
+ "backend": "transformers",
63
  "model": self.model_id,
64
+ "model_origin": self.model_source.origin,
65
+ "model_local_only": self.model_source.local_files_only,
66
  "document_parts": len(parts),
67
  "max_pages": max_pages,
68
  "downsample_mode": self.downsample_mode,
69
+ "extraction_prompt": EXTRACTION_PROMPT,
70
+ "user_message_preview": summarize_document_parts(parts),
71
+ **document_intake_metadata(file_path, parts),
72
+ "messages_preview": _messages_preview(messages),
73
  },
74
  )
75
 
 
93
  return content
94
 
95
 
96
+ def _messages_preview(messages: list[dict[str, Any]]) -> str:
97
+ """Serialize message structure without embedding image data URLs."""
98
+ preview: list[dict[str, Any]] = []
99
+ for message in messages:
100
+ content = message.get("content")
101
+ if isinstance(content, str):
102
+ preview.append({"role": message.get("role"), "content": _truncate_preview(content)})
103
+ continue
104
+ if not isinstance(content, list):
105
+ continue
106
+ items: list[dict[str, str]] = []
107
+ for item in content:
108
+ if not isinstance(item, dict):
109
+ continue
110
+ if item.get("type") == "image":
111
+ items.append({"type": "image", "url": "[image omitted]"})
112
+ elif item.get("type") == "text":
113
+ items.append({"type": "text", "text": _truncate_preview(str(item.get("text") or ""))})
114
+ elif item.get("type") == "image_url":
115
+ items.append({"type": "image_url", "url": "[image omitted]"})
116
+ preview.append({"role": message.get("role"), "content": items})
117
+ import json
118
+
119
+ return json.dumps(preview, indent=2)
120
+
121
+
122
+ def _truncate_preview(text: str, limit: int = 1200) -> str:
123
+ cleaned = text.strip()
124
+ if len(cleaned) <= limit:
125
+ return cleaned
126
+ return cleaned[: limit - 3] + "..."
127
+
128
+
129
+ def _load_model(source: TransformersModelSource):
130
  import torch
131
  from transformers import AutoModelForImageTextToText, AutoProcessor
132
 
133
+ from src.model_paths import hub_cache_dir
134
+
135
+ pretrained_kwargs: dict[str, Any] = {
136
+ "trust_remote_code": True,
137
+ "local_files_only": source.local_files_only,
138
+ }
139
+ if not source.local_files_only:
140
+ pretrained_kwargs["cache_dir"] = str(hub_cache_dir())
141
+
142
+ processor = AutoProcessor.from_pretrained(source.model_id, **pretrained_kwargs)
143
 
 
 
 
144
  use_4bit = os.getenv("ZEROGPU_QUANTIZE", "1") != "0" and torch.cuda.is_available()
145
+ load_kwargs: dict[str, Any] = {"device_map": "auto", "trust_remote_code": True, "local_files_only": source.local_files_only}
146
+ if not source.local_files_only:
147
+ load_kwargs["cache_dir"] = str(hub_cache_dir())
148
  if use_4bit:
149
  from transformers import BitsAndBytesConfig
150
 
 
154
  bnb_4bit_compute_dtype=torch.bfloat16,
155
  bnb_4bit_use_double_quant=True,
156
  )
157
+ elif torch.cuda.is_available():
158
+ load_kwargs["torch_dtype"] = torch.bfloat16
159
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
160
+ load_kwargs["torch_dtype"] = torch.float16
161
  else:
162
+ load_kwargs["torch_dtype"] = torch.float32
163
 
164
+ model = AutoModelForImageTextToText.from_pretrained(source.model_id, **load_kwargs)
165
  model.eval()
166
  return processor, model
167
 
 
169
  _MODEL_CACHE: dict[str, tuple[Any, Any]] = {}
170
 
171
 
172
+ def _cache_key(source: TransformersModelSource) -> str:
173
+ return f"{source.model_id}|local={int(source.local_files_only)}|origin={source.origin}"
174
+
175
+
176
+ def _get_model(source: TransformersModelSource) -> tuple[Any, Any]:
177
+ from src.model_paths import hub_cache_dir
178
+
179
+ key = _cache_key(source)
180
+ if key not in _MODEL_CACHE:
181
+ if source.local_files_only:
182
+ print(f"[Blood Test Explainer] loading local Transformers model from {source.model_id}", flush=True)
183
+ else:
184
+ print(
185
+ f"[Blood Test Explainer] downloading Transformers model {source.model_id} "
186
+ f"(cache: {hub_cache_dir()}) and loading into memory",
187
+ flush=True,
188
+ )
189
+ _MODEL_CACHE[key] = _load_model(source)
190
+ return _MODEL_CACHE[key]
191
 
192
 
193
  try:
 
207
  @spaces.GPU(duration=120)
208
  def _run_zerogpu_generation(
209
  messages: list[dict[str, Any]],
210
+ model_source: TransformersModelSource,
211
  max_new_tokens: int,
212
  downsample_mode: str,
213
  ) -> str:
214
  import torch
215
 
216
  try:
217
+ processor, model = _get_model(model_source)
218
  inputs = processor.apply_chat_template(
219
  messages,
220
  tokenize=True,
 
244
  return str(output_text[0]).strip() if output_text else ""
245
  except Exception as exc:
246
  raise RuntimeError(
247
+ "MiniCPM-V Transformers generation failed. "
248
  f"Inner error: {type(exc).__name__}: {exc}"
249
  ) from exc
src/local_env.py CHANGED
@@ -7,6 +7,7 @@ from pathlib import Path
7
  def load_local_env(path: str = ".env") -> None:
8
  env_path = Path(path)
9
  if not env_path.exists():
 
10
  return
11
 
12
  for raw_line in env_path.read_text(encoding="utf-8").splitlines():
@@ -20,3 +21,11 @@ def load_local_env(path: str = ".env") -> None:
20
 
21
  if key and key not in os.environ:
22
  os.environ[key] = value
 
 
 
 
 
 
 
 
 
7
  def load_local_env(path: str = ".env") -> None:
8
  env_path = Path(path)
9
  if not env_path.exists():
10
+ _apply_model_defaults()
11
  return
12
 
13
  for raw_line in env_path.read_text(encoding="utf-8").splitlines():
 
21
 
22
  if key and key not in os.environ:
23
  os.environ[key] = value
24
+
25
+ _apply_model_defaults()
26
+
27
+
28
+ def _apply_model_defaults() -> None:
29
+ from src.model_paths import apply_local_model_defaults
30
+
31
+ apply_local_model_defaults()
src/markers.py CHANGED
@@ -1,9 +1,13 @@
1
  """Canonical lab-marker reference.
2
 
3
  Single source of truth shared by the synthetic-data generator, the evaluation harness,
4
- and (later) the interpretation knowledge base. Reference ranges are adult, general-population
5
- defaults for synthetic-data generation and flag computation only; the production KB will carry
6
- sex/age-specific ranges with citations. These values are for an educational tool, not diagnosis.
 
 
 
 
7
 
8
  Each marker: canonical name, common aliases (for matching extracted text), unit, an adult
9
  reference interval, a category, and a one-line "what it measures".
@@ -46,15 +50,25 @@ def _fmt(v: float) -> str:
46
  return str(int(v)) if float(v).is_integer() else f"{v:g}"
47
 
48
 
49
- # ~30 of the most common markers across CBC, metabolic, lipid, thyroid, and vitamins.
50
  MARKERS: tuple[Marker, ...] = (
51
  # --- Complete blood count ---
52
- Marker("Hemoglobin", "g/dL", 13.5, 17.5, "CBC", "oxygen-carrying protein in red blood cells", ("Hgb", "HGB", "Hb")),
53
- Marker("Hematocrit", "%", 38.8, 50.0, "CBC", "fraction of blood made up of red cells", ("Hct", "HCT", "PCV")),
54
- Marker("White Blood Cell Count", "10^3/uL", 4.5, 11.0, "CBC", "immune cells that fight infection", ("WBC", "Leukocytes", "WBC Count", "TLC", "Total Leucocyte Count")),
55
  Marker("Platelet Count", "10^3/uL", 150, 400, "CBC", "cell fragments that help blood clot", ("Platelets", "PLT")),
56
- Marker("Red Blood Cell Count", "10^6/uL", 4.5, 5.9, "CBC", "number of oxygen-carrying red cells", ("RBC", "Erythrocytes")),
57
- Marker("MCV", "fL", 80, 100, "CBC", "average size of red blood cells", ("Mean Corpuscular Volume",)),
 
 
 
 
 
 
 
 
 
 
58
  # --- Metabolic panel ---
59
  Marker("Glucose", "mg/dL", 70, 99, "Metabolic", "blood sugar level", ("Fasting Glucose", "GLU", "Blood Sugar", "FBS", "RBS", "Fasting Blood Sugar")),
60
  Marker("Creatinine", "mg/dL", 0.7, 1.3, "Metabolic", "kidney-function waste product", ("Cr", "Serum Creatinine")),
@@ -66,25 +80,90 @@ MARKERS: tuple[Marker, ...] = (
66
  Marker("Calcium", "mg/dL", 8.6, 10.3, "Metabolic", "mineral for bones, nerves, and muscle", ("Ca", "Total Calcium")),
67
  Marker("Albumin", "g/dL", 3.5, 5.0, "Metabolic", "main protein made by the liver", ("ALB",)),
68
  Marker("Total Protein", "g/dL", 6.0, 8.3, "Metabolic", "total of all blood proteins", ("TP", "Protein, Total")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  # --- Liver enzymes ---
70
  Marker("ALT", "U/L", 7, 56, "Liver", "liver enzyme released when liver cells are stressed", ("Alanine Aminotransferase", "SGPT")),
71
  Marker("AST", "U/L", 10, 40, "Liver", "enzyme from liver and muscle cells", ("Aspartate Aminotransferase", "SGOT")),
72
  Marker("ALP", "U/L", 44, 147, "Liver", "enzyme from liver and bone", ("Alkaline Phosphatase",)),
73
  Marker("GGT", "U/L", 9, 48, "Liver", "liver enzyme sensitive to bile and alcohol", ("Gamma-Glutamyl Transferase", "Gamma GT")),
74
  Marker("Total Bilirubin", "mg/dL", 0.1, 1.2, "Liver", "pigment from red-cell breakdown", ("Bilirubin, Total", "TBIL")),
 
 
 
75
  # --- Lipid panel ---
76
  Marker("Total Cholesterol", "mg/dL", None, 200, "Lipid", "total cholesterol in the blood", ("Cholesterol, Total", "TC")),
77
  Marker("LDL Cholesterol", "mg/dL", None, 100, "Lipid", "'bad' cholesterol that builds in arteries", ("LDL", "LDL-C")),
78
  Marker("HDL Cholesterol", "mg/dL", 40, None, "Lipid", "'good' cholesterol that clears arteries", ("HDL", "HDL-C")),
79
  Marker("Triglycerides", "mg/dL", None, 150, "Lipid", "fat circulating in the blood", ("TG", "Trig")),
 
 
 
 
80
  # --- Thyroid ---
81
  Marker("TSH", "mIU/L", 0.4, 4.0, "Thyroid", "pituitary signal that controls the thyroid", ("Thyroid Stimulating Hormone",)),
82
  Marker("Free T4", "ng/dL", 0.8, 1.8, "Thyroid", "active thyroid hormone, free fraction", ("FT4", "Free Thyroxine")),
 
 
 
 
83
  # --- Vitamins / iron ---
84
  Marker("Vitamin D", "ng/mL", 30, 100, "Vitamin", "vitamin for bone and immune health", ("25-OH Vitamin D", "25-Hydroxyvitamin D", "Vit D")),
85
  Marker("Vitamin B12", "pg/mL", 200, 900, "Vitamin", "vitamin for nerves and red-cell production", ("B12", "Cobalamin")),
86
  Marker("Ferritin", "ng/mL", 30, 400, "Vitamin", "stored-iron protein", ("FERR",)),
87
  Marker("HbA1c", "%", 4.0, 5.6, "Metabolic", "average blood sugar over ~3 months", ("A1c", "Hemoglobin A1c", "Glycated Hemoglobin")),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  )
89
 
90
 
 
1
  """Canonical lab-marker reference.
2
 
3
  Single source of truth shared by the synthetic-data generator, the evaluation harness,
4
+ and the interpretation knowledge base. Reference ranges are adult, general-population defaults
5
+ for synthetic-data generation and flag computation only.
6
+
7
+ CBC marker intervals match `kb/cbc_knowledge_graph.json` → `statistics_per_group_age.adult`
8
+ (the age-only fallback used when patient sex is unknown). Sex/age-specific ranges live in the
9
+ JSON graph and take precedence in the report pipeline when patient context is available.
10
+ These values are for an educational tool, not diagnosis.
11
 
12
  Each marker: canonical name, common aliases (for matching extracted text), unit, an adult
13
  reference interval, a category, and a one-line "what it measures".
 
50
  return str(int(v)) if float(v).is_integer() else f"{v:g}"
51
 
52
 
53
+ # ~100 of the most common markers across CBC, metabolic, lipid, thyroid, coagulation, hormones, and vitamins.
54
  MARKERS: tuple[Marker, ...] = (
55
  # --- Complete blood count ---
56
+ Marker("Hemoglobin", "g/dL", 11.9, 17.7, "CBC", "oxygen-carrying protein in red blood cells", ("Hgb", "HGB", "Hb")),
57
+ Marker("Hematocrit", "%", 35, 52, "CBC", "fraction of blood made up of red cells", ("Hct", "HCT", "PCV")),
58
+ Marker("White Blood Cell Count", "10^3/uL", 3.7, 10.5, "CBC", "immune cells that fight infection", ("WBC", "Leukocytes", "WBC Count", "TLC", "Total Leucocyte Count")),
59
  Marker("Platelet Count", "10^3/uL", 150, 400, "CBC", "cell fragments that help blood clot", ("Platelets", "PLT")),
60
+ Marker("Red Blood Cell Count", "10^6/uL", 4.0, 6.2, "CBC", "number of oxygen-carrying red cells", ("RBC", "Erythrocytes")),
61
+ Marker("MCV", "fL", 82, 99, "CBC", "average size of red blood cells", ("Mean Corpuscular Volume",)),
62
+ Marker("MCH", "pg", 25, 35, "CBC", "average hemoglobin per red blood cell", ("Mean Corpuscular Hemoglobin",)),
63
+ Marker("MCHC", "g/dL", 32, 36, "CBC", "average hemoglobin concentration in red cells", ("Mean Corpuscular Hemoglobin Concentration",)),
64
+ Marker("RDW", "%", 9.0, 14.5, "CBC", "variation in red blood cell size", ("Red Cell Distribution Width",)),
65
+ Marker("MPV", "fL", 7.5, 11.5, "CBC", "average size of platelets", ("Mean Platelet Volume",)),
66
+ Marker("Absolute Neutrophil Count", "10^3/uL", 1.8, 7.7, "CBC", "count of infection-fighting white cells", ("ANC", "Neutrophils Absolute", "Abs Neutrophils")),
67
+ Marker("Absolute Lymphocyte Count", "10^3/uL", 0.875, 4.8, "CBC", "count of adaptive immune white cells", ("ALC", "Lymphocytes Absolute", "Abs Lymphocytes")),
68
+ Marker("Absolute Monocyte Count", "10^3/uL", 0.2, 0.8, "CBC", "count of cleanup and immune white cells", ("AMC", "Monocytes Absolute", "Abs Monocytes")),
69
+ Marker("Absolute Eosinophil Count", "10^3/uL", 0, 0.5, "CBC", "count of allergy and parasite-related white cells", ("AEC", "Eosinophils Absolute", "Abs Eosinophils")),
70
+ Marker("Absolute Basophil Count", "10^3/uL", 0, 0.2, "CBC", "count of histamine-related white cells", ("ABC", "Basophils Absolute", "Abs Basophils")),
71
+ Marker("Reticulocyte Count", "%", 0.5, 2.5, "CBC", "young red cells recently released from bone marrow", ("Retic Count", "Retics")),
72
  # --- Metabolic panel ---
73
  Marker("Glucose", "mg/dL", 70, 99, "Metabolic", "blood sugar level", ("Fasting Glucose", "GLU", "Blood Sugar", "FBS", "RBS", "Fasting Blood Sugar")),
74
  Marker("Creatinine", "mg/dL", 0.7, 1.3, "Metabolic", "kidney-function waste product", ("Cr", "Serum Creatinine")),
 
80
  Marker("Calcium", "mg/dL", 8.6, 10.3, "Metabolic", "mineral for bones, nerves, and muscle", ("Ca", "Total Calcium")),
81
  Marker("Albumin", "g/dL", 3.5, 5.0, "Metabolic", "main protein made by the liver", ("ALB",)),
82
  Marker("Total Protein", "g/dL", 6.0, 8.3, "Metabolic", "total of all blood proteins", ("TP", "Protein, Total")),
83
+ Marker("Globulin", "g/dL", 2.0, 3.5, "Metabolic", "non-albumin blood proteins including antibodies", ("Globulins",)),
84
+ Marker("Bicarbonate", "mmol/L", 22, 28, "Metabolic", "main blood buffer for acid-base balance", ("CO2", "Bicarb", "Total CO2", "Carbon Dioxide")),
85
+ Marker("Anion Gap", "mEq/L", 7, 13, "Metabolic", "calculated gap from electrolytes suggesting acid-base issues", ("AG",)),
86
+ Marker("Magnesium", "mg/dL", 1.7, 2.2, "Metabolic", "electrolyte for nerves, muscle, and heart rhythm", ("Mg", "Mg++")),
87
+ Marker("Phosphate", "mg/dL", 2.5, 4.5, "Metabolic", "mineral for bones, energy, and cell membranes", ("Phosphorus", "PO4", "Inorganic Phosphate")),
88
+ Marker("Uric Acid", "mg/dL", 3.5, 7.2, "Metabolic", "breakdown product of purines; linked to gout", ("UA", "Urate")),
89
+ Marker("Serum Iron", "mcg/dL", 60, 170, "Metabolic", "circulating iron available for red-cell production", ("Iron", "Fe", "Iron, Serum")),
90
+ Marker("TIBC", "mcg/dL", 250, 450, "Metabolic", "blood's capacity to bind and transport iron", ("Total Iron Binding Capacity", "Iron Binding Capacity")),
91
+ Marker("Transferrin Saturation", "%", 20, 50, "Metabolic", "percent of iron-binding sites occupied", ("TSAT", "Iron Saturation")),
92
+ Marker("LDH", "U/L", 140, 280, "Metabolic", "enzyme released when cells are damaged", ("Lactate Dehydrogenase",)),
93
+ Marker("Osmolality", "mOsm/kg", 275, 295, "Metabolic", "concentration of particles in the blood", ("Serum Osmolality",)),
94
+ Marker("Ammonia", "mcg/dL", 15, 45, "Metabolic", "waste product processed by the liver", ("NH3", "Blood Ammonia")),
95
+ Marker("Lactate", "mmol/L", 0.5, 2.0, "Metabolic", "byproduct of anaerobic metabolism", ("Lactic Acid", "Lactate, Blood")),
96
+ Marker("Homocysteine", "umol/L", 5, 15, "Metabolic", "amino acid linked to B-vitamin status and vascular risk", ("Hcy",)),
97
+ Marker("Cystatin C", "mg/L", 0.53, 0.95, "Metabolic", "kidney-function marker less affected by muscle mass", ("CysC",)),
98
+ Marker("Prealbumin", "mg/dL", 20, 40, "Metabolic", "short-lived protein reflecting recent nutrition", ("Transthyretin",)),
99
+ Marker("Beta-2 Microglobulin", "mg/L", 0.7, 1.8, "Metabolic", "small protein from cell turnover; kidney and immune marker", ("B2M", "β2-Microglobulin")),
100
  # --- Liver enzymes ---
101
  Marker("ALT", "U/L", 7, 56, "Liver", "liver enzyme released when liver cells are stressed", ("Alanine Aminotransferase", "SGPT")),
102
  Marker("AST", "U/L", 10, 40, "Liver", "enzyme from liver and muscle cells", ("Aspartate Aminotransferase", "SGOT")),
103
  Marker("ALP", "U/L", 44, 147, "Liver", "enzyme from liver and bone", ("Alkaline Phosphatase",)),
104
  Marker("GGT", "U/L", 9, 48, "Liver", "liver enzyme sensitive to bile and alcohol", ("Gamma-Glutamyl Transferase", "Gamma GT")),
105
  Marker("Total Bilirubin", "mg/dL", 0.1, 1.2, "Liver", "pigment from red-cell breakdown", ("Bilirubin, Total", "TBIL")),
106
+ Marker("Direct Bilirubin", "mg/dL", 0, 0.3, "Liver", "conjugated bilirubin processed by the liver", ("Conjugated Bilirubin", "DBIL")),
107
+ Marker("Lipase", "U/L", 0, 160, "Liver", "pancreatic enzyme for fat digestion", ("LPS",)),
108
+ Marker("Amylase", "U/L", 25, 125, "Liver", "pancreatic and salivary enzyme for starch digestion", ("AMS",)),
109
  # --- Lipid panel ---
110
  Marker("Total Cholesterol", "mg/dL", None, 200, "Lipid", "total cholesterol in the blood", ("Cholesterol, Total", "TC")),
111
  Marker("LDL Cholesterol", "mg/dL", None, 100, "Lipid", "'bad' cholesterol that builds in arteries", ("LDL", "LDL-C")),
112
  Marker("HDL Cholesterol", "mg/dL", 40, None, "Lipid", "'good' cholesterol that clears arteries", ("HDL", "HDL-C")),
113
  Marker("Triglycerides", "mg/dL", None, 150, "Lipid", "fat circulating in the blood", ("TG", "Trig")),
114
+ Marker("Non-HDL Cholesterol", "mg/dL", None, 130, "Lipid", "all cholesterol except HDL; atherogenic fraction", ("Non-HDL-C", "Non HDL Cholesterol")),
115
+ Marker("Apolipoprotein B", "mg/dL", None, 90, "Lipid", "protein on LDL and related particles", ("Apo B", "ApoB")),
116
+ Marker("Apolipoprotein A-1", "mg/dL", 120, None, "Lipid", "main protein on HDL particles", ("Apo A-1", "ApoA1")),
117
+ Marker("Lipoprotein(a)", "mg/dL", None, 30, "Lipid", "genetically influenced LDL-like particle", ("Lp(a)", "Lipoprotein a")),
118
  # --- Thyroid ---
119
  Marker("TSH", "mIU/L", 0.4, 4.0, "Thyroid", "pituitary signal that controls the thyroid", ("Thyroid Stimulating Hormone",)),
120
  Marker("Free T4", "ng/dL", 0.8, 1.8, "Thyroid", "active thyroid hormone, free fraction", ("FT4", "Free Thyroxine")),
121
+ Marker("Free T3", "pg/mL", 2.3, 4.2, "Thyroid", "active thyroid hormone, free fraction", ("FT3", "Free Triiodothyronine")),
122
+ Marker("Total T4", "mcg/dL", 4.5, 12.0, "Thyroid", "total thyroxine including bound and free", ("T4", "Thyroxine")),
123
+ Marker("Total T3", "ng/dL", 80, 200, "Thyroid", "total triiodothyronine including bound and free", ("T3", "Triiodothyronine")),
124
+ Marker("Anti-TPO Antibodies", "IU/mL", None, 35, "Thyroid", "antibodies against thyroid peroxidase", ("TPO Antibodies", "Thyroid Peroxidase Antibodies", "Anti-TPO")),
125
  # --- Vitamins / iron ---
126
  Marker("Vitamin D", "ng/mL", 30, 100, "Vitamin", "vitamin for bone and immune health", ("25-OH Vitamin D", "25-Hydroxyvitamin D", "Vit D")),
127
  Marker("Vitamin B12", "pg/mL", 200, 900, "Vitamin", "vitamin for nerves and red-cell production", ("B12", "Cobalamin")),
128
  Marker("Ferritin", "ng/mL", 30, 400, "Vitamin", "stored-iron protein", ("FERR",)),
129
  Marker("HbA1c", "%", 4.0, 5.6, "Metabolic", "average blood sugar over ~3 months", ("A1c", "Hemoglobin A1c", "Glycated Hemoglobin")),
130
+ # --- Coagulation ---
131
+ Marker("Prothrombin Time", "seconds", 11, 13.5, "Coagulation", "time for the clotting cascade to form fibrin", ("PT",)),
132
+ Marker("INR", "ratio", 0.9, 1.1, "Coagulation", "standardized prothrombin time for warfarin monitoring", ("International Normalized Ratio",)),
133
+ Marker("aPTT", "seconds", 25, 35, "Coagulation", "time for the intrinsic clotting pathway", ("PTT", "APTT", "Activated Partial Thromboplastin Time")),
134
+ Marker("Fibrinogen", "mg/dL", 200, 400, "Coagulation", "clotting protein and acute-phase reactant", ("Factor I",)),
135
+ Marker("D-Dimer", "ng/mL", None, 500, "Coagulation", "breakdown product of clots; elevated when clotting is active", ("D Dimer",)),
136
+ # --- Inflammation / immune ---
137
+ Marker("C-Reactive Protein", "mg/L", None, 10, "Inflammation", "general marker of inflammation", ("CRP",)),
138
+ Marker("hs-CRP", "mg/L", None, 3.0, "Inflammation", "high-sensitivity CRP for cardiovascular risk", ("High-Sensitivity CRP", "High Sensitivity C-Reactive Protein")),
139
+ Marker("ESR", "mm/hr", 0, 20, "Inflammation", "rate red cells settle; nonspecific inflammation marker", ("Erythrocyte Sedimentation Rate", "Sed Rate")),
140
+ Marker("Procalcitonin", "ng/mL", None, 0.1, "Inflammation", "marker that rises with bacterial infection", ("PCT",)),
141
+ Marker("Complement C3", "mg/dL", 90, 180, "Inflammation", "complement protein in immune activation", ("C3",)),
142
+ Marker("Complement C4", "mg/dL", 10, 40, "Inflammation", "complement protein in immune activation", ("C4",)),
143
+ Marker("Rheumatoid Factor", "IU/mL", None, 14, "Inflammation", "antibody sometimes seen in autoimmune arthritis", ("RF",)),
144
+ # --- Cardiac ---
145
+ Marker("BNP", "pg/mL", None, 100, "Cardiac", "hormone released when the heart is stretched", ("B-Type Natriuretic Peptide", "Brain Natriuretic Peptide")),
146
+ Marker("Troponin I", "ng/mL", None, 0.04, "Cardiac", "heart-muscle protein released with injury", ("TnI", "High-Sensitivity Troponin I")),
147
+ Marker("Creatine Kinase", "U/L", 30, 200, "Cardiac", "enzyme from muscle including heart and skeletal", ("CK", "CPK", "Creatine Phosphokinase")),
148
+ Marker("CK-MB", "ng/mL", None, 5, "Cardiac", "heart-enriched fraction of creatine kinase", ("CKMB", "Creatine Kinase-MB")),
149
+ # --- Hormones ---
150
+ Marker("Cortisol", "mcg/dL", 6, 18, "Hormone", "stress hormone from the adrenal glands", ("AM Cortisol", "Serum Cortisol")),
151
+ Marker("Insulin", "uIU/mL", 2.6, 24.9, "Hormone", "hormone that lowers blood sugar", ("Fasting Insulin",)),
152
+ Marker("Testosterone", "ng/dL", 300, 1000, "Hormone", "androgen sex hormone", ("Total Testosterone",)),
153
+ Marker("Estradiol", "pg/mL", 15, 350, "Hormone", "primary estrogen sex hormone", ("E2", "Estrogen")),
154
+ Marker("Prolactin", "ng/mL", None, 20, "Hormone", "pituitary hormone for lactation and more", ("PRL",)),
155
+ Marker("FSH", "mIU/mL", 1.5, 12.4, "Hormone", "pituitary signal for egg and sperm production", ("Follicle Stimulating Hormone",)),
156
+ Marker("LH", "mIU/mL", 1.5, 9.3, "Hormone", "pituitary signal for ovulation and testosterone", ("Luteinizing Hormone",)),
157
+ Marker("Progesterone", "ng/mL", 0.2, 25, "Hormone", "hormone that supports the uterine lining", ("P4",)),
158
+ Marker("Parathyroid Hormone", "pg/mL", 15, 65, "Hormone", "hormone that regulates blood calcium", ("PTH", "Intact PTH")),
159
+ Marker("ACTH", "pg/mL", 7, 63, "Hormone", "pituitary signal that drives cortisol production", ("Adrenocorticotropic Hormone",)),
160
+ Marker("SHBG", "nmol/L", 10, 80, "Hormone", "protein that binds sex hormones in the blood", ("Sex Hormone Binding Globulin",)),
161
+ Marker("IGF-1", "ng/mL", 115, 355, "Hormone", "growth factor reflecting growth-hormone activity", ("Insulin-Like Growth Factor 1", "Somatomedin C")),
162
+ # --- Oncology / screening ---
163
+ Marker("PSA", "ng/mL", None, 4.0, "Oncology", "prostate-specific protein used in screening", ("Prostate Specific Antigen",)),
164
+ # --- Vitamins / iron (continued) ---
165
+ Marker("Folate", "ng/mL", 3, 20, "Vitamin", "B vitamin needed for DNA and red-cell production", ("Folic Acid", "Serum Folate")),
166
+ Marker("Vitamin A", "mcg/dL", 30, 65, "Vitamin", "fat-soluble vitamin for vision and immunity", ("Retinol",)),
167
  )
168
 
169
 
src/model_paths.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve MiniCPM-V Transformers weights: local disk first, Hub download fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ DEFAULT_HF_REPO = "openbmb/MiniCPM-V-4.6"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TransformersModelSource:
14
+ model_id: str
15
+ local_files_only: bool
16
+ origin: str
17
+
18
+
19
+ def project_root() -> Path:
20
+ return Path(__file__).resolve().parents[1]
21
+
22
+
23
+ def models_dir() -> Path:
24
+ raw = os.getenv("BTE_MODELS_DIR", "models").strip()
25
+ path = Path(raw)
26
+ if not path.is_absolute():
27
+ path = project_root() / path
28
+ return path.resolve()
29
+
30
+
31
+ def hub_cache_dir() -> Path:
32
+ cache = models_dir() / ".cache" / "huggingface" / "hub"
33
+ cache.mkdir(parents=True, exist_ok=True)
34
+ return cache
35
+
36
+
37
+ def apply_local_model_defaults() -> None:
38
+ """Send Hugging Face downloads to the project models/ cache by default."""
39
+ models = models_dir()
40
+ os.environ.setdefault("BTE_MODELS_DIR", str(models))
41
+ hf_home = models / ".cache" / "huggingface"
42
+ hf_home.mkdir(parents=True, exist_ok=True)
43
+ os.environ.setdefault("HF_HOME", str(hf_home))
44
+ os.environ.setdefault("HUGGINGFACE_HUB_CACHE", str(hub_cache_dir()))
45
+
46
+
47
+ def resolve_transformers_model_source(model_id: str | None = None) -> TransformersModelSource:
48
+ """Use a complete local checkpoint when present; otherwise download and load from Hub."""
49
+ configured = (model_id or os.getenv("ZEROGPU_MODEL_ID") or DEFAULT_HF_REPO).strip()
50
+ repo_id = DEFAULT_HF_REPO if configured.startswith(".") or configured.startswith("/") else configured
51
+
52
+ explicit = Path(configured).expanduser()
53
+ if explicit.is_dir() and is_transformers_model_dir(explicit):
54
+ return TransformersModelSource(str(explicit.resolve()), True, "local-dir")
55
+
56
+ for candidate in (
57
+ models_dir() / "MiniCPM-V-4.6",
58
+ models_dir() / "openbmb" / "MiniCPM-V-4.6",
59
+ models_dir() / repo_id.split("/", 1)[-1],
60
+ ):
61
+ if is_transformers_model_dir(candidate):
62
+ return TransformersModelSource(str(candidate.resolve()), True, "local-dir")
63
+
64
+ for cache_root in (hub_cache_dir(), Path.home() / ".cache" / "huggingface" / "hub"):
65
+ snapshot = latest_complete_snapshot(repo_id, cache_root)
66
+ if snapshot:
67
+ label = "local-cache" if cache_root == hub_cache_dir() else "local-cache-global"
68
+ return TransformersModelSource(str(snapshot), True, label)
69
+
70
+ return TransformersModelSource(repo_id, False, "hub-download")
71
+
72
+
73
+ def is_transformers_model_dir(path: Path) -> bool:
74
+ if not path.is_dir() or not (path / "config.json").is_file():
75
+ return False
76
+ if list(path.glob("*.safetensors")) or list(path.glob("model*.bin")):
77
+ return True
78
+ return any(path.glob("model*.safetensors.index.json"))
79
+
80
+
81
+ def latest_complete_snapshot(repo_id: str, hub_cache: Path) -> Path | None:
82
+ if not hub_cache.is_dir():
83
+ return None
84
+ repo_dir = hub_cache / f"models--{repo_id.replace('/', '--')}" / "snapshots"
85
+ if not repo_dir.is_dir():
86
+ return None
87
+ snapshots = sorted(
88
+ (p for p in repo_dir.iterdir() if p.is_dir()),
89
+ key=lambda p: p.stat().st_mtime,
90
+ reverse=True,
91
+ )
92
+ for snapshot in snapshots:
93
+ if is_transformers_model_dir(snapshot):
94
+ return snapshot
95
+ return None
src/openbmb_client.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import json
4
  import os
5
  import re
 
6
  from dataclasses import dataclass, field
7
  from typing import Any
8
 
@@ -10,7 +11,7 @@ import requests
10
  from json_repair import loads as repair_json_loads
11
  from requests import HTTPError
12
 
13
- from src.document_processing import document_to_payload_parts
14
  from src.local_env import load_local_env
15
 
16
 
@@ -106,6 +107,7 @@ class OpenBMBExtractor:
106
  "max_tokens": 2048,
107
  }
108
 
 
109
  response = requests.post(
110
  self.api_url,
111
  headers={
@@ -115,6 +117,7 @@ class OpenBMBExtractor:
115
  json=payload,
116
  timeout=self.timeout_seconds,
117
  )
 
118
  try:
119
  response.raise_for_status()
120
  except HTTPError as error:
@@ -133,14 +136,33 @@ class OpenBMBExtractor:
133
  notes=_normalize_notes(parsed.get("notes", [])),
134
  raw_response=raw_response,
135
  request_summary={
 
136
  "api_url": self.api_url,
137
  "model": self.model,
138
  "document_parts": len(document_parts),
139
- "pages": "auto",
 
 
 
 
 
 
140
  },
141
  )
142
 
143
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  def _extract_message_content(payload: dict[str, Any]) -> str:
145
  try:
146
  message = payload["choices"][0]["message"]
 
3
  import json
4
  import os
5
  import re
6
+ import time
7
  from dataclasses import dataclass, field
8
  from typing import Any
9
 
 
11
  from json_repair import loads as repair_json_loads
12
  from requests import HTTPError
13
 
14
+ from src.document_processing import document_intake_metadata, document_to_payload_parts
15
  from src.local_env import load_local_env
16
 
17
 
 
107
  "max_tokens": 2048,
108
  }
109
 
110
+ started = time.perf_counter()
111
  response = requests.post(
112
  self.api_url,
113
  headers={
 
117
  json=payload,
118
  timeout=self.timeout_seconds,
119
  )
120
+ duration_ms = int((time.perf_counter() - started) * 1000)
121
  try:
122
  response.raise_for_status()
123
  except HTTPError as error:
 
136
  notes=_normalize_notes(parsed.get("notes", [])),
137
  raw_response=raw_response,
138
  request_summary={
139
+ "backend": "api",
140
  "api_url": self.api_url,
141
  "model": self.model,
142
  "document_parts": len(document_parts),
143
+ "pages": max_pages or "auto",
144
+ "extraction_prompt": EXTRACTION_PROMPT,
145
+ "user_message_preview": summarize_document_parts(document_parts),
146
+ **document_intake_metadata(file_path, document_parts),
147
+ "http_status": response.status_code,
148
+ "return_code": 0,
149
+ "duration_ms": duration_ms,
150
  },
151
  )
152
 
153
 
154
+ def summarize_document_parts(parts: list[dict[str, Any]]) -> dict[str, int]:
155
+ """Lightweight payload stats for pipeline traces (no base64 blobs)."""
156
+ image_count = 0
157
+ text_characters = 0
158
+ for part in parts:
159
+ if part.get("type") == "image_url":
160
+ image_count += 1
161
+ elif part.get("type") == "text":
162
+ text_characters += len(str(part.get("text") or ""))
163
+ return {"image_count": image_count, "text_characters": text_characters}
164
+
165
+
166
  def _extract_message_content(payload: dict[str, Any]) -> str:
167
  try:
168
  message = payload["choices"][0]["message"]
src/pipeline_trace.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build a step-by-step trace of the analysis pipeline for the agent trace panel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from dataclasses import asdict, dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from src.interpretation import Interpretation, build_interpretation
12
+ from src.openbmb_client import EXTRACTION_PROMPT, ExtractionResult
13
+
14
+ _MAX_PREVIEW = 2400
15
+ _TRACE_TITLE = "Agent pipeline trace"
16
+
17
+ _PIPELINE_STEP_DEFS: tuple[tuple[str, str], ...] = (
18
+ ("document_intake", "Step 1 — Document intake"),
19
+ ("vision_extraction", "Step 2 — Vision extraction (LLM)"),
20
+ ("schema_normalization", "Step 3 — Schema normalization"),
21
+ ("knowledge_graph", "Step 4 — Knowledge graph enrichment"),
22
+ ("pattern_detection", "Step 5 — Cross-marker pattern detection"),
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PipelineStep:
28
+ id: str
29
+ title: str
30
+ status: str
31
+ summary: str
32
+ return_code: int | None = 0
33
+ prompt: str | None = None
34
+ input_preview: str | None = None
35
+ output_preview: str | None = None
36
+ metadata: dict[str, Any] = field(default_factory=dict)
37
+
38
+
39
+ def _truncate(text: str | None, limit: int = _MAX_PREVIEW) -> str | None:
40
+ if not text:
41
+ return None
42
+ cleaned = text.strip()
43
+ if len(cleaned) <= limit:
44
+ return cleaned
45
+ return cleaned[: limit - 3].rstrip() + "..."
46
+
47
+
48
+ def _marker_preview(tests: list[dict[str, Any]], limit: int = 3) -> str:
49
+ lines: list[str] = []
50
+ for test in tests[:limit]:
51
+ marker = test.get("marker", "?")
52
+ value = test.get("value", "?")
53
+ unit = test.get("unit") or ""
54
+ status = test.get("status") or "unknown"
55
+ lines.append(f"- {marker}: {value} {unit} ({status})".strip())
56
+ if len(tests) > limit:
57
+ lines.append(f"- … and {len(tests) - limit} more")
58
+ return "\n".join(lines)
59
+
60
+
61
+ def build_pipeline_trace(
62
+ extraction: ExtractionResult,
63
+ health_report: dict[str, Any],
64
+ *,
65
+ source_path: str | None = None,
66
+ ) -> list[PipelineStep]:
67
+ summary = extraction.request_summary or {}
68
+ patient = health_report.get("patient") or extraction.patient or {}
69
+ report_summary = health_report.get("summary") or {}
70
+ interpretation = build_interpretation(extraction.tests)
71
+
72
+ backend = summary.get("backend") or summary.get("api_url") or "unknown"
73
+ file_name = Path(source_path).name if source_path else None
74
+ runtime_return_code = summary.get("return_code", 0)
75
+
76
+ intake_lines = [
77
+ f"Backend: {backend}",
78
+ f"Input modality: {summary.get('input_modality', 'unknown')}",
79
+ f"Document parts: {summary.get('document_parts', '?')}",
80
+ ]
81
+ if summary.get("pages_rendered") is not None:
82
+ intake_lines.append(f"Pages rendered to images: {summary.get('pages_rendered')}")
83
+ if summary.get("max_pages") is not None:
84
+ intake_lines.append(f"Max pages: {summary.get('max_pages')}")
85
+ if file_name:
86
+ intake_lines.append(f"File: {file_name}")
87
+ preview = summary.get("user_message_preview") or {}
88
+ if preview:
89
+ intake_lines.append(
90
+ f"Payload preview: {preview.get('image_count', 0)} image(s), "
91
+ f"{preview.get('text_characters', 0)} text character(s)"
92
+ )
93
+
94
+ steps: list[PipelineStep] = [
95
+ PipelineStep(
96
+ id="document_intake",
97
+ title="Step 1 — Document intake",
98
+ status="complete",
99
+ return_code=0,
100
+ summary="\n".join(intake_lines),
101
+ input_preview=file_name,
102
+ metadata={
103
+ "backend": backend,
104
+ "document_parts": summary.get("document_parts"),
105
+ "max_pages": summary.get("max_pages"),
106
+ "file": file_name,
107
+ **preview,
108
+ },
109
+ ),
110
+ PipelineStep(
111
+ id="vision_extraction",
112
+ title="Step 2 — Vision extraction (LLM)",
113
+ status="complete",
114
+ return_code=runtime_return_code,
115
+ summary=(
116
+ f"Model/backend: {summary.get('model') or summary.get('repo') or backend}. "
117
+ f"Extracted structured JSON from the document."
118
+ ),
119
+ prompt=summary.get("extraction_prompt") or EXTRACTION_PROMPT,
120
+ input_preview=_stringify_preview(
121
+ summary.get("composed_prompt") or summary.get("messages_preview")
122
+ ),
123
+ output_preview=_truncate(extraction.raw_response),
124
+ metadata={
125
+ "backend": backend,
126
+ "model": summary.get("model") or summary.get("repo"),
127
+ "api_url": summary.get("api_url") or summary.get("url"),
128
+ "http_status": summary.get("http_status"),
129
+ "duration_ms": summary.get("duration_ms"),
130
+ "return_code": runtime_return_code,
131
+ "document_parts": summary.get("document_parts"),
132
+ },
133
+ ),
134
+ PipelineStep(
135
+ id="schema_normalization",
136
+ title="Step 3 — Schema normalization",
137
+ status="complete",
138
+ return_code=0,
139
+ summary=(
140
+ f"Parsed {len(extraction.tests)} marker(s), "
141
+ f"{len(extraction.notes)} note(s). "
142
+ f"Patient sex: {patient.get('sex', 'unknown')}; "
143
+ f"age group: {patient.get('age_group', 'unknown')}."
144
+ ),
145
+ output_preview=_marker_preview(extraction.tests),
146
+ metadata={
147
+ "markers_parsed": len(extraction.tests),
148
+ "notes_parsed": len(extraction.notes),
149
+ "patient_sex": patient.get("sex", "unknown"),
150
+ "patient_age_group": patient.get("age_group", "unknown"),
151
+ "notes": extraction.notes[:5],
152
+ },
153
+ ),
154
+ PipelineStep(
155
+ id="knowledge_graph",
156
+ title="Step 4 — Knowledge graph enrichment",
157
+ status="complete",
158
+ return_code=0,
159
+ summary=(
160
+ f"Enriched {report_summary.get('enriched_markers', 0)} of "
161
+ f"{report_summary.get('total_markers', 0)} marker(s). "
162
+ f"Unmatched: {len(report_summary.get('unmatched_markers') or [])}."
163
+ ),
164
+ output_preview=_truncate(json.dumps(report_summary, indent=2)),
165
+ metadata={
166
+ "enriched_markers": report_summary.get("enriched_markers", 0),
167
+ "total_markers": report_summary.get("total_markers", 0),
168
+ "unmatched_markers": report_summary.get("unmatched_markers") or [],
169
+ },
170
+ ),
171
+ PipelineStep(
172
+ id="pattern_detection",
173
+ title="Step 5 — Cross-marker pattern detection",
174
+ status="complete",
175
+ return_code=0,
176
+ summary=_pattern_summary(interpretation),
177
+ output_preview=_pattern_output(interpretation),
178
+ metadata={
179
+ "flagged_markers": len(interpretation.flagged),
180
+ "patterns_detected": len(interpretation.patterns),
181
+ "normal_count": interpretation.normal_count,
182
+ },
183
+ ),
184
+ ]
185
+ return steps
186
+
187
+
188
+ def _stringify_preview(value: Any) -> str | None:
189
+ if value is None:
190
+ return None
191
+ if isinstance(value, str):
192
+ return _truncate(value)
193
+ return _truncate(json.dumps(value, indent=2))
194
+
195
+
196
+ def _pattern_summary(interpretation: Interpretation) -> str:
197
+ flagged = len(interpretation.flagged)
198
+ patterns = len(interpretation.patterns)
199
+ return (
200
+ f"Flagged markers: {flagged}. "
201
+ f"Cross-marker patterns detected: {patterns}. "
202
+ f"In-range recognized markers: {interpretation.normal_count}."
203
+ )
204
+
205
+
206
+ def _pattern_output(interpretation: Interpretation) -> str | None:
207
+ if not interpretation.patterns and not interpretation.flagged:
208
+ return "No flagged markers or cross-marker patterns."
209
+ lines: list[str] = []
210
+ for insight in interpretation.flagged[:6]:
211
+ note = insight.note or "(no KB note)"
212
+ lines.append(f"- {insight.marker} ({insight.status}): {note}")
213
+ if len(interpretation.flagged) > 6:
214
+ lines.append(f"- … and {len(interpretation.flagged) - 6} more flagged marker(s)")
215
+ for pattern in interpretation.patterns:
216
+ lines.append(f"- Pattern — {pattern.name}: {pattern.note}")
217
+ return "\n".join(lines)
218
+
219
+
220
+ def _step_teaser(step: PipelineStep) -> str:
221
+ first_line = step.summary.strip().split("\n", 1)[0]
222
+ if len(first_line) > 96:
223
+ return first_line[:93].rstrip() + "..."
224
+ return first_line
225
+
226
+
227
+ def _format_return_code(code: int | None) -> str:
228
+ if code is None:
229
+ return "—"
230
+ return str(code)
231
+
232
+
233
+ def _format_meta_value(value: Any) -> str:
234
+ if value is None:
235
+ return "—"
236
+ if isinstance(value, (dict, list)):
237
+ return json.dumps(value, indent=2)
238
+ if isinstance(value, float):
239
+ return f"{value:.2f}"
240
+ return str(value)
241
+
242
+
243
+ def _status_badge(status: str) -> str:
244
+ css = {
245
+ "complete": "bte-trace-status--complete",
246
+ "running": "bte-trace-status--running",
247
+ "failed": "bte-trace-status--failed",
248
+ }.get(status, "bte-trace-status--unknown")
249
+ label = status.replace("_", " ").title()
250
+ return f'<span class="bte-trace-status {css}">{html.escape(label)}</span>'
251
+
252
+
253
+ def _metrics_table(step: PipelineStep) -> str:
254
+ rows: list[tuple[str, str]] = [
255
+ ("Status", step.status.replace("_", " ").title()),
256
+ ("Return code", _format_return_code(step.return_code)),
257
+ ]
258
+ skip_keys = {"notes", "unmatched_markers"}
259
+ for key, value in step.metadata.items():
260
+ if key in skip_keys or value in (None, "", [], {}):
261
+ continue
262
+ label = key.replace("_", " ").title()
263
+ rows.append((label, _format_meta_value(value)))
264
+
265
+ cells = "".join(
266
+ f"<div><dt>{html.escape(label)}</dt><dd>{html.escape(value)}</dd></div>"
267
+ for label, value in rows
268
+ )
269
+ return f'<dl class="bte-trace-meta">{cells}</dl>'
270
+
271
+
272
+ def _trace_block(title: str, body: str, *, subtitle: str | None = None) -> str:
273
+ subtitle_html = (
274
+ f'<p class="bte-trace-subtitle">{html.escape(subtitle)}</p>' if subtitle else ""
275
+ )
276
+ return f"""
277
+ <section class="bte-trace-panel" aria-label="Agent pipeline trace">
278
+ <header class="bte-trace-panel-header">
279
+ <strong>{html.escape(title)}</strong>
280
+ {subtitle_html}
281
+ </header>
282
+ <div class="bte-trace-steps">
283
+ {body}
284
+ </div>
285
+ </section>
286
+ """
287
+
288
+
289
+ def step_to_html(step: PipelineStep) -> str:
290
+ sections: list[str] = [
291
+ _metrics_table(step),
292
+ f'<p class="bte-trace-summary">{html.escape(step.summary)}</p>',
293
+ ]
294
+ if step.prompt:
295
+ sections.append(
296
+ '<details class="bte-trace-subdetails">'
297
+ "<summary>Full prompt</summary>"
298
+ f"<pre>{html.escape(step.prompt)}</pre>"
299
+ "</details>"
300
+ )
301
+ if step.input_preview:
302
+ sections.append(
303
+ '<details class="bte-trace-subdetails">'
304
+ "<summary>Input preview</summary>"
305
+ f"<pre>{html.escape(step.input_preview)}</pre>"
306
+ "</details>"
307
+ )
308
+ if step.output_preview:
309
+ sections.append(
310
+ '<details class="bte-trace-subdetails">'
311
+ "<summary>Output preview</summary>"
312
+ f"<pre>{html.escape(step.output_preview)}</pre>"
313
+ "</details>"
314
+ )
315
+ return f"""
316
+ <details class="bte-trace-step">
317
+ <summary class="bte-trace-step-summary">
318
+ <span class="bte-trace-step-heading">
319
+ <span class="bte-trace-step-title">{html.escape(step.title)}</span>
320
+ {_status_badge(step.status)}
321
+ </span>
322
+ <span class="bte-trace-step-meta">
323
+ Return code: {html.escape(_format_return_code(step.return_code))}
324
+ </span>
325
+ <span class="bte-trace-step-teaser">{html.escape(_step_teaser(step))}</span>
326
+ </summary>
327
+ <div class="bte-trace-step-body">
328
+ {"".join(sections)}
329
+ </div>
330
+ </details>
331
+ """
332
+
333
+
334
+ def trace_to_html(steps: list[PipelineStep]) -> str:
335
+ body = "".join(step_to_html(step) for step in steps)
336
+ return _trace_block(
337
+ _TRACE_TITLE,
338
+ body,
339
+ subtitle="Expand any step to inspect status, return code, prompts, and outputs.",
340
+ )
341
+
342
+
343
+ def empty_trace_html() -> str:
344
+ body = """
345
+ <p class="bte-trace-empty">
346
+ Upload a lab report to see every agent pipeline step here.
347
+ </p>
348
+ """
349
+ return _trace_block(_TRACE_TITLE, body)
350
+
351
+
352
+ def processing_trace_html() -> str:
353
+ steps = [
354
+ PipelineStep(
355
+ id=step_id,
356
+ title=title,
357
+ status="running",
358
+ return_code=None,
359
+ summary="Waiting for upstream steps to finish…",
360
+ metadata={"pipeline_phase": "processing"},
361
+ )
362
+ for step_id, title in _PIPELINE_STEP_DEFS
363
+ ]
364
+ return _trace_block(
365
+ _TRACE_TITLE,
366
+ "".join(step_to_html(step) for step in steps),
367
+ subtitle="Pipeline running — reading your document and enriching results.",
368
+ )
369
+
370
+
371
+ def error_trace_html(message: str) -> str:
372
+ failed_step = PipelineStep(
373
+ id="vision_extraction",
374
+ title="Step 2 — Vision extraction (LLM)",
375
+ status="failed",
376
+ return_code=1,
377
+ summary=message,
378
+ metadata={"pipeline_phase": "failed"},
379
+ )
380
+ body = step_to_html(failed_step)
381
+ return _trace_block(
382
+ _TRACE_TITLE,
383
+ body,
384
+ subtitle="Pipeline failed before the report could be generated.",
385
+ )
386
+
387
+
388
+ def step_to_markdown(step: PipelineStep) -> str:
389
+ parts = [f"**{step.title}**", step.summary]
390
+ if step.prompt:
391
+ parts.append(
392
+ f"<details><summary>Full prompt</summary>\n\n```\n{step.prompt}\n```\n</details>"
393
+ )
394
+ if step.input_preview:
395
+ parts.append(
396
+ f"<details><summary>Input preview</summary>\n\n```\n{step.input_preview}\n```\n</details>"
397
+ )
398
+ if step.output_preview:
399
+ parts.append(
400
+ f"<details><summary>Output preview</summary>\n\n```\n{step.output_preview}\n```\n</details>"
401
+ )
402
+ return "\n\n".join(parts)
403
+
404
+
405
+ def trace_to_chat_messages(steps: list[PipelineStep]) -> list[dict[str, str]]:
406
+ intro = (
407
+ "**Analysis pipeline complete.** Below are the agent steps that processed your document."
408
+ )
409
+ messages = [{"role": "assistant", "content": intro}]
410
+ for step in steps:
411
+ messages.append({"role": "assistant", "content": step_to_markdown(step)})
412
+ return messages
413
+
414
+
415
+ def serialize_steps(steps: list[PipelineStep]) -> list[dict[str, Any]]:
416
+ return [asdict(step) for step in steps]
417
+
418
+
419
+ def interpretation_to_dict(interpretation: Interpretation) -> dict[str, Any]:
420
+ return {
421
+ "flagged": [
422
+ {
423
+ "marker": item.marker,
424
+ "value": item.value,
425
+ "unit": item.unit,
426
+ "status": item.status,
427
+ "reference_range": item.reference_range,
428
+ "note": item.note,
429
+ "questions": list(item.questions),
430
+ }
431
+ for item in interpretation.flagged
432
+ ],
433
+ "normal_count": interpretation.normal_count,
434
+ "patterns": [{"name": p.name, "note": p.note} for p in interpretation.patterns],
435
+ "disclaimer": interpretation.disclaimer,
436
+ }
437
+
438
+
439
+ def extraction_to_dict(extraction: ExtractionResult) -> dict[str, Any]:
440
+ return {
441
+ "patient": extraction.patient,
442
+ "tests": extraction.tests,
443
+ "notes": extraction.notes,
444
+ "raw_response": extraction.raw_response,
445
+ "request_summary": extraction.request_summary,
446
+ }
447
+
448
+
449
+ def build_session_state(
450
+ extraction: ExtractionResult,
451
+ health_report: dict[str, Any],
452
+ steps: list[PipelineStep],
453
+ ) -> dict[str, Any]:
454
+ interpretation = build_interpretation(extraction.tests)
455
+ return {
456
+ "extraction": extraction_to_dict(extraction),
457
+ "health_report": health_report,
458
+ "interpretation": interpretation_to_dict(interpretation),
459
+ "trace_steps": serialize_steps(steps),
460
+ }
src/results_chat.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Context-aware chat about uploaded blood test results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from src.extraction.text_generation import generate_text_chat
9
+
10
+ CHAT_SYSTEM_PROMPT = """
11
+ You are an educational assistant helping a patient understand blood test results.
12
+
13
+ Rules:
14
+ - Use ONLY the patient context, extracted lab values, knowledge-graph enrichment, and grounded
15
+ interpretation notes provided below.
16
+ - Do not diagnose, prescribe, or invent medical facts not present in the context.
17
+ - Use plain language and say when a clinician should interpret a result in person.
18
+ - If the user asks about something not in the context, say you do not have that information.
19
+ - Keep answers concise unless the user asks for detail.
20
+ """.strip()
21
+
22
+ _MAX_CONTEXT_CHARS = 8000
23
+ _MAX_HISTORY_TURNS = 6
24
+
25
+
26
+ class ResultsChatAssistant:
27
+ def reply(
28
+ self,
29
+ user_message: str,
30
+ chat_history: list[dict[str, str]] | None,
31
+ session: dict[str, Any] | None,
32
+ ) -> str:
33
+ message = (user_message or "").strip()
34
+ if not message:
35
+ return "Please enter a question about your blood test results."
36
+
37
+ if not session or not session.get("health_report"):
38
+ return "Upload and analyze a lab report first, then I can answer questions about your results."
39
+
40
+ context = build_chat_context(session)
41
+ messages = _build_messages(context, chat_history or [], message)
42
+ try:
43
+ return generate_text_chat(messages)
44
+ except Exception as exc:
45
+ return (
46
+ "I couldn't generate a chat reply with the current model backend. "
47
+ f"Details: {exc}"
48
+ )
49
+
50
+
51
+ def build_chat_context(session: dict[str, Any]) -> str:
52
+ health_report = session.get("health_report") or {}
53
+ extraction = session.get("extraction") or {}
54
+ interpretation = session.get("interpretation") or {}
55
+
56
+ patient = health_report.get("patient") or extraction.get("patient") or {}
57
+ markers = health_report.get("markers") or []
58
+ summary = health_report.get("summary") or {}
59
+
60
+ lines: list[str] = [
61
+ "=== Patient context ===",
62
+ json.dumps(
63
+ {
64
+ "age": patient.get("age"),
65
+ "age_years": patient.get("age_years"),
66
+ "age_group": patient.get("age_group"),
67
+ "sex": patient.get("sex"),
68
+ },
69
+ indent=2,
70
+ ),
71
+ "",
72
+ "=== Report summary ===",
73
+ json.dumps(summary, indent=2),
74
+ "",
75
+ "=== Extracted markers ===",
76
+ ]
77
+
78
+ for marker in markers[:40]:
79
+ lines.append(
80
+ json.dumps(
81
+ {
82
+ "name": marker.get("display_name") or marker.get("raw_name"),
83
+ "value": marker.get("value"),
84
+ "unit": marker.get("unit"),
85
+ "status": marker.get("status"),
86
+ "lab_reference_range": marker.get("lab_reference_range"),
87
+ "comparison_basis": (marker.get("comparison") or {}).get("basis"),
88
+ "kg_description": ((marker.get("knowledge") or {}).get("description")),
89
+ "kg_importance": ((marker.get("knowledge") or {}).get("why_important")),
90
+ },
91
+ ensure_ascii=False,
92
+ )
93
+ )
94
+
95
+ if interpretation.get("flagged"):
96
+ lines.extend(["", "=== Flagged markers (KB-grounded) ==="])
97
+ for item in interpretation["flagged"]:
98
+ lines.append(json.dumps(item, ensure_ascii=False))
99
+
100
+ if interpretation.get("patterns"):
101
+ lines.extend(["", "=== Cross-marker patterns ==="])
102
+ for item in interpretation["patterns"]:
103
+ lines.append(json.dumps(item, ensure_ascii=False))
104
+
105
+ if extraction.get("notes"):
106
+ lines.extend(["", "=== Extraction notes ===", json.dumps(extraction["notes"], indent=2)])
107
+
108
+ lines.extend(["", "=== Disclaimer ===", interpretation.get("disclaimer", "")])
109
+
110
+ context = "\n".join(lines)
111
+ if len(context) <= _MAX_CONTEXT_CHARS:
112
+ return context
113
+ return context[: _MAX_CONTEXT_CHARS - 3].rstrip() + "..."
114
+
115
+
116
+ def _build_messages(
117
+ context: str,
118
+ chat_history: list[dict[str, str]],
119
+ user_message: str,
120
+ ) -> list[dict[str, str]]:
121
+ messages: list[dict[str, str]] = [
122
+ {"role": "system", "content": CHAT_SYSTEM_PROMPT},
123
+ {"role": "user", "content": f"Blood test context:\n\n{context}"},
124
+ {
125
+ "role": "assistant",
126
+ "content": "I have your blood test context. Ask me anything about these results.",
127
+ },
128
+ ]
129
+
130
+ recent = _recent_chat_turns(chat_history)
131
+ messages.extend(recent)
132
+ messages.append({"role": "user", "content": user_message})
133
+ return messages
134
+
135
+
136
+ def _recent_chat_turns(chat_history: list[dict[str, str]]) -> list[dict[str, str]]:
137
+ """Keep only user follow-up turns, skipping pipeline trace assistant messages."""
138
+ turns: list[dict[str, str]] = []
139
+ for item in chat_history:
140
+ role = item.get("role")
141
+ content = str(item.get("content") or "").strip()
142
+ if not content or role not in {"user", "assistant"}:
143
+ continue
144
+ if role == "assistant" and content.startswith("**Step "):
145
+ continue
146
+ if role == "assistant" and content.startswith("**Analysis pipeline complete."):
147
+ continue
148
+ if role == "assistant" and content.startswith("**Pipeline running"):
149
+ continue
150
+ if role == "assistant" and content.startswith("Upload a lab report"):
151
+ continue
152
+ turns.append({"role": role, "content": content})
153
+
154
+ if len(turns) > _MAX_HISTORY_TURNS * 2:
155
+ turns = turns[-(_MAX_HISTORY_TURNS * 2) :]
156
+ return turns
tests/test_document_processing.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import fitz
6
+ from PIL import Image
7
+
8
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
9
+
10
+ from src.document_processing import document_intake_metadata, document_to_payload_parts, validate_upload
11
+
12
+
13
+ def test_png_upload_returns_image_url_part():
14
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
15
+ path = tmp.name
16
+ Image.new("RGB", (32, 32), color="white").save(path)
17
+
18
+ parts = document_to_payload_parts(path)
19
+ assert len(parts) == 1
20
+ assert parts[0]["type"] == "image_url"
21
+ assert parts[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
22
+
23
+
24
+ def test_jpeg_upload_returns_image_url_part():
25
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
26
+ path = tmp.name
27
+ Image.new("RGB", (24, 24), color="red").save(path, format="JPEG")
28
+
29
+ parts = document_to_payload_parts(path)
30
+ assert len(parts) == 1
31
+ assert parts[0]["type"] == "image_url"
32
+
33
+
34
+ def test_pdf_upload_renders_pages_to_images():
35
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
36
+ path = tmp.name
37
+ document = fitz.open()
38
+ page = document.new_page()
39
+ page.insert_text((72, 72), "Hemoglobin 12.5 g/dL")
40
+ document.save(path)
41
+ document.close()
42
+
43
+ parts = document_to_payload_parts(path, max_pages=1)
44
+ assert len(parts) == 1
45
+ assert parts[0]["type"] == "image_url"
46
+ assert parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
47
+
48
+
49
+ def test_text_upload_still_returns_text_part():
50
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w", encoding="utf-8") as tmp:
51
+ tmp.write("Hemoglobin 13.1 g/dL")
52
+ path = tmp.name
53
+
54
+ parts = document_to_payload_parts(path)
55
+ assert len(parts) == 1
56
+ assert parts[0]["type"] == "text"
57
+ assert "Hemoglobin" in parts[0]["text"]
58
+
59
+
60
+ def test_validate_upload_rejects_unknown_extension():
61
+ with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
62
+ path = tmp.name
63
+ try:
64
+ validate_upload(path)
65
+ raise AssertionError("expected ValueError")
66
+ except ValueError as error:
67
+ assert "Unsupported file type" in str(error)
68
+
69
+
70
+ def test_document_intake_metadata_for_pdf():
71
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
72
+ path = tmp.name
73
+ document = fitz.open()
74
+ page = document.new_page()
75
+ page.insert_text((72, 72), "Sample")
76
+ document.save(path)
77
+ document.close()
78
+
79
+ parts = document_to_payload_parts(path, max_pages=1)
80
+ metadata = document_intake_metadata(path, parts)
81
+ assert metadata["input_modality"] == "vision"
82
+ assert metadata["pages_rendered"] == 1
83
+ assert metadata["image_count"] == 1
84
+
85
+
86
+ if __name__ == "__main__":
87
+ test_png_upload_returns_image_url_part()
88
+ test_jpeg_upload_returns_image_url_part()
89
+ test_pdf_upload_renders_pages_to_images()
90
+ test_text_upload_still_returns_text_part()
91
+ test_validate_upload_rejects_unknown_extension()
92
+ test_document_intake_metadata_for_pdf()
93
+ print("test_document_processing: ok")
tests/test_model_paths.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
6
+
7
+ from src.model_paths import is_transformers_model_dir, resolve_transformers_model_source
8
+
9
+
10
+ def test_resolve_uses_hub_download_when_no_local_weights():
11
+ with tempfile.TemporaryDirectory() as tmp:
12
+ source = resolve_transformers_model_source("openbmb/MiniCPM-V-4.6")
13
+ assert source.local_files_only is False
14
+ assert source.origin == "hub-download"
15
+ assert source.model_id == "openbmb/MiniCPM-V-4.6"
16
+
17
+
18
+ def test_resolve_uses_local_dir_when_complete():
19
+ with tempfile.TemporaryDirectory() as tmp:
20
+ model_dir = Path(tmp) / "MiniCPM-V-4.6"
21
+ model_dir.mkdir()
22
+ (model_dir / "config.json").write_text("{}", encoding="utf-8")
23
+ (model_dir / "model.safetensors").write_bytes(b"test")
24
+
25
+ source = resolve_transformers_model_source(str(model_dir))
26
+ assert source.local_files_only is True
27
+ assert source.origin == "local-dir"
28
+ assert Path(source.model_id) == model_dir.resolve()
29
+
30
+
31
+ def test_is_transformers_model_dir_requires_weights():
32
+ with tempfile.TemporaryDirectory() as tmp:
33
+ model_dir = Path(tmp) / "partial"
34
+ model_dir.mkdir()
35
+ (model_dir / "config.json").write_text("{}", encoding="utf-8")
36
+ assert is_transformers_model_dir(model_dir) is False
37
+
38
+
39
+ if __name__ == "__main__":
40
+ test_resolve_uses_hub_download_when_no_local_weights()
41
+ test_resolve_uses_local_dir_when_complete()
42
+ test_is_transformers_model_dir_requires_weights()
43
+ print("test_model_paths: ok")
tests/test_pipeline_trace.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
5
+
6
+ from src.openbmb_client import EXTRACTION_PROMPT, ExtractionResult
7
+ from src.pipeline_trace import build_pipeline_trace, trace_to_html, trace_to_chat_messages
8
+ from src.report_pipeline import build_health_report
9
+
10
+
11
+ def _sample_extraction() -> ExtractionResult:
12
+ return ExtractionResult(
13
+ patient={"age": "42y", "age_years": 42.0, "sex": "female", "age_group": "adult"},
14
+ tests=[
15
+ {
16
+ "marker": "Hemoglobin",
17
+ "value": "11.2",
18
+ "unit": "g/dL",
19
+ "reference_range": "12.0-16.0",
20
+ "status": "low",
21
+ "source_text": "Hgb 11.2",
22
+ "confidence": 0.95,
23
+ },
24
+ {
25
+ "marker": "WBC",
26
+ "value": "6.5",
27
+ "unit": "10^3/uL",
28
+ "reference_range": "4.5-11.0",
29
+ "status": "normal",
30
+ "source_text": "WBC 6.5",
31
+ "confidence": 0.9,
32
+ },
33
+ ],
34
+ notes=["Sample note"],
35
+ raw_response='{"tests":[{"marker":"Hemoglobin","value":"11.2"}]}',
36
+ request_summary={
37
+ "backend": "test",
38
+ "extraction_prompt": EXTRACTION_PROMPT,
39
+ "document_parts": 2,
40
+ "http_status": 200,
41
+ "return_code": 0,
42
+ "duration_ms": 842,
43
+ "user_message_preview": {"image_count": 1, "text_characters": 120},
44
+ },
45
+ )
46
+
47
+
48
+ def test_build_pipeline_trace_has_five_steps():
49
+ extraction = _sample_extraction()
50
+ report = build_health_report(extraction)
51
+ steps = build_pipeline_trace(extraction, report, source_path="/tmp/report.pdf")
52
+ assert len(steps) == 5
53
+ assert [step.id for step in steps] == [
54
+ "document_intake",
55
+ "vision_extraction",
56
+ "schema_normalization",
57
+ "knowledge_graph",
58
+ "pattern_detection",
59
+ ]
60
+
61
+
62
+ def test_extraction_step_includes_full_prompt():
63
+ extraction = _sample_extraction()
64
+ report = build_health_report(extraction)
65
+ steps = build_pipeline_trace(extraction, report)
66
+ extraction_step = steps[1]
67
+ assert extraction_step.prompt == EXTRACTION_PROMPT
68
+ assert extraction_step.return_code == 0
69
+ assert extraction_step.metadata["http_status"] == 200
70
+ assert extraction_step.metadata["duration_ms"] == 842
71
+
72
+
73
+ def test_trace_to_html_collapsible_steps():
74
+ extraction = _sample_extraction()
75
+ report = build_health_report(extraction)
76
+ steps = build_pipeline_trace(extraction, report)
77
+ html = trace_to_html(steps)
78
+ assert "bte-trace-step" in html
79
+ assert html.count('<details class="bte-trace-step">') == len(steps)
80
+ assert "Return code" in html
81
+ assert "bte-trace-status--complete" in html
82
+ assert "Full prompt" in html
83
+
84
+
85
+ def test_trace_to_chat_messages_shape():
86
+ extraction = _sample_extraction()
87
+ report = build_health_report(extraction)
88
+ steps = build_pipeline_trace(extraction, report)
89
+ messages = trace_to_chat_messages(steps)
90
+ assert messages[0]["role"] == "assistant"
91
+ assert all(msg["role"] == "assistant" for msg in messages)
92
+ assert len(messages) == len(steps) + 1
93
+
94
+
95
+ if __name__ == "__main__":
96
+ test_build_pipeline_trace_has_five_steps()
97
+ test_extraction_step_includes_full_prompt()
98
+ test_trace_to_html_collapsible_steps()
99
+ test_trace_to_chat_messages_shape()
100
+ print("test_pipeline_trace: ok")
tests/test_report_pipeline.py CHANGED
@@ -90,6 +90,50 @@ def test_knowledge_graph_has_sex_guidance_for_every_marker():
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(
 
90
  assert all("sex_specific_statistics_per_group_age" in test for test in high)
91
 
92
 
93
+ # CBC markers in src/markers.py must match KG adult fallback intervals (fix #3).
94
+ _KG_CBC_MARKER_MAP = {
95
+ "Hemoglobin": "hemoglobin",
96
+ "Hematocrit": "hct",
97
+ "Red Blood Cell Count": "rbc",
98
+ "White Blood Cell Count": "wbc",
99
+ "Platelet Count": "plt",
100
+ "MCV": "mcv",
101
+ "MCH": "mch",
102
+ "MCHC": "mchc",
103
+ "RDW": "rdw_cv",
104
+ "Absolute Lymphocyte Count": "lym_absolute",
105
+ "ESR": "esr",
106
+ }
107
+
108
+
109
+ def test_knowledge_graph_normal_values_are_midpoints():
110
+ graph = LabKnowledgeGraph.load()
111
+ for test in graph.tests:
112
+ for stats_key in ("statistics_per_group_age",):
113
+ stats = test.get(stats_key) or {}
114
+ for vals in stats.values():
115
+ lo, hi, mid = vals["minimal_value"], vals["maximum_value"], vals["normal_value"]
116
+ assert mid == round((lo + hi) / 2, 2)
117
+ sex_stats = test.get("sex_specific_statistics_per_group_age") or {}
118
+ for group_stats in sex_stats.values():
119
+ for vals in group_stats.values():
120
+ lo, hi, mid = vals["minimal_value"], vals["maximum_value"], vals["normal_value"]
121
+ assert mid == round((lo + hi) / 2, 2)
122
+
123
+
124
+ def test_markers_py_cbc_ranges_match_knowledge_graph():
125
+ from src.markers import MARKERS
126
+
127
+ graph = LabKnowledgeGraph.load()
128
+ by_name = {m.name: m for m in MARKERS}
129
+ for marker_name, node_id in _KG_CBC_MARKER_MAP.items():
130
+ marker = by_name[marker_name]
131
+ node = graph.get(node_id)
132
+ adult = node["statistics_per_group_age"]["adult"]
133
+ assert marker.ref_low == adult["minimal_value"], marker_name
134
+ assert marker.ref_high == adult["maximum_value"], marker_name
135
+
136
+
137
  def test_final_report_bar_uses_kg_min_normal_and_max_values():
138
  report = build_health_report(
139
  _result(
tests/test_results_chat.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
6
+
7
+ from src.openbmb_client import ExtractionResult
8
+ from src.pipeline_trace import build_pipeline_trace, build_session_state
9
+ from src.report_pipeline import build_health_report
10
+ from src.results_chat import ResultsChatAssistant, build_chat_context
11
+
12
+
13
+ def _session() -> dict:
14
+ extraction = ExtractionResult(
15
+ patient={"age_years": 42, "sex": "female"},
16
+ tests=[
17
+ {
18
+ "marker": "Hemoglobin",
19
+ "value": "11.2",
20
+ "unit": "g/dL",
21
+ "reference_range": "12.0-16.0",
22
+ "status": "low",
23
+ "confidence": 0.9,
24
+ }
25
+ ],
26
+ notes=[],
27
+ raw_response="{}",
28
+ request_summary={"backend": "test", "document_parts": 1},
29
+ )
30
+ report = build_health_report(extraction)
31
+ steps = build_pipeline_trace(extraction, report)
32
+ return build_session_state(extraction, report, steps)
33
+
34
+
35
+ def test_build_chat_context_includes_patient_and_markers():
36
+ context = build_chat_context(_session())
37
+ assert "female" in context
38
+ assert "Hemoglobin" in context
39
+ assert "Report summary" in context
40
+
41
+
42
+ def test_reply_requires_session():
43
+ assistant = ResultsChatAssistant()
44
+ reply = assistant.reply("What is low hemoglobin?", [], {})
45
+ assert "Upload and analyze" in reply
46
+
47
+
48
+ def test_reply_uses_llm_when_session_present():
49
+ assistant = ResultsChatAssistant()
50
+ session = _session()
51
+ with patch("src.results_chat.generate_text_chat", return_value="Educational reply."):
52
+ reply = assistant.reply("Explain my hemoglobin.", [], session)
53
+ assert reply == "Educational reply."
54
+
55
+
56
+ if __name__ == "__main__":
57
+ test_build_chat_context_includes_patient_and_markers()
58
+ test_reply_requires_session()
59
+ test_reply_uses_llm_when_session_present()
60
+ print("test_results_chat: ok")