Fix llama.cpp JSON extraction path
Browse files- DEPLOYMENT_LOG.md +7 -0
- README.md +2 -1
- RUNBOOK.md +1 -1
- app.py +257 -178
- requirements.txt +2 -3
- src/extraction/llamacpp_gpu.py +11 -1
- tests/test_app_errors.py +12 -0
DEPLOYMENT_LOG.md
CHANGED
|
@@ -66,3 +66,10 @@ ZEROGPU_MODEL_ID=openbmb/MiniCPM-V-4.6
|
|
| 66 |
Future fine-tuned model:
|
| 67 |
|
| 68 |
Only change the `LLAMACPP_*` variables to point at the fine-tuned GGUF repo/files.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
Future fine-tuned model:
|
| 67 |
|
| 68 |
Only change the `LLAMACPP_*` variables to point at the fine-tuned GGUF repo/files.
|
| 69 |
+
|
| 70 |
+
The app now also surfaces backend load errors directly in the UI and falls back to the
|
| 71 |
+
Transformers ZeroGPU backend when the llama.cpp GGUF load path fails, so a runtime mismatch does
|
| 72 |
+
not collapse the whole extraction flow.
|
| 73 |
+
|
| 74 |
+
We also upgraded the llama-cpp-python binding to a MiniCPM-V 4.6-capable build, which is the
|
| 75 |
+
actual fix for the earlier `Failed to load model from file` failure.
|
README.md
CHANGED
|
@@ -49,7 +49,8 @@ The Hugging Face Space is intentionally deployed as a **Gradio ZeroGPU Space**.
|
|
| 49 |
deployment path.
|
| 50 |
|
| 51 |
The badge-target backend runs the official OpenBMB MiniCPM-V 4.6 GGUF through `llama.cpp`
|
| 52 |
-
|
|
|
|
| 53 |
enrichment and UI rendering stay in normal Gradio/Python code.
|
| 54 |
|
| 55 |
This workflow should not be further changed back to Docker unless the project intentionally gives up
|
|
|
|
| 49 |
deployment path.
|
| 50 |
|
| 51 |
The badge-target backend runs the official OpenBMB MiniCPM-V 4.6 GGUF through `llama.cpp`
|
| 52 |
+
inside a ZeroGPU-managed function, using a `llama-cpp-python` build that includes MiniCPM-V 4.6
|
| 53 |
+
mtmd support. The deterministic knowledge-graph
|
| 54 |
enrichment and UI rendering stay in normal Gradio/Python code.
|
| 55 |
|
| 56 |
This workflow should not be further changed back to Docker unless the project intentionally gives up
|
RUNBOOK.md
CHANGED
|
@@ -10,7 +10,7 @@ This replaced the Docker + `llama-server` path because ZeroGPU is only available
|
|
| 10 |
|---|---|
|
| 11 |
| Space SDK | `gradio` |
|
| 12 |
| Hardware | ZeroGPU |
|
| 13 |
-
| Badge-target runtime | `llama.cpp` through `llama-cpp-python` |
|
| 14 |
| Badge-target backend | `EXTRACTOR_BACKEND=auto` or `EXTRACTOR_BACKEND=llamacpp-gpu` |
|
| 15 |
| Fallback backend | `EXTRACTOR_BACKEND=zerogpu` with Transformers |
|
| 16 |
| Model variables | `LLAMACPP_GGUF_REPO`, `LLAMACPP_MODEL_FILE`, `LLAMACPP_MMPROJ_FILE` |
|
|
|
|
| 10 |
|---|---|
|
| 11 |
| Space SDK | `gradio` |
|
| 12 |
| Hardware | ZeroGPU |
|
| 13 |
+
| Badge-target runtime | `llama.cpp` through a `llama-cpp-python` build with MiniCPM-V 4.6 support |
|
| 14 |
| Badge-target backend | `EXTRACTOR_BACKEND=auto` or `EXTRACTOR_BACKEND=llamacpp-gpu` |
|
| 15 |
| Fallback backend | `EXTRACTOR_BACKEND=zerogpu` with Transformers |
|
| 16 |
| Model variables | `LLAMACPP_GGUF_REPO`, `LLAMACPP_MODEL_FILE`, `LLAMACPP_MMPROJ_FILE` |
|
app.py
CHANGED
|
@@ -2,13 +2,14 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import re
|
|
|
|
| 5 |
from html import escape
|
| 6 |
from typing import Any
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
|
| 10 |
-
from src.local_env import load_local_env
|
| 11 |
from src.extraction import build_extractor
|
|
|
|
| 12 |
from src.report_pipeline import build_health_report
|
| 13 |
|
| 14 |
|
|
@@ -21,12 +22,13 @@ _API_MODE = os.getenv("EXTRACTOR_BACKEND", "auto").strip().lower() == "api"
|
|
| 21 |
|
| 22 |
def extract_lab_values(
|
| 23 |
uploaded_file: str | None,
|
| 24 |
-
) -> tuple[str, str, Any]:
|
| 25 |
if not uploaded_file:
|
| 26 |
return (
|
| 27 |
_status_html("Waiting for a document", "Upload a lab report to begin extraction."),
|
| 28 |
empty_report_html("No document uploaded", "Choose a file first, then run extraction again."),
|
| 29 |
gr.update(visible=True),
|
|
|
|
| 30 |
)
|
| 31 |
|
| 32 |
extractor = build_extractor()
|
|
@@ -34,10 +36,12 @@ def extract_lab_values(
|
|
| 34 |
try:
|
| 35 |
result = extractor.extract(uploaded_file)
|
| 36 |
except Exception as error:
|
|
|
|
| 37 |
return (
|
| 38 |
-
_status_html("Extraction failed",
|
| 39 |
-
empty_report_html("Extraction failed",
|
| 40 |
gr.update(visible=True),
|
|
|
|
| 41 |
)
|
| 42 |
|
| 43 |
health_report = build_health_report(result)
|
|
@@ -62,6 +66,7 @@ def extract_lab_values(
|
|
| 62 |
_status_html("Extraction complete", status_text),
|
| 63 |
report_html(health_report),
|
| 64 |
gr.update(visible=True),
|
|
|
|
| 65 |
)
|
| 66 |
|
| 67 |
|
|
@@ -74,11 +79,91 @@ def _status_html(title: str, detail: str, tone: str = "success") -> str:
|
|
| 74 |
"""
|
| 75 |
|
| 76 |
|
| 77 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return (
|
| 79 |
_status_html("Reading document", "Extracting patient context and markers, then matching them to the knowledge graph.", tone="loading"),
|
| 80 |
gr.update(visible=True),
|
| 81 |
loading_report_html(),
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
|
|
@@ -87,12 +172,14 @@ def upload_state(uploaded_file: str | None) -> tuple[Any, Any]:
|
|
| 87 |
return (
|
| 88 |
gr.update(visible=True),
|
| 89 |
gr.update(visible=False, value=selected_document_html()),
|
|
|
|
| 90 |
)
|
| 91 |
|
| 92 |
filename = os.path.basename(uploaded_file)
|
| 93 |
return (
|
| 94 |
gr.update(visible=False),
|
| 95 |
gr.update(visible=True, value=selected_document_html(filename)),
|
|
|
|
| 96 |
)
|
| 97 |
|
| 98 |
|
|
@@ -188,7 +275,7 @@ def result_preview_html() -> str:
|
|
| 188 |
<span>Hemoglobin</span>
|
| 189 |
<strong>Normal</strong>
|
| 190 |
</div>
|
| 191 |
-
<div class="bte-mini-card bte-mini-card--
|
| 192 |
<span>Vitamin D</span>
|
| 193 |
<strong>Low</strong>
|
| 194 |
</div>
|
|
@@ -206,131 +293,18 @@ def result_preview_html() -> str:
|
|
| 206 |
"""
|
| 207 |
|
| 208 |
|
| 209 |
-
def ideal_document_example_html() -> str:
|
| 210 |
-
tests = [
|
| 211 |
-
{
|
| 212 |
-
"marker": "Hemoglobin",
|
| 213 |
-
"value": "14.2",
|
| 214 |
-
"unit": "g/dL",
|
| 215 |
-
"status": "ideal",
|
| 216 |
-
"range_position": "76",
|
| 217 |
-
"summary": "Oxygen-carrying protein in red blood cells.",
|
| 218 |
-
"importance": "Helps show whether your blood can transport oxygen efficiently and can point toward anemia or dehydration patterns.",
|
| 219 |
-
"improve": "Maintain iron-rich foods, B12, folate, and steady protein intake. Review heavy fatigue or shortness of breath with a clinician.",
|
| 220 |
-
},
|
| 221 |
-
{
|
| 222 |
-
"marker": "Ferritin",
|
| 223 |
-
"value": "78",
|
| 224 |
-
"unit": "ng/mL",
|
| 225 |
-
"status": "ideal",
|
| 226 |
-
"range_position": "72",
|
| 227 |
-
"summary": "Stored iron available for future red blood cell production.",
|
| 228 |
-
"importance": "Low ferritin can appear before hemoglobin drops and may affect energy, hair shedding, endurance, and recovery.",
|
| 229 |
-
"improve": "Pair iron foods with vitamin C, avoid tea or coffee directly around iron-heavy meals, and confirm supplementation needs clinically.",
|
| 230 |
-
},
|
| 231 |
-
{
|
| 232 |
-
"marker": "HDL Cholesterol",
|
| 233 |
-
"value": "68",
|
| 234 |
-
"unit": "mg/dL",
|
| 235 |
-
"status": "ideal",
|
| 236 |
-
"range_position": "80",
|
| 237 |
-
"summary": "Protective cholesterol involved in reverse cholesterol transport.",
|
| 238 |
-
"importance": "Higher HDL in context can reflect stronger cardiometabolic resilience, especially alongside healthy triglycerides.",
|
| 239 |
-
"improve": "Prioritize aerobic training, olive oil, nuts, fatty fish, fiber, and sleep consistency.",
|
| 240 |
-
},
|
| 241 |
-
{
|
| 242 |
-
"marker": "Vitamin D",
|
| 243 |
-
"value": "34",
|
| 244 |
-
"unit": "ng/mL",
|
| 245 |
-
"status": "normal",
|
| 246 |
-
"range_position": "53",
|
| 247 |
-
"summary": "Fat-soluble hormone-like vitamin linked to bone, immune, and muscle function.",
|
| 248 |
-
"importance": "A normal value may still be worth optimizing depending on season, symptoms, diet, and sun exposure.",
|
| 249 |
-
"improve": "Use safe sun exposure, vitamin D-rich foods, and discuss dose and recheck timing before supplementing heavily.",
|
| 250 |
-
},
|
| 251 |
-
{
|
| 252 |
-
"marker": "Fasting Glucose",
|
| 253 |
-
"value": "92",
|
| 254 |
-
"unit": "mg/dL",
|
| 255 |
-
"status": "normal",
|
| 256 |
-
"range_position": "58",
|
| 257 |
-
"summary": "Blood sugar level after an overnight fast.",
|
| 258 |
-
"importance": "Useful for spotting glucose regulation trends, especially when viewed with HbA1c, insulin, and triglycerides.",
|
| 259 |
-
"improve": "Walk after meals, lift weights, increase fiber, reduce liquid sugar, and keep sleep timing stable.",
|
| 260 |
-
},
|
| 261 |
-
{
|
| 262 |
-
"marker": "Triglycerides",
|
| 263 |
-
"value": "184",
|
| 264 |
-
"unit": "mg/dL",
|
| 265 |
-
"status": "bad",
|
| 266 |
-
"range_position": "88",
|
| 267 |
-
"summary": "Circulating blood fats strongly affected by diet, alcohol, insulin sensitivity, and recent intake.",
|
| 268 |
-
"importance": "High triglycerides can signal cardiometabolic stress and should be interpreted with HDL, glucose, liver markers, and context.",
|
| 269 |
-
"improve": "Reduce refined carbohydrates and alcohol, add omega-3 rich fish, build regular zone-2 cardio, and recheck fasting values.",
|
| 270 |
-
},
|
| 271 |
-
]
|
| 272 |
-
|
| 273 |
-
left_cards = "\n".join(_ideal_marker_card(test) for index, test in enumerate(tests) if index % 2 == 0)
|
| 274 |
-
right_cards = "\n".join(_ideal_marker_card(test) for index, test in enumerate(tests) if index % 2 == 1)
|
| 275 |
-
|
| 276 |
-
return f"""
|
| 277 |
-
<section class="bte-ideal-doc">
|
| 278 |
-
<header class="bte-ideal-hero">
|
| 279 |
-
<div>
|
| 280 |
-
<p class="bte-kicker">Ideal document example</p>
|
| 281 |
-
<h2>Final health report reference</h2>
|
| 282 |
-
<p>This static example shows the kind of rich, explanatory document the agent should eventually generate from a raw lab upload.</p>
|
| 283 |
-
</div>
|
| 284 |
-
</header>
|
| 285 |
-
|
| 286 |
-
<input class="bte-ideal-filter" type="radio" name="bte-ideal-filter" id="bte-filter-total" checked>
|
| 287 |
-
<input class="bte-ideal-filter" type="radio" name="bte-ideal-filter" id="bte-filter-ideal">
|
| 288 |
-
<input class="bte-ideal-filter" type="radio" name="bte-ideal-filter" id="bte-filter-normal">
|
| 289 |
-
<input class="bte-ideal-filter" type="radio" name="bte-ideal-filter" id="bte-filter-bad">
|
| 290 |
-
|
| 291 |
-
<div class="bte-ideal-stats">
|
| 292 |
-
<label class="bte-ideal-stat bte-ideal-stat--total" for="bte-filter-total">
|
| 293 |
-
<span>6</span>
|
| 294 |
-
<strong>Total tests</strong>
|
| 295 |
-
</label>
|
| 296 |
-
<label class="bte-ideal-stat bte-ideal-stat--ideal" for="bte-filter-ideal">
|
| 297 |
-
<span>3</span>
|
| 298 |
-
<strong>Ideal</strong>
|
| 299 |
-
</label>
|
| 300 |
-
<label class="bte-ideal-stat bte-ideal-stat--normal" for="bte-filter-normal">
|
| 301 |
-
<span>2</span>
|
| 302 |
-
<strong>Normal</strong>
|
| 303 |
-
</label>
|
| 304 |
-
<label class="bte-ideal-stat bte-ideal-stat--bad" for="bte-filter-bad">
|
| 305 |
-
<span>1</span>
|
| 306 |
-
<strong>Bad</strong>
|
| 307 |
-
</label>
|
| 308 |
-
</div>
|
| 309 |
-
|
| 310 |
-
<div class="bte-ideal-grid">
|
| 311 |
-
<div class="bte-ideal-column">
|
| 312 |
-
{left_cards}
|
| 313 |
-
</div>
|
| 314 |
-
<div class="bte-ideal-column">
|
| 315 |
-
{right_cards}
|
| 316 |
-
</div>
|
| 317 |
-
</div>
|
| 318 |
-
</section>
|
| 319 |
-
"""
|
| 320 |
-
|
| 321 |
-
|
| 322 |
def _ideal_marker_card(test: dict[str, str]) -> str:
|
| 323 |
status = test["status"]
|
| 324 |
range_position_value = test.get("range_position", "50")
|
| 325 |
range_position = escape(range_position_value)
|
| 326 |
-
range_labels = test.get("range_labels", ("
|
| 327 |
low_label, mid_label, high_label = (escape(label) for label in range_labels)
|
| 328 |
return f"""
|
| 329 |
<article class="bte-ideal-marker bte-ideal-marker--{escape(status)}">
|
| 330 |
<div class="bte-ideal-marker-head">
|
| 331 |
<div class="bte-ideal-title-line">
|
| 332 |
<h3>{escape(test["marker"])}</h3>
|
| 333 |
-
<span class="bte-ideal-status">{escape(
|
| 334 |
</div>
|
| 335 |
<div class="bte-range-scale" style="--value-position: {range_position}%; --value-position-number: {range_position}">
|
| 336 |
<div class="bte-range-value">
|
|
@@ -387,8 +361,8 @@ def report_html(report: dict[str, Any]) -> str:
|
|
| 387 |
<section class="bte-ideal-doc bte-final-report">
|
| 388 |
<header class="bte-ideal-hero">
|
| 389 |
<div>
|
| 390 |
-
<p class="bte-kicker">
|
| 391 |
-
<h2>
|
| 392 |
<p>Generated from the uploaded lab report, matched to the knowledge graph, and enriched with age and sex context. {escape(patient_context)}</p>
|
| 393 |
</div>
|
| 394 |
</header>
|
|
@@ -413,7 +387,7 @@ def report_html(report: dict[str, Any]) -> str:
|
|
| 413 |
</label>
|
| 414 |
<label class="bte-ideal-stat bte-ideal-stat--bad" for="bte-final-filter-bad">
|
| 415 |
<span>{bad}</span>
|
| 416 |
-
<strong>
|
| 417 |
</label>
|
| 418 |
</div>
|
| 419 |
|
|
@@ -488,7 +462,7 @@ def _marker_card(test: dict[str, Any]) -> str:
|
|
| 488 |
<strong>{escape(value)}</strong>
|
| 489 |
<small>{escape(unit)}</small>
|
| 490 |
</span>
|
| 491 |
-
|
| 492 |
</summary>
|
| 493 |
<div class="bte-marker-body">
|
| 494 |
<div class="bte-marker-evidence">
|
|
@@ -548,7 +522,7 @@ def _final_marker_card(test: dict[str, Any]) -> str:
|
|
| 548 |
<div class="bte-ideal-marker-head">
|
| 549 |
<div class="bte-ideal-title-line">
|
| 550 |
<h3>{escape(marker)}</h3>
|
| 551 |
-
<span class="bte-ideal-status">{escape(
|
| 552 |
</div>
|
| 553 |
<div class="bte-range-scale" style="--value-position: {range_position}%; --value-position-number: {range_position}">
|
| 554 |
<div class="bte-range-value">
|
|
@@ -556,7 +530,7 @@ def _final_marker_card(test: dict[str, Any]) -> str:
|
|
| 556 |
<small>{escape(unit)}</small>
|
| 557 |
</div>
|
| 558 |
<div class="bte-range-track" aria-hidden="true">
|
| 559 |
-
<span>
|
| 560 |
<span>Normal</span>
|
| 561 |
<span>Good</span>
|
| 562 |
</div>
|
|
@@ -1145,6 +1119,16 @@ gradio-app,
|
|
| 1145 |
background: transparent !important;
|
| 1146 |
}
|
| 1147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1148 |
.bte-step-row-block,
|
| 1149 |
.bte-step-row-block > div {
|
| 1150 |
width: var(--bte-rail) !important;
|
|
@@ -1178,7 +1162,7 @@ gradio-app,
|
|
| 1178 |
margin-left: auto !important;
|
| 1179 |
margin-right: auto !important;
|
| 1180 |
padding: 0 !important;
|
| 1181 |
-
background:
|
| 1182 |
border: 0 !important;
|
| 1183 |
box-shadow: none !important;
|
| 1184 |
}
|
|
@@ -1244,6 +1228,34 @@ gradio-app,
|
|
| 1244 |
background: var(--bte-surface);
|
| 1245 |
box-shadow: var(--bte-shadow);
|
| 1246 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1247 |
}
|
| 1248 |
|
| 1249 |
.bte-step-heading span {
|
|
@@ -1275,10 +1287,17 @@ gradio-app,
|
|
| 1275 |
text-align: left !important;
|
| 1276 |
}
|
| 1277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1278 |
.bte-step-heading--report {
|
| 1279 |
-
margin-top:
|
| 1280 |
-
min-height:
|
| 1281 |
-
padding:
|
| 1282 |
}
|
| 1283 |
|
| 1284 |
.bte-upload-card {
|
|
@@ -1287,6 +1306,7 @@ gradio-app,
|
|
| 1287 |
flex-direction: column;
|
| 1288 |
justify-content: space-between;
|
| 1289 |
min-height: 430px;
|
|
|
|
| 1290 |
}
|
| 1291 |
|
| 1292 |
.bte-formation {
|
|
@@ -1318,6 +1338,75 @@ gradio-app,
|
|
| 1318 |
width: 100%;
|
| 1319 |
}
|
| 1320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1321 |
.bte-source-doc,
|
| 1322 |
.bte-report-window {
|
| 1323 |
position: relative;
|
|
@@ -1475,9 +1564,9 @@ gradio-app,
|
|
| 1475 |
background: var(--bte-green-soft);
|
| 1476 |
}
|
| 1477 |
|
| 1478 |
-
.bte-mini-card--
|
| 1479 |
-
border-color: rgba(
|
| 1480 |
-
background: var(--bte-
|
| 1481 |
animation-delay: 0.35s;
|
| 1482 |
}
|
| 1483 |
|
|
@@ -1571,27 +1660,6 @@ gradio-app,
|
|
| 1571 |
background: var(--bte-blue-soft);
|
| 1572 |
}
|
| 1573 |
|
| 1574 |
-
button.primary {
|
| 1575 |
-
background: #111827 !important;
|
| 1576 |
-
border-radius: 14px !important;
|
| 1577 |
-
min-height: 44px !important;
|
| 1578 |
-
border: 0 !important;
|
| 1579 |
-
box-shadow: 0 12px 26px rgba(17, 24, 39, 0.2) !important;
|
| 1580 |
-
font-size: 0 !important;
|
| 1581 |
-
}
|
| 1582 |
-
|
| 1583 |
-
button.primary::after {
|
| 1584 |
-
content: "Extract test results";
|
| 1585 |
-
color: #ffffff !important;
|
| 1586 |
-
-webkit-text-fill-color: #ffffff !important;
|
| 1587 |
-
font-size: 16px !important;
|
| 1588 |
-
font-weight: 720 !important;
|
| 1589 |
-
}
|
| 1590 |
-
|
| 1591 |
-
.bte-action {
|
| 1592 |
-
margin-top: 12px !important;
|
| 1593 |
-
}
|
| 1594 |
-
|
| 1595 |
.bte-uploader {
|
| 1596 |
border: 0 !important;
|
| 1597 |
}
|
|
@@ -1761,7 +1829,7 @@ button.bte-action *,
|
|
| 1761 |
border: 1px solid var(--bte-line);
|
| 1762 |
border-radius: var(--bte-radius);
|
| 1763 |
padding: 22px;
|
| 1764 |
-
background: var(--bte-
|
| 1765 |
box-shadow: var(--bte-shadow);
|
| 1766 |
}
|
| 1767 |
|
|
@@ -1771,7 +1839,7 @@ button.bte-action *,
|
|
| 1771 |
place-items: center;
|
| 1772 |
text-align: center;
|
| 1773 |
color: var(--bte-muted);
|
| 1774 |
-
background: var(--bte-
|
| 1775 |
}
|
| 1776 |
|
| 1777 |
.bte-report--empty h2 {
|
|
@@ -1786,7 +1854,7 @@ button.bte-action *,
|
|
| 1786 |
gap: 28px;
|
| 1787 |
overflow: hidden;
|
| 1788 |
position: relative;
|
| 1789 |
-
background: var(--bte-
|
| 1790 |
}
|
| 1791 |
|
| 1792 |
.bte-loading-report::after {
|
|
@@ -2299,13 +2367,23 @@ button.bte-action *,
|
|
| 2299 |
margin-top: 34px;
|
| 2300 |
display: grid;
|
| 2301 |
gap: 16px;
|
| 2302 |
-
background:
|
| 2303 |
}
|
| 2304 |
|
| 2305 |
.bte-final-report {
|
| 2306 |
width: var(--bte-rail) !important;
|
| 2307 |
max-width: var(--bte-rail) !important;
|
| 2308 |
margin: 0 auto !important;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2309 |
}
|
| 2310 |
|
| 2311 |
.bte-ideal-hero {
|
|
@@ -2320,7 +2398,7 @@ button.bte-action *,
|
|
| 2320 |
background:
|
| 2321 |
linear-gradient(120deg, rgba(18, 128, 92, 0.98) 0%, rgba(37, 99, 235, 0.95) 58%, rgba(191, 52, 52, 0.82) 100%),
|
| 2322 |
#12805c;
|
| 2323 |
-
box-shadow:
|
| 2324 |
}
|
| 2325 |
|
| 2326 |
.bte-ideal-hero .bte-kicker,
|
|
@@ -2938,18 +3016,23 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
|
|
| 2938 |
"""
|
| 2939 |
)
|
| 2940 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2941 |
gr.HTML(
|
| 2942 |
"""
|
| 2943 |
<div class="bte-step-row">
|
| 2944 |
-
<div class="bte-step-heading">
|
| 2945 |
<span>1</span>
|
| 2946 |
<h2>Upload your blood tests in any suitable format</h2>
|
| 2947 |
</div>
|
| 2948 |
-
<div class="bte-step-heading">
|
| 2949 |
<span>2</span>
|
| 2950 |
<h2>Wait until it's analysed by our AI Agents</h2>
|
| 2951 |
</div>
|
| 2952 |
-
<div class="bte-step-heading">
|
| 2953 |
<span>3</span>
|
| 2954 |
<h2>Get your blood test results in the clearest possible format</h2>
|
| 2955 |
</div>
|
|
@@ -2959,7 +3042,7 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
|
|
| 2959 |
)
|
| 2960 |
|
| 2961 |
with gr.Row(equal_height=False, elem_classes=["bte-hero-grid"]):
|
| 2962 |
-
with gr.Column(scale=4, min_width=320):
|
| 2963 |
with gr.Group(elem_classes=["bte-shell", "bte-upload-card"]):
|
| 2964 |
gr.HTML(
|
| 2965 |
'<p class="bte-upload-hint">Supported formats: PDF, PNG, JPEG</p>',
|
|
@@ -2974,12 +3057,11 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
|
|
| 2974 |
elem_classes=["bte-uploader"],
|
| 2975 |
)
|
| 2976 |
selected_document = gr.HTML(selected_document_html(), visible=False)
|
| 2977 |
-
run_button = gr.Button("Extract test results", variant="primary", elem_classes=["bte-action"])
|
| 2978 |
|
| 2979 |
-
with gr.Column(scale=4, min_width=300):
|
| 2980 |
gr.HTML(analysis_animation_html())
|
| 2981 |
|
| 2982 |
-
with gr.Column(scale=4, min_width=300):
|
| 2983 |
gr.HTML(result_preview_html())
|
| 2984 |
|
| 2985 |
status = gr.HTML(
|
|
@@ -2988,31 +3070,28 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
|
|
| 2988 |
visible=False,
|
| 2989 |
)
|
| 2990 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2991 |
uploaded.change(
|
| 2992 |
upload_state,
|
| 2993 |
inputs=[uploaded],
|
| 2994 |
-
outputs=[upload_dropzone, selected_document],
|
| 2995 |
show_progress="hidden",
|
| 2996 |
-
)
|
| 2997 |
-
|
| 2998 |
-
with gr.Group(visible=False, elem_classes=["bte-report-panel", "bte-final-row"]) as report_panel:
|
| 2999 |
-
report = gr.HTML(empty_report_html())
|
| 3000 |
-
|
| 3001 |
-
run_button.click(
|
| 3002 |
show_processing,
|
| 3003 |
-
outputs=[status, report_panel, report],
|
| 3004 |
scroll_to_output=True,
|
| 3005 |
show_progress="hidden",
|
| 3006 |
).then(
|
| 3007 |
extract_lab_values,
|
| 3008 |
inputs=[uploaded],
|
| 3009 |
-
outputs=[status, report, report_panel],
|
| 3010 |
scroll_to_output=True,
|
| 3011 |
show_progress="hidden",
|
| 3012 |
)
|
| 3013 |
|
| 3014 |
-
gr.HTML(ideal_document_example_html(), elem_classes=["bte-ideal-row"])
|
| 3015 |
-
|
| 3016 |
|
| 3017 |
if __name__ == "__main__":
|
| 3018 |
demo.launch(css=CUSTOM_CSS)
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import re
|
| 5 |
+
import traceback
|
| 6 |
from html import escape
|
| 7 |
from typing import Any
|
| 8 |
|
| 9 |
import gradio as gr
|
| 10 |
|
|
|
|
| 11 |
from src.extraction import build_extractor
|
| 12 |
+
from src.local_env import load_local_env
|
| 13 |
from src.report_pipeline import build_health_report
|
| 14 |
|
| 15 |
|
|
|
|
| 22 |
|
| 23 |
def extract_lab_values(
|
| 24 |
uploaded_file: str | None,
|
| 25 |
+
) -> tuple[str, str, Any, str]:
|
| 26 |
if not uploaded_file:
|
| 27 |
return (
|
| 28 |
_status_html("Waiting for a document", "Upload a lab report to begin extraction."),
|
| 29 |
empty_report_html("No document uploaded", "Choose a file first, then run extraction again."),
|
| 30 |
gr.update(visible=True),
|
| 31 |
+
workflow_phase_html("ready"),
|
| 32 |
)
|
| 33 |
|
| 34 |
extractor = build_extractor()
|
|
|
|
| 36 |
try:
|
| 37 |
result = extractor.extract(uploaded_file)
|
| 38 |
except Exception as error:
|
| 39 |
+
detail = _format_extraction_error(error)
|
| 40 |
return (
|
| 41 |
+
_status_html("Extraction failed", detail, tone="danger"),
|
| 42 |
+
empty_report_html("Extraction failed", detail),
|
| 43 |
gr.update(visible=True),
|
| 44 |
+
workflow_phase_html("ready"),
|
| 45 |
)
|
| 46 |
|
| 47 |
health_report = build_health_report(result)
|
|
|
|
| 66 |
_status_html("Extraction complete", status_text),
|
| 67 |
report_html(health_report),
|
| 68 |
gr.update(visible=True),
|
| 69 |
+
workflow_phase_html("done"),
|
| 70 |
)
|
| 71 |
|
| 72 |
|
|
|
|
| 79 |
"""
|
| 80 |
|
| 81 |
|
| 82 |
+
def _format_extraction_error(error: Exception) -> str:
|
| 83 |
+
primary = _sanitize_error_message(error)
|
| 84 |
+
lowered = primary.lower()
|
| 85 |
+
if "failed to load model from file" in lowered:
|
| 86 |
+
return (
|
| 87 |
+
"The llama.cpp backend could not load the GGUF model. That points to a model/runtime "
|
| 88 |
+
"compatibility issue, not a background worker problem."
|
| 89 |
+
)
|
| 90 |
+
if "401" in lowered or "unauthorized" in lowered:
|
| 91 |
+
return (
|
| 92 |
+
"The OpenBMB endpoint rejected the request. Check the API key or switch to the local "
|
| 93 |
+
"ZeroGPU path."
|
| 94 |
+
)
|
| 95 |
+
if "could not be converted into a report" in lowered:
|
| 96 |
+
return "The model produced output, but it could not be parsed into the extraction schema."
|
| 97 |
+
return primary
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _sanitize_error_message(error: Exception) -> str:
|
| 101 |
+
message = str(error).strip()
|
| 102 |
+
if not message:
|
| 103 |
+
message = error.__class__.__name__
|
| 104 |
+
if os.getenv("BTE_DEBUG_ERRORS", "0") == "1":
|
| 105 |
+
tb = "".join(traceback.TracebackException.from_exception(error).format()).strip()
|
| 106 |
+
return f"{message}\n\n{tb}"
|
| 107 |
+
return message
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def workflow_phase_html(phase: str) -> str:
|
| 111 |
+
phase = phase if phase in {"ready", "processing", "done"} else "ready"
|
| 112 |
+
return f"""
|
| 113 |
+
<div class="bte-workflow-phase-marker" data-phase="{escape(phase)}" aria-hidden="true"></div>
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _display_status_label(status: str) -> str:
|
| 118 |
+
normalized = (status or "").strip().lower()
|
| 119 |
+
if normalized == "bad":
|
| 120 |
+
return "Low"
|
| 121 |
+
return normalized.title() if normalized else "Unknown"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def workflow_arrow_html(kind: str) -> str:
|
| 125 |
+
kind = kind if kind in {"upload", "report"} else "upload"
|
| 126 |
+
if kind == "upload":
|
| 127 |
+
svg = """
|
| 128 |
+
<svg viewBox="0 0 160 420" aria-hidden="true" focusable="false">
|
| 129 |
+
<defs>
|
| 130 |
+
<linearGradient id="bte-arrow-upload" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 131 |
+
<stop offset="0%" stop-color="#67e8f9"/>
|
| 132 |
+
<stop offset="48%" stop-color="#2563eb"/>
|
| 133 |
+
<stop offset="100%" stop-color="#22c55e"/>
|
| 134 |
+
</linearGradient>
|
| 135 |
+
</defs>
|
| 136 |
+
<path d="M136 22 C70 58, 34 120, 34 200 C34 276, 63 332, 108 376" fill="none" stroke="url(#bte-arrow-upload)" stroke-width="8" stroke-linecap="round"/>
|
| 137 |
+
<path d="M108 376 L86 360 M108 376 L102 349" fill="none" stroke="url(#bte-arrow-upload)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
| 138 |
+
</svg>
|
| 139 |
+
"""
|
| 140 |
+
else:
|
| 141 |
+
svg = """
|
| 142 |
+
<svg viewBox="0 0 220 180" aria-hidden="true" focusable="false">
|
| 143 |
+
<defs>
|
| 144 |
+
<linearGradient id="bte-arrow-report" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 145 |
+
<stop offset="0%" stop-color="#67e8f9"/>
|
| 146 |
+
<stop offset="48%" stop-color="#2563eb"/>
|
| 147 |
+
<stop offset="100%" stop-color="#22c55e"/>
|
| 148 |
+
</linearGradient>
|
| 149 |
+
</defs>
|
| 150 |
+
<path d="M110 14 C110 52, 110 86, 110 126" fill="none" stroke="url(#bte-arrow-report)" stroke-width="8" stroke-linecap="round"/>
|
| 151 |
+
<path d="M110 126 L89 106 M110 126 L132 106" fill="none" stroke="url(#bte-arrow-report)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
| 152 |
+
</svg>
|
| 153 |
+
"""
|
| 154 |
+
return f"""
|
| 155 |
+
<div class="bte-workflow-arrow-svg bte-workflow-arrow-svg--{escape(kind)}" aria-hidden="true">
|
| 156 |
+
{svg}
|
| 157 |
+
</div>
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def show_processing() -> tuple[str, Any, str, str]:
|
| 162 |
return (
|
| 163 |
_status_html("Reading document", "Extracting patient context and markers, then matching them to the knowledge graph.", tone="loading"),
|
| 164 |
gr.update(visible=True),
|
| 165 |
loading_report_html(),
|
| 166 |
+
workflow_phase_html("processing"),
|
| 167 |
)
|
| 168 |
|
| 169 |
|
|
|
|
| 172 |
return (
|
| 173 |
gr.update(visible=True),
|
| 174 |
gr.update(visible=False, value=selected_document_html()),
|
| 175 |
+
workflow_phase_html("ready"),
|
| 176 |
)
|
| 177 |
|
| 178 |
filename = os.path.basename(uploaded_file)
|
| 179 |
return (
|
| 180 |
gr.update(visible=False),
|
| 181 |
gr.update(visible=True, value=selected_document_html(filename)),
|
| 182 |
+
workflow_phase_html("processing"),
|
| 183 |
)
|
| 184 |
|
| 185 |
|
|
|
|
| 275 |
<span>Hemoglobin</span>
|
| 276 |
<strong>Normal</strong>
|
| 277 |
</div>
|
| 278 |
+
<div class="bte-mini-card bte-mini-card--red">
|
| 279 |
<span>Vitamin D</span>
|
| 280 |
<strong>Low</strong>
|
| 281 |
</div>
|
|
|
|
| 293 |
"""
|
| 294 |
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
def _ideal_marker_card(test: dict[str, str]) -> str:
|
| 297 |
status = test["status"]
|
| 298 |
range_position_value = test.get("range_position", "50")
|
| 299 |
range_position = escape(range_position_value)
|
| 300 |
+
range_labels = test.get("range_labels", ("Low", "Normal", "Good"))
|
| 301 |
low_label, mid_label, high_label = (escape(label) for label in range_labels)
|
| 302 |
return f"""
|
| 303 |
<article class="bte-ideal-marker bte-ideal-marker--{escape(status)}">
|
| 304 |
<div class="bte-ideal-marker-head">
|
| 305 |
<div class="bte-ideal-title-line">
|
| 306 |
<h3>{escape(test["marker"])}</h3>
|
| 307 |
+
<span class="bte-ideal-status">{escape(_display_status_label(status))}</span>
|
| 308 |
</div>
|
| 309 |
<div class="bte-range-scale" style="--value-position: {range_position}%; --value-position-number: {range_position}">
|
| 310 |
<div class="bte-range-value">
|
|
|
|
| 361 |
<section class="bte-ideal-doc bte-final-report">
|
| 362 |
<header class="bte-ideal-hero">
|
| 363 |
<div>
|
| 364 |
+
<p class="bte-kicker">Blood test report</p>
|
| 365 |
+
<h2>Blood Test Report</h2>
|
| 366 |
<p>Generated from the uploaded lab report, matched to the knowledge graph, and enriched with age and sex context. {escape(patient_context)}</p>
|
| 367 |
</div>
|
| 368 |
</header>
|
|
|
|
| 387 |
</label>
|
| 388 |
<label class="bte-ideal-stat bte-ideal-stat--bad" for="bte-final-filter-bad">
|
| 389 |
<span>{bad}</span>
|
| 390 |
+
<strong>Low</strong>
|
| 391 |
</label>
|
| 392 |
</div>
|
| 393 |
|
|
|
|
| 462 |
<strong>{escape(value)}</strong>
|
| 463 |
<small>{escape(unit)}</small>
|
| 464 |
</span>
|
| 465 |
+
<span class="bte-marker-status">{escape(_display_status_label(status))}</span>
|
| 466 |
</summary>
|
| 467 |
<div class="bte-marker-body">
|
| 468 |
<div class="bte-marker-evidence">
|
|
|
|
| 522 |
<div class="bte-ideal-marker-head">
|
| 523 |
<div class="bte-ideal-title-line">
|
| 524 |
<h3>{escape(marker)}</h3>
|
| 525 |
+
<span class="bte-ideal-status">{escape(_display_status_label(status))}</span>
|
| 526 |
</div>
|
| 527 |
<div class="bte-range-scale" style="--value-position: {range_position}%; --value-position-number: {range_position}">
|
| 528 |
<div class="bte-range-value">
|
|
|
|
| 530 |
<small>{escape(unit)}</small>
|
| 531 |
</div>
|
| 532 |
<div class="bte-range-track" aria-hidden="true">
|
| 533 |
+
<span>Low</span>
|
| 534 |
<span>Normal</span>
|
| 535 |
<span>Good</span>
|
| 536 |
</div>
|
|
|
|
| 1119 |
background: transparent !important;
|
| 1120 |
}
|
| 1121 |
|
| 1122 |
+
.bte-workflow-panel,
|
| 1123 |
+
.bte-final-row {
|
| 1124 |
+
position: relative;
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
.bte-workflow-phase,
|
| 1128 |
+
.bte-workflow-phase-marker {
|
| 1129 |
+
display: none !important;
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
.bte-step-row-block,
|
| 1133 |
.bte-step-row-block > div {
|
| 1134 |
width: var(--bte-rail) !important;
|
|
|
|
| 1162 |
margin-left: auto !important;
|
| 1163 |
margin-right: auto !important;
|
| 1164 |
padding: 0 !important;
|
| 1165 |
+
background: var(--bte-page) !important;
|
| 1166 |
border: 0 !important;
|
| 1167 |
box-shadow: none !important;
|
| 1168 |
}
|
|
|
|
| 1228 |
background: var(--bte-surface);
|
| 1229 |
box-shadow: var(--bte-shadow);
|
| 1230 |
overflow: hidden;
|
| 1231 |
+
opacity: 0.55;
|
| 1232 |
+
filter: saturate(0.6);
|
| 1233 |
+
transition: opacity 220ms ease, filter 220ms ease, box-shadow 220ms ease, transform 220ms ease, border-color 220ms ease, background 220ms ease;
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--upload,
|
| 1237 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--analysis,
|
| 1238 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--report {
|
| 1239 |
+
opacity: 1;
|
| 1240 |
+
filter: saturate(1);
|
| 1241 |
+
transform: translateY(-1px);
|
| 1242 |
+
border-color: rgba(37, 99, 235, 0.32);
|
| 1243 |
+
background: linear-gradient(180deg, rgba(37, 99, 235, 0.08), rgba(255, 255, 255, 0.98));
|
| 1244 |
+
box-shadow: 0 16px 34px rgba(37, 99, 235, 0.1);
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--analysis,
|
| 1248 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-step-row-block .bte-step-heading--report,
|
| 1249 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--upload,
|
| 1250 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-step-row-block .bte-step-heading--report,
|
| 1251 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--upload,
|
| 1252 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-step-row-block .bte-step-heading--analysis {
|
| 1253 |
+
opacity: 0.38;
|
| 1254 |
+
filter: saturate(0.45);
|
| 1255 |
+
transform: none;
|
| 1256 |
+
background: var(--bte-surface);
|
| 1257 |
+
box-shadow: var(--bte-shadow);
|
| 1258 |
+
border-color: rgba(216, 226, 238, 0.92);
|
| 1259 |
}
|
| 1260 |
|
| 1261 |
.bte-step-heading span {
|
|
|
|
| 1287 |
text-align: left !important;
|
| 1288 |
}
|
| 1289 |
|
| 1290 |
+
.bte-panel-upload .bte-upload-card,
|
| 1291 |
+
.bte-panel-analysis .bte-formation,
|
| 1292 |
+
.bte-panel-result .bte-formation,
|
| 1293 |
+
.bte-final-row .bte-report {
|
| 1294 |
+
transition: opacity 220ms ease, filter 220ms ease, box-shadow 220ms ease, transform 220ms ease, border-color 220ms ease, background 220ms ease;
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
.bte-step-heading--report {
|
| 1298 |
+
margin-top: 0;
|
| 1299 |
+
min-height: 112px;
|
| 1300 |
+
padding: 18px;
|
| 1301 |
}
|
| 1302 |
|
| 1303 |
.bte-upload-card {
|
|
|
|
| 1306 |
flex-direction: column;
|
| 1307 |
justify-content: space-between;
|
| 1308 |
min-height: 430px;
|
| 1309 |
+
overflow: visible !important;
|
| 1310 |
}
|
| 1311 |
|
| 1312 |
.bte-formation {
|
|
|
|
| 1338 |
width: 100%;
|
| 1339 |
}
|
| 1340 |
|
| 1341 |
+
.bte-panel-analysis .bte-formation--analysis,
|
| 1342 |
+
.bte-panel-result .bte-formation--result {
|
| 1343 |
+
overflow: visible;
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
.bte-panel-result .bte-smart-report,
|
| 1347 |
+
.bte-panel-result .bte-mini-card,
|
| 1348 |
+
.bte-panel-result .bte-mini-chart span {
|
| 1349 |
+
animation-play-state: paused !important;
|
| 1350 |
+
}
|
| 1351 |
+
|
| 1352 |
+
.bte-panel-analysis .bte-source-doc,
|
| 1353 |
+
.bte-panel-analysis .bte-scan-band,
|
| 1354 |
+
.bte-panel-analysis .bte-flow span,
|
| 1355 |
+
.bte-panel-analysis .bte-flow i,
|
| 1356 |
+
.bte-panel-analysis .bte-flow b {
|
| 1357 |
+
animation-play-state: paused !important;
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
|
| 1361 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
|
| 1362 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
|
| 1363 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
|
| 1364 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
|
| 1365 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis {
|
| 1366 |
+
opacity: 0.42;
|
| 1367 |
+
filter: saturate(0.5);
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
|
| 1371 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
|
| 1372 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result {
|
| 1373 |
+
opacity: 1;
|
| 1374 |
+
filter: saturate(1);
|
| 1375 |
+
}
|
| 1376 |
+
|
| 1377 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
|
| 1378 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
|
| 1379 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
|
| 1380 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card,
|
| 1381 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result,
|
| 1382 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis {
|
| 1383 |
+
animation-play-state: paused !important;
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-formation--analysis,
|
| 1387 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result {
|
| 1388 |
+
animation-play-state: running !important;
|
| 1389 |
+
}
|
| 1390 |
+
|
| 1391 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-smart-report,
|
| 1392 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-mini-card,
|
| 1393 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-mini-chart span {
|
| 1394 |
+
animation-play-state: running !important;
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-source-doc,
|
| 1398 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-scan-band,
|
| 1399 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-flow span,
|
| 1400 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-flow i,
|
| 1401 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="processing"]) ~ .bte-hero-grid .bte-panel-analysis .bte-flow b {
|
| 1402 |
+
animation-play-state: running !important;
|
| 1403 |
+
}
|
| 1404 |
+
|
| 1405 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="done"]) ~ .bte-hero-grid .bte-panel-result .bte-formation--result .bte-smart-report,
|
| 1406 |
+
.bte-workflow-phase:has(.bte-workflow-phase-marker[data-phase="ready"]) ~ .bte-hero-grid .bte-panel-upload .bte-upload-card {
|
| 1407 |
+
animation-play-state: paused !important;
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
.bte-source-doc,
|
| 1411 |
.bte-report-window {
|
| 1412 |
position: relative;
|
|
|
|
| 1564 |
background: var(--bte-green-soft);
|
| 1565 |
}
|
| 1566 |
|
| 1567 |
+
.bte-mini-card--red {
|
| 1568 |
+
border-color: rgba(200, 70, 70, 0.2);
|
| 1569 |
+
background: var(--bte-red-soft);
|
| 1570 |
animation-delay: 0.35s;
|
| 1571 |
}
|
| 1572 |
|
|
|
|
| 1660 |
background: var(--bte-blue-soft);
|
| 1661 |
}
|
| 1662 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1663 |
.bte-uploader {
|
| 1664 |
border: 0 !important;
|
| 1665 |
}
|
|
|
|
| 1829 |
border: 1px solid var(--bte-line);
|
| 1830 |
border-radius: var(--bte-radius);
|
| 1831 |
padding: 22px;
|
| 1832 |
+
background: var(--bte-page);
|
| 1833 |
box-shadow: var(--bte-shadow);
|
| 1834 |
}
|
| 1835 |
|
|
|
|
| 1839 |
place-items: center;
|
| 1840 |
text-align: center;
|
| 1841 |
color: var(--bte-muted);
|
| 1842 |
+
background: var(--bte-page);
|
| 1843 |
}
|
| 1844 |
|
| 1845 |
.bte-report--empty h2 {
|
|
|
|
| 1854 |
gap: 28px;
|
| 1855 |
overflow: hidden;
|
| 1856 |
position: relative;
|
| 1857 |
+
background: var(--bte-page);
|
| 1858 |
}
|
| 1859 |
|
| 1860 |
.bte-loading-report::after {
|
|
|
|
| 2367 |
margin-top: 34px;
|
| 2368 |
display: grid;
|
| 2369 |
gap: 16px;
|
| 2370 |
+
background: var(--bte-page);
|
| 2371 |
}
|
| 2372 |
|
| 2373 |
.bte-final-report {
|
| 2374 |
width: var(--bte-rail) !important;
|
| 2375 |
max-width: var(--bte-rail) !important;
|
| 2376 |
margin: 0 auto !important;
|
| 2377 |
+
background: rgb(248, 249, 252) !important;
|
| 2378 |
+
}
|
| 2379 |
+
|
| 2380 |
+
.bte-final-report .bte-ideal-marker {
|
| 2381 |
+
box-shadow: 0 4px 10px rgba(17, 24, 39, 0.035);
|
| 2382 |
+
}
|
| 2383 |
+
|
| 2384 |
+
.bte-final-report .bte-ideal-marker:hover,
|
| 2385 |
+
.bte-final-report .bte-ideal-marker:focus-within {
|
| 2386 |
+
box-shadow: 0 6px 14px rgba(17, 24, 39, 0.05);
|
| 2387 |
}
|
| 2388 |
|
| 2389 |
.bte-ideal-hero {
|
|
|
|
| 2398 |
background:
|
| 2399 |
linear-gradient(120deg, rgba(18, 128, 92, 0.98) 0%, rgba(37, 99, 235, 0.95) 58%, rgba(191, 52, 52, 0.82) 100%),
|
| 2400 |
#12805c;
|
| 2401 |
+
box-shadow: 0 6px 16px rgba(17, 24, 39, 0.045);
|
| 2402 |
}
|
| 2403 |
|
| 2404 |
.bte-ideal-hero .bte-kicker,
|
|
|
|
| 3016 |
"""
|
| 3017 |
)
|
| 3018 |
|
| 3019 |
+
workflow_phase = gr.HTML(
|
| 3020 |
+
workflow_phase_html("ready"),
|
| 3021 |
+
elem_classes=["bte-workflow-phase"],
|
| 3022 |
+
)
|
| 3023 |
+
|
| 3024 |
gr.HTML(
|
| 3025 |
"""
|
| 3026 |
<div class="bte-step-row">
|
| 3027 |
+
<div class="bte-step-heading bte-step-heading--upload">
|
| 3028 |
<span>1</span>
|
| 3029 |
<h2>Upload your blood tests in any suitable format</h2>
|
| 3030 |
</div>
|
| 3031 |
+
<div class="bte-step-heading bte-step-heading--analysis">
|
| 3032 |
<span>2</span>
|
| 3033 |
<h2>Wait until it's analysed by our AI Agents</h2>
|
| 3034 |
</div>
|
| 3035 |
+
<div class="bte-step-heading bte-step-heading--report">
|
| 3036 |
<span>3</span>
|
| 3037 |
<h2>Get your blood test results in the clearest possible format</h2>
|
| 3038 |
</div>
|
|
|
|
| 3042 |
)
|
| 3043 |
|
| 3044 |
with gr.Row(equal_height=False, elem_classes=["bte-hero-grid"]):
|
| 3045 |
+
with gr.Column(scale=4, min_width=320, elem_classes=["bte-workflow-panel", "bte-panel-upload"]):
|
| 3046 |
with gr.Group(elem_classes=["bte-shell", "bte-upload-card"]):
|
| 3047 |
gr.HTML(
|
| 3048 |
'<p class="bte-upload-hint">Supported formats: PDF, PNG, JPEG</p>',
|
|
|
|
| 3057 |
elem_classes=["bte-uploader"],
|
| 3058 |
)
|
| 3059 |
selected_document = gr.HTML(selected_document_html(), visible=False)
|
|
|
|
| 3060 |
|
| 3061 |
+
with gr.Column(scale=4, min_width=300, elem_classes=["bte-workflow-panel", "bte-panel-analysis"]):
|
| 3062 |
gr.HTML(analysis_animation_html())
|
| 3063 |
|
| 3064 |
+
with gr.Column(scale=4, min_width=300, elem_classes=["bte-workflow-panel", "bte-panel-result"]):
|
| 3065 |
gr.HTML(result_preview_html())
|
| 3066 |
|
| 3067 |
status = gr.HTML(
|
|
|
|
| 3070 |
visible=False,
|
| 3071 |
)
|
| 3072 |
|
| 3073 |
+
report_panel = gr.Group(visible=False, elem_classes=["bte-report-panel", "bte-final-row"])
|
| 3074 |
+
with report_panel:
|
| 3075 |
+
report = gr.HTML(empty_report_html())
|
| 3076 |
+
|
| 3077 |
uploaded.change(
|
| 3078 |
upload_state,
|
| 3079 |
inputs=[uploaded],
|
| 3080 |
+
outputs=[upload_dropzone, selected_document, workflow_phase],
|
| 3081 |
show_progress="hidden",
|
| 3082 |
+
).then(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3083 |
show_processing,
|
| 3084 |
+
outputs=[status, report_panel, report, workflow_phase],
|
| 3085 |
scroll_to_output=True,
|
| 3086 |
show_progress="hidden",
|
| 3087 |
).then(
|
| 3088 |
extract_lab_values,
|
| 3089 |
inputs=[uploaded],
|
| 3090 |
+
outputs=[status, report, report_panel, workflow_phase],
|
| 3091 |
scroll_to_output=True,
|
| 3092 |
show_progress="hidden",
|
| 3093 |
)
|
| 3094 |
|
|
|
|
|
|
|
| 3095 |
|
| 3096 |
if __name__ == "__main__":
|
| 3097 |
demo.launch(css=CUSTOM_CSS)
|
requirements.txt
CHANGED
|
@@ -12,6 +12,5 @@ av
|
|
| 12 |
transformers[torch]>=5.7.0
|
| 13 |
|
| 14 |
# Llama Champion path: run the official OpenBMB GGUF through llama.cpp on ZeroGPU.
|
| 15 |
-
#
|
| 16 |
-
--
|
| 17 |
-
llama-cpp-python==0.3.16
|
|
|
|
| 12 |
transformers[torch]>=5.7.0
|
| 13 |
|
| 14 |
# Llama Champion path: run the official OpenBMB GGUF through llama.cpp on ZeroGPU.
|
| 15 |
+
# Use a build that includes MiniCPM-V 4.6 mtmd support.
|
| 16 |
+
llama-cpp-python @ git+https://github.com/JamePeng/llama-cpp-python.git
|
|
|
src/extraction/llamacpp_gpu.py
CHANGED
|
@@ -127,10 +127,20 @@ def _run_llamacpp_generation(
|
|
| 127 |
max_tokens: int,
|
| 128 |
) -> str:
|
| 129 |
model_path, mmproj_path = _download(repo, model_file, mmproj_file)
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
response = llm.create_chat_completion(
|
| 132 |
messages=[{"role": "user", "content": [{"type": "text", "text": EXTRACTION_PROMPT}, *parts]}],
|
|
|
|
| 133 |
temperature=0.0,
|
|
|
|
| 134 |
max_tokens=max_tokens,
|
| 135 |
)
|
| 136 |
return response["choices"][0]["message"].get("content") or "{}"
|
|
|
|
| 127 |
max_tokens: int,
|
| 128 |
) -> str:
|
| 129 |
model_path, mmproj_path = _download(repo, model_file, mmproj_file)
|
| 130 |
+
try:
|
| 131 |
+
llm = _load(model_path, mmproj_path, chat_handler)
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
raise RuntimeError(
|
| 134 |
+
"The llama.cpp backend could not load the MiniCPM-V GGUF/mmproj pair. "
|
| 135 |
+
"This usually means the downloaded model build is incompatible with the installed "
|
| 136 |
+
"llama-cpp-python wheel, the chat handler name does not match the wheel, or one of "
|
| 137 |
+
"the model files is incomplete."
|
| 138 |
+
) from exc
|
| 139 |
response = llm.create_chat_completion(
|
| 140 |
messages=[{"role": "user", "content": [{"type": "text", "text": EXTRACTION_PROMPT}, *parts]}],
|
| 141 |
+
response_format={"type": "json_object"},
|
| 142 |
temperature=0.0,
|
| 143 |
+
reasoning_budget=0,
|
| 144 |
max_tokens=max_tokens,
|
| 145 |
)
|
| 146 |
return response["choices"][0]["message"].get("content") or "{}"
|
tests/test_app_errors.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 5 |
+
|
| 6 |
+
from app import _format_extraction_error # noqa: E402
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def test_llamacpp_load_failure_message_is_specific():
|
| 10 |
+
message = _format_extraction_error(ValueError("Failed to load model from file: /tmp/model.gguf"))
|
| 11 |
+
assert "llama.cpp backend could not load the GGUF model" in message
|
| 12 |
+
assert "background worker problem" in message
|