r0mant1c commited on
Commit
f641c04
·
1 Parent(s): ea433a3

Fix llama.cpp JSON extraction path

Browse files
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
- (`llama-cpp-python`) inside a ZeroGPU-managed function. The deterministic knowledge-graph
 
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", str(error), tone="danger"),
39
- empty_report_html("Extraction failed", "The model response could not be converted into a report."),
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 show_processing() -> tuple[str, Any, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  return (
79
  _status_html("Reading document", "Extracting patient context and markers, then matching them to the knowledge graph.", tone="loading"),
80
  gr.update(visible=True),
81
  loading_report_html(),
 
82
  )
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--amber">
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", ("Bad", "Normal", "Good"))
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(status.title())}</span>
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">Final health report</p>
391
- <h2>Final health report reference</h2>
392
  <p>Generated from the uploaded lab report, matched to the knowledge graph, and enriched with age and sex context. {escape(patient_context)}</p>
393
  </div>
394
  </header>
@@ -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>Bad</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
- <span class="bte-marker-status">{escape(status.title())}</span>
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(status.title())}</span>
552
  </div>
553
  <div class="bte-range-scale" style="--value-position: {range_position}%; --value-position-number: {range_position}">
554
  <div class="bte-range-value">
@@ -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>Bad</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: transparent !important;
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: 8px;
1280
- min-height: auto;
1281
- padding: 0;
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--amber {
1479
- border-color: rgba(154, 103, 0, 0.2);
1480
- background: var(--bte-amber-soft);
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-surface);
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-surface);
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-surface);
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: transparent;
2303
  }
2304
 
2305
  .bte-final-report {
2306
  width: var(--bte-rail) !important;
2307
  max-width: var(--bte-rail) !important;
2308
  margin: 0 auto !important;
 
 
 
 
 
 
 
 
 
 
2309
  }
2310
 
2311
  .bte-ideal-hero {
@@ -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: var(--bte-shadow-strong);
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
- # The extra index provides prebuilt CUDA wheels so the Space does not compile llama.cpp.
16
- --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu121
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
- llm = _load(model_path, mmproj_path, chat_handler)
 
 
 
 
 
 
 
 
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