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,197 @@
|
|
|
1
|
+
"""Provider resolution for the VLM extractor — importable without the extra.
|
|
2
|
+
|
|
3
|
+
The client is always the `openai` SDK; a router is injected through `base_url`,
|
|
4
|
+
never through a second SDK. This module is stdlib-only so `client_settings` can
|
|
5
|
+
be imported (and tested) without `backdraft[vlm]` installed; `vlm.py` re-exports
|
|
6
|
+
it next to the extractor that uses it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..credentials import setting
|
|
12
|
+
from .base import ExtractionError
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_CONCURRENCY",
|
|
16
|
+
"DEFAULT_RETRIES",
|
|
17
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
18
|
+
"MAX_IMAGE_HEIGHT",
|
|
19
|
+
"DEFAULT_MODEL",
|
|
20
|
+
"OPENROUTER_BASE_URL",
|
|
21
|
+
"client_settings",
|
|
22
|
+
"concurrency",
|
|
23
|
+
"is_retryable",
|
|
24
|
+
"retries",
|
|
25
|
+
"run_ordered",
|
|
26
|
+
"timeout_seconds",
|
|
27
|
+
"with_retries",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
DEFAULT_MODEL = "google/gemini-3.1-flash-lite-preview"
|
|
31
|
+
"""The default: Gemini 3.1 Flash Lite through OpenRouter — what the HighRoad
|
|
32
|
+
pipeline runs in production. Overridden with `--config model=...` or
|
|
33
|
+
`BACKDRAFT_VLM_MODEL`."""
|
|
34
|
+
|
|
35
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def client_settings(config: dict) -> tuple[str, str, str | None]:
|
|
39
|
+
"""Resolve `(model, api_key, base_url)` from backdraft-scoped settings only.
|
|
40
|
+
|
|
41
|
+
The client is always the `openai` SDK; a router is injected through
|
|
42
|
+
`base_url`, never through a second SDK. There is one default provider —
|
|
43
|
+
OpenRouter running Gemini 3.1 Flash Lite — and one rule for credentials:
|
|
44
|
+
**ambient provider variables are never read.** `OPENAI_API_KEY` and
|
|
45
|
+
`OPENROUTER_API_KEY` exported for other tools do not exist as far as this
|
|
46
|
+
function is concerned; presence of a generic key is not consent to send
|
|
47
|
+
documents to its provider. See `backdraft.credentials`.
|
|
48
|
+
|
|
49
|
+
* `api_key`: `--config api_key=` → `BACKDRAFT_VLM_API_KEY` (env or
|
|
50
|
+
`.backdraft/env`). Required.
|
|
51
|
+
* `base_url`: `--config base_url=` → `BACKDRAFT_VLM_BASE_URL` → OpenRouter.
|
|
52
|
+
* `model`: `--config model=` → `BACKDRAFT_VLM_MODEL` → Gemini 3.1 Flash
|
|
53
|
+
Lite. Direct OpenAI (or any OpenAI-compatible endpoint) is explicit:
|
|
54
|
+
set the base_url and the model together.
|
|
55
|
+
"""
|
|
56
|
+
api_key = setting("BACKDRAFT_VLM_API_KEY", config, config_key="api_key")
|
|
57
|
+
if not api_key:
|
|
58
|
+
raise ExtractionError(
|
|
59
|
+
"the vlm extractor needs a backdraft-scoped API key: set "
|
|
60
|
+
"BACKDRAFT_VLM_API_KEY (env or .backdraft/env), or pass "
|
|
61
|
+
"--config api_key=... — ambient OPENAI_API_KEY/OPENROUTER_API_KEY "
|
|
62
|
+
"are deliberately not read"
|
|
63
|
+
)
|
|
64
|
+
base_url = (
|
|
65
|
+
setting("BACKDRAFT_VLM_BASE_URL", config, config_key="base_url")
|
|
66
|
+
or OPENROUTER_BASE_URL
|
|
67
|
+
)
|
|
68
|
+
model = setting("BACKDRAFT_VLM_MODEL", config, config_key="model") or DEFAULT_MODEL
|
|
69
|
+
return str(model), str(api_key), str(base_url)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
DEFAULT_CONCURRENCY = 15
|
|
73
|
+
"""Concurrent page transcriptions — minimal_zerox's production value."""
|
|
74
|
+
|
|
75
|
+
DEFAULT_TIMEOUT_SECONDS = 120
|
|
76
|
+
"""Per-page request timeout, as tested in production."""
|
|
77
|
+
|
|
78
|
+
DEFAULT_RETRIES = 4
|
|
79
|
+
"""Attempts per page before the error propagates."""
|
|
80
|
+
|
|
81
|
+
MAX_IMAGE_HEIGHT = 1056
|
|
82
|
+
"""Rendered page images are downscaled to this height — token-cost control,
|
|
83
|
+
ported from minimal_zerox (dpi buys legibility, height caps what a page costs)."""
|
|
84
|
+
|
|
85
|
+
SNAPSHOT_QUALITY = 85
|
|
86
|
+
"""WebP quality for stored page snapshots (embed-ses generate-preview's
|
|
87
|
+
production setting). Lives here rather than vlm.py so `snapshot-pages` can
|
|
88
|
+
import it without pulling in the openai client."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _int_setting(name: str, config: dict, config_key: str, default: int) -> int:
|
|
92
|
+
raw = setting(name, config, config_key=config_key)
|
|
93
|
+
try:
|
|
94
|
+
return max(1, int(raw)) if raw else default
|
|
95
|
+
except ValueError:
|
|
96
|
+
return default
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def concurrency(config: dict) -> int:
|
|
100
|
+
"""`--config concurrency=` → `BACKDRAFT_VLM_CONCURRENCY` → 15."""
|
|
101
|
+
return _int_setting("BACKDRAFT_VLM_CONCURRENCY", config, "concurrency", DEFAULT_CONCURRENCY)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def timeout_seconds(config: dict) -> int:
|
|
105
|
+
"""`--config timeout=` → `BACKDRAFT_VLM_TIMEOUT` → 120."""
|
|
106
|
+
return _int_setting("BACKDRAFT_VLM_TIMEOUT", config, "timeout", DEFAULT_TIMEOUT_SECONDS)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def retries(config: dict) -> int:
|
|
110
|
+
"""`--config retries=` → `BACKDRAFT_VLM_RETRIES` → 4."""
|
|
111
|
+
return _int_setting("BACKDRAFT_VLM_RETRIES", config, "retries", DEFAULT_RETRIES)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_ordered(items, fn, *, workers, progress=None):
|
|
115
|
+
"""Map `fn` over `items` concurrently, yielding results in input order.
|
|
116
|
+
|
|
117
|
+
`progress(done, total)` is called after each completion, in completion
|
|
118
|
+
order — the caller renders it however it likes. Exceptions propagate from
|
|
119
|
+
the yield of the item that raised, so ordering of failures is deterministic
|
|
120
|
+
too.
|
|
121
|
+
"""
|
|
122
|
+
import threading
|
|
123
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
124
|
+
|
|
125
|
+
items = list(items)
|
|
126
|
+
total = len(items)
|
|
127
|
+
done = 0
|
|
128
|
+
lock = threading.Lock()
|
|
129
|
+
|
|
130
|
+
def wrapped(item):
|
|
131
|
+
nonlocal done
|
|
132
|
+
try:
|
|
133
|
+
return fn(item)
|
|
134
|
+
finally:
|
|
135
|
+
with lock:
|
|
136
|
+
done += 1
|
|
137
|
+
if progress is not None:
|
|
138
|
+
progress(done, total)
|
|
139
|
+
|
|
140
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
141
|
+
futures = [pool.submit(wrapped, item) for item in items]
|
|
142
|
+
for future in futures:
|
|
143
|
+
yield future.result()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_TRANSIENT_MARKERS = (
|
|
147
|
+
"rate limit",
|
|
148
|
+
"too many requests",
|
|
149
|
+
"overloaded",
|
|
150
|
+
"overload",
|
|
151
|
+
"temporarily unavailable",
|
|
152
|
+
"timed out",
|
|
153
|
+
"timeout",
|
|
154
|
+
"connection reset",
|
|
155
|
+
"connection aborted",
|
|
156
|
+
"gateway",
|
|
157
|
+
"upstream",
|
|
158
|
+
"server disconnected",
|
|
159
|
+
)
|
|
160
|
+
"""Message fragments that mark a transient upstream failure — minimal_zerox's
|
|
161
|
+
production-tested classification, ported verbatim."""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def is_retryable(error: Exception) -> bool:
|
|
165
|
+
"""Whether a page transcription failure is worth another attempt.
|
|
166
|
+
|
|
167
|
+
Retryable: timeouts, HTTP 429/502/503/504 (via a `status_code` attribute,
|
|
168
|
+
the shape the openai SDK raises), transient upstream messages, and
|
|
169
|
+
connection-level failures. Everything else — auth, bad request, content —
|
|
170
|
+
fails fast; retrying those only spends money on the same answer.
|
|
171
|
+
"""
|
|
172
|
+
if isinstance(error, TimeoutError):
|
|
173
|
+
return True
|
|
174
|
+
if getattr(error, "status_code", None) in {429, 502, 503, 504}:
|
|
175
|
+
return True
|
|
176
|
+
message = str(error).lower()
|
|
177
|
+
if any(marker in message for marker in _TRANSIENT_MARKERS):
|
|
178
|
+
return True
|
|
179
|
+
return isinstance(error, (ConnectionError, OSError))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def with_retries(fn, *, attempts, base_delay=1.0, sleep=None, jitter=None):
|
|
183
|
+
"""Run `fn` with minimal_zerox's retry shape: classify, exponential backoff
|
|
184
|
+
plus jitter, raise the last error once attempts are spent or on the first
|
|
185
|
+
non-retryable one. `sleep`/`jitter` are injectable for tests."""
|
|
186
|
+
import random
|
|
187
|
+
import time
|
|
188
|
+
|
|
189
|
+
sleep = sleep or time.sleep
|
|
190
|
+
jitter = jitter or (lambda high: random.uniform(0, high))
|
|
191
|
+
for attempt in range(1, max(1, attempts) + 1):
|
|
192
|
+
try:
|
|
193
|
+
return fn()
|
|
194
|
+
except Exception as error: # noqa: BLE001 - classification decides
|
|
195
|
+
if not is_retryable(error) or attempt >= attempts:
|
|
196
|
+
raise
|
|
197
|
+
sleep(base_delay * (2 ** (attempt - 1)) + jitter(base_delay))
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Workbooks: one page per sheet, rendered as a markdown table with in-band refs.
|
|
2
|
+
|
|
3
|
+
This is the representation the HighRoad app proved — the only one whose sub-page
|
|
4
|
+
attribution worked, because the coordinates sit *in the text the model reads*:
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
## Sheet: rent-roll - Values View with cell references
|
|
8
|
+
|
|
9
|
+
| Row | A | B |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| 1 | [A1] Unit | [B1] Rent |
|
|
12
|
+
| 2 | [A2] 101 | [B2] 2400 |
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
A model that copies `2400` out of that table has already seen `[B2]`, so the
|
|
16
|
+
registry can compose `bd:model:rent-roll!B2:9e2f` for it. Nothing about the
|
|
17
|
+
location travels in a side channel.
|
|
18
|
+
|
|
19
|
+
The app's hardening is kept because every bit of it was earned on real files:
|
|
20
|
+
hard row/column caps, an inflated-dimension placeholder page (Excel formats empty
|
|
21
|
+
cells and reports a million rows), trailing empty row/column trimming, and cell
|
|
22
|
+
truncation. NOTE: the caps mean a snapshot can be a *partial* view of a sheet;
|
|
23
|
+
the page title says so, which is the honest failure the spec's "failures are
|
|
24
|
+
data" principle asks for.
|
|
25
|
+
|
|
26
|
+
Reader choice: openpyxl alone. python-calamine tolerates more malformed files,
|
|
27
|
+
but openpyxl is already a dependency (column-letter arithmetic, fixtures) and one
|
|
28
|
+
reader means one representation to keep golden. NOTE: if malformed workbooks show
|
|
29
|
+
up in practice, calamine belongs behind an optional fallback, not a second
|
|
30
|
+
default.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from decimal import Decimal
|
|
36
|
+
from math import isfinite
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Iterator
|
|
39
|
+
|
|
40
|
+
from openpyxl import load_workbook
|
|
41
|
+
from openpyxl.utils import get_column_letter
|
|
42
|
+
|
|
43
|
+
from ..kernel.model import CellValue
|
|
44
|
+
from .base import ExtractedPage, Extractor, ExtractionError, register
|
|
45
|
+
|
|
46
|
+
__all__ = ["MAX_COLS", "MAX_ROWS", "XlsxExtractor", "EXTRACTOR"]
|
|
47
|
+
|
|
48
|
+
MAX_ROWS = 2_000
|
|
49
|
+
"""Hard cap on rows scanned per sheet."""
|
|
50
|
+
|
|
51
|
+
MAX_COLS = 200
|
|
52
|
+
"""Hard cap on columns scanned per sheet."""
|
|
53
|
+
|
|
54
|
+
MAX_CELL_CHARS = 150
|
|
55
|
+
"""A cell longer than this is truncated with an ellipsis."""
|
|
56
|
+
|
|
57
|
+
_MEDIA_TYPES = frozenset({"xlsx"})
|
|
58
|
+
_SUFFIXES = frozenset({".xlsx", ".xlsm", ".xltx", ".xltm"})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class XlsxExtractor:
|
|
62
|
+
"""openpyxl workbook reader. Deterministic: values in, markdown out."""
|
|
63
|
+
|
|
64
|
+
name = "xlsx"
|
|
65
|
+
version = "1"
|
|
66
|
+
deterministic = True
|
|
67
|
+
|
|
68
|
+
def can_handle(self, path: Path, media_type: str) -> bool:
|
|
69
|
+
return media_type in _MEDIA_TYPES or path.suffix.lower() in _SUFFIXES
|
|
70
|
+
|
|
71
|
+
def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]:
|
|
72
|
+
"""Yield one page per sheet, in workbook order.
|
|
73
|
+
|
|
74
|
+
Sheet names are yielded as the workbook spells them; the registry
|
|
75
|
+
sanitizes them to the sheetref charset when it mints tokens.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
workbook = load_workbook(path, data_only=True, read_only=True)
|
|
79
|
+
except Exception as error: # noqa: BLE001 - openpyxl raises broadly
|
|
80
|
+
raise ExtractionError(f"could not open {path}: {error}") from error
|
|
81
|
+
try:
|
|
82
|
+
for number, name in enumerate(workbook.sheetnames, start=1):
|
|
83
|
+
yield self._sheet(workbook[name], number, name)
|
|
84
|
+
finally:
|
|
85
|
+
workbook.close()
|
|
86
|
+
|
|
87
|
+
def _sheet(self, worksheet: Any, number: int, name: str) -> ExtractedPage:
|
|
88
|
+
reported_rows = worksheet.max_row or 0
|
|
89
|
+
reported_cols = worksheet.max_column or 0
|
|
90
|
+
if reported_rows > MAX_ROWS or reported_cols > MAX_COLS:
|
|
91
|
+
return ExtractedPage(
|
|
92
|
+
number=number,
|
|
93
|
+
kind="sheet",
|
|
94
|
+
name=name,
|
|
95
|
+
text=_placeholder(name, reported_rows, reported_cols),
|
|
96
|
+
cells=[],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
grid = _read_grid(worksheet)
|
|
100
|
+
rows, cols = _bounds(grid)
|
|
101
|
+
if not rows or not cols:
|
|
102
|
+
return ExtractedPage(
|
|
103
|
+
number=number,
|
|
104
|
+
kind="sheet",
|
|
105
|
+
name=name,
|
|
106
|
+
text=_title(name, rows, cols, reported_rows, reported_cols),
|
|
107
|
+
cells=[],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
table, cells = _render(grid, rows, cols)
|
|
111
|
+
title = _title(name, rows, cols, reported_rows, reported_cols)
|
|
112
|
+
return ExtractedPage(
|
|
113
|
+
number=number,
|
|
114
|
+
kind="sheet",
|
|
115
|
+
name=name,
|
|
116
|
+
text=f"{title}\n\n{table}",
|
|
117
|
+
cells=cells,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _read_grid(worksheet: Any) -> list[list[Any]]:
|
|
122
|
+
"""The sheet's values, capped. Read once: read-only sheets stream."""
|
|
123
|
+
return [
|
|
124
|
+
list(row)
|
|
125
|
+
for row in worksheet.iter_rows(
|
|
126
|
+
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _has_data(value: Any) -> bool:
|
|
132
|
+
"""True if a cell holds data rather than formatting or emptiness."""
|
|
133
|
+
return value is not None and bool(str(value).strip())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _bounds(grid: list[list[Any]]) -> tuple[int, int]:
|
|
137
|
+
"""The (rows, cols) actually holding data — trailing empties trimmed off."""
|
|
138
|
+
rows = 0
|
|
139
|
+
cols = 0
|
|
140
|
+
for row_index, row in enumerate(grid, start=1):
|
|
141
|
+
for col_index, value in enumerate(row, start=1):
|
|
142
|
+
if _has_data(value):
|
|
143
|
+
rows = row_index
|
|
144
|
+
cols = max(cols, col_index)
|
|
145
|
+
return rows, cols
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _format_value(value: Any) -> str:
|
|
149
|
+
"""A cell as text: integers unadorned, floats at full precision, dates ISO-8601.
|
|
150
|
+
|
|
151
|
+
Rounding here would be a correctness bug, not a display choice. The page text
|
|
152
|
+
is the snapshot and the snapshot is the receipt: a cap rate stored as
|
|
153
|
+
`0.0575` and written down as `0.058` cannot be value-traced against the claim
|
|
154
|
+
"5.75%" that was read off it, and the cell anchor's snippet — the thing a
|
|
155
|
+
reader is shown as proof — would disagree with the workbook.
|
|
156
|
+
"""
|
|
157
|
+
if value is None:
|
|
158
|
+
return ""
|
|
159
|
+
if isinstance(value, bool):
|
|
160
|
+
# NOTE: before the int branch — bool is an int and `str(int(True))` is "1".
|
|
161
|
+
return str(value)
|
|
162
|
+
if isinstance(value, int):
|
|
163
|
+
return str(value)
|
|
164
|
+
if isinstance(value, float):
|
|
165
|
+
return _float_text(value)
|
|
166
|
+
if hasattr(value, "isoformat"):
|
|
167
|
+
return value.isoformat()
|
|
168
|
+
return str(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _float_text(value: float) -> str:
|
|
172
|
+
"""A float as the shortest text that reads back as the same float.
|
|
173
|
+
|
|
174
|
+
`repr` gives that shortest round-trip form, which is what keeps `0.0575` from
|
|
175
|
+
becoming `0.057499999999999996`. Routing it through `Decimal` only drops the
|
|
176
|
+
exponent, so a small rate reads as `0.000001` rather than `1e-06` — the form
|
|
177
|
+
it has in the workbook, and the form a claim would quote.
|
|
178
|
+
"""
|
|
179
|
+
if not isfinite(value):
|
|
180
|
+
return repr(value)
|
|
181
|
+
if value.is_integer():
|
|
182
|
+
return str(int(value))
|
|
183
|
+
return format(Decimal(repr(value)), "f")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _cell_text(value: Any) -> str:
|
|
187
|
+
"""A formatted cell, truncated, with markdown's `|` neutralized."""
|
|
188
|
+
text = _format_value(value).replace("\n", " ").replace("|", "\\|")
|
|
189
|
+
if len(text) > MAX_CELL_CHARS:
|
|
190
|
+
text = text[: MAX_CELL_CHARS - 3] + "..."
|
|
191
|
+
return text
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _render(
|
|
195
|
+
grid: list[list[Any]], rows: int, cols: int
|
|
196
|
+
) -> tuple[str, list[CellValue]]:
|
|
197
|
+
"""The markdown table and the cell values it carries.
|
|
198
|
+
|
|
199
|
+
Every non-empty cell gets a `[B10]` prefix in the table and a `CellValue`
|
|
200
|
+
whose `value` is the cell text *as it appears in the table* — the receipt has
|
|
201
|
+
to be verbatim from the snapshot, truncation and all.
|
|
202
|
+
"""
|
|
203
|
+
header = ["Row", *(get_column_letter(col) for col in range(1, cols + 1))]
|
|
204
|
+
lines = [
|
|
205
|
+
"| " + " | ".join(header) + " |",
|
|
206
|
+
"|" + "|".join(["---"] * len(header)) + "|",
|
|
207
|
+
]
|
|
208
|
+
cells: list[CellValue] = []
|
|
209
|
+
for row_index in range(1, rows + 1):
|
|
210
|
+
row = grid[row_index - 1] if row_index <= len(grid) else []
|
|
211
|
+
rendered = [str(row_index)]
|
|
212
|
+
for col_index in range(1, cols + 1):
|
|
213
|
+
value = row[col_index - 1] if col_index <= len(row) else None
|
|
214
|
+
text = _cell_text(value)
|
|
215
|
+
if text:
|
|
216
|
+
ref = f"{get_column_letter(col_index)}{row_index}"
|
|
217
|
+
cells.append(CellValue(ref=ref, value=text))
|
|
218
|
+
text = f"[{ref}] {text}"
|
|
219
|
+
rendered.append(text)
|
|
220
|
+
lines.append("| " + " | ".join(rendered) + " |")
|
|
221
|
+
return "\n".join(lines), cells
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _title(name: str, rows: int, cols: int, reported_rows: int, reported_cols: int) -> str:
|
|
225
|
+
"""The page's heading, which says when the view is partial."""
|
|
226
|
+
note = ""
|
|
227
|
+
if reported_rows > rows or reported_cols > cols:
|
|
228
|
+
note = f" (showing {rows}x{cols} of {reported_rows}x{reported_cols})"
|
|
229
|
+
return f"## Sheet: {name}{note} - Values View with cell references"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _placeholder(name: str, rows: int, cols: int) -> str:
|
|
233
|
+
"""The page a sheet gets when its reported dimensions are absurd."""
|
|
234
|
+
return (
|
|
235
|
+
f"## Sheet: {name}\n\n"
|
|
236
|
+
f"**Sheet could not be processed.**\n\n"
|
|
237
|
+
f"Reported dimensions ({rows:,} rows x {cols:,} columns) exceed processing "
|
|
238
|
+
f"limits ({MAX_ROWS:,} x {MAX_COLS:,}). This typically occurs when Excel "
|
|
239
|
+
f"applies formatting to empty cells, inflating the apparent sheet size."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
EXTRACTOR: Extractor = XlsxExtractor()
|
|
244
|
+
register(EXTRACTOR)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The gate: the only way source text reaches a writer's context.
|
|
2
|
+
|
|
3
|
+
`read` lists, tables-of-contents and pages; `search` queries the FTS index. Both
|
|
4
|
+
mint what they emit into the session ledger, which is what makes the design's
|
|
5
|
+
central claim true — the set of citable tokens is exactly the set the gate
|
|
6
|
+
emitted, and bind can tell a cited-what-you-saw token from a token the writer
|
|
7
|
+
never had.
|
|
8
|
+
|
|
9
|
+
`gate.cli` is mounted by the top-level CLI (SPEC Addendum B) and is imported
|
|
10
|
+
separately, so this package stays importable without typer.
|
|
11
|
+
|
|
12
|
+
Export style (shared by every package above the kernel): **names**, never
|
|
13
|
+
submodules, and only what a consumer outside the package imports.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .reader import (
|
|
19
|
+
GateError,
|
|
20
|
+
Selection,
|
|
21
|
+
read,
|
|
22
|
+
render_documents,
|
|
23
|
+
render_page_read,
|
|
24
|
+
render_toc,
|
|
25
|
+
select_pages,
|
|
26
|
+
)
|
|
27
|
+
from .searcher import render_search, search
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"GateError",
|
|
31
|
+
"Selection",
|
|
32
|
+
"read",
|
|
33
|
+
"render_documents",
|
|
34
|
+
"render_toc",
|
|
35
|
+
"render_page_read",
|
|
36
|
+
"select_pages",
|
|
37
|
+
"search",
|
|
38
|
+
"render_search",
|
|
39
|
+
]
|
backdraft/gate/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The gate's CLI surface: `read`, `search`, `session`.
|
|
2
|
+
|
|
3
|
+
Mounted by the top-level `cli.py` (SPEC Addendum B), which owns the typer app.
|
|
4
|
+
This module holds no logic of its own — it parses flags, calls `reader`/
|
|
5
|
+
`searcher`, prints, and lets `cli_context.guard` map a `GateError` to exit code 1
|
|
6
|
+
(usage/env error).
|
|
7
|
+
|
|
8
|
+
Discovery and session resolution come from `backdraft.cli_context`, imported at
|
|
9
|
+
module level: that module deliberately knows nothing about the sub-apps, so the
|
|
10
|
+
mount is not a cycle and there is no lazy lookup to keep in sync.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from typing import Annotated
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
|
|
20
|
+
from ..cli_context import SESSION_ENV, opened_registry, resolve_session
|
|
21
|
+
from .reader import cells as mint_cells
|
|
22
|
+
from .reader import read as read_pages
|
|
23
|
+
from .searcher import search as run_search
|
|
24
|
+
|
|
25
|
+
__all__ = ["app", "session_app"]
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(help="Read and search ingested documents through the gate.")
|
|
28
|
+
session_app = typer.Typer(help="Inspect or start a ledger session.")
|
|
29
|
+
app.add_typer(session_app, name="session")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _session_source(explicit: str | None) -> str:
|
|
33
|
+
"""Which rule supplied the session id — shown by `session show`."""
|
|
34
|
+
if explicit:
|
|
35
|
+
return "--session"
|
|
36
|
+
if os.environ.get(SESSION_ENV):
|
|
37
|
+
return SESSION_ENV
|
|
38
|
+
return "default"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# commands
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_SessionOption = Annotated[
|
|
46
|
+
str | None,
|
|
47
|
+
typer.Option("--session", "-s", help="Ledger session to mint into."),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def read(
|
|
53
|
+
slug: Annotated[str | None, typer.Argument(help="Document slug.")] = None,
|
|
54
|
+
selector: Annotated[
|
|
55
|
+
str | None, typer.Argument(help="Page (`p3`), range (`p3-5`), or sheet name.")
|
|
56
|
+
] = None,
|
|
57
|
+
session: _SessionOption = None,
|
|
58
|
+
offset: Annotated[
|
|
59
|
+
int, typer.Option("--offset", help="Units to skip (chars for pages, rows for sheets).")
|
|
60
|
+
] = 0,
|
|
61
|
+
limit: Annotated[
|
|
62
|
+
int | None, typer.Option("--limit", help="Units to show. Default: all of them.")
|
|
63
|
+
] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""List documents, show a document's contents, or read pages from one."""
|
|
66
|
+
# The list and the table of contents emit no tokens, so they mint nothing.
|
|
67
|
+
minting = slug is not None and selector is not None
|
|
68
|
+
session_id = resolve_session(session) if minting else None
|
|
69
|
+
with opened_registry() as registry:
|
|
70
|
+
typer.echo(
|
|
71
|
+
read_pages(
|
|
72
|
+
registry, slug, selector, session=session_id, offset=offset, limit=limit
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def cell(
|
|
79
|
+
slug: Annotated[str, typer.Argument(help="Document slug.")],
|
|
80
|
+
refs: Annotated[
|
|
81
|
+
list[str], typer.Argument(help="Cell locators, like rent-roll!B10. Repeatable.")
|
|
82
|
+
],
|
|
83
|
+
session: _SessionOption = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Mint tokens for specific cells: the token, then the verbatim value.
|
|
86
|
+
|
|
87
|
+
The direct path to citing a cell you can see in a windowed sheet read —
|
|
88
|
+
no need to search for the cell's own value.
|
|
89
|
+
"""
|
|
90
|
+
session_id = resolve_session(session)
|
|
91
|
+
with opened_registry() as registry:
|
|
92
|
+
typer.echo(mint_cells(registry, slug, refs, session=session_id))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def search(
|
|
97
|
+
query: Annotated[str, typer.Argument(help="FTS5 query.")],
|
|
98
|
+
in_: Annotated[
|
|
99
|
+
str | None, typer.Option("--in", help="Restrict to one document slug.")
|
|
100
|
+
] = None,
|
|
101
|
+
session: _SessionOption = None,
|
|
102
|
+
limit: Annotated[int, typer.Option("--limit", help="Maximum results.")] = 20,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Search every anchor's snippet. Results are citable without a page read."""
|
|
105
|
+
session_id = resolve_session(session)
|
|
106
|
+
with opened_registry() as registry:
|
|
107
|
+
typer.echo(run_search(registry, query, slug=in_, limit=limit, session=session_id))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@session_app.command("start")
|
|
111
|
+
def session_start(
|
|
112
|
+
id_: Annotated[
|
|
113
|
+
str | None, typer.Option("--id", help="Use this id instead of a generated one.")
|
|
114
|
+
] = None,
|
|
115
|
+
label: Annotated[str | None, typer.Option("--label", help="Human note.")] = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Start a ledger session and print the id to export."""
|
|
118
|
+
with opened_registry() as registry:
|
|
119
|
+
session_id = registry.ensure_session(id_, label)
|
|
120
|
+
typer.echo(f"session {session_id} started")
|
|
121
|
+
typer.echo(f"[Use it: export {SESSION_ENV}={session_id}]")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@session_app.command("show")
|
|
125
|
+
def session_show(session: _SessionOption = None) -> None:
|
|
126
|
+
"""Show which session reads and searches would mint into."""
|
|
127
|
+
resolved = resolve_session(session)
|
|
128
|
+
with opened_registry() as registry:
|
|
129
|
+
typer.echo(
|
|
130
|
+
f"session {registry.ensure_session(resolved)} (from {_session_source(session)})"
|
|
131
|
+
)
|