Coverage for src/lilbee/data/extract/backends/vision_ocr.py: 100%
106 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""lilbee's vision model exposed as a xberg custom OCR backend."""
3from __future__ import annotations
5import json
6import threading
7import uuid
8from contextlib import contextmanager
9from dataclasses import dataclass
10from importlib.metadata import PackageNotFoundError, version
11from pathlib import Path
12from typing import TYPE_CHECKING, Protocol
14from lilbee.data.types import MARKDOWN_MIME, OcrBackendName
15from lilbee.runtime.cancellation import CancelSignal, TaskCancelledError
16from lilbee.vision import resolve_ocr_prompt
18from .registry import BackendKind, XbergBinding, register_binding
20if TYPE_CHECKING:
21 from collections.abc import Callable, Generator
23 # OcrBackend types the config as the public xberg.OcrConfig; its
24 # backend_options arrives as a JSON string at runtime (see _OcrConfigView).
25 from xberg import ExtractedDocument, OcrBackendType, OcrConfig
27# xberg doesn't propagate contextvars into process_image, so per-request state
28# travels as this token in OcrConfig.backend_options and resolves via the registry.
29_REQUEST_TOKEN_KEY = "req" # noqa: S105 # JSON key name, not a secret
32@dataclass(frozen=True)
33class OcrRequestContext:
34 """Per-extraction state the backend needs but xberg won't carry for it."""
36 timeout: float = 0.0
37 cancel: CancelSignal | None = None
40class _OcrRequestRegistry:
41 """Token-keyed request contexts; lock-guarded (process_image runs on xberg threads)."""
43 def __init__(self) -> None:
44 self._lock = threading.Lock()
45 self._by_token: dict[str, OcrRequestContext] = {}
47 def register(self, ctx: OcrRequestContext) -> str:
48 token = uuid.uuid4().hex
49 with self._lock:
50 self._by_token[token] = ctx
51 return token
53 def get(self, token: str | None) -> OcrRequestContext | None:
54 if token is None:
55 return None
56 with self._lock:
57 return self._by_token.get(token)
59 def unregister(self, token: str) -> None:
60 with self._lock:
61 self._by_token.pop(token, None)
64ocr_requests = _OcrRequestRegistry()
67@contextmanager
68def ocr_request(
69 *, timeout: float = 0.0, cancel: CancelSignal | None = None
70) -> Generator[str, None, None]:
71 """Register a per-extraction context and yield its token for OcrConfig.backend_options."""
72 token = ocr_requests.register(OcrRequestContext(timeout=timeout, cancel=cancel))
73 try:
74 yield token
75 finally:
76 ocr_requests.unregister(token)
79def backend_options_for(token: str) -> str:
80 """The OcrConfig.backend_options JSON that carries a request token to process_image."""
81 return json.dumps({_REQUEST_TOKEN_KEY: token})
84class _OcrConfigView:
85 """Typed reader over the xberg OcrConfig passed to process_image."""
87 def __init__(self, config: OcrConfig) -> None:
88 self._config = config
90 @property
91 def vlm_prompt(self) -> str | None:
92 return self._config.vlm_prompt
94 @property
95 def request_token(self) -> str | None:
96 options = self._config.backend_options
97 if options is None:
98 return None
99 try:
100 decoded = json.loads(options)
101 except json.JSONDecodeError:
102 return None
103 # backend_options is free-form JSON; only lilbee's own object carries a token.
104 token = decoded.get(_REQUEST_TOKEN_KEY) if isinstance(decoded, dict) else None
105 return token if isinstance(token, str) else None
108class _OcrFn(Protocol):
109 # Positional-only so the provider's vision_ocr (named png_bytes) matches structurally.
110 def __call__(
111 self,
112 image_bytes: bytes,
113 model: str,
114 prompt: str,
115 /,
116 *,
117 timeout: float,
118 cancel: CancelSignal | None,
119 ) -> str: ...
122def _lilbee_version() -> str:
123 try:
124 return version("lilbee")
125 except PackageNotFoundError:
126 return "0"
129class VisionOcrBackend:
130 """Routes xberg OCR calls to lilbee's vision model through the injected
131 ``ocr_fn`` (single-image OCR) and ``model_ref_fn`` (the active vision model)."""
133 def __init__(self, *, ocr_fn: _OcrFn, model_ref_fn: Callable[[], str]) -> None:
134 self._ocr_fn = ocr_fn
135 self._model_ref_fn = model_ref_fn
137 def name(self) -> str:
138 return OcrBackendName.LILBEE_VISION
140 def version(self) -> str:
141 return _lilbee_version()
143 def supported_languages(self) -> list[str]:
144 return []
146 def supports_language(self, lang: str) -> bool:
147 return True
149 def initialize(self) -> None: ...
151 def shutdown(self) -> None: ...
153 def backend_type(self) -> OcrBackendType:
154 from xberg import OcrBackendType
156 return OcrBackendType.CUSTOM
158 def supports_table_detection(self) -> bool:
159 return False
161 def supports_document_processing(self) -> bool:
162 return False
164 def emits_structured_markdown(self) -> bool:
165 # False keeps xberg's layout reconstruction (the validated path). The model
166 # does emit markdown, so True is a valid optimization but changes OCR output.
167 return False
169 def process_image(self, image_bytes: bytes, config: OcrConfig) -> ExtractedDocument:
170 # xberg passes a native OcrConfig and expects a native ExtractedDocument back.
171 from xberg import ExtractedDocument
173 view = _OcrConfigView(config)
174 model = self._model_ref_fn()
175 prompt = view.vlm_prompt or resolve_ocr_prompt(model)
176 ctx = ocr_requests.get(view.request_token)
177 cancel = ctx.cancel if ctx is not None else None
178 if cancel is not None and cancel.is_set():
179 raise TaskCancelledError
180 timeout = ctx.timeout if ctx is not None else 0.0
181 text = self._ocr_fn(image_bytes, model, prompt, timeout=timeout, cancel=cancel)
182 return ExtractedDocument(content=text, mime_type=MARKDOWN_MIME)
184 def process_image_file(self, path: str, config: OcrConfig) -> ExtractedDocument:
185 # OCR an image file by reading its bytes and delegating to process_image
186 # (mirrors xberg's default OcrBackend::process_image_file).
187 return self.process_image(Path(path).read_bytes(), config)
189 def process_document(self, _path: str, _config: OcrConfig) -> ExtractedDocument:
190 # Image-only backend: xberg only calls this when supports_document_processing()
191 # is True, which it never is here, so document-level OCR is unreachable.
192 raise NotImplementedError(
193 "Document-level OCR is not supported by the lilbee vision backend"
194 )
197register_binding(
198 XbergBinding(
199 kind=BackendKind.OCR,
200 name=OcrBackendName.LILBEE_VISION,
201 enabled=lambda cfg: bool(cfg.vision_model),
202 make=lambda provider, cfg: VisionOcrBackend(
203 ocr_fn=provider.vision_ocr, model_ref_fn=lambda: cfg.vision_model
204 ),
205 )
206)