Merge pull request #4 from r0m4k/feat/offline-backend-and-ui
Browse files- .gitignore +4 -2
- app.py +12 -2
- src/extraction/factory.py +15 -10
- src/extraction/local_server.py +105 -0
- src/openbmb_client.py +16 -4
- tests/test_parsing.py +37 -0
.gitignore
CHANGED
|
@@ -12,11 +12,13 @@ train/data/
|
|
| 12 |
eval/data/synth*
|
| 13 |
runs/
|
| 14 |
|
| 15 |
-
# Model artifacts
|
| 16 |
*.safetensors
|
| 17 |
*.bin
|
|
|
|
| 18 |
adapters/
|
| 19 |
-
|
|
|
|
| 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
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
- `
|
|
|
|
| 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 |
-
|
|
|
|
| 31 |
return LocalMiniCPMVExtractor()
|
| 32 |
|
| 33 |
# auto
|
| 34 |
-
if
|
|
|
|
|
|
|
| 35 |
try:
|
| 36 |
return LocalMiniCPMVExtractor()
|
| 37 |
except Exception:
|
| 38 |
-
pass
|
| 39 |
return OpenBMBExtractor(api_url=api_url, model=model, api_key=api_key)
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
| 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
|
| 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"
|