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.
- backdraft/__init__.py +13 -0
- backdraft/bind/__init__.py +37 -0
- backdraft/bind/binder.py +653 -0
- backdraft/bind/cli.py +133 -0
- backdraft/bind/evidence.py +232 -0
- backdraft/bind/verify/__init__.py +30 -0
- backdraft/bind/verify/base.py +70 -0
- backdraft/bind/verify/entail.py +156 -0
- backdraft/bind/verify/overlap.py +114 -0
- backdraft/bind/verify/recompute.py +45 -0
- backdraft/bind/verify/value_trace.py +352 -0
- backdraft/cli.py +341 -0
- backdraft/cli_context.py +152 -0
- backdraft/credentials.py +106 -0
- backdraft/extract/__init__.py +37 -0
- backdraft/extract/base.py +184 -0
- backdraft/extract/pdf_text.py +124 -0
- backdraft/extract/text.py +44 -0
- backdraft/extract/vlm.py +175 -0
- backdraft/extract/vlm_settings.py +197 -0
- backdraft/extract/xlsx.py +244 -0
- backdraft/gate/__init__.py +39 -0
- backdraft/gate/cli.py +131 -0
- backdraft/gate/reader.py +592 -0
- backdraft/gate/searcher.py +123 -0
- backdraft/kernel/__init__.py +17 -0
- backdraft/kernel/artifact.py +234 -0
- backdraft/kernel/chunking.py +162 -0
- backdraft/kernel/claims.py +142 -0
- backdraft/kernel/errors.py +35 -0
- backdraft/kernel/hashing.py +89 -0
- backdraft/kernel/model.py +336 -0
- backdraft/kernel/tokens.py +291 -0
- backdraft/registry/__init__.py +39 -0
- backdraft/registry/ledger.py +82 -0
- backdraft/registry/schema.sql +90 -0
- backdraft/registry/store.py +915 -0
- backdraft/render/__init__.py +31 -0
- backdraft/render/_text.py +69 -0
- backdraft/render/cli.py +107 -0
- backdraft/render/footnotes.py +172 -0
- backdraft/render/html.py +1211 -0
- backdraft/render/markdown.py +313 -0
- backdraft/render/placement.py +105 -0
- backdraft/render/sidecar.py +164 -0
- backdraft-0.2.0.dist-info/METADATA +253 -0
- backdraft-0.2.0.dist-info/RECORD +50 -0
- backdraft-0.2.0.dist-info/WHEEL +4 -0
- backdraft-0.2.0.dist-info/entry_points.txt +2 -0
- 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)
|
backdraft/extract/vlm.py
ADDED
|
@@ -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)
|