backdraft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,184 @@
1
+ """The Extractor protocol and the registry of extractors.
2
+
3
+ An extractor turns a file into an ordered sequence of `ExtractedPage`s — a PDF
4
+ page, a sheet, or the single page a text file is. That sequence is the snapshot
5
+ the registry stores and every receipt quotes; nothing downstream reopens the
6
+ file.
7
+
8
+ `deterministic` is the honest self-report that makes the re-ingest contract
9
+ possible: a deterministic extractor run twice over identical bytes produces
10
+ identical pages, so the registry can skip the work and keep every token. The VLM
11
+ extractor says `False`; it is the preferred `auto` choice for PDFs when it is
12
+ ready (installed, key configured), because the snapshot is the receipt and
13
+ glossy layouts extract badly from the text layer.
14
+
15
+ Extractors are registered in a plain dict keyed by name. Modules register
16
+ themselves on import and `get` imports the module that owns a name on demand, so
17
+ neither pdfplumber nor openpyxl — nor the `[vlm]` extra — is imported until an
18
+ extractor that needs it is asked for.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import importlib
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Iterator, Protocol, runtime_checkable
27
+
28
+ from ..kernel.errors import BackdraftError
29
+ from ..kernel.model import CellValue, PageKind
30
+
31
+ __all__ = [
32
+ "AUTO_ORDER",
33
+ "vlm_ready",
34
+ "EXTRACTORS",
35
+ "ExtractedPage",
36
+ "Extractor",
37
+ "ExtractionError",
38
+ "PageImage",
39
+ "get",
40
+ "names",
41
+ "register",
42
+ "select",
43
+ ]
44
+
45
+
46
+ class ExtractionError(BackdraftError):
47
+ """An extractor could not produce a snapshot of a file."""
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class PageImage:
52
+ """A page's visual snapshot: the image an extractor derived the text from.
53
+
54
+ For the VLM extractor this is the page as the model was shown it (re-encoded
55
+ WebP, quality 85 — the embed-ses production shape), which for a
56
+ non-deterministic extractor is the one reproducible half of the receipt.
57
+ """
58
+
59
+ data: bytes
60
+ format: str # 'webp' | 'png' | 'jpeg'
61
+ width: int
62
+ height: int
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class ExtractedPage:
67
+ """One ordered unit of a snapshot: a PDF page or a sheet.
68
+
69
+ `text` is the page as the model will read it — for sheets, the markdown table
70
+ with in-band `[B10]` cell references. `cells` carries the sheet's populated
71
+ cells so the registry can mint one anchor per cell and value-trace has values
72
+ to compare against; each `value` is a verbatim substring of `text`. `image`,
73
+ when present, is the page's visual snapshot; the registry stores it so bind
74
+ can embed the cited pages into the artifact.
75
+ """
76
+
77
+ number: int
78
+ kind: PageKind
79
+ text: str
80
+ name: str | None = None
81
+ cells: list[CellValue] | None = None
82
+ image: PageImage | None = None
83
+
84
+
85
+ @runtime_checkable
86
+ class Extractor(Protocol):
87
+ """Turns a file into pages. Stateless: one instance serves every ingest."""
88
+
89
+ name: str
90
+ version: str
91
+ deterministic: bool
92
+
93
+ def can_handle(self, path: Path, media_type: str) -> bool:
94
+ """True if this extractor claims the file."""
95
+ ...
96
+
97
+ def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]:
98
+ """Yield the file's pages in order, 1-based. Raises `ExtractionError`."""
99
+ ...
100
+
101
+
102
+ EXTRACTORS: dict[str, Extractor] = {}
103
+ """Name -> extractor. Populated as modules are imported."""
104
+
105
+ AUTO_ORDER = ("xlsx", "pdf-text", "text")
106
+ """The built-in fallback order for `--extractor auto`. `vlm` is preferred
107
+ for PDFs when ready — see `select` — and otherwise only chosen by name."""
108
+
109
+ # NOTE: the spec is silent on how a name finds its module; a table beats a scan
110
+ # because it keeps the import of an extractor's dependencies lazy.
111
+ _MODULES = {
112
+ "xlsx": "xlsx",
113
+ "pdf-text": "pdf_text",
114
+ "text": "text",
115
+ "vlm": "vlm",
116
+ }
117
+
118
+
119
+ def register(extractor: Extractor) -> None:
120
+ """Register an extractor under its own name."""
121
+ EXTRACTORS[extractor.name] = extractor
122
+
123
+
124
+ def names() -> list[str]:
125
+ """Every extractor name this build knows, whether or not it can be loaded."""
126
+ return sorted(_MODULES)
127
+
128
+
129
+ def get(name: str) -> Extractor:
130
+ """The extractor called `name`, importing its module on first use.
131
+
132
+ Raises `ExtractionError` for an unknown name, or when the module cannot be
133
+ imported — the `[vlm]` extra not being installed is the case that matters.
134
+ """
135
+ if name in EXTRACTORS:
136
+ return EXTRACTORS[name]
137
+ if name not in _MODULES:
138
+ raise ExtractionError(f"unknown extractor {name!r}; known: {', '.join(names())}")
139
+ try:
140
+ importlib.import_module(f"{__package__}.{_MODULES[name]}")
141
+ except ImportError as error:
142
+ raise ExtractionError(f"extractor {name!r} is unavailable: {error}") from error
143
+ if name not in EXTRACTORS: # pragma: no cover - a module that forgot to register
144
+ raise ExtractionError(f"extractor module for {name!r} registered nothing")
145
+ return EXTRACTORS[name]
146
+
147
+
148
+ def select(path: Path, media_type: str, config: dict | None = None) -> Extractor:
149
+ """The extractor `--extractor auto` picks.
150
+
151
+ For PDFs, `vlm` is preferred when it is ready (installed, key configured):
152
+ glossy layouts and info boxes extract badly from the text layer, and the
153
+ snapshot is the receipt. Otherwise the first built-in that can handle the
154
+ file wins; the CLI owns the one-line nudge naming the VLM option, so
155
+ selection itself stays quiet. An explicit `--extractor` never reaches this
156
+ function.
157
+ """
158
+ if media_type == "pdf" and vlm_ready(config):
159
+ extractor = get("vlm")
160
+ if extractor.can_handle(path, media_type):
161
+ return extractor
162
+ for name in AUTO_ORDER:
163
+ extractor = get(name)
164
+ if extractor.can_handle(path, media_type):
165
+ return extractor
166
+ raise ExtractionError(f"no extractor handles {media_type!r} file {path.name!r}")
167
+
168
+
169
+ def vlm_ready(config: dict | None = None) -> bool:
170
+ """True when the `[vlm]` extra is importable and a backdraft-scoped key exists.
171
+
172
+ Equivalent to consent: `auto` may only choose the paid, off-machine path
173
+ when the user deliberately configured backdraft to use it. Ambient
174
+ provider keys are never read (see `backdraft.credentials`).
175
+ """
176
+ from ..credentials import setting # noqa: PLC0415 - keep base import-light
177
+
178
+ if not setting("BACKDRAFT_VLM_API_KEY", config, config_key="api_key"):
179
+ return False
180
+ try:
181
+ get("vlm")
182
+ except ExtractionError:
183
+ return False
184
+ return True
@@ -0,0 +1,124 @@
1
+ """PDF text layer, via pdfplumber. One `ExtractedPage` per PDF page.
2
+
3
+ This extractor reads the text the PDF already carries; it does not look at the
4
+ pixels. A scan has no text layer, so it produces nothing to anchor — that is an
5
+ error naming the `[vlm]` extra rather than an empty registry entry, because a
6
+ document that silently ingests as blank is worse than one that refuses.
7
+
8
+ **Paragraph reconstruction.** `page.extract_text()` joins every line with a
9
+ single `\\n` and never emits a blank one, because a PDF has no paragraphs — it
10
+ has glyphs at coordinates. The chunker's first rule splits on blank lines, so
11
+ without this step that rule is dead on every PDF and sub-page anchors only ever
12
+ appear via the long-segment split. The paragraph break is still *in* the file,
13
+ just expressed as vertical space, so this extractor reads it back off the
14
+ geometry: lines in document order, the page's median line pitch as the body
15
+ leading, and a blank line inserted wherever the pitch jumps past
16
+ `PARAGRAPH_GAP_RATIO` times that median.
17
+
18
+ The line texts themselves are pdfplumber's own — `"\\n".join(extract_text_lines())`
19
+ is character-for-character `extract_text()` — so this only ever *adds* blank
20
+ lines. NOTE: it is a heuristic over a format that does not carry the answer.
21
+ A layout it reads wrong shifts chunk boundaries; it does so deterministically,
22
+ which is the property anchors actually depend on.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import statistics
28
+ from pathlib import Path
29
+ from typing import Any, Iterator
30
+
31
+ import pdfplumber
32
+
33
+ from .base import ExtractedPage, Extractor, ExtractionError, register
34
+
35
+ __all__ = ["PARAGRAPH_GAP_RATIO", "PdfTextExtractor", "EXTRACTOR", "page_text"]
36
+
37
+ PARAGRAPH_GAP_RATIO = 1.5
38
+ """A line starting this much further down than the median pitch opens a paragraph.
39
+
40
+ Tuned on the demo corpus, whose gaps fall in three tight clusters: 14.0pt
41
+ between body lines, 24.0pt between paragraphs (1.71x), and 31.1pt below a
42
+ heading (2.22x). 1.5 sits in the empty band between the first two clusters,
43
+ which is where typographic convention puts it too — space-after under half the
44
+ leading reads as one block, over half reads as a break.
45
+ """
46
+
47
+ _NO_TEXT_LAYER = (
48
+ "{path} has no text layer on any page — it is probably a scan. "
49
+ "Install the VLM extractor and pass it explicitly: "
50
+ "`pip install 'backdraft[vlm]'` then `backdraft ingest {path} --extractor vlm`."
51
+ )
52
+
53
+
54
+ class PdfTextExtractor:
55
+ """pdfplumber text-layer extraction. Deterministic for a given pdfplumber."""
56
+
57
+ name = "pdf-text"
58
+ version = "1"
59
+ deterministic = True
60
+
61
+ def can_handle(self, path: Path, media_type: str) -> bool:
62
+ return media_type == "pdf"
63
+
64
+ def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]:
65
+ """Yield one page per PDF page, in file order.
66
+
67
+ Individual blank pages are yielded as empty pages — they are part of the
68
+ document and their numbers must not shift. Only a PDF with no text
69
+ anywhere raises.
70
+ """
71
+ yield from self._pages(path)
72
+
73
+ def _pages(self, path: Path) -> Iterator[ExtractedPage]:
74
+ found_text = False
75
+ try:
76
+ document = pdfplumber.open(path)
77
+ except Exception as error: # noqa: BLE001 - pdfminer raises broadly
78
+ raise ExtractionError(f"could not open {path}: {error}") from error
79
+ with document:
80
+ for number, page in enumerate(document.pages, start=1):
81
+ text = page_text(page)
82
+ if text.strip():
83
+ found_text = True
84
+ yield ExtractedPage(number=number, kind="page", text=text)
85
+ if not found_text:
86
+ raise ExtractionError(_NO_TEXT_LAYER.format(path=path))
87
+
88
+
89
+ def page_text(page: Any) -> str:
90
+ """One page's text, with paragraph breaks restored from its layout.
91
+
92
+ Falls back to `extract_text()` whenever the geometry cannot answer: no
93
+ lines, a single line, or a degenerate median pitch. The fallback is the
94
+ old behaviour exactly, so a PDF this cannot read is no worse off.
95
+ """
96
+ try:
97
+ lines = page.extract_text_lines()
98
+ except Exception: # noqa: BLE001 - pdfminer raises broadly; never lose the page
99
+ lines = []
100
+ if len(lines) < 2:
101
+ return page.extract_text() or ""
102
+ return _join(lines)
103
+
104
+
105
+ def _join(lines: list[dict[str, Any]]) -> str:
106
+ """Line texts joined, blank-line separated wherever the pitch jumps."""
107
+ gaps = [
108
+ float(later["top"]) - float(earlier["top"])
109
+ for earlier, later in zip(lines, lines[1:], strict=False)
110
+ ]
111
+ pitch = statistics.median(gaps)
112
+ if pitch <= 0:
113
+ # Overlapping or unordered lines: no baseline to compare against.
114
+ return "\n".join(line["text"] for line in lines)
115
+ threshold = pitch * PARAGRAPH_GAP_RATIO
116
+ out = [lines[0]["text"]]
117
+ for gap, line in zip(gaps, lines[1:], strict=True):
118
+ out.append("\n\n" if gap > threshold else "\n")
119
+ out.append(line["text"])
120
+ return "".join(out)
121
+
122
+
123
+ EXTRACTOR: Extractor = PdfTextExtractor()
124
+ register(EXTRACTOR)
@@ -0,0 +1,44 @@
1
+ """Markdown and plain text: the whole file is one page.
2
+
3
+ A text file has no native pagination, so it gets page 1 and the chunker supplies
4
+ the sub-page structure — `bd:notes:p1.c3:a7f3` addresses the third paragraph
5
+ group. NOTE: the spec leaves heading-path and line-range locator forms open; page
6
+ 1 plus chunks needs no new locator form, so v0 uses it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Iterator
13
+
14
+ from .base import ExtractedPage, Extractor, register
15
+
16
+ __all__ = ["TextExtractor", "EXTRACTOR"]
17
+
18
+
19
+ class TextExtractor:
20
+ """md/txt passthrough. Deterministic: it is a decode."""
21
+
22
+ name = "text"
23
+ version = "1"
24
+ deterministic = True
25
+
26
+ def can_handle(self, path: Path, media_type: str) -> bool:
27
+ return media_type == "text"
28
+
29
+ def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]:
30
+ """The file's text as page 1.
31
+
32
+ NOTE: undecodable bytes are replaced rather than raising. A snapshot with
33
+ a replacement character still anchors; a failed ingest anchors nothing.
34
+ Universal newlines mean CRLF and LF files snapshot identically.
35
+ """
36
+ yield ExtractedPage(
37
+ number=1,
38
+ kind="page",
39
+ text=path.read_text(encoding="utf-8", errors="replace"),
40
+ )
41
+
42
+
43
+ EXTRACTOR: Extractor = TextExtractor()
44
+ register(EXTRACTOR)
@@ -0,0 +1,175 @@
1
+ """The `[vlm]` extra: pdf -> page images -> a vision model, one page at a time.
2
+
3
+ The zerox-minimal pattern, kept thin: render each page, ask an OpenAI-compatible
4
+ vision model for markdown, use the answer as the page. This is the recommended
5
+ path for real PDFs — glossy layouts, info boxes, scans — because the snapshot is
6
+ the receipt, and the model's clean reading of the page beats scrambled
7
+ text-layer order.
8
+
9
+ The client is always the `openai` SDK; a router is injected through `base_url`.
10
+ The default provider is OpenRouter running Gemini 3.1 Flash Lite (what the
11
+ HighRoad pipeline uses in production); a bare `OPENAI_API_KEY` routes direct to
12
+ OpenAI instead. See `client_settings`.
13
+
14
+ `deterministic = False`, honestly: the same PDF ingested twice yields different
15
+ text, so the registry never short-circuits a re-ingest and every anchor whose
16
+ snippet moved gets a new token. That is the correct, visible outcome — a model's
17
+ transcription is not a snapshot anyone can reproduce.
18
+
19
+ `--extractor auto` prefers this extractor for PDFs when it is ready (installed,
20
+ key configured); otherwise `auto` falls back to the text layer and the CLI says
21
+ so. Importing this module requires the extra (`pip install 'backdraft[vlm]'`)
22
+ and `pdf2image` requires poppler on the PATH.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import io
29
+ import sys
30
+ import tempfile
31
+ from pathlib import Path
32
+ from typing import Iterator
33
+
34
+ from openai import OpenAI
35
+ from pdf2image import convert_from_path
36
+
37
+ from .base import ExtractedPage, Extractor, ExtractionError, PageImage, register
38
+ from .vlm_settings import ( # noqa: F401 (re-exported)
39
+ DEFAULT_MODEL,
40
+ MAX_IMAGE_HEIGHT,
41
+ OPENROUTER_BASE_URL,
42
+ SNAPSHOT_QUALITY,
43
+ client_settings,
44
+ concurrency,
45
+ retries,
46
+ run_ordered,
47
+ timeout_seconds,
48
+ with_retries,
49
+ )
50
+
51
+ __all__ = [
52
+ "DEFAULT_MODEL",
53
+ "OPENROUTER_BASE_URL",
54
+ "SYSTEM_PROMPT",
55
+ "VlmExtractor",
56
+ "EXTRACTOR",
57
+ "client_settings",
58
+ ]
59
+
60
+ DEFAULT_DPI = 200
61
+
62
+ SYSTEM_PROMPT = (
63
+ "Convert this document page to markdown. Follow these rules strictly:\n\n"
64
+ "VERBATIM TEXT: Write all visible text exactly as shown — headings, labels, "
65
+ "captions, data values.\n\n"
66
+ "VISUAL ELEMENTS: Use [IMAGE: description], [MAP: description] or "
67
+ "[CHART: description] brackets. Describe what the visual communicates.\n\n"
68
+ "TABLES: Use markdown table format with all data preserved.\n\n"
69
+ "STRUCTURE: Use markdown headings (#, ##) to reflect page hierarchy.\n\n"
70
+ "DO NOT: add commentary, preambles like 'Here is...', or explanatory text "
71
+ "outside brackets. Never invent URLs.\n\n"
72
+ "Output only the converted content."
73
+ )
74
+
75
+
76
+ class VlmExtractor:
77
+ """pdf -> page images -> vision model. Non-deterministic by construction."""
78
+
79
+ name = "vlm"
80
+ version = "1"
81
+ deterministic = False
82
+
83
+ def can_handle(self, path: Path, media_type: str) -> bool:
84
+ return media_type == "pdf"
85
+
86
+ def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]:
87
+ """Yield one page per PDF page, transcribed by the model.
88
+
89
+ Config keys (all optional): `model`, `base_url`, `api_key`, `dpi`.
90
+ Provider resolution is `client_settings`; OpenRouter is first-class.
91
+ """
92
+ model, api_key, base_url = client_settings(config)
93
+ client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout_seconds(config))
94
+ dpi = int(config.get("dpi") or DEFAULT_DPI)
95
+ attempts = retries(config)
96
+
97
+ with tempfile.TemporaryDirectory() as workdir:
98
+ try:
99
+ images = convert_from_path(str(path), dpi=dpi, fmt="png")
100
+ except Exception as error: # noqa: BLE001 - pdf2image raises broadly
101
+ raise ExtractionError(f"could not render {path} to images: {error}") from error
102
+ image_paths: list[Path] = []
103
+ snapshots: list[PageImage] = []
104
+ for number, image in enumerate(images, start=1):
105
+ if image.height > MAX_IMAGE_HEIGHT:
106
+ scale = MAX_IMAGE_HEIGHT / image.height
107
+ image = image.resize(
108
+ (max(1, round(image.width * scale)), MAX_IMAGE_HEIGHT)
109
+ )
110
+ image_path = Path(workdir) / f"page_{number:04d}.png"
111
+ image.save(image_path, format="PNG")
112
+ image_paths.append(image_path)
113
+ buffer = io.BytesIO()
114
+ image.save(buffer, format="WEBP", quality=SNAPSHOT_QUALITY)
115
+ snapshots.append(PageImage(
116
+ data=buffer.getvalue(), format="webp",
117
+ width=image.width, height=image.height,
118
+ ))
119
+
120
+ texts = run_ordered(
121
+ image_paths,
122
+ lambda image_path: with_retries(
123
+ lambda: _transcribe(client, model, image_path), attempts=attempts
124
+ ),
125
+ workers=concurrency(config),
126
+ progress=_progress(path.name, len(image_paths)),
127
+ )
128
+ for number, text in enumerate(texts, start=1):
129
+ yield ExtractedPage(
130
+ number=number, kind="page", text=text,
131
+ image=snapshots[number - 1],
132
+ )
133
+
134
+
135
+ def _progress(name: str, total: int):
136
+ """Progress to stderr: a live counter on a tty, one line per page otherwise."""
137
+ tty = sys.stderr.isatty()
138
+
139
+ def report(done: int, _total: int) -> None:
140
+ if tty:
141
+ end = "\n" if done == total else "\r"
142
+ print(f"vlm: {name} {done}/{total} pages", file=sys.stderr, end=end, flush=True)
143
+ else:
144
+ print(f"vlm: {name} page {done}/{total}", file=sys.stderr, flush=True)
145
+
146
+ return report
147
+
148
+
149
+ def _transcribe(client: OpenAI, model: str, image_path: Path) -> str:
150
+ """One page image -> markdown. Raises `ExtractionError` on any API failure."""
151
+ encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
152
+ try:
153
+ response = client.chat.completions.create(
154
+ model=model,
155
+ messages=[
156
+ {"role": "system", "content": SYSTEM_PROMPT},
157
+ {
158
+ "role": "user",
159
+ "content": [
160
+ {"type": "text", "text": "Extract text from this page."},
161
+ {
162
+ "type": "image_url",
163
+ "image_url": {"url": f"data:image/png;base64,{encoded}"},
164
+ },
165
+ ],
166
+ },
167
+ ],
168
+ )
169
+ except Exception as error: # noqa: BLE001 - client raises broadly
170
+ raise ExtractionError(f"vlm call failed for {image_path.name}: {error}") from error
171
+ return response.choices[0].message.content or ""
172
+
173
+
174
+ EXTRACTOR: Extractor = VlmExtractor()
175
+ register(EXTRACTOR)