Dimitris Codex commited on
Commit
e0169bf
·
1 Parent(s): df889fb

feat(extraction): GBNF grammar + local MiniCPM-V (llama.cpp) backend + factory

Browse files

Co-authored-by: Codex <chatgpt-codex-connector[bot]@users.noreply.github.com>

app.py CHANGED
@@ -8,7 +8,8 @@ from typing import Any
8
  import gradio as gr
9
 
10
  from src.local_env import load_local_env
11
- from src.openbmb_client import DEFAULT_API_URL, DEFAULT_MODEL, OpenBMBExtractor
 
12
 
13
 
14
  load_local_env()
@@ -41,7 +42,9 @@ def extract_lab_values(
41
  gr.update(visible=True),
42
  )
43
 
44
- extractor = OpenBMBExtractor(
 
 
45
  api_url=api_url,
46
  model=model,
47
  api_key=(api_key_override or "").strip() or None,
 
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.openbmb_client import DEFAULT_API_URL, DEFAULT_MODEL
13
 
14
 
15
  load_local_env()
 
42
  gr.update(visible=True),
43
  )
44
 
45
+ # Backend chosen by EXTRACTOR_BACKEND (auto|local|api). Local = offline MiniCPM-V GGUF;
46
+ # api = the hosted OpenBMB endpoint (dev fallback). Defaults to auto.
47
+ extractor = build_extractor(
48
  api_url=api_url,
49
  model=model,
50
  api_key=(api_key_override or "").strip() or None,
requirements.txt CHANGED
@@ -3,3 +3,6 @@ requests==2.32.5
3
  pillow==12.0.0
4
  pymupdf==1.26.6
5
  json-repair==0.60.1
 
 
 
 
3
  pillow==12.0.0
4
  pymupdf==1.26.6
5
  json-repair==0.60.1
6
+ # Local, offline inference (off-grid + quantization). Prebuilt CPU wheels exist; the Space
7
+ # build may need build tools if it compiles from source. Required once EXTRACTOR_BACKEND=local.
8
+ llama-cpp-python==0.3.16
src/extraction/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extraction backends behind one interface.
2
+
3
+ `build_extractor()` returns the right backend for the environment:
4
+ - **local** (off-grid): fine-tuned MiniCPM-V as GGUF under llama.cpp, fully on-device.
5
+ - **api**: the original OpenBMB hosted endpoint (kept as a dev fallback only).
6
+
7
+ Default is `auto`: use local when a GGUF is configured + llama.cpp is importable, else api.
8
+ """
9
+
10
+ from src.extraction.base import Extractor, ExtractionResult
11
+ from src.extraction.factory import build_extractor
12
+
13
+ __all__ = ["Extractor", "ExtractionResult", "build_extractor"]
src/extraction/base.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The extractor interface shared by every backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ # Reuse the existing result dataclass so there is exactly one definition in the codebase.
8
+ from src.openbmb_client import ExtractionResult
9
+
10
+ __all__ = ["Extractor", "ExtractionResult"]
11
+
12
+
13
+ @runtime_checkable
14
+ class Extractor(Protocol):
15
+ """Anything that turns an uploaded document into structured lab values."""
16
+
17
+ def extract(self, file_path: str, max_pages: int = 3) -> ExtractionResult: ...
src/extraction/factory.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
12
+
13
+ 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
+
20
+ def build_extractor(
21
+ api_url: str | None = None,
22
+ model: str | None = None,
23
+ api_key: str | None = None,
24
+ ) -> Extractor:
25
+ backend = os.getenv("EXTRACTOR_BACKEND", "auto").strip().lower()
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:
46
+ import llama_cpp # noqa: F401
47
+ except ImportError:
48
+ return False
49
+ return True
src/extraction/local_minicpmv.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local, offline extraction with fine-tuned MiniCPM-V under llama.cpp.
2
+
3
+ This is the off-grid backend: the (fine-tuned) MiniCPM-V vision model as a quantized GGUF,
4
+ plus its multimodal projector (mmproj), run entirely on-device via llama-cpp-python. The same
5
+ PDF/image → data-URL pipeline used by the API backend feeds the model here, and the output is
6
+ GBNF-constrained to our extraction schema so it is always valid JSON.
7
+
8
+ No network calls. Earns: off-grid (local model), fine-tune (LoRA → merged GGUF), quantization
9
+ (Q4_K_M GGUF). The GGUF + mmproj files come from the fine-tune pipeline (see train/ + scripts/).
10
+
11
+ Configuration (env):
12
+ LOCAL_MODEL_PATH path to the (quantized) MiniCPM-V GGUF [required for local]
13
+ LOCAL_MMPROJ_PATH path to the mmproj GGUF (vision projector) [required for local]
14
+ LOCAL_N_CTX context window (default 4096)
15
+ LOCAL_N_GPU_LAYERS GPU offload layers (0 = pure CPU; >0 on ZeroGPU/CUDA)
16
+ LOCAL_CHAT_HANDLER llama_cpp chat-handler class name (default: MiniCPMv26ChatHandler)
17
+
18
+ ⚠️ The exact chat-handler class is version-dependent. MiniCPM-V 2.6 uses
19
+ `MiniCPMv26ChatHandler`; confirm the handler shipped with your llama-cpp-python build for the
20
+ 4.6 checkpoint and override via LOCAL_CHAT_HANDLER if needed. Verify on real hardware.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ from functools import lru_cache
28
+
29
+ from src.document_processing import document_to_payload_parts
30
+ from src.grammar import extraction_grammar
31
+ from src.openbmb_client import (
32
+ EXTRACTION_PROMPT,
33
+ ExtractionResult,
34
+ _normalize_notes,
35
+ _normalize_tests,
36
+ )
37
+
38
+
39
+ class LocalMiniCPMVExtractor:
40
+ """Offline MiniCPM-V extractor (llama.cpp). Implements the `Extractor` protocol."""
41
+
42
+ def __init__(
43
+ self,
44
+ model_path: str | None = None,
45
+ mmproj_path: str | None = None,
46
+ n_ctx: int | None = None,
47
+ n_gpu_layers: int | None = None,
48
+ chat_handler_name: str | None = None,
49
+ ) -> None:
50
+ self.model_path = model_path or os.getenv("LOCAL_MODEL_PATH")
51
+ self.mmproj_path = mmproj_path or os.getenv("LOCAL_MMPROJ_PATH")
52
+ self.n_ctx = n_ctx if n_ctx is not None else int(os.getenv("LOCAL_N_CTX", "4096"))
53
+ self.n_gpu_layers = (
54
+ n_gpu_layers if n_gpu_layers is not None else int(os.getenv("LOCAL_N_GPU_LAYERS", "0"))
55
+ )
56
+ self.chat_handler_name = (
57
+ chat_handler_name or os.getenv("LOCAL_CHAT_HANDLER", "MiniCPMv26ChatHandler")
58
+ )
59
+ if not self.model_path or not self.mmproj_path:
60
+ raise RuntimeError(
61
+ "Local backend needs LOCAL_MODEL_PATH and LOCAL_MMPROJ_PATH (the fine-tuned "
62
+ "MiniCPM-V GGUF + mmproj). Run the fine-tune + GGUF pipeline first, or set "
63
+ "EXTRACTOR_BACKEND=api to use the hosted endpoint."
64
+ )
65
+ # Fail fast if the model files are missing.
66
+ for path in (self.model_path, self.mmproj_path):
67
+ if not os.path.exists(path):
68
+ raise RuntimeError(f"Model file not found: {path}")
69
+
70
+ @property
71
+ def is_configured(self) -> bool:
72
+ return bool(self.model_path and self.mmproj_path)
73
+
74
+ def extract(self, file_path: str, max_pages: int = 3) -> ExtractionResult:
75
+ llm = _load_model(
76
+ self.model_path, self.mmproj_path, self.n_ctx, self.n_gpu_layers, self.chat_handler_name
77
+ )
78
+ parts = document_to_payload_parts(file_path, max_pages=max_pages)
79
+
80
+ response = llm.create_chat_completion(
81
+ messages=[{"role": "user", "content": [{"type": "text", "text": EXTRACTION_PROMPT}, *parts]}],
82
+ grammar=_grammar(),
83
+ temperature=0.0,
84
+ max_tokens=2048,
85
+ )
86
+ raw = response["choices"][0]["message"]["content"] or "{}"
87
+ # GBNF guarantees valid JSON, but never trust a single parse.
88
+ try:
89
+ parsed = json.loads(raw)
90
+ except json.JSONDecodeError:
91
+ parsed = {}
92
+
93
+ return ExtractionResult(
94
+ tests=_normalize_tests(parsed.get("tests", [])),
95
+ notes=_normalize_notes(parsed.get("notes", [])),
96
+ raw_response=raw,
97
+ request_summary={
98
+ "backend": "local-minicpmv",
99
+ "model_path": os.path.basename(self.model_path),
100
+ "document_parts": len(parts),
101
+ "max_pages": max_pages,
102
+ },
103
+ )
104
+
105
+
106
+ @lru_cache(maxsize=1)
107
+ def _grammar():
108
+ from llama_cpp import LlamaGrammar # lazy
109
+
110
+ return LlamaGrammar.from_string(extraction_grammar())
111
+
112
+
113
+ @lru_cache(maxsize=2)
114
+ def _load_model(model_path: str, mmproj_path: str, n_ctx: int, n_gpu_layers: int, handler_name: str):
115
+ """Load the GGUF + vision projector once and cache it (cold start is expensive)."""
116
+ try:
117
+ from llama_cpp import Llama
118
+ from llama_cpp import llama_chat_format
119
+ except ImportError as exc: # pragma: no cover - optional heavy dep
120
+ raise ImportError(
121
+ "llama-cpp-python is not installed. Install it (see requirements.txt) to use the "
122
+ "local backend, or set EXTRACTOR_BACKEND=api."
123
+ ) from exc
124
+
125
+ handler_cls = getattr(llama_chat_format, handler_name, None)
126
+ if handler_cls is None:
127
+ raise RuntimeError(
128
+ f"Chat handler '{handler_name}' not found in llama_cpp.llama_chat_format. "
129
+ "Set LOCAL_CHAT_HANDLER to the handler matching your MiniCPM-V build."
130
+ )
131
+ chat_handler = handler_cls(clip_model_path=mmproj_path)
132
+ return Llama(
133
+ model_path=model_path,
134
+ chat_handler=chat_handler,
135
+ n_ctx=n_ctx,
136
+ n_gpu_layers=n_gpu_layers,
137
+ verbose=False,
138
+ )
src/grammar.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GBNF grammar for the extraction schema.
2
+
3
+ Grammar-constrained decoding makes the local model **physically unable** to emit anything but
4
+ a valid `{tests:[...], notes:[...]}` object in our exact schema. For a small model this is the
5
+ single biggest reliability lever: no parse failures, no stray prose, no hallucinated keys.
6
+ Passed to llama.cpp via `LlamaGrammar.from_string(...)`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ EXTRACTION_GRAMMAR = r"""
12
+ root ::= "{" ws "\"tests\"" ws ":" ws tests ws "," ws "\"notes\"" ws ":" ws notes ws "}"
13
+
14
+ tests ::= "[" ws ( test ( ws "," ws test )* )? ws "]"
15
+ test ::= "{" ws
16
+ "\"marker\"" ws ":" ws string ws "," ws
17
+ "\"value\"" ws ":" ws string ws "," ws
18
+ "\"unit\"" ws ":" ws strornull ws "," ws
19
+ "\"reference_range\"" ws ":" ws strornull ws "," ws
20
+ "\"status\"" ws ":" ws status ws "," ws
21
+ "\"source_text\"" ws ":" ws strornull ws "," ws
22
+ "\"confidence\"" ws ":" ws number ws
23
+ "}"
24
+
25
+ notes ::= "[" ws ( string ( ws "," ws string )* )? ws "]"
26
+
27
+ status ::= "\"low\"" | "\"normal\"" | "\"high\"" | "\"abnormal\"" | "\"unknown\""
28
+ strornull ::= string | "null"
29
+ string ::= "\"" char* "\""
30
+ char ::= [^"\\] | "\\" ["\\/bfnrt]
31
+ number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)?
32
+ ws ::= [ \t\n]*
33
+ """
34
+
35
+
36
+ def extraction_grammar() -> str:
37
+ return EXTRACTION_GRAMMAR