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
backdraft/gate/reader.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""The read side of the gate: document list, table of contents, page read.
|
|
2
|
+
|
|
3
|
+
The gate is the mechanism the whole design rests on: *the set of citable tokens
|
|
4
|
+
is exactly the set the gate emitted*. Two consequences shape every function here.
|
|
5
|
+
|
|
6
|
+
1. **Recompute nothing.** Tokens are read off `Registry.anchors_for_page`, never
|
|
7
|
+
re-derived from page text. A token printed here is a token that already
|
|
8
|
+
exists as a row, so it resolves later by construction.
|
|
9
|
+
2. **Emitting is minting.** Every anchor whose token (or whose in-band `[B10]`
|
|
10
|
+
reference) reaches the output is recorded in the session ledger before the
|
|
11
|
+
text is returned. Bind's `not_shown` status is what catches a token the writer
|
|
12
|
+
never saw; that status is only meaningful if this module never under-records.
|
|
13
|
+
|
|
14
|
+
The registry is consumed through the pinned surface in SPEC Addendum A and
|
|
15
|
+
nothing else. Nothing in this module writes to `registry/`.
|
|
16
|
+
|
|
17
|
+
Output is plain ASCII and line-oriented: it is read by an agent in a terminal, so
|
|
18
|
+
it is scannable, and it is a contract, so it is stable enough to diff.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from ..kernel.errors import BackdraftError
|
|
28
|
+
from ..kernel.hashing import normalize
|
|
29
|
+
from ..kernel.tokens import CellLocator, ChunkLocator
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Iterable, Sequence
|
|
33
|
+
|
|
34
|
+
from ..kernel.model import Anchor, Document, Page
|
|
35
|
+
from ..registry.store import Registry
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"GateError",
|
|
39
|
+
"cells",
|
|
40
|
+
"TOC_PREVIEW_CHARS",
|
|
41
|
+
"Selection",
|
|
42
|
+
"read",
|
|
43
|
+
"render_documents",
|
|
44
|
+
"render_toc",
|
|
45
|
+
"render_page_read",
|
|
46
|
+
"select_pages",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GateError(BackdraftError):
|
|
51
|
+
"""The gate cannot serve this read: unknown slug, unusable selector."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
TOC_PREVIEW_CHARS = 120
|
|
55
|
+
"""How much of a page's text stands in for a missing summary (SPEC § Gate)."""
|
|
56
|
+
|
|
57
|
+
_ELLIPSIS = "..."
|
|
58
|
+
_PAGE_SELECTOR = re.compile(r"p(?P<first>[1-9][0-9]*)(?:-(?P<last>[1-9][0-9]*))?")
|
|
59
|
+
_BARE_NUMBER = re.compile(r"[1-9][0-9]*")
|
|
60
|
+
_MARKDOWN_RULE = re.compile(r"[\s|:-]*-[\s|:-]*")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# selection
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Selection:
|
|
70
|
+
"""The pages one read targets, plus the selector text that targeted them.
|
|
71
|
+
|
|
72
|
+
`text` is echoed back verbatim in continuation hints, so the command the
|
|
73
|
+
reader is told to run next is the command it just ran.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
numbers: tuple[int, ...]
|
|
77
|
+
text: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def select_pages(pages: Sequence[Page], selector: str) -> Selection:
|
|
81
|
+
"""Resolve `p3`, `p3-5`, a sheet name, or a bare page number to page numbers.
|
|
82
|
+
|
|
83
|
+
Sheet names win over the numeric forms so that a sheet named `p3` or `3` is
|
|
84
|
+
still reachable by name. Matching is on `Page.name`, insensitive to case and
|
|
85
|
+
to the `-`/space/underscore difference between a sheet name and the sheetref
|
|
86
|
+
it was sanitized into at ingest.
|
|
87
|
+
|
|
88
|
+
Raises `GateError` if the selector names nothing.
|
|
89
|
+
"""
|
|
90
|
+
wanted = _fold(selector)
|
|
91
|
+
named = [page.number for page in pages if page.name and _fold(page.name) == wanted]
|
|
92
|
+
if named:
|
|
93
|
+
return Selection(numbers=tuple(sorted(named)), text=selector)
|
|
94
|
+
|
|
95
|
+
known = {page.number for page in pages}
|
|
96
|
+
if match := _PAGE_SELECTOR.fullmatch(selector):
|
|
97
|
+
first = int(match["first"])
|
|
98
|
+
last = int(match["last"]) if match["last"] else first
|
|
99
|
+
if last < first:
|
|
100
|
+
raise GateError(f"page range runs backwards: {selector!r}")
|
|
101
|
+
numbers = tuple(number for number in range(first, last + 1) if number in known)
|
|
102
|
+
if not numbers:
|
|
103
|
+
raise GateError(f"no such page: {selector!r}")
|
|
104
|
+
return Selection(numbers=numbers, text=selector)
|
|
105
|
+
|
|
106
|
+
if _BARE_NUMBER.fullmatch(selector) and int(selector) in known:
|
|
107
|
+
# NOTE: the spec shows `p3`; a bare `3` is accepted because it costs
|
|
108
|
+
# nothing and is what a reader types by accident.
|
|
109
|
+
return Selection(numbers=(int(selector),), text=f"p{selector}")
|
|
110
|
+
|
|
111
|
+
raise GateError(f"no page or sheet named {selector!r}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fold(name: str) -> str:
|
|
115
|
+
"""Compare sheet names the way a reader types them."""
|
|
116
|
+
return re.sub(r"[\s_-]+", "-", name.strip().lower())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# dispatch
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def read(
|
|
125
|
+
registry: Registry,
|
|
126
|
+
slug: str | None = None,
|
|
127
|
+
selector: str | None = None,
|
|
128
|
+
*,
|
|
129
|
+
session: str | None = None,
|
|
130
|
+
offset: int = 0,
|
|
131
|
+
limit: int | None = None,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""The unified `backdraft read` dispatch.
|
|
134
|
+
|
|
135
|
+
No arguments lists documents; a slug alone prints its table of contents;
|
|
136
|
+
a slug plus a selector prints token-marked content and mints it into
|
|
137
|
+
`session`. Listing and the table of contents emit no tokens, so they mint
|
|
138
|
+
nothing.
|
|
139
|
+
"""
|
|
140
|
+
if slug is None:
|
|
141
|
+
return render_documents(registry)
|
|
142
|
+
if selector is None:
|
|
143
|
+
return render_toc(registry, slug)
|
|
144
|
+
return render_page_read(
|
|
145
|
+
registry, slug, selector, session=session, offset=offset, limit=limit
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# document list
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def render_documents(registry: Registry) -> str:
|
|
155
|
+
"""One line per document: slug, filename, media type, page count.
|
|
156
|
+
|
|
157
|
+
Emits no tokens: a document is not evidence, its pages are.
|
|
158
|
+
"""
|
|
159
|
+
documents = registry.documents()
|
|
160
|
+
if not documents:
|
|
161
|
+
return _block(
|
|
162
|
+
["No documents.", "", "[Ingest one with: backdraft ingest <file>]"]
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
rows = []
|
|
166
|
+
for document in documents:
|
|
167
|
+
pages = registry.pages(document.slug)
|
|
168
|
+
# NOTE: page count is derived rather than read off Document, which
|
|
169
|
+
# Addendum A does not carry one on.
|
|
170
|
+
rows.append((document.slug, document.filename, document.media_type, pages))
|
|
171
|
+
|
|
172
|
+
slug_width = max(len(row[0]) for row in rows)
|
|
173
|
+
file_width = max(len(row[1]) for row in rows)
|
|
174
|
+
media_width = max(len(row[2]) for row in rows)
|
|
175
|
+
count_width = max(len(str(len(row[3]))) for row in rows)
|
|
176
|
+
|
|
177
|
+
lines = [_plural(len(documents), "document"), ""]
|
|
178
|
+
lines += [
|
|
179
|
+
" ".join(
|
|
180
|
+
(
|
|
181
|
+
slug.ljust(slug_width),
|
|
182
|
+
filename.ljust(file_width),
|
|
183
|
+
media.ljust(media_width),
|
|
184
|
+
f"{len(pages):>{count_width}} {_unit(pages)}",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
for slug, filename, media, pages in rows
|
|
188
|
+
]
|
|
189
|
+
lines += ["", "[Table of contents: backdraft read <slug>]"]
|
|
190
|
+
return _block(lines)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# table of contents
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def render_toc(registry: Registry, slug: str) -> str:
|
|
199
|
+
"""One line per page or sheet: number, name, summary or first 120 chars.
|
|
200
|
+
|
|
201
|
+
Emits no tokens: a preview is not a receipt, and a reader that cites from a
|
|
202
|
+
table of contents is citing text it was shown only in part.
|
|
203
|
+
"""
|
|
204
|
+
document = _require_document(registry, slug)
|
|
205
|
+
pages = registry.pages(slug)
|
|
206
|
+
if not pages:
|
|
207
|
+
return _block([_document_headline(document, pages), "", "(no pages)"])
|
|
208
|
+
|
|
209
|
+
labels = [f"p{page.number}" for page in pages]
|
|
210
|
+
label_width = max(len(label) for label in labels)
|
|
211
|
+
names = [page.name or "" for page in pages]
|
|
212
|
+
name_width = max(len(name) for name in names)
|
|
213
|
+
|
|
214
|
+
lines = [_document_headline(document, pages), ""]
|
|
215
|
+
for page, label, name in zip(pages, labels, names, strict=True):
|
|
216
|
+
cells = [label.ljust(label_width)]
|
|
217
|
+
if name_width:
|
|
218
|
+
cells.append(name.ljust(name_width))
|
|
219
|
+
cells.append(_preview(page))
|
|
220
|
+
lines.append(" ".join(cells).rstrip())
|
|
221
|
+
|
|
222
|
+
first = pages[0]
|
|
223
|
+
lines += ["", f"[Read one: backdraft read {slug} p{first.number}]"]
|
|
224
|
+
if len(pages) > 1 and first.kind == "page":
|
|
225
|
+
lines.append(
|
|
226
|
+
f"[Read a range: backdraft read {slug} p{first.number}-{pages[-1].number}]"
|
|
227
|
+
)
|
|
228
|
+
if first.name:
|
|
229
|
+
lines.append(f'[Read by name: backdraft read {slug} "{first.name}"]')
|
|
230
|
+
return _block(lines)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _preview(page: Page) -> str:
|
|
234
|
+
"""A page's summary, or its opening text collapsed onto one line."""
|
|
235
|
+
if page.summary:
|
|
236
|
+
return normalize(page.summary)
|
|
237
|
+
text = normalize(page.text)
|
|
238
|
+
if len(text) <= TOC_PREVIEW_CHARS:
|
|
239
|
+
return text
|
|
240
|
+
return text[:TOC_PREVIEW_CHARS] + _ELLIPSIS
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# page read
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def render_page_read(
|
|
249
|
+
registry: Registry,
|
|
250
|
+
slug: str,
|
|
251
|
+
selector: str,
|
|
252
|
+
*,
|
|
253
|
+
session: str | None = None,
|
|
254
|
+
offset: int = 0,
|
|
255
|
+
limit: int | None = None,
|
|
256
|
+
) -> str:
|
|
257
|
+
"""Token-marked content for the selected pages, minted into `session`.
|
|
258
|
+
|
|
259
|
+
PDF pages render one chunk per token: `[bd:slug:p3.c1:a7f3]` alone on a line
|
|
260
|
+
above the chunk's verbatim snippet, chunks separated by a blank line. Sheets
|
|
261
|
+
render `Page.text` unchanged — the `[B10]` references are already in band —
|
|
262
|
+
under a header line carrying the page token.
|
|
263
|
+
|
|
264
|
+
Windowing shares one budget across the whole request, counted in the unit
|
|
265
|
+
each page kind is native to: characters for `page`, rows for `sheet`.
|
|
266
|
+
`offset` skips that many units and `limit` caps how many are shown; a
|
|
267
|
+
continuation hint reports the window and the command that reads the next one.
|
|
268
|
+
NOTE: windows never cut a chunk or a row in half, so `offset` snaps outward
|
|
269
|
+
to the enclosing unit. A token is never printed above partial text.
|
|
270
|
+
|
|
271
|
+
Every anchor shown is recorded, including the cell anchors a sheet window
|
|
272
|
+
exposes by their in-band references but does not print tokens for.
|
|
273
|
+
"""
|
|
274
|
+
if offset < 0:
|
|
275
|
+
raise GateError(f"offset must not be negative: {offset!r}")
|
|
276
|
+
if limit is not None and limit < 0:
|
|
277
|
+
raise GateError(f"limit must not be negative: {limit!r}")
|
|
278
|
+
|
|
279
|
+
document = _require_document(registry, slug)
|
|
280
|
+
pages = registry.pages(slug)
|
|
281
|
+
selection = select_pages(pages, selector)
|
|
282
|
+
by_number = {page.number: page for page in pages}
|
|
283
|
+
total_pages = len(pages)
|
|
284
|
+
|
|
285
|
+
window = _Window(offset=offset, limit=limit)
|
|
286
|
+
blocks: list[str] = []
|
|
287
|
+
minted: list[int] = []
|
|
288
|
+
# NOTE: one unit name for the whole request, taken from the first page. An
|
|
289
|
+
# extraction is all pages or all sheets, so a mixed selection cannot arise.
|
|
290
|
+
unit = "rows" if by_number[selection.numbers[0]].kind == "sheet" else "chars"
|
|
291
|
+
for number in selection.numbers:
|
|
292
|
+
page = by_number[number]
|
|
293
|
+
anchors = registry.anchors_for_page(slug, number)
|
|
294
|
+
if page.kind == "sheet":
|
|
295
|
+
block, shown = _render_sheet(document, page, anchors, total_pages, window)
|
|
296
|
+
else:
|
|
297
|
+
block, shown = _render_page(document, page, anchors, total_pages, window)
|
|
298
|
+
if block:
|
|
299
|
+
blocks.append(block)
|
|
300
|
+
minted.extend(shown)
|
|
301
|
+
|
|
302
|
+
_mint(registry, session, minted)
|
|
303
|
+
|
|
304
|
+
lines = "\n\n".join(blocks).split("\n") if blocks else ["(nothing to show at this offset)"]
|
|
305
|
+
if (hint := window.hint(slug, selection.text, unit)) is not None:
|
|
306
|
+
lines += ["", hint]
|
|
307
|
+
return _block(lines)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(slots=True)
|
|
311
|
+
class _Window:
|
|
312
|
+
"""The shared offset/limit budget for one read, in page-native units.
|
|
313
|
+
|
|
314
|
+
`consumed` counts units skipped or shown so far; `total` counts every unit
|
|
315
|
+
the request covers, whether shown or not, so the hint can say how much is
|
|
316
|
+
left.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
offset: int
|
|
320
|
+
limit: int | None
|
|
321
|
+
consumed: int = 0
|
|
322
|
+
shown: int = 0
|
|
323
|
+
start: int | None = None
|
|
324
|
+
total: int = 0
|
|
325
|
+
full: bool = False
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def open(self) -> bool:
|
|
329
|
+
"""True while the window is neither still skipping nor already closed."""
|
|
330
|
+
return not self.full and self.consumed >= self.offset
|
|
331
|
+
|
|
332
|
+
def take(self, size: int) -> bool:
|
|
333
|
+
"""Offer one indivisible group of `size` units. True if it is shown.
|
|
334
|
+
|
|
335
|
+
A group is shown whole or not at all, so `offset` snaps outward to the
|
|
336
|
+
enclosing group and a group larger than `limit` is still shown when it is
|
|
337
|
+
the first one — a read never prints a token above partial text.
|
|
338
|
+
|
|
339
|
+
The first group that will not fit closes the window for good: a window is
|
|
340
|
+
one contiguous run, so `--offset` on the continuation hint resumes exactly
|
|
341
|
+
where this one stopped rather than at a gap.
|
|
342
|
+
"""
|
|
343
|
+
self.total += size
|
|
344
|
+
if not self.open:
|
|
345
|
+
self.consumed += size
|
|
346
|
+
return False
|
|
347
|
+
if self.limit is not None and self.shown and self.shown + size > self.limit:
|
|
348
|
+
self.full = True
|
|
349
|
+
self.consumed += size
|
|
350
|
+
return False
|
|
351
|
+
if self.start is None:
|
|
352
|
+
self.start = self.consumed
|
|
353
|
+
self.consumed += size
|
|
354
|
+
self.shown += size
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
def hint(self, slug: str, selector: str, unit: str) -> str | None:
|
|
358
|
+
"""The continuation line, or None when the window covered everything."""
|
|
359
|
+
start = self.start if self.start is not None else min(self.offset, self.total)
|
|
360
|
+
end = start + self.shown
|
|
361
|
+
if start == 0 and end == self.total:
|
|
362
|
+
return None
|
|
363
|
+
line = f"[Showing {start}-{end} of {self.total} {unit}."
|
|
364
|
+
if end < self.total:
|
|
365
|
+
line += f" Continue with: backdraft read {slug} {selector} --offset {end}"
|
|
366
|
+
return line + "]"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _render_page(
|
|
370
|
+
document: Document,
|
|
371
|
+
page: Page,
|
|
372
|
+
anchors: Sequence[Anchor],
|
|
373
|
+
total_pages: int,
|
|
374
|
+
window: _Window,
|
|
375
|
+
) -> tuple[str, list[int]]:
|
|
376
|
+
"""A PDF/text page as its chunks, each under its own token."""
|
|
377
|
+
header = f"# {document.slug} p{page.number} (page {page.number} of {total_pages})"
|
|
378
|
+
chunks = sorted(
|
|
379
|
+
(a for a in anchors if isinstance(a.locator, ChunkLocator)),
|
|
380
|
+
key=lambda a: a.locator.ordinal, # type: ignore[union-attr]
|
|
381
|
+
)
|
|
382
|
+
parts: list[str] = []
|
|
383
|
+
minted: list[int] = []
|
|
384
|
+
for anchor in chunks:
|
|
385
|
+
if not window.take(len(anchor.receipt.snippet)):
|
|
386
|
+
continue
|
|
387
|
+
parts.append(f"[{anchor.token}]\n{anchor.receipt.snippet}")
|
|
388
|
+
if anchor.id is not None:
|
|
389
|
+
minted.append(anchor.id)
|
|
390
|
+
if not parts:
|
|
391
|
+
if not chunks and window.open:
|
|
392
|
+
# NOTE: a page with no chunk anchors has no citable text; say so
|
|
393
|
+
# rather than printing text no token covers.
|
|
394
|
+
return f"{header}\n\n(no text on this page)", []
|
|
395
|
+
return "", []
|
|
396
|
+
return "\n\n".join([header, *parts]), minted
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _render_sheet(
|
|
400
|
+
document: Document,
|
|
401
|
+
page: Page,
|
|
402
|
+
anchors: Sequence[Anchor],
|
|
403
|
+
total_pages: int,
|
|
404
|
+
window: _Window,
|
|
405
|
+
) -> tuple[str, list[int]]:
|
|
406
|
+
"""A sheet as its markdown table, header row repeated on every window."""
|
|
407
|
+
name = page.name or f"p{page.number}"
|
|
408
|
+
header = f"# {document.slug} p{page.number} (sheet {page.number} of {total_pages}: {name})"
|
|
409
|
+
page_anchor = next((a for a in anchors if a.locator.kind == "page"), None)
|
|
410
|
+
if page_anchor is not None:
|
|
411
|
+
header = f"{header} [{page_anchor.token}]"
|
|
412
|
+
|
|
413
|
+
table_header, rows = _split_table(page.text)
|
|
414
|
+
shown = [row for row in rows if window.take(1)]
|
|
415
|
+
if not shown:
|
|
416
|
+
return "", []
|
|
417
|
+
|
|
418
|
+
body = "\n".join([*table_header, *shown])
|
|
419
|
+
minted = [page_anchor.id] if page_anchor is not None and page_anchor.id is not None else []
|
|
420
|
+
minted += _cells_in(anchors, body)
|
|
421
|
+
return f"{header}\n\n{body}", minted
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _split_table(text: str) -> tuple[list[str], list[str]]:
|
|
425
|
+
"""Separate a sheet page's header block from the table's data rows.
|
|
426
|
+
|
|
427
|
+
The header block is *everything above the first data row*: the extractor puts
|
|
428
|
+
a `## Sheet: … - Values View with cell references` title and a blank line
|
|
429
|
+
above the table (`extract/xlsx.py`), then the `| Row | A | B |` column header
|
|
430
|
+
and its `|---|` rule. Repeating only the first line would repeat the title and
|
|
431
|
+
window the column header away, leaving continuation windows with a headerless
|
|
432
|
+
table — SPEC § Gate requires the header row on every window, so the whole
|
|
433
|
+
block travels with every one of them, blank line included.
|
|
434
|
+
|
|
435
|
+
Rows are lines, so a window can never cut one in half.
|
|
436
|
+
"""
|
|
437
|
+
lines = text.split("\n")
|
|
438
|
+
header: list[str] = []
|
|
439
|
+
index = 0
|
|
440
|
+
while index < len(lines) and not _is_table_row(lines[index]):
|
|
441
|
+
header.append(lines[index])
|
|
442
|
+
index += 1
|
|
443
|
+
if index < len(lines):
|
|
444
|
+
header.append(lines[index]) # the column header row
|
|
445
|
+
index += 1
|
|
446
|
+
if index < len(lines) and _is_rule(lines[index]):
|
|
447
|
+
header.append(lines[index])
|
|
448
|
+
index += 1
|
|
449
|
+
rows = [line for line in lines[index:] if line.strip()]
|
|
450
|
+
# NOTE: a page whose text is a placeholder rather than a table (the
|
|
451
|
+
# inflated-sheet case, or a sheet with no data) has no rows to repeat a header
|
|
452
|
+
# above; show it whole.
|
|
453
|
+
if not rows:
|
|
454
|
+
return [], [line for line in lines if line.strip()]
|
|
455
|
+
return _trimmed(header), rows
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _is_table_row(line: str) -> bool:
|
|
459
|
+
"""True for a markdown table line, which the extractor always leads with `|`."""
|
|
460
|
+
return line.lstrip().startswith("|")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _is_rule(line: str) -> bool:
|
|
464
|
+
"""True for a `|---|---|` separator under a table's column header."""
|
|
465
|
+
return bool(_MARKDOWN_RULE.fullmatch(line))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _trimmed(header: list[str]) -> list[str]:
|
|
469
|
+
"""The header block without leading or trailing blank lines.
|
|
470
|
+
|
|
471
|
+
The blank line *between* the title and the table is content — it is what keeps
|
|
472
|
+
the title from being read as part of the table — so only the edges go.
|
|
473
|
+
"""
|
|
474
|
+
while header and not header[0].strip():
|
|
475
|
+
header.pop(0)
|
|
476
|
+
while header and not header[-1].strip():
|
|
477
|
+
header.pop()
|
|
478
|
+
return header
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _cells_in(anchors: Iterable[Anchor], text: str) -> list[int]:
|
|
482
|
+
"""Cell anchor ids whose in-band `[B10]` reference appears in `text`.
|
|
483
|
+
|
|
484
|
+
Sheets do not print per-cell tokens — the spec puts the references in band
|
|
485
|
+
and lets bind compose `bd:slug:sheet!B10:hash` from the registry. Those
|
|
486
|
+
composed tokens must still bind `resolved`, so the cells a window exposes are
|
|
487
|
+
minted exactly as if their tokens had been printed. Membership is decided on
|
|
488
|
+
the rendered text itself, which needs no assumption about the table's shape.
|
|
489
|
+
"""
|
|
490
|
+
ids: list[int] = []
|
|
491
|
+
for anchor in anchors:
|
|
492
|
+
locator = anchor.locator
|
|
493
|
+
if not isinstance(locator, CellLocator) or locator.end is not None:
|
|
494
|
+
continue
|
|
495
|
+
if anchor.id is not None and f"[{locator.cell.format()}]" in text:
|
|
496
|
+
ids.append(anchor.id)
|
|
497
|
+
return ids
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def cells(
|
|
501
|
+
registry: Registry, slug: str, refs: Sequence[str], *, session: str | None = None
|
|
502
|
+
) -> str:
|
|
503
|
+
"""Mint cell tokens directly: `refs` are `sheet!REF` locators.
|
|
504
|
+
|
|
505
|
+
The ergonomic path to citing a cell you are looking at in a windowed read —
|
|
506
|
+
the alternative was searching for the cell's own value and copying the
|
|
507
|
+
token off the hit. Each line mirrors a search hit: the token, then the
|
|
508
|
+
verbatim value. Emitting a token records it as shown, exactly as a read
|
|
509
|
+
would (the gate's whole contract).
|
|
510
|
+
"""
|
|
511
|
+
_require_document(registry, slug)
|
|
512
|
+
# Folded like `read`'s sheet selector, so the name a reader types (or the
|
|
513
|
+
# display name a registry stores) both land on the same sheet.
|
|
514
|
+
sheet_pages = {
|
|
515
|
+
_fold(page.name): (page.name, page.number)
|
|
516
|
+
for page in registry.pages(slug)
|
|
517
|
+
if page.kind == "sheet" and page.name
|
|
518
|
+
}
|
|
519
|
+
lines: list[str] = []
|
|
520
|
+
minted: list[int] = []
|
|
521
|
+
for ref in refs:
|
|
522
|
+
sheet, separator, cell_ref = ref.partition("!")
|
|
523
|
+
if not separator or not cell_ref:
|
|
524
|
+
raise GateError(f"expected sheet!REF, got {ref!r} (for example rent-roll!B10)")
|
|
525
|
+
found_sheet = sheet_pages.get(_fold(sheet))
|
|
526
|
+
if found_sheet is None:
|
|
527
|
+
known = ", ".join(sorted(name for name, _ in sheet_pages.values())) or "none"
|
|
528
|
+
raise GateError(f"no sheet {sheet!r} in {slug!r}; sheets: {known}")
|
|
529
|
+
_, number = found_sheet
|
|
530
|
+
found = None
|
|
531
|
+
for anchor in registry.anchors_for_page(slug, number):
|
|
532
|
+
locator = anchor.locator
|
|
533
|
+
if (
|
|
534
|
+
isinstance(locator, CellLocator)
|
|
535
|
+
and locator.end is None
|
|
536
|
+
and locator.cell.format() == cell_ref
|
|
537
|
+
):
|
|
538
|
+
found = anchor
|
|
539
|
+
break
|
|
540
|
+
if found is None:
|
|
541
|
+
raise GateError(f"no cell {cell_ref!r} on sheet {sheet!r} (empty cells mint nothing)")
|
|
542
|
+
lines.append(f"[{found.token}] {found.receipt.snippet}")
|
|
543
|
+
if found.id is not None:
|
|
544
|
+
minted.append(found.id)
|
|
545
|
+
_mint(registry, session, minted)
|
|
546
|
+
return "\n".join(lines)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ---------------------------------------------------------------------------
|
|
550
|
+
# shared
|
|
551
|
+
# ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _mint(registry: Registry, session: str | None, anchor_ids: Sequence[int]) -> None:
|
|
555
|
+
"""Record every emitted anchor under the session. The gate's whole contract.
|
|
556
|
+
|
|
557
|
+
Called once per read, on the anchors that reached the output — never on the
|
|
558
|
+
anchors a window skipped.
|
|
559
|
+
"""
|
|
560
|
+
if session is None or not anchor_ids:
|
|
561
|
+
return
|
|
562
|
+
session_id = registry.ensure_session(session)
|
|
563
|
+
registry.record_shown(session_id, sorted(set(anchor_ids)))
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _require_document(registry: Registry, slug: str) -> Document:
|
|
567
|
+
document = registry.document(slug)
|
|
568
|
+
if document is None:
|
|
569
|
+
raise GateError(f"no such document: {slug!r}")
|
|
570
|
+
return document
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _document_headline(document: Document, pages: Sequence[Page]) -> str:
|
|
574
|
+
return (
|
|
575
|
+
f"{document.slug} ({document.filename}, {document.media_type}, "
|
|
576
|
+
f"{len(pages)} {_unit(pages)})"
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _unit(pages: Sequence[Page]) -> str:
|
|
581
|
+
"""`sheets` when every page is one, else `pages`. Singular when there is one."""
|
|
582
|
+
word = "sheet" if pages and all(page.kind == "sheet" for page in pages) else "page"
|
|
583
|
+
return word if len(pages) == 1 else f"{word}s"
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _plural(count: int, word: str) -> str:
|
|
587
|
+
return f"{count} {word}" if count == 1 else f"{count} {word}s"
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _block(lines: Iterable[str]) -> str:
|
|
591
|
+
"""Join rendered lines into the final output: no trailing blank lines."""
|
|
592
|
+
return "\n".join(line.rstrip() for line in lines).rstrip("\n")
|