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,915 @@
|
|
|
1
|
+
"""The Registry: the only stateful object in the system.
|
|
2
|
+
|
|
3
|
+
It owns one SQLite file under `.backdraft/` and everything in it — documents,
|
|
4
|
+
extraction generations, pages, anchors, the FTS index, the ledger, bindings. The
|
|
5
|
+
kernel stays pure; the gate, bind and render layers read through this surface and
|
|
6
|
+
implement none of it (SPEC.md § Addendum A pins the surface).
|
|
7
|
+
|
|
8
|
+
Two behaviours carry the design and are worth stating plainly:
|
|
9
|
+
|
|
10
|
+
**Anchors are eager.** Ingest mints every chunk and every populated cell up
|
|
11
|
+
front. They are cheap rows, and it means `search`, `read` and `bind` all hit one
|
|
12
|
+
table — the contract is only that any token the gate ever emitted resolves, and
|
|
13
|
+
minting everything at ingest is the simplest way to guarantee it.
|
|
14
|
+
|
|
15
|
+
**Tokens survive re-ingest.** A new extraction is a new generation; the old one
|
|
16
|
+
keeps its anchors and loses `is_current`. Each new anchor whose locator *and*
|
|
17
|
+
snippet hash match the prior current generation keeps that generation's token
|
|
18
|
+
verbatim, so editing page 4 of a PDF leaves every citation on pages 1-3 exactly
|
|
19
|
+
where it was. `resolve` reports which generation answered, and bind turns
|
|
20
|
+
"answered by an old one" into `drifted`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import sqlite3
|
|
28
|
+
import unicodedata
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Iterable, Sequence
|
|
33
|
+
|
|
34
|
+
from ..extract import ExtractedPage, base as extract_base
|
|
35
|
+
from ..kernel import chunking
|
|
36
|
+
from ..kernel.errors import BackdraftError, TokenError
|
|
37
|
+
from ..kernel.hashing import TOKEN_HASH_LENGTHS, config_hash, content_hash, snippet_hash
|
|
38
|
+
from ..kernel.model import Anchor, CellValue, Document, MediaType, Page, Receipt
|
|
39
|
+
from ..kernel.tokens import ChunkLocator, PageLocator, format_token
|
|
40
|
+
from ..kernel.tokens import parse as parse_token
|
|
41
|
+
from ..kernel.tokens import parse_locator
|
|
42
|
+
from . import ledger as ledger_module
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"DIRECTORY",
|
|
46
|
+
"EXPORT_FORMAT",
|
|
47
|
+
"Registry",
|
|
48
|
+
"RegistryError",
|
|
49
|
+
"Resolution",
|
|
50
|
+
"SearchHit",
|
|
51
|
+
"SearchResults",
|
|
52
|
+
"media_type_for",
|
|
53
|
+
"sanitize_sheet_name",
|
|
54
|
+
"slug_for",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
DIRECTORY = ".backdraft"
|
|
58
|
+
"""The per-project registry directory, found by walking up from cwd."""
|
|
59
|
+
|
|
60
|
+
DATABASE = "registry.db"
|
|
61
|
+
|
|
62
|
+
EXPORT_FORMAT = "backdraft/registry-v1"
|
|
63
|
+
"""NOTE: the spec does not name the export; a format string makes the dump
|
|
64
|
+
self-describing, matching the artifact's `$format` habit."""
|
|
65
|
+
|
|
66
|
+
SLUG_MAX = 32
|
|
67
|
+
"""A slug is 2-32 characters (kernel/tokens.py enforces the grammar)."""
|
|
68
|
+
|
|
69
|
+
_SCHEMA = Path(__file__).with_name("schema.sql")
|
|
70
|
+
_NON_SLUG = re.compile(r"[^a-z0-9]+")
|
|
71
|
+
_MEDIA_SUFFIXES: dict[str, MediaType] = {
|
|
72
|
+
".pdf": "pdf",
|
|
73
|
+
".xlsx": "xlsx",
|
|
74
|
+
".xlsm": "xlsx",
|
|
75
|
+
".xltx": "xlsx",
|
|
76
|
+
".xltm": "xlsx",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RegistryError(BackdraftError):
|
|
81
|
+
"""The registry could not do what was asked of it."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class Resolution:
|
|
86
|
+
"""What `resolve` found: the anchor, and whether it is still current.
|
|
87
|
+
|
|
88
|
+
`current=False` means the token names a superseded generation — the snippet
|
|
89
|
+
is what the writer actually saw, and bind maps this to status `drifted`.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
anchor: Anchor
|
|
93
|
+
current: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class SearchHit:
|
|
98
|
+
"""One search result: a mintable anchor and where it lives."""
|
|
99
|
+
|
|
100
|
+
anchor: Anchor
|
|
101
|
+
slug: str
|
|
102
|
+
page_number: int
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SearchResults(list[SearchHit]):
|
|
106
|
+
"""The hits, plus how the query had to be run to get them.
|
|
107
|
+
|
|
108
|
+
`search` retries a query FTS5 cannot parse as a quoted phrase, which changes
|
|
109
|
+
what "no results" means: `1.42x` as a phrase matches the two tokens `1.42`
|
|
110
|
+
and `x` adjacent, not the boolean query the caller wrote. A silent retry
|
|
111
|
+
makes an empty result look like an absent fact, so the fallback is reported.
|
|
112
|
+
|
|
113
|
+
A `list` subclass rather than a new return type on purpose: Addendum A pins
|
|
114
|
+
`search` to `list[SearchHit]`, and this still is one — every caller that
|
|
115
|
+
iterates, indexes or measures it is unaffected, and the one caller that
|
|
116
|
+
cares reads the flag.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
__slots__ = ("phrase_fallback",)
|
|
120
|
+
|
|
121
|
+
def __init__(self, hits: Iterable[SearchHit] = (), *, phrase_fallback: bool = False):
|
|
122
|
+
super().__init__(hits)
|
|
123
|
+
self.phrase_fallback = phrase_fallback
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def media_type_for(path: Path) -> MediaType:
|
|
127
|
+
"""The media type of a file, by extension. Anything unknown is text."""
|
|
128
|
+
return _MEDIA_SUFFIXES.get(path.suffix.lower(), "text")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def slug_for(filename: str) -> str:
|
|
132
|
+
"""The slug a filename suggests: kebab, lowercase, truncated to 32.
|
|
133
|
+
|
|
134
|
+
NOTE: the grammar needs at least two characters starting with alnum-lower, so
|
|
135
|
+
a degenerate stem (`a.pdf`, `_.txt`) is padded rather than rejected —
|
|
136
|
+
refusing to ingest a file over its name would be absurd.
|
|
137
|
+
"""
|
|
138
|
+
stem = unicodedata.normalize("NFKD", Path(filename).stem).encode("ascii", "ignore")
|
|
139
|
+
base = _NON_SLUG.sub("-", stem.decode("ascii").lower()).strip("-")[:SLUG_MAX]
|
|
140
|
+
if len(base) < 2:
|
|
141
|
+
base = f"{base}-doc".strip("-") if base else "doc"
|
|
142
|
+
return base
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def sanitize_sheet_name(name: str) -> str:
|
|
146
|
+
"""A sheet name reduced to the sheetref charset: lowercase kebab.
|
|
147
|
+
|
|
148
|
+
The grammar forbids `:` `!` `;` `(` `)` and whitespace inside a sheetref;
|
|
149
|
+
this goes further and kebabs the whole name, so `Rent Roll (2025)` becomes
|
|
150
|
+
`rent-roll-2025` and a token stays readable and typeable.
|
|
151
|
+
"""
|
|
152
|
+
reduced = _NON_SLUG.sub("-", unicodedata.normalize("NFC", name).lower()).strip("-")
|
|
153
|
+
return reduced or "sheet"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Registry:
|
|
157
|
+
"""SQLite + FTS5 behind the surface in SPEC.md § Addendum A."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, root: Path, connection: sqlite3.Connection) -> None:
|
|
160
|
+
self.root = root
|
|
161
|
+
self._connection = connection
|
|
162
|
+
|
|
163
|
+
# ---- lifecycle ----------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def open(cls, root: Path) -> "Registry":
|
|
167
|
+
"""Open (creating if needed) the registry under `root/.backdraft/`.
|
|
168
|
+
|
|
169
|
+
Discovery — which `root` — lives in the CLI. Creating the schema here
|
|
170
|
+
rather than in `init` keeps every entry point working against a fresh
|
|
171
|
+
checkout, and the DDL is idempotent.
|
|
172
|
+
"""
|
|
173
|
+
root = Path(root)
|
|
174
|
+
directory = root / DIRECTORY
|
|
175
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
connection = sqlite3.connect(directory / DATABASE)
|
|
177
|
+
connection.row_factory = sqlite3.Row
|
|
178
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
179
|
+
with connection:
|
|
180
|
+
connection.executescript(_SCHEMA.read_text(encoding="utf-8"))
|
|
181
|
+
return cls(root, connection)
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
"""Close the underlying connection. Idempotent."""
|
|
185
|
+
self._connection.close()
|
|
186
|
+
|
|
187
|
+
def __enter__(self) -> "Registry":
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def __exit__(self, *_: object) -> None:
|
|
191
|
+
self.close()
|
|
192
|
+
|
|
193
|
+
# ---- write side ---------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def ingest(
|
|
196
|
+
self,
|
|
197
|
+
path: Path,
|
|
198
|
+
*,
|
|
199
|
+
extractor: str | None = None,
|
|
200
|
+
slug: str | None = None,
|
|
201
|
+
config: dict | None = None,
|
|
202
|
+
) -> Document:
|
|
203
|
+
"""Snapshot a file into a new extraction generation.
|
|
204
|
+
|
|
205
|
+
Runs the extractor, writes pages, mints every anchor and rebuilds the
|
|
206
|
+
file's search rows. Tokens carry over from the prior current generation
|
|
207
|
+
wherever locator and snippet hash both match.
|
|
208
|
+
|
|
209
|
+
Re-ingesting identical bytes with the same deterministic extractor and
|
|
210
|
+
config is a no-op: the existing generation is already the answer, so no
|
|
211
|
+
second generation is created and every token is untouched.
|
|
212
|
+
|
|
213
|
+
`extractor` names one (`None` or `"auto"` picks the first that can handle
|
|
214
|
+
the file). `slug` is honoured only when the document is new — a slug is
|
|
215
|
+
stable once assigned. `config` is hashed into the generation's identity.
|
|
216
|
+
"""
|
|
217
|
+
path = Path(path)
|
|
218
|
+
try:
|
|
219
|
+
data = path.read_bytes()
|
|
220
|
+
except OSError as error:
|
|
221
|
+
raise RegistryError(f"cannot read {path}: {error}") from error
|
|
222
|
+
|
|
223
|
+
sha256 = content_hash(data)
|
|
224
|
+
media_type = media_type_for(path)
|
|
225
|
+
chosen = (
|
|
226
|
+
extract_base.select(path, media_type, config)
|
|
227
|
+
if extractor in (None, "auto")
|
|
228
|
+
else extract_base.get(extractor)
|
|
229
|
+
)
|
|
230
|
+
settings = dict(config or {})
|
|
231
|
+
settings_hash = config_hash(settings)
|
|
232
|
+
|
|
233
|
+
existing = self._find_document(sha256=sha256, path=path)
|
|
234
|
+
if existing is not None and self._is_noop(existing, sha256, chosen, settings_hash):
|
|
235
|
+
return existing
|
|
236
|
+
|
|
237
|
+
pages = list(chosen.extract(path, settings))
|
|
238
|
+
|
|
239
|
+
now = _now()
|
|
240
|
+
with self._connection:
|
|
241
|
+
document = self._upsert_document(existing, sha256, path, media_type, slug, now)
|
|
242
|
+
previous = self._current_extraction_id(document.id)
|
|
243
|
+
self._connection.execute(
|
|
244
|
+
"UPDATE extractions SET is_current = 0 WHERE document_id = ? AND is_current = 1",
|
|
245
|
+
(document.id,),
|
|
246
|
+
)
|
|
247
|
+
extraction_id = self._insert_extraction(document.id, chosen, settings_hash, now)
|
|
248
|
+
self._write_pages(document, extraction_id, previous, pages, now)
|
|
249
|
+
return document
|
|
250
|
+
|
|
251
|
+
# ---- read side ----------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def documents(self) -> list[Document]:
|
|
254
|
+
"""Every ingested document, oldest first."""
|
|
255
|
+
return [
|
|
256
|
+
_document(row)
|
|
257
|
+
for row in self._connection.execute(
|
|
258
|
+
"SELECT * FROM documents ORDER BY created_at, id"
|
|
259
|
+
)
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
def document(self, slug: str) -> Document | None:
|
|
263
|
+
"""One document by slug, or None."""
|
|
264
|
+
row = self._connection.execute(
|
|
265
|
+
"SELECT * FROM documents WHERE slug = ?", (slug,)
|
|
266
|
+
).fetchone()
|
|
267
|
+
return _document(row) if row else None
|
|
268
|
+
|
|
269
|
+
def pages(self, slug: str) -> list[Page]:
|
|
270
|
+
"""The current extraction's pages, in order. Sheets carry their cells."""
|
|
271
|
+
extraction_id = self._current_extraction_for_slug(slug)
|
|
272
|
+
if extraction_id is None:
|
|
273
|
+
return []
|
|
274
|
+
cells = self._cells_by_page(extraction_id)
|
|
275
|
+
return [
|
|
276
|
+
_page(row, cells.get(row["number"], ()))
|
|
277
|
+
for row in self._connection.execute(
|
|
278
|
+
"SELECT * FROM pages WHERE extraction_id = ? ORDER BY number",
|
|
279
|
+
(extraction_id,),
|
|
280
|
+
)
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
def page(self, slug: str, number: int) -> Page | None:
|
|
284
|
+
"""One page of the current extraction, or None."""
|
|
285
|
+
extraction_id = self._current_extraction_for_slug(slug)
|
|
286
|
+
if extraction_id is None:
|
|
287
|
+
return None
|
|
288
|
+
row = self._connection.execute(
|
|
289
|
+
"SELECT * FROM pages WHERE extraction_id = ? AND number = ?",
|
|
290
|
+
(extraction_id, number),
|
|
291
|
+
).fetchone()
|
|
292
|
+
if row is None:
|
|
293
|
+
return None
|
|
294
|
+
return _page(row, self._cells_by_page(extraction_id).get(number, ()))
|
|
295
|
+
|
|
296
|
+
def page_image(self, slug: str, number: int) -> extract_base.PageImage | None:
|
|
297
|
+
"""The stored visual snapshot of one current-extraction page, or None.
|
|
298
|
+
|
|
299
|
+
Present when the extractor supplied one at ingest (the VLM path) or when
|
|
300
|
+
`snapshot-pages` backfilled it; absent for text-only extractions.
|
|
301
|
+
"""
|
|
302
|
+
extraction_id = self._current_extraction_for_slug(slug)
|
|
303
|
+
if extraction_id is None:
|
|
304
|
+
return None
|
|
305
|
+
row = self._connection.execute(
|
|
306
|
+
"SELECT format, width, height, data FROM page_images "
|
|
307
|
+
"WHERE extraction_id = ? AND number = ?",
|
|
308
|
+
(extraction_id, number),
|
|
309
|
+
).fetchone()
|
|
310
|
+
if row is None:
|
|
311
|
+
return None
|
|
312
|
+
return extract_base.PageImage(
|
|
313
|
+
data=row["data"], format=row["format"],
|
|
314
|
+
width=int(row["width"]), height=int(row["height"]),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def save_page_image(
|
|
318
|
+
self,
|
|
319
|
+
extraction_id: int,
|
|
320
|
+
number: int,
|
|
321
|
+
*,
|
|
322
|
+
data: bytes,
|
|
323
|
+
format: str,
|
|
324
|
+
width: int,
|
|
325
|
+
height: int,
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Store (or replace) one page's visual snapshot. Commits."""
|
|
328
|
+
with self._connection:
|
|
329
|
+
self._connection.execute(
|
|
330
|
+
"INSERT OR REPLACE INTO page_images "
|
|
331
|
+
"(extraction_id, number, format, width, height, data) "
|
|
332
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
333
|
+
(extraction_id, number, format, width, height, data),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def current_extraction_id(self, slug: str) -> int | None:
|
|
337
|
+
"""The current generation's id for `slug`, or None. For backfill tools."""
|
|
338
|
+
return self._current_extraction_for_slug(slug)
|
|
339
|
+
|
|
340
|
+
def anchors_for_page(self, slug: str, number: int) -> list[Anchor]:
|
|
341
|
+
"""Every anchor on one page of the current extraction, in mint order.
|
|
342
|
+
|
|
343
|
+
The page anchor comes first, then its chunks or cells — the order the
|
|
344
|
+
gate renders them in.
|
|
345
|
+
"""
|
|
346
|
+
extraction_id = self._current_extraction_for_slug(slug)
|
|
347
|
+
if extraction_id is None:
|
|
348
|
+
return []
|
|
349
|
+
return [
|
|
350
|
+
_anchor(row, slug)
|
|
351
|
+
for row in self._connection.execute(
|
|
352
|
+
"SELECT * FROM anchors WHERE extraction_id = ? AND page_number = ? ORDER BY id",
|
|
353
|
+
(extraction_id, number),
|
|
354
|
+
)
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
def resolve(self, token: str) -> Resolution | None:
|
|
358
|
+
"""Find the anchor a token names, in any generation.
|
|
359
|
+
|
|
360
|
+
Current generation wins; otherwise the most recent generation that has
|
|
361
|
+
it answers with `current=False`. A token that does not parse — including
|
|
362
|
+
the reserved `bd:calc(...)` form — resolves to None, because a malformed
|
|
363
|
+
citation is bind's to report, not the registry's to guess at.
|
|
364
|
+
"""
|
|
365
|
+
try:
|
|
366
|
+
parse_token(token)
|
|
367
|
+
except TokenError:
|
|
368
|
+
return None
|
|
369
|
+
row = self._connection.execute(
|
|
370
|
+
"SELECT anchors.*, documents.slug AS slug, extractions.is_current AS is_current "
|
|
371
|
+
"FROM anchors "
|
|
372
|
+
"JOIN extractions ON extractions.id = anchors.extraction_id "
|
|
373
|
+
"JOIN documents ON documents.id = extractions.document_id "
|
|
374
|
+
"WHERE anchors.token = ? "
|
|
375
|
+
"ORDER BY extractions.is_current DESC, extractions.created_at DESC, extractions.id DESC "
|
|
376
|
+
"LIMIT 1",
|
|
377
|
+
(token,),
|
|
378
|
+
).fetchone()
|
|
379
|
+
if row is None:
|
|
380
|
+
return None
|
|
381
|
+
return Resolution(anchor=_anchor(row, row["slug"]), current=bool(row["is_current"]))
|
|
382
|
+
|
|
383
|
+
def search(
|
|
384
|
+
self, query: str, *, slug: str | None = None, limit: int = 20
|
|
385
|
+
) -> SearchResults:
|
|
386
|
+
"""Full-text search over the current generation's anchors.
|
|
387
|
+
|
|
388
|
+
Results are anchors, so a searched snippet is citable without a page read
|
|
389
|
+
— the gap that forced the app's readers to page through a document just
|
|
390
|
+
to get something quotable.
|
|
391
|
+
|
|
392
|
+
The query is FTS5 syntax, so `covenant OR NOI` and `NEAR(rent roll)`
|
|
393
|
+
work. NOTE: a query FTS5 cannot parse — `1.42x`, `Q4/2025`, anything with
|
|
394
|
+
punctuation — is retried as a quoted phrase rather than rejected. Analysts
|
|
395
|
+
search for numbers, and a number is not a syntax error. The retry is
|
|
396
|
+
recorded on the result (`phrase_fallback`) rather than hidden: it changes
|
|
397
|
+
what the query means, so the caller gets to say so.
|
|
398
|
+
"""
|
|
399
|
+
try:
|
|
400
|
+
return SearchResults(self._search(query, slug, limit))
|
|
401
|
+
except sqlite3.OperationalError:
|
|
402
|
+
phrase = '"' + query.replace('"', '""') + '"'
|
|
403
|
+
try:
|
|
404
|
+
return SearchResults(
|
|
405
|
+
self._search(phrase, slug, limit), phrase_fallback=True
|
|
406
|
+
)
|
|
407
|
+
except sqlite3.OperationalError as error: # pragma: no cover - phrases always parse
|
|
408
|
+
raise RegistryError(f"invalid search query {query!r}: {error}") from error
|
|
409
|
+
|
|
410
|
+
def _search(self, query: str, slug: str | None, limit: int) -> list[SearchHit]:
|
|
411
|
+
sql = (
|
|
412
|
+
"SELECT anchors.*, documents.slug AS slug "
|
|
413
|
+
"FROM search "
|
|
414
|
+
"JOIN anchors ON anchors.token = search.token "
|
|
415
|
+
"JOIN extractions ON extractions.id = anchors.extraction_id AND extractions.is_current = 1 "
|
|
416
|
+
"JOIN documents ON documents.id = extractions.document_id "
|
|
417
|
+
"WHERE search MATCH ?"
|
|
418
|
+
)
|
|
419
|
+
parameters: list[Any] = [query]
|
|
420
|
+
if slug is not None:
|
|
421
|
+
sql += " AND documents.slug = ?"
|
|
422
|
+
parameters.append(slug)
|
|
423
|
+
sql += " ORDER BY rank LIMIT ?"
|
|
424
|
+
parameters.append(limit)
|
|
425
|
+
rows = self._connection.execute(sql, parameters).fetchall()
|
|
426
|
+
return [
|
|
427
|
+
SearchHit(
|
|
428
|
+
anchor=_anchor(row, row["slug"]),
|
|
429
|
+
slug=row["slug"],
|
|
430
|
+
page_number=row["page_number"],
|
|
431
|
+
)
|
|
432
|
+
for row in rows
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
# ---- ledger -------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def ensure_session(self, session_id: str | None, label: str | None = None) -> str:
|
|
438
|
+
"""Return an existing session id, or create one (generating an id if needed)."""
|
|
439
|
+
with self._connection:
|
|
440
|
+
return ledger_module.ensure_session(self._connection, session_id, label, _now())
|
|
441
|
+
|
|
442
|
+
def record_shown(self, session_id: str, anchor_ids: Sequence[int]) -> None:
|
|
443
|
+
"""Record that these anchors were minted into the session's context."""
|
|
444
|
+
with self._connection:
|
|
445
|
+
ledger_module.record_shown(self._connection, session_id, anchor_ids, _now())
|
|
446
|
+
|
|
447
|
+
def was_shown(self, session_id: str, token: str) -> bool:
|
|
448
|
+
"""True if this session was ever shown the anchor this token names."""
|
|
449
|
+
return ledger_module.was_shown(self._connection, session_id, token)
|
|
450
|
+
|
|
451
|
+
# ---- bind persistence ---------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def save_binding(
|
|
454
|
+
self,
|
|
455
|
+
*,
|
|
456
|
+
doc_path: str,
|
|
457
|
+
session_id: str | None,
|
|
458
|
+
mode: str,
|
|
459
|
+
report_json: str,
|
|
460
|
+
) -> int:
|
|
461
|
+
"""Store one bind run's report. Returns the binding's row id."""
|
|
462
|
+
with self._connection:
|
|
463
|
+
cursor = self._connection.execute(
|
|
464
|
+
"INSERT INTO bindings (doc_path, session_id, mode, report_json, bound_at) "
|
|
465
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
466
|
+
(doc_path, session_id, mode, report_json, _now()),
|
|
467
|
+
)
|
|
468
|
+
return int(cursor.lastrowid or 0)
|
|
469
|
+
|
|
470
|
+
def export_json(self) -> dict:
|
|
471
|
+
"""The whole registry as plain JSON-able data, for portability and diffing.
|
|
472
|
+
|
|
473
|
+
Every generation is included, not just the current one: the superseded
|
|
474
|
+
anchors are what make a drifted citation explainable.
|
|
475
|
+
"""
|
|
476
|
+
documents: list[dict] = []
|
|
477
|
+
for document in self.documents():
|
|
478
|
+
extractions = []
|
|
479
|
+
for extraction in self._connection.execute(
|
|
480
|
+
"SELECT * FROM extractions WHERE document_id = ? ORDER BY created_at, id",
|
|
481
|
+
(document.id,),
|
|
482
|
+
):
|
|
483
|
+
extractions.append(
|
|
484
|
+
{
|
|
485
|
+
"id": extraction["id"],
|
|
486
|
+
"extractor": extraction["extractor"],
|
|
487
|
+
"extractor_version": extraction["extractor_version"],
|
|
488
|
+
"config_hash": extraction["config_hash"],
|
|
489
|
+
"deterministic": bool(extraction["deterministic"]),
|
|
490
|
+
"is_current": bool(extraction["is_current"]),
|
|
491
|
+
"created_at": extraction["created_at"],
|
|
492
|
+
"pages": [
|
|
493
|
+
{
|
|
494
|
+
"number": page["number"],
|
|
495
|
+
"kind": page["kind"],
|
|
496
|
+
"name": page["name"],
|
|
497
|
+
"text": page["text"],
|
|
498
|
+
"summary": page["summary"],
|
|
499
|
+
}
|
|
500
|
+
for page in self._connection.execute(
|
|
501
|
+
"SELECT * FROM pages WHERE extraction_id = ? ORDER BY number",
|
|
502
|
+
(extraction["id"],),
|
|
503
|
+
)
|
|
504
|
+
],
|
|
505
|
+
"anchors": [
|
|
506
|
+
{
|
|
507
|
+
"id": anchor["id"],
|
|
508
|
+
"page_number": anchor["page_number"],
|
|
509
|
+
"kind": anchor["kind"],
|
|
510
|
+
"locator": anchor["locator"],
|
|
511
|
+
"snippet": anchor["snippet"],
|
|
512
|
+
"snippet_sha256": anchor["snippet_sha256"],
|
|
513
|
+
"token": anchor["token"],
|
|
514
|
+
"start": anchor["start_off"],
|
|
515
|
+
"end": anchor["end_off"],
|
|
516
|
+
"created_at": anchor["created_at"],
|
|
517
|
+
}
|
|
518
|
+
for anchor in self._connection.execute(
|
|
519
|
+
"SELECT * FROM anchors WHERE extraction_id = ? ORDER BY id",
|
|
520
|
+
(extraction["id"],),
|
|
521
|
+
)
|
|
522
|
+
],
|
|
523
|
+
}
|
|
524
|
+
)
|
|
525
|
+
documents.append(
|
|
526
|
+
{
|
|
527
|
+
"slug": document.slug,
|
|
528
|
+
"sha256": document.sha256,
|
|
529
|
+
"path": document.path,
|
|
530
|
+
"filename": document.filename,
|
|
531
|
+
"media_type": document.media_type,
|
|
532
|
+
"created_at": document.created_at,
|
|
533
|
+
"extractions": extractions,
|
|
534
|
+
}
|
|
535
|
+
)
|
|
536
|
+
return {
|
|
537
|
+
"$format": EXPORT_FORMAT,
|
|
538
|
+
"documents": documents,
|
|
539
|
+
"sessions": ledger_module.sessions(self._connection),
|
|
540
|
+
"ledger": [
|
|
541
|
+
{
|
|
542
|
+
"session_id": row["session_id"],
|
|
543
|
+
"token": row["token"],
|
|
544
|
+
"shown_at": row["shown_at"],
|
|
545
|
+
}
|
|
546
|
+
for row in self._connection.execute(
|
|
547
|
+
"SELECT ledger.session_id, anchors.token AS token, ledger.shown_at "
|
|
548
|
+
"FROM ledger JOIN anchors ON anchors.id = ledger.anchor_id "
|
|
549
|
+
"ORDER BY ledger.shown_at, ledger.anchor_id"
|
|
550
|
+
)
|
|
551
|
+
],
|
|
552
|
+
"bindings": [
|
|
553
|
+
{
|
|
554
|
+
"id": row["id"],
|
|
555
|
+
"doc_path": row["doc_path"],
|
|
556
|
+
"session_id": row["session_id"],
|
|
557
|
+
"mode": row["mode"],
|
|
558
|
+
"report": json.loads(row["report_json"]),
|
|
559
|
+
"bound_at": row["bound_at"],
|
|
560
|
+
}
|
|
561
|
+
for row in self._connection.execute("SELECT * FROM bindings ORDER BY id")
|
|
562
|
+
],
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
# ---- ingest internals ---------------------------------------------------
|
|
566
|
+
|
|
567
|
+
def _find_document(self, *, sha256: str, path: Path) -> Document | None:
|
|
568
|
+
"""The document this ingest is about, if the registry already has it.
|
|
569
|
+
|
|
570
|
+
Matched by bytes, then by path. NOTE: the spec identifies a document by
|
|
571
|
+
its bytes, but a re-ingest of an *edited* file has different bytes and
|
|
572
|
+
must still be the same document — otherwise nothing ever drifts, it just
|
|
573
|
+
becomes a second document. Path continuity carries identity across an
|
|
574
|
+
edit. `slug` deliberately does not participate: it names a *new*
|
|
575
|
+
document, and a taken slug is an error rather than a silent retarget.
|
|
576
|
+
"""
|
|
577
|
+
row = self._connection.execute(
|
|
578
|
+
"SELECT * FROM documents WHERE sha256 = ?", (sha256,)
|
|
579
|
+
).fetchone()
|
|
580
|
+
if row is not None:
|
|
581
|
+
return _document(row)
|
|
582
|
+
target = path.resolve()
|
|
583
|
+
for row in self._connection.execute("SELECT * FROM documents ORDER BY id"):
|
|
584
|
+
if Path(row["path"]).resolve() == target:
|
|
585
|
+
return _document(row)
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
def _is_noop(
|
|
589
|
+
self, document: Document, sha256: str, extractor: Any, settings_hash: str
|
|
590
|
+
) -> bool:
|
|
591
|
+
"""True if re-running this extractor would reproduce the current generation."""
|
|
592
|
+
if document.sha256 != sha256 or not extractor.deterministic:
|
|
593
|
+
return False
|
|
594
|
+
row = self._connection.execute(
|
|
595
|
+
"SELECT 1 FROM extractions WHERE document_id = ? AND is_current = 1 "
|
|
596
|
+
"AND extractor = ? AND extractor_version = ? AND config_hash = ? "
|
|
597
|
+
"AND deterministic = 1",
|
|
598
|
+
(document.id, extractor.name, extractor.version, settings_hash),
|
|
599
|
+
).fetchone()
|
|
600
|
+
return row is not None
|
|
601
|
+
|
|
602
|
+
def _upsert_document(
|
|
603
|
+
self,
|
|
604
|
+
existing: Document | None,
|
|
605
|
+
sha256: str,
|
|
606
|
+
path: Path,
|
|
607
|
+
media_type: MediaType,
|
|
608
|
+
slug: str | None,
|
|
609
|
+
now: str,
|
|
610
|
+
) -> Document:
|
|
611
|
+
"""Insert a new document, or point an existing one at the new bytes."""
|
|
612
|
+
if existing is not None:
|
|
613
|
+
self._connection.execute(
|
|
614
|
+
"UPDATE documents SET sha256 = ?, path = ?, filename = ?, media_type = ? "
|
|
615
|
+
"WHERE id = ?",
|
|
616
|
+
(sha256, str(path), path.name, media_type, existing.id),
|
|
617
|
+
)
|
|
618
|
+
# NOTE: `slug` is ignored here on purpose — a slug is stable once
|
|
619
|
+
# assigned, because tokens already in an authored document use it.
|
|
620
|
+
return Document(
|
|
621
|
+
slug=existing.slug,
|
|
622
|
+
sha256=sha256,
|
|
623
|
+
path=str(path),
|
|
624
|
+
filename=path.name,
|
|
625
|
+
media_type=media_type,
|
|
626
|
+
created_at=existing.created_at,
|
|
627
|
+
id=existing.id,
|
|
628
|
+
)
|
|
629
|
+
assigned = self._assign_slug(slug, path.name)
|
|
630
|
+
cursor = self._connection.execute(
|
|
631
|
+
"INSERT INTO documents (slug, sha256, path, filename, media_type, created_at) "
|
|
632
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
633
|
+
(assigned, sha256, str(path), path.name, media_type, now),
|
|
634
|
+
)
|
|
635
|
+
return Document(
|
|
636
|
+
slug=assigned,
|
|
637
|
+
sha256=sha256,
|
|
638
|
+
path=str(path),
|
|
639
|
+
filename=path.name,
|
|
640
|
+
media_type=media_type,
|
|
641
|
+
created_at=now,
|
|
642
|
+
id=int(cursor.lastrowid or 0),
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
def _assign_slug(self, requested: str | None, filename: str) -> str:
|
|
646
|
+
"""A slug unique in this registry, deduped `-2`, `-3`, ....
|
|
647
|
+
|
|
648
|
+
An explicitly requested slug is never silently renamed: if it is taken,
|
|
649
|
+
that is an error the caller has to see.
|
|
650
|
+
"""
|
|
651
|
+
taken = {row[0] for row in self._connection.execute("SELECT slug FROM documents")}
|
|
652
|
+
if requested is not None:
|
|
653
|
+
if requested in taken:
|
|
654
|
+
raise RegistryError(f"slug {requested!r} is already taken")
|
|
655
|
+
return requested
|
|
656
|
+
return _dedupe(slug_for(filename), taken, SLUG_MAX)
|
|
657
|
+
|
|
658
|
+
def _insert_extraction(
|
|
659
|
+
self, document_id: int | None, extractor: Any, settings_hash: str, now: str
|
|
660
|
+
) -> int:
|
|
661
|
+
cursor = self._connection.execute(
|
|
662
|
+
"INSERT INTO extractions (document_id, extractor, extractor_version, "
|
|
663
|
+
"config_hash, deterministic, is_current, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)",
|
|
664
|
+
(
|
|
665
|
+
document_id,
|
|
666
|
+
extractor.name,
|
|
667
|
+
extractor.version,
|
|
668
|
+
settings_hash,
|
|
669
|
+
int(bool(extractor.deterministic)),
|
|
670
|
+
now,
|
|
671
|
+
),
|
|
672
|
+
)
|
|
673
|
+
return int(cursor.lastrowid or 0)
|
|
674
|
+
|
|
675
|
+
def _current_extraction_id(self, document_id: int | None) -> int | None:
|
|
676
|
+
row = self._connection.execute(
|
|
677
|
+
"SELECT id FROM extractions WHERE document_id = ? AND is_current = 1",
|
|
678
|
+
(document_id,),
|
|
679
|
+
).fetchone()
|
|
680
|
+
return int(row[0]) if row else None
|
|
681
|
+
|
|
682
|
+
def _current_extraction_for_slug(self, slug: str) -> int | None:
|
|
683
|
+
row = self._connection.execute(
|
|
684
|
+
"SELECT extractions.id FROM extractions "
|
|
685
|
+
"JOIN documents ON documents.id = extractions.document_id "
|
|
686
|
+
"WHERE documents.slug = ? AND extractions.is_current = 1",
|
|
687
|
+
(slug,),
|
|
688
|
+
).fetchone()
|
|
689
|
+
return int(row[0]) if row else None
|
|
690
|
+
|
|
691
|
+
def _write_pages(
|
|
692
|
+
self,
|
|
693
|
+
document: Document,
|
|
694
|
+
extraction_id: int,
|
|
695
|
+
previous: int | None,
|
|
696
|
+
pages: Iterable[ExtractedPage],
|
|
697
|
+
now: str,
|
|
698
|
+
) -> None:
|
|
699
|
+
"""Write pages, mint their anchors, and rebuild the document's search rows."""
|
|
700
|
+
carried = self._prior_anchors(previous)
|
|
701
|
+
minted: dict[str, str] = {}
|
|
702
|
+
sheet_names: set[str] = set()
|
|
703
|
+
self._connection.execute("DELETE FROM search WHERE slug = ?", (document.slug,))
|
|
704
|
+
|
|
705
|
+
for page in pages:
|
|
706
|
+
name = page.name
|
|
707
|
+
if page.kind == "sheet":
|
|
708
|
+
name = _dedupe(sanitize_sheet_name(page.name or ""), sheet_names, SLUG_MAX)
|
|
709
|
+
sheet_names.add(name)
|
|
710
|
+
self._connection.execute(
|
|
711
|
+
"INSERT INTO pages (extraction_id, number, kind, name, text, summary) "
|
|
712
|
+
"VALUES (?, ?, ?, ?, ?, NULL)",
|
|
713
|
+
(extraction_id, page.number, page.kind, name, page.text),
|
|
714
|
+
)
|
|
715
|
+
if page.image is not None:
|
|
716
|
+
# Inline rather than save_page_image: this runs inside ingest's
|
|
717
|
+
# transaction, which must not be committed early.
|
|
718
|
+
self._connection.execute(
|
|
719
|
+
"INSERT OR REPLACE INTO page_images "
|
|
720
|
+
"(extraction_id, number, format, width, height, data) "
|
|
721
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
722
|
+
(
|
|
723
|
+
extraction_id, page.number, page.image.format,
|
|
724
|
+
page.image.width, page.image.height, page.image.data,
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
for locator, snippet, offsets in _locations(page, name):
|
|
728
|
+
self._insert_anchor(
|
|
729
|
+
document=document,
|
|
730
|
+
extraction_id=extraction_id,
|
|
731
|
+
page_number=page.number,
|
|
732
|
+
locator=locator,
|
|
733
|
+
snippet=snippet,
|
|
734
|
+
offsets=offsets,
|
|
735
|
+
carried=carried,
|
|
736
|
+
minted=minted,
|
|
737
|
+
now=now,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
def _prior_anchors(self, extraction_id: int | None) -> dict[str, tuple[str, str]]:
|
|
741
|
+
"""locator -> (token, snippet_sha256) for the generation being superseded."""
|
|
742
|
+
if extraction_id is None:
|
|
743
|
+
return {}
|
|
744
|
+
return {
|
|
745
|
+
row["locator"]: (row["token"], row["snippet_sha256"])
|
|
746
|
+
for row in self._connection.execute(
|
|
747
|
+
"SELECT locator, token, snippet_sha256 FROM anchors WHERE extraction_id = ?",
|
|
748
|
+
(extraction_id,),
|
|
749
|
+
)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
def _insert_anchor(
|
|
753
|
+
self,
|
|
754
|
+
*,
|
|
755
|
+
document: Document,
|
|
756
|
+
extraction_id: int,
|
|
757
|
+
page_number: int,
|
|
758
|
+
locator: Any,
|
|
759
|
+
snippet: str,
|
|
760
|
+
offsets: tuple[int, int] | None,
|
|
761
|
+
carried: dict[str, tuple[str, str]],
|
|
762
|
+
minted: dict[str, str],
|
|
763
|
+
now: str,
|
|
764
|
+
) -> None:
|
|
765
|
+
text = locator.format()
|
|
766
|
+
digest = snippet_hash(snippet)
|
|
767
|
+
previous = carried.get(text)
|
|
768
|
+
if previous is not None and previous[1] == digest:
|
|
769
|
+
token = previous[0]
|
|
770
|
+
# NOTE: an inherited hash length claims its prefix only if free; token
|
|
771
|
+
# stability outranks tidiness in the (vanishing) contested case.
|
|
772
|
+
minted.setdefault(parse_token(token).hash, digest)
|
|
773
|
+
else:
|
|
774
|
+
token = format_token(document.slug, locator, _mint(digest, minted))
|
|
775
|
+
|
|
776
|
+
start, end = offsets if offsets is not None else (None, None)
|
|
777
|
+
self._connection.execute(
|
|
778
|
+
"INSERT INTO anchors (extraction_id, page_number, kind, locator, snippet, "
|
|
779
|
+
"snippet_sha256, token, start_off, end_off, created_at) "
|
|
780
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
781
|
+
(
|
|
782
|
+
extraction_id,
|
|
783
|
+
page_number,
|
|
784
|
+
locator.kind,
|
|
785
|
+
text,
|
|
786
|
+
snippet,
|
|
787
|
+
digest,
|
|
788
|
+
token,
|
|
789
|
+
start,
|
|
790
|
+
end,
|
|
791
|
+
now,
|
|
792
|
+
),
|
|
793
|
+
)
|
|
794
|
+
if locator.kind != "page":
|
|
795
|
+
# NOTE: page anchors are deliberately not indexed — a page's text is
|
|
796
|
+
# the concatenation of its chunks, so indexing both would return every
|
|
797
|
+
# hit twice and let a whole page outrank the paragraph that matched.
|
|
798
|
+
self._connection.execute(
|
|
799
|
+
"INSERT INTO search (snippet, token, slug, page) VALUES (?, ?, ?, ?)",
|
|
800
|
+
(snippet, token, document.slug, str(page_number)),
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
def _cells_by_page(self, extraction_id: int) -> dict[int, tuple[CellValue, ...]]:
|
|
804
|
+
"""Cell values per page, rebuilt from the cell anchors that already hold them."""
|
|
805
|
+
cells: dict[int, list[CellValue]] = {}
|
|
806
|
+
for row in self._connection.execute(
|
|
807
|
+
"SELECT page_number, locator, snippet FROM anchors "
|
|
808
|
+
"WHERE extraction_id = ? AND kind = 'cell' ORDER BY id",
|
|
809
|
+
(extraction_id,),
|
|
810
|
+
):
|
|
811
|
+
locator = parse_locator(row["locator"])
|
|
812
|
+
cells.setdefault(row["page_number"], []).append(
|
|
813
|
+
CellValue(ref=locator.cell.format(), value=row["snippet"]) # type: ignore[union-attr]
|
|
814
|
+
)
|
|
815
|
+
return {number: tuple(values) for number, values in cells.items()}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# ---- module helpers ---------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _locations(
|
|
822
|
+
page: ExtractedPage, name: str | None
|
|
823
|
+
) -> list[tuple[Any, str, tuple[int, int] | None]]:
|
|
824
|
+
"""Every anchor a page yields: the page itself, then its chunks or cells.
|
|
825
|
+
|
|
826
|
+
One page-kind anchor always exists, even for a blank page, so `bd:doc:p4:...`
|
|
827
|
+
names something for every page number the document has.
|
|
828
|
+
"""
|
|
829
|
+
locations: list[tuple[Any, str, tuple[int, int] | None]] = [
|
|
830
|
+
(PageLocator(page=page.number), page.text, None)
|
|
831
|
+
]
|
|
832
|
+
if page.kind == "sheet":
|
|
833
|
+
for cell in page.cells or ():
|
|
834
|
+
locator = parse_locator(f"{name}!{cell.ref}")
|
|
835
|
+
locations.append((locator, cell.value, None))
|
|
836
|
+
return locations
|
|
837
|
+
for piece in chunking.chunk(page.text):
|
|
838
|
+
locations.append(
|
|
839
|
+
(
|
|
840
|
+
ChunkLocator(page=page.number, ordinal=piece.ordinal),
|
|
841
|
+
piece.text,
|
|
842
|
+
(piece.start, piece.end),
|
|
843
|
+
)
|
|
844
|
+
)
|
|
845
|
+
return locations
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _mint(digest: str, minted: dict[str, str]) -> str:
|
|
849
|
+
"""The shortest hash prefix that is unambiguous within this generation.
|
|
850
|
+
|
|
851
|
+
Steps 4 -> 6 -> 8. Two anchors with the *same* snippet share a prefix
|
|
852
|
+
happily; only two different snippets colliding force an extension.
|
|
853
|
+
"""
|
|
854
|
+
for length in TOKEN_HASH_LENGTHS:
|
|
855
|
+
prefix = digest[:length]
|
|
856
|
+
if minted.setdefault(prefix, digest) == digest:
|
|
857
|
+
return prefix
|
|
858
|
+
raise RegistryError(f"snippet hash {digest[:8]} collides at every minted length")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _dedupe(base: str, taken: set[str] | frozenset[str], limit: int) -> str:
|
|
862
|
+
"""`base`, or `base-2`, `base-3`, ... — whichever is free, within `limit` chars."""
|
|
863
|
+
if base not in taken:
|
|
864
|
+
return base
|
|
865
|
+
ordinal = 2
|
|
866
|
+
while True:
|
|
867
|
+
suffix = f"-{ordinal}"
|
|
868
|
+
candidate = f"{base[: limit - len(suffix)]}{suffix}"
|
|
869
|
+
if candidate not in taken:
|
|
870
|
+
return candidate
|
|
871
|
+
ordinal += 1
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _now() -> str:
|
|
875
|
+
"""ISO-8601 UTC, the only timestamp format in the registry."""
|
|
876
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _document(row: sqlite3.Row) -> Document:
|
|
880
|
+
return Document(
|
|
881
|
+
slug=row["slug"],
|
|
882
|
+
sha256=row["sha256"],
|
|
883
|
+
path=row["path"],
|
|
884
|
+
filename=row["filename"],
|
|
885
|
+
media_type=row["media_type"],
|
|
886
|
+
created_at=row["created_at"],
|
|
887
|
+
id=row["id"],
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _page(row: sqlite3.Row, cells: tuple[CellValue, ...]) -> Page:
|
|
892
|
+
return Page(
|
|
893
|
+
number=row["number"],
|
|
894
|
+
kind=row["kind"],
|
|
895
|
+
text=row["text"],
|
|
896
|
+
name=row["name"],
|
|
897
|
+
summary=row["summary"],
|
|
898
|
+
cells=cells,
|
|
899
|
+
extraction_id=row["extraction_id"],
|
|
900
|
+
id=row["id"],
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _anchor(row: sqlite3.Row, slug: str) -> Anchor:
|
|
905
|
+
return Anchor(
|
|
906
|
+
slug=slug,
|
|
907
|
+
locator=parse_locator(row["locator"]),
|
|
908
|
+
receipt=Receipt(snippet=row["snippet"], snippet_sha256=row["snippet_sha256"]),
|
|
909
|
+
token=row["token"],
|
|
910
|
+
extraction_id=row["extraction_id"],
|
|
911
|
+
page_number=row["page_number"],
|
|
912
|
+
start=row["start_off"],
|
|
913
|
+
end=row["end_off"],
|
|
914
|
+
id=row["id"],
|
|
915
|
+
)
|