backdraft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )