Add agent trace panel, vision extraction, and local Transformers backend.
Browse filesReplace 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 +2 -0
- app.py +1306 -257
- assets/logos/HF.webp +3 -0
- assets/logos/acg.png +3 -0
- assets/logos/codex.png +3 -0
- assets/logos/modal.png +3 -0
- assets/logos/openbmb.png +3 -0
- kb/cbc_knowledge_graph.json +1103 -228
- kb/knowledge_base.py +380 -0
- requirements.txt +3 -4
- src/document_processing.py +58 -18
- src/extraction/__init__.py +3 -4
- src/extraction/auto.py +12 -64
- src/extraction/factory.py +17 -29
- src/extraction/llamacpp_gpu.py +61 -1
- src/extraction/local_minicpmv.py +9 -1
- src/extraction/local_server.py +11 -1
- src/extraction/text_generation.py +59 -0
- src/extraction/zerogpu_transformers.py +91 -21
- src/local_env.py +9 -0
- src/markers.py +88 -9
- src/model_paths.py +95 -0
- src/openbmb_client.py +24 -2
- src/pipeline_trace.py +460 -0
- src/results_chat.py +156 -0
- tests/test_document_processing.py +93 -0
- tests/test_model_paths.py +43 -0
- tests/test_pipeline_trace.py +100 -0
- tests/test_report_pipeline.py +44 -0
- tests/test_results_chat.py +60 -0
.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 |
-
|
| 30 |
-
|
| 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
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
]
|
| 145 |
-
|
| 146 |
f"""
|
| 147 |
-
<li class=
|
| 148 |
-
<
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
</li>
|
| 151 |
"""
|
| 152 |
-
for
|
| 153 |
)
|
| 154 |
return f"""
|
| 155 |
-
<
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 1042 |
display: grid;
|
| 1043 |
-
grid-template-columns: minmax(0,
|
| 1044 |
-
gap:
|
| 1045 |
-
align-items:
|
| 1046 |
border: 1px solid rgba(255, 255, 255, 0.42);
|
| 1047 |
border-radius: var(--bte-radius);
|
| 1048 |
background:
|
| 1049 |
-
linear-gradient(120deg, rgba(
|
| 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:
|
| 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:
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
|
|
|
|
|
|
| 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:
|
| 1102 |
height: 34px;
|
| 1103 |
-
|
| 1104 |
display: grid;
|
| 1105 |
place-items: center;
|
| 1106 |
-
color: #
|
| 1107 |
font-size: 11px;
|
| 1108 |
font-weight: 800;
|
| 1109 |
letter-spacing: 0;
|
| 1110 |
-
background:
|
| 1111 |
-
box-shadow:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 1119 |
font-weight: 700;
|
| 1120 |
}
|
| 1121 |
|
| 1122 |
-
.bte-hero-badge--
|
| 1123 |
-
|
| 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-
|
| 1131 |
-
|
|
|
|
| 1132 |
}
|
| 1133 |
|
| 1134 |
-
.bte-hero-badge--acg .bte-hero-badge-
|
| 1135 |
-
|
|
|
|
| 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 |
-
|
| 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-
|
| 1228 |
-
.bte-hero-grid div:has(> .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
|
| 1388 |
-
background:
|
| 1389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1390 |
}
|
| 1391 |
|
| 1392 |
-
.bte-
|
| 1393 |
-
|
| 1394 |
-
|
| 1395 |
-
|
| 1396 |
-
|
| 1397 |
-
|
| 1398 |
-
|
| 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-
|
| 1407 |
-
|
| 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-
|
| 1421 |
-
|
| 1422 |
-
|
| 1423 |
-
-
|
|
|
|
|
|
|
| 1424 |
}
|
| 1425 |
|
| 1426 |
-
.bte-step-
|
| 1427 |
-
margin: 0
|
| 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-
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
-
|
| 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-
|
| 1443 |
-
|
| 1444 |
-
|
| 1445 |
-
|
| 1446 |
}
|
| 1447 |
|
| 1448 |
-
.bte-
|
| 1449 |
-
|
| 1450 |
-
|
| 1451 |
-
flex-direction: column;
|
| 1452 |
-
justify-content: space-between;
|
| 1453 |
-
min-height: 430px;
|
| 1454 |
-
overflow: visible !important;
|
| 1455 |
}
|
| 1456 |
|
| 1457 |
-
.bte-
|
| 1458 |
-
|
| 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-
|
| 1471 |
-
|
| 1472 |
-
|
| 1473 |
-
|
| 1474 |
-
|
| 1475 |
-
|
| 1476 |
-
align-items: center;
|
| 1477 |
-
gap: 14px;
|
| 1478 |
}
|
| 1479 |
|
| 1480 |
-
.bte-
|
| 1481 |
-
|
| 1482 |
-
|
| 1483 |
-
|
|
|
|
|
|
|
| 1484 |
}
|
| 1485 |
|
| 1486 |
-
.bte-
|
| 1487 |
-
|
| 1488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 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-
|
| 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-
|
|
|
|
| 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-
|
| 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-
|
| 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-
|
| 1533 |
-
|
| 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-
|
| 1827 |
-
|
| 1828 |
-
|
| 1829 |
-
|
| 1830 |
-
|
| 1831 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1845 |
border-radius: 18px !important;
|
| 1846 |
color: var(--bte-ink) !important;
|
| 1847 |
-
|
| 1848 |
-
|
| 1849 |
-
|
| 1850 |
-
|
| 1851 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1852 |
}
|
| 1853 |
|
| 1854 |
.bte-selected-document {
|
| 1855 |
display: grid;
|
| 1856 |
grid-template-columns: minmax(0, 1fr);
|
| 1857 |
-
gap:
|
| 1858 |
align-items: stretch;
|
| 1859 |
-
|
| 1860 |
-
|
| 1861 |
-
border
|
| 1862 |
-
|
| 1863 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1864 |
}
|
| 1865 |
|
| 1866 |
.bte-selected-preview {
|
| 1867 |
position: relative;
|
| 1868 |
-
|
| 1869 |
-
|
| 1870 |
-
border:
|
|
|
|
| 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:
|
| 1879 |
-
border-radius:
|
| 1880 |
}
|
| 1881 |
|
| 1882 |
.bte-upload-preview-image {
|
| 1883 |
-
width:
|
| 1884 |
-
height:
|
| 1885 |
-
object-fit:
|
| 1886 |
-
|
| 1887 |
-
|
| 1888 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 1962 |
-
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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(
|
| 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
|
| 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=
|
| 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=
|
|
|
|
|
|
|
| 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>
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
assets/logos/acg.png
ADDED
|
Git LFS Details
|
assets/logos/codex.png
ADDED
|
Git LFS Details
|
assets/logos/modal.png
ADDED
|
Git LFS Details
|
assets/logos/openbmb.png
ADDED
|
Git LFS Details
|
kb/cbc_knowledge_graph.json
CHANGED
|
@@ -39,7 +39,11 @@
|
|
| 39 |
{
|
| 40 |
"id": "hemoglobin",
|
| 41 |
"display_name": "Hemoglobin",
|
| 42 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": {
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
},
|
| 58 |
"teenager": {
|
| 59 |
-
"male": {
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
},
|
| 63 |
"adult": {
|
| 64 |
-
"male": {
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
},
|
| 68 |
"elder": {
|
| 69 |
-
"male": {
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
},
|
| 74 |
"instructions_to_improve": {
|
| 75 |
-
"food": [
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
},
|
| 79 |
"statistics_per_group_age": {
|
| 80 |
-
"child": {
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
},
|
| 85 |
-
"related_tests": [
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
},
|
| 88 |
{
|
| 89 |
"id": "rbc",
|
| 90 |
"display_name": "Red Blood Cell Count",
|
| 91 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": {
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
},
|
| 107 |
"teenager": {
|
| 108 |
-
"male": {
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
},
|
| 112 |
"adult": {
|
| 113 |
-
"male": {
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
},
|
| 117 |
"elder": {
|
| 118 |
-
"male": {
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
}
|
| 122 |
},
|
| 123 |
"instructions_to_improve": {
|
| 124 |
-
"food": [
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
},
|
| 128 |
"statistics_per_group_age": {
|
| 129 |
-
"child": {
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
},
|
| 134 |
-
"related_tests": [
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
},
|
| 137 |
{
|
| 138 |
"id": "hct",
|
| 139 |
"display_name": "Hematocrit",
|
| 140 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": {
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
},
|
| 156 |
"teenager": {
|
| 157 |
-
"male": {
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
},
|
| 161 |
"adult": {
|
| 162 |
-
"male": {
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
},
|
| 166 |
"elder": {
|
| 167 |
-
"male": {
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
}
|
| 171 |
},
|
| 172 |
"instructions_to_improve": {
|
| 173 |
-
"food": [
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
},
|
| 177 |
"statistics_per_group_age": {
|
| 178 |
-
"child": {
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
},
|
| 183 |
-
"related_tests": [
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
},
|
| 186 |
{
|
| 187 |
"id": "mcv",
|
| 188 |
"display_name": "Mean Corpuscular Volume",
|
| 189 |
-
"aliases": [
|
|
|
|
|
|
|
| 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": [
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
},
|
| 204 |
"statistics_per_group_age": {
|
| 205 |
-
"child": {
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
},
|
| 210 |
-
"related_tests": [
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
},
|
| 213 |
{
|
| 214 |
"id": "mch",
|
| 215 |
"display_name": "Mean Corpuscular Hemoglobin",
|
| 216 |
-
"aliases": [
|
|
|
|
|
|
|
| 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": [
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
},
|
| 231 |
"statistics_per_group_age": {
|
| 232 |
-
"child": {
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
},
|
| 237 |
-
"related_tests": [
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
},
|
| 240 |
{
|
| 241 |
"id": "mchc",
|
| 242 |
"display_name": "Mean Corpuscular Hemoglobin Concentration",
|
| 243 |
-
"aliases": [
|
|
|
|
|
|
|
| 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": [
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
},
|
| 258 |
"statistics_per_group_age": {
|
| 259 |
-
"child": {
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
},
|
| 264 |
-
"related_tests": [
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
},
|
| 267 |
{
|
| 268 |
"id": "rdw_cv",
|
| 269 |
"display_name": "Red Cell Distribution Width - CV",
|
| 270 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
},
|
| 285 |
"statistics_per_group_age": {
|
| 286 |
-
"child": {
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
},
|
| 291 |
-
"related_tests": [
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
},
|
| 294 |
{
|
| 295 |
"id": "rdw_sd",
|
| 296 |
"display_name": "Red Cell Distribution Width - SD",
|
| 297 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
},
|
| 312 |
"statistics_per_group_age": {
|
| 313 |
-
"child": {
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
},
|
| 318 |
-
"related_tests": [
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
},
|
| 321 |
{
|
| 322 |
"id": "wbc",
|
| 323 |
"display_name": "White Blood Cell Count",
|
| 324 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
},
|
| 339 |
"statistics_per_group_age": {
|
| 340 |
-
"child": {
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
},
|
| 345 |
-
"related_tests": [
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
},
|
| 348 |
{
|
| 349 |
"id": "neu_percent",
|
| 350 |
"display_name": "Neutrophils Percent",
|
| 351 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
},
|
| 366 |
"statistics_per_group_age": {
|
| 367 |
-
"child": {
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
},
|
| 372 |
-
"related_tests": [
|
| 373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
},
|
| 375 |
{
|
| 376 |
"id": "lym_percent",
|
| 377 |
"display_name": "Lymphocytes Percent",
|
| 378 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 390 |
-
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
},
|
| 393 |
"statistics_per_group_age": {
|
| 394 |
-
"child": {
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
},
|
| 399 |
-
"related_tests": [
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
},
|
| 402 |
{
|
| 403 |
"id": "mon_percent",
|
| 404 |
"display_name": "Monocytes Percent",
|
| 405 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
},
|
| 420 |
"statistics_per_group_age": {
|
| 421 |
-
"child": {
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
},
|
| 426 |
-
"related_tests": [
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
},
|
| 429 |
{
|
| 430 |
"id": "eos_percent",
|
| 431 |
"display_name": "Eosinophils Percent",
|
| 432 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 444 |
-
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
},
|
| 447 |
"statistics_per_group_age": {
|
| 448 |
-
"child": {
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
},
|
| 453 |
-
"related_tests": [
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
},
|
| 456 |
{
|
| 457 |
"id": "bas_percent",
|
| 458 |
"display_name": "Basophils Percent",
|
| 459 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 471 |
-
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
},
|
| 474 |
"statistics_per_group_age": {
|
| 475 |
-
"child": {
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
},
|
| 480 |
-
"related_tests": [
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
},
|
| 483 |
{
|
| 484 |
"id": "lym_absolute",
|
| 485 |
"display_name": "Absolute Lymphocyte Count",
|
| 486 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 498 |
-
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
},
|
| 501 |
"statistics_per_group_age": {
|
| 502 |
-
"child": {
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
},
|
| 507 |
-
"related_tests": [
|
| 508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
},
|
| 510 |
{
|
| 511 |
"id": "gra_absolute",
|
| 512 |
"display_name": "Absolute Granulocyte Count",
|
| 513 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
},
|
| 528 |
"statistics_per_group_age": {
|
| 529 |
-
"child": {
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
},
|
| 534 |
-
"related_tests": [
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
},
|
| 537 |
{
|
| 538 |
"id": "plt",
|
| 539 |
"display_name": "Platelet Count",
|
| 540 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": [
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
},
|
| 555 |
"statistics_per_group_age": {
|
| 556 |
-
"child": {
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
},
|
| 561 |
-
"related_tests": [
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
},
|
| 564 |
{
|
| 565 |
"id": "esr",
|
| 566 |
"display_name": "Erythrocyte Sedimentation Rate",
|
| 567 |
-
"aliases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": {
|
| 580 |
-
|
| 581 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
},
|
| 583 |
"teenager": {
|
| 584 |
-
"male": {
|
| 585 |
-
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
},
|
| 588 |
"adult": {
|
| 589 |
-
"male": {
|
| 590 |
-
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
},
|
| 593 |
"elder": {
|
| 594 |
-
"male": {
|
| 595 |
-
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
}
|
| 598 |
},
|
| 599 |
"instructions_to_improve": {
|
| 600 |
-
"food": [
|
| 601 |
-
|
| 602 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
},
|
| 604 |
"statistics_per_group_age": {
|
| 605 |
-
"child": {
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
},
|
| 610 |
-
"related_tests": [
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 8 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 39 |
-
|
| 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 |
-
|
| 45 |
-
for page_index in range(
|
| 46 |
page = document.load_page(page_index)
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- **
|
| 5 |
-
- **
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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]
|
| 29 |
-
f"
|
| 30 |
flush=True,
|
| 31 |
)
|
| 32 |
-
self._selected =
|
| 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 |
-
"""
|
| 48 |
-
|
| 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 |
-
- `
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
-
def build_extractor(
|
| 26 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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.
|
|
|
|
| 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 |
-
|
| 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": "
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
import torch
|
| 88 |
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 141 |
max_new_tokens: int,
|
| 142 |
downsample_mode: str,
|
| 143 |
) -> str:
|
| 144 |
import torch
|
| 145 |
|
| 146 |
try:
|
| 147 |
-
processor, model = _get_model(
|
| 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 |
-
"
|
| 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
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# ~
|
| 50 |
MARKERS: tuple[Marker, ...] = (
|
| 51 |
# --- Complete blood count ---
|
| 52 |
-
Marker("Hemoglobin", "g/dL",
|
| 53 |
-
Marker("Hematocrit", "%",
|
| 54 |
-
Marker("White Blood Cell Count", "10^3/uL",
|
| 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.
|
| 57 |
-
Marker("MCV", "fL",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|