Dimitris commited on
Commit
b6e892a
·
2 Parent(s): fbd2cc7d6f3827

Merge pull request #4 from r0m4k/feat/offline-backend-and-ui

Browse files
.gitignore CHANGED
@@ -12,11 +12,13 @@ train/data/
12
  eval/data/synth*
13
  runs/
14
 
15
- # Model artifacts (bundle the final GGUF via git-lfs deliberately, not by accident)
16
  *.safetensors
17
  *.bin
 
18
  adapters/
19
- # NOTE: the shipped GGUF + mmproj are tracked via .gitattributes (git-lfs), not ignored.
 
20
 
21
  # OS / editor
22
  .DS_Store
 
12
  eval/data/synth*
13
  runs/
14
 
15
+ # Model artifacts never commit downloaded/large weights by accident.
16
  *.safetensors
17
  *.bin
18
+ *.gguf
19
  adapters/
20
+ models/
21
+ # NOTE: if you ever ship a GGUF inside the repo, track it deliberately via git-lfs.
22
 
23
  # OS / editor
24
  .DS_Store
app.py CHANGED
@@ -12,6 +12,11 @@ from src.extraction import build_extractor
12
 
13
  load_local_env()
14
 
 
 
 
 
 
15
  def extract_lab_values(
16
  uploaded_file: str | None,
17
  api_key_override: str,
@@ -2302,6 +2307,11 @@ button.bte-action *,
2302
 
2303
 
2304
  with gr.Blocks(title="Blood Test Explainer") as demo:
 
 
 
 
 
2305
  with gr.Row(equal_height=True, elem_classes=["bte-title"]):
2306
  with gr.Column(scale=6, min_width=420, elem_classes=["bte-title-copy"]):
2307
  gr.HTML(
@@ -2313,7 +2323,7 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
2313
  </div>
2314
  """
2315
  )
2316
- with gr.Column(scale=4, min_width=360):
2317
  with gr.Group(elem_classes=["bte-api-key-panel"]):
2318
  api_key_override = gr.Textbox(
2319
  label="OpenBMB API key",
@@ -2379,7 +2389,7 @@ with gr.Blocks(title="Blood Test Explainer") as demo:
2379
  show_progress="hidden",
2380
  )
2381
 
2382
- with gr.Group(visible=False) as report_panel:
2383
  report = gr.HTML(empty_report_html())
2384
 
2385
  run_button.click(
 
12
 
13
  load_local_env()
14
 
15
+ # The hosted-API key field is only relevant when the API backend is active; in offline
16
+ # (local) modes it's dead weight, so we only show it when EXTRACTOR_BACKEND == "api".
17
+ _API_MODE = os.getenv("EXTRACTOR_BACKEND", "auto").strip().lower() == "api"
18
+
19
+
20
  def extract_lab_values(
21
  uploaded_file: str | None,
22
  api_key_override: str,
 
2307
 
2308
 
2309
  with gr.Blocks(title="Blood Test Explainer") as demo:
2310
+ # Flatten the report container so only the inner .bte-report card shows (no double box).
2311
+ gr.HTML(
2312
+ "<style>.bte-report-panel,.bte-report-panel>*{background:transparent !important;"
2313
+ "border:0 !important;box-shadow:none !important;padding:0 !important;}</style>"
2314
+ )
2315
  with gr.Row(equal_height=True, elem_classes=["bte-title"]):
2316
  with gr.Column(scale=6, min_width=420, elem_classes=["bte-title-copy"]):
2317
  gr.HTML(
 
2323
  </div>
2324
  """
2325
  )
2326
+ with gr.Column(scale=4, min_width=360, visible=_API_MODE):
2327
  with gr.Group(elem_classes=["bte-api-key-panel"]):
2328
  api_key_override = gr.Textbox(
2329
  label="OpenBMB API key",
 
2389
  show_progress="hidden",
2390
  )
2391
 
2392
+ with gr.Group(visible=False, elem_classes=["bte-report-panel"]) as report_panel:
2393
  report = gr.HTML(empty_report_html())
2394
 
2395
  run_button.click(
src/extraction/factory.py CHANGED
@@ -1,11 +1,12 @@
1
  """Backend selection.
2
 
3
  `EXTRACTOR_BACKEND` env:
4
- - `auto` (default): local if a GGUF is configured + importable, otherwise the API backend.
5
- This keeps the app working today (API) and flips to fully-offline the moment the fine-tuned
6
- GGUF is bundled no code change.
7
- - `local`: force the offline MiniCPM-V backend (errors if not configured).
8
- - `api`: force the hosted OpenBMB endpoint (dev fallback only).
 
9
  """
10
 
11
  from __future__ import annotations
@@ -14,6 +15,7 @@ import os
14
 
15
  from src.extraction.base import Extractor
16
  from src.extraction.local_minicpmv import LocalMiniCPMVExtractor
 
17
  from src.openbmb_client import OpenBMBExtractor
18
 
19
 
@@ -26,20 +28,23 @@ def build_extractor(
26
 
27
  if backend == "api":
28
  return OpenBMBExtractor(api_url=api_url, model=model, api_key=api_key)
29
-
30
- if backend == "local":
 
31
  return LocalMiniCPMVExtractor()
32
 
33
  # auto
34
- if _local_available():
 
 
35
  try:
36
  return LocalMiniCPMVExtractor()
37
  except Exception:
38
- pass # fall through to API
39
  return OpenBMBExtractor(api_url=api_url, model=model, api_key=api_key)
40
 
41
 
42
- def _local_available() -> bool:
43
  if not (os.getenv("LOCAL_MODEL_PATH") and os.getenv("LOCAL_MMPROJ_PATH")):
44
  return False
45
  try:
 
1
  """Backend selection.
2
 
3
  `EXTRACTOR_BACKEND` env:
4
+ - `auto` (default): local-server if LLAMA_SERVER_URL is set; else the in-process llama.cpp
5
+ backend if a GGUF + llama-cpp-python are available; else the hosted API.
6
+ - `local` / `server`: the offline **llama-server** backend (the path that works for MiniCPM-V
7
+ 4.6). Run `llama-server -m model.gguf --mmproj mmproj.gguf --port 8080` alongside the app.
8
+ - `llamacpp`: in-process llama-cpp-python (only once it supports the model build).
9
+ - `api`: the hosted OpenBMB endpoint (dev fallback only).
10
  """
11
 
12
  from __future__ import annotations
 
15
 
16
  from src.extraction.base import Extractor
17
  from src.extraction.local_minicpmv import LocalMiniCPMVExtractor
18
+ from src.extraction.local_server import LocalServerExtractor
19
  from src.openbmb_client import OpenBMBExtractor
20
 
21
 
 
28
 
29
  if backend == "api":
30
  return OpenBMBExtractor(api_url=api_url, model=model, api_key=api_key)
31
+ if backend in ("local", "server", "local-server"):
32
+ return LocalServerExtractor()
33
+ if backend == "llamacpp":
34
  return LocalMiniCPMVExtractor()
35
 
36
  # auto
37
+ if os.getenv("LLAMA_SERVER_URL"):
38
+ return LocalServerExtractor()
39
+ if _llamacpp_available():
40
  try:
41
  return LocalMiniCPMVExtractor()
42
  except Exception:
43
+ pass
44
  return OpenBMBExtractor(api_url=api_url, model=model, api_key=api_key)
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:
src/extraction/local_server.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline extraction via a local llama.cpp server (llama-server).
2
+
3
+ This is the off-grid backend that actually works for MiniCPM-V 4.6. The pip `llama-cpp-python`
4
+ bundles an llama.cpp too old to load 4.6, but the current `llama-server` (brew / release build)
5
+ runs it fine. We POST to a llama-server on localhost with the document image plus:
6
+ - our **GBNF grammar**, so the output is always the `{tests, notes}` schema, and
7
+ - `enable_thinking: false`, so the model doesn't spend its whole token budget on a `<think>`
8
+ ramble (the cause of the "could not be converted into a report" failure).
9
+
10
+ localhost = the model running on this machine, so it is still fully off-grid (no external call).
11
+
12
+ Run the server next to the app:
13
+ llama-server -m model.gguf --mmproj mmproj.gguf --port 8080
14
+
15
+ Config (env):
16
+ LLAMA_SERVER_URL default http://127.0.0.1:8080/v1/chat/completions
17
+ LLAMA_SERVER_MODEL default "minicpm-v"
18
+ LLAMA_SERVER_GRAMMAR set to "1" to send the GBNF grammar (OFF by default: the current
19
+ llama-server build rejects our grammar, and `enable_thinking:false`
20
+ plus the tolerant parser already yield clean {tests,notes} output)
21
+ """
22
+
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,
33
+ ExtractionResult,
34
+ _normalize_notes,
35
+ _normalize_tests,
36
+ _parse_json_response,
37
+ )
38
+
39
+ DEFAULT_SERVER_URL = "http://127.0.0.1:8080/v1/chat/completions"
40
+
41
+
42
+ class LocalServerExtractor:
43
+ """Implements the `Extractor` protocol against a local llama-server."""
44
+
45
+ def __init__(
46
+ self,
47
+ url: str | None = None,
48
+ model: str | None = None,
49
+ timeout_seconds: int = 180,
50
+ ) -> None:
51
+ self.url = (url or os.getenv("LLAMA_SERVER_URL") or DEFAULT_SERVER_URL).strip()
52
+ self.model = (model or os.getenv("LLAMA_SERVER_MODEL") or "minicpm-v").strip()
53
+ self.timeout_seconds = timeout_seconds
54
+ self.use_grammar = os.getenv("LLAMA_SERVER_GRAMMAR", "0") == "1"
55
+
56
+ def extract(self, file_path: str, max_pages: int = 3) -> ExtractionResult:
57
+ parts = document_to_payload_parts(file_path, max_pages=max_pages)
58
+ payload = {
59
+ "model": self.model,
60
+ "messages": [
61
+ {"role": "user", "content": [{"type": "text", "text": EXTRACTION_PROMPT}, *parts]}
62
+ ],
63
+ "temperature": 0,
64
+ "max_tokens": 2048,
65
+ # Stop the model from emitting a <think> reasoning block (it otherwise burns the
66
+ # whole token budget before producing JSON). Unknown fields are ignored by the server.
67
+ "chat_template_kwargs": {"enable_thinking": False},
68
+ }
69
+ if self.use_grammar:
70
+ # Grammar-constrained decoding: output can only be our {tests, notes} schema.
71
+ payload["grammar"] = extraction_grammar()
72
+
73
+ response = requests.post(
74
+ self.url,
75
+ json=payload,
76
+ headers={"Content-Type": "application/json"},
77
+ timeout=self.timeout_seconds,
78
+ )
79
+ response.raise_for_status()
80
+
81
+ raw = _message_content(response.json())
82
+ parsed = _parse_json_response(raw)
83
+ return ExtractionResult(
84
+ tests=_normalize_tests(parsed.get("tests", [])),
85
+ notes=_normalize_notes(parsed.get("notes", [])),
86
+ raw_response=raw,
87
+ request_summary={
88
+ "backend": "local-server",
89
+ "url": self.url,
90
+ "document_parts": len(parts),
91
+ "max_pages": max_pages,
92
+ "grammar": self.use_grammar,
93
+ },
94
+ )
95
+
96
+
97
+ def _message_content(payload: dict) -> str:
98
+ try:
99
+ message = payload["choices"][0]["message"]
100
+ except (KeyError, IndexError, TypeError) as error:
101
+ raise ValueError("llama-server response did not include choices[0].message.") from error
102
+ content = message.get("content") or ""
103
+ if isinstance(content, list):
104
+ content = "\n".join(p.get("text", "") for p in content if isinstance(p, dict))
105
+ return content.strip()
src/openbmb_client.py CHANGED
@@ -160,11 +160,15 @@ def _normalize_api_key(value: str | None) -> str | None:
160
 
161
 
162
  def _parse_json_response(text: str) -> dict[str, Any]:
163
- cleaned = _strip_code_fence(text)
164
  parsed = _loads_model_json(cleaned)
165
 
 
 
 
 
166
  if not isinstance(parsed, dict):
167
- raise ValueError("Model response JSON must be an object.")
168
  return parsed
169
 
170
 
@@ -175,9 +179,9 @@ def _loads_model_json(text: str) -> Any:
175
  try:
176
  return json.loads(text, strict=False)
177
  except json.JSONDecodeError:
178
- match = re.search(r"\{.*\}", text, flags=re.DOTALL)
179
  if not match:
180
- raise ValueError("Model response did not contain a JSON object.")
181
  snippet = match.group(0)
182
  try:
183
  return json.loads(snippet)
@@ -196,6 +200,14 @@ def _strip_code_fence(text: str) -> str:
196
  return stripped.strip()
197
 
198
 
 
 
 
 
 
 
 
 
199
  def _normalize_tests(value: Any) -> list[dict[str, Any]]:
200
  if not isinstance(value, list):
201
  return []
 
160
 
161
 
162
  def _parse_json_response(text: str) -> dict[str, Any]:
163
+ cleaned = _strip_think(_strip_code_fence(text))
164
  parsed = _loads_model_json(cleaned)
165
 
166
+ # Some models (e.g. MiniCPM-V in "thinking" mode) return a bare array of tests
167
+ # instead of the {tests, notes} object. Wrap it so the rest of the app is unchanged.
168
+ if isinstance(parsed, list):
169
+ return {"tests": parsed, "notes": []}
170
  if not isinstance(parsed, dict):
171
+ raise ValueError("Model response JSON must be an object or array.")
172
  return parsed
173
 
174
 
 
179
  try:
180
  return json.loads(text, strict=False)
181
  except json.JSONDecodeError:
182
+ match = re.search(r"\[.*\]|\{.*\}", text, flags=re.DOTALL)
183
  if not match:
184
+ raise ValueError("Model response did not contain JSON.")
185
  snippet = match.group(0)
186
  try:
187
  return json.loads(snippet)
 
200
  return stripped.strip()
201
 
202
 
203
+ _THINK_RE = re.compile(r"<think(?:ing)?>.*?</think(?:ing)?>", flags=re.DOTALL | re.IGNORECASE)
204
+
205
+
206
+ def _strip_think(text: str) -> str:
207
+ """Drop <think>...</think> reasoning blocks some models emit before the JSON."""
208
+ return _THINK_RE.sub("", text).strip()
209
+
210
+
211
  def _normalize_tests(value: Any) -> list[dict[str, Any]]:
212
  if not isinstance(value, list):
213
  return []
tests/test_parsing.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parser must survive MiniCPM-V's thinking mode + bare-array output."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
7
+
8
+ from src.openbmb_client import _normalize_tests, _parse_json_response # noqa: E402
9
+
10
+
11
+ def test_think_block_then_bare_array():
12
+ raw = (
13
+ "<think>\nWe need to extract the tests...\n</think>\n\n"
14
+ '[{"marker": "Glucose", "value": "95", "unit": "mg/dL", "reference_range": "70-99"}]'
15
+ )
16
+ parsed = _parse_json_response(raw)
17
+ assert set(parsed) == {"tests", "notes"}
18
+ tests = _normalize_tests(parsed["tests"])
19
+ assert len(tests) == 1 and tests[0]["marker"] == "Glucose"
20
+
21
+
22
+ def test_plain_object_still_works():
23
+ raw = '{"tests": [{"marker": "ALT", "value": "30"}], "notes": ["ok"]}'
24
+ parsed = _parse_json_response(raw)
25
+ assert parsed["notes"] == ["ok"]
26
+ assert _normalize_tests(parsed["tests"])[0]["marker"] == "ALT"
27
+
28
+
29
+ def test_code_fenced_array():
30
+ raw = '```json\n[{"marker": "TSH", "value": "2.1", "unit": "mIU/L"}]\n```'
31
+ tests = _normalize_tests(_parse_json_response(raw)["tests"])
32
+ assert tests[0]["marker"] == "TSH"
33
+
34
+
35
+ def test_prose_then_object_is_recovered():
36
+ raw = 'Here are the results:\n{"tests": [{"marker": "HDL", "value": "55"}], "notes": []}'
37
+ assert _normalize_tests(_parse_json_response(raw)["tests"])[0]["marker"] == "HDL"