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,291 @@
|
|
|
1
|
+
"""The citation token grammar: parse, format, validate.
|
|
2
|
+
|
|
3
|
+
A token is the textual name of an anchor — the thing a model transcribes into
|
|
4
|
+
authored text as a markdown link href: `[claim text](bd:t12-audit:p8.c3:a7f3)`.
|
|
5
|
+
|
|
6
|
+
EBNF (normative; prose copy in spec/tokens.md)::
|
|
7
|
+
|
|
8
|
+
token = "bd:" slug ":" locator ":" hash
|
|
9
|
+
slug = alnum-lower (alnum-lower | "-"){1,31} ; unique per registry
|
|
10
|
+
locator = page-loc | chunk-loc | cell-loc
|
|
11
|
+
page-loc = "p" int ; whole page
|
|
12
|
+
chunk-loc = "p" int "." "c" int ; chunk ordinal, 1-based
|
|
13
|
+
cell-loc = sheetref "!" cell [":" cell] ; cell or rectangular range
|
|
14
|
+
sheetref = slug-sanitized sheet name (no ":" "!" whitespace)
|
|
15
|
+
cell = column-letters row-int ; A1 notation, uppercase
|
|
16
|
+
hash = lowercase-hex{4,8} ; prefix of snippet sha256
|
|
17
|
+
|
|
18
|
+
Examples: ``bd:t12-audit:p8.c3:a7f3`` · ``bd:model:rent-roll!B10:9e2f``
|
|
19
|
+
· ``bd:t12-audit:p8:c114`` · ``bd:model:rent-roll!B10:C12:9e2f``
|
|
20
|
+
|
|
21
|
+
Wire form: one markdown link per claim span; multiple citations are `;`-separated
|
|
22
|
+
inside one href (`[claim](bd:...;bd:...)`). One grammar, no alternates.
|
|
23
|
+
|
|
24
|
+
Reserved and not implemented in v0: the derivation form ``bd:calc(<expr>)``.
|
|
25
|
+
`parse` raises `UnsupportedTokenError` for it — recognized, not supported.
|
|
26
|
+
|
|
27
|
+
Parsing is strict: no surrounding whitespace is tolerated and no canonicalization
|
|
28
|
+
is performed, so `format(parse(t)) == t` for every token this module accepts.
|
|
29
|
+
Callers that read from documents strip first (see `kernel.claims`).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
|
|
37
|
+
from .errors import MalformedTokenError, UnsupportedTokenError
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"PREFIX",
|
|
41
|
+
"SEPARATOR",
|
|
42
|
+
"Cell",
|
|
43
|
+
"PageLocator",
|
|
44
|
+
"ChunkLocator",
|
|
45
|
+
"CellLocator",
|
|
46
|
+
"Locator",
|
|
47
|
+
"Token",
|
|
48
|
+
"parse",
|
|
49
|
+
"parse_locator",
|
|
50
|
+
"format_token",
|
|
51
|
+
"format_locator",
|
|
52
|
+
"validate",
|
|
53
|
+
"split_href",
|
|
54
|
+
"is_token_href",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
PREFIX = "bd:"
|
|
58
|
+
"""Every token starts with this."""
|
|
59
|
+
|
|
60
|
+
SEPARATOR = ";"
|
|
61
|
+
"""Separates multiple citations inside one markdown href."""
|
|
62
|
+
|
|
63
|
+
_RESERVED_DERIVATION = "calc("
|
|
64
|
+
"""Reserved derivation form: `bd:calc(<expr over tokens>)`. Grammar TBD."""
|
|
65
|
+
|
|
66
|
+
_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{1,31}")
|
|
67
|
+
_HASH_RE = re.compile(r"[0-9a-f]{4,8}")
|
|
68
|
+
# NOTE: the spec constrains sheetrefs to exclude ":" "!" and whitespace. We also
|
|
69
|
+
# exclude ";" "(" ")" because those terminate a citation or a markdown link — a
|
|
70
|
+
# sheet name containing them could not survive the wire form.
|
|
71
|
+
_SHEETREF_RE = re.compile(r"[^:!;()\s]+")
|
|
72
|
+
# NOTE: integers are canonical — no leading zeros — so parse/format round-trips.
|
|
73
|
+
_INT_RE = r"(?:0|[1-9][0-9]*)"
|
|
74
|
+
_PAGE_RE = re.compile(rf"p(?P<page>{_INT_RE})")
|
|
75
|
+
_CHUNK_RE = re.compile(rf"p(?P<page>{_INT_RE})\.c(?P<ordinal>{_INT_RE})")
|
|
76
|
+
_CELL_RE = re.compile(
|
|
77
|
+
rf"(?P<sheet>[^:!;()\s]+)!"
|
|
78
|
+
rf"(?P<col>[A-Z]+)(?P<row>{_INT_RE})"
|
|
79
|
+
rf"(?::(?P<col2>[A-Z]+)(?P<row2>{_INT_RE}))?"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class Cell:
|
|
85
|
+
"""An A1-notation cell reference: uppercase column letters + 1-based row."""
|
|
86
|
+
|
|
87
|
+
column: str
|
|
88
|
+
row: int
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
if not re.fullmatch(r"[A-Z]+", self.column):
|
|
92
|
+
raise MalformedTokenError(f"cell column must be uppercase letters: {self.column!r}")
|
|
93
|
+
if self.row < 1:
|
|
94
|
+
raise MalformedTokenError(f"cell row must be 1-based: {self.row!r}")
|
|
95
|
+
|
|
96
|
+
def format(self) -> str:
|
|
97
|
+
return f"{self.column}{self.row}"
|
|
98
|
+
|
|
99
|
+
def __str__(self) -> str:
|
|
100
|
+
return self.format()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class PageLocator:
|
|
105
|
+
"""A whole page or sheet: `p8`."""
|
|
106
|
+
|
|
107
|
+
page: int
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
if self.page < 1:
|
|
111
|
+
raise MalformedTokenError(f"page numbers are 1-based: {self.page!r}")
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def kind(self) -> str:
|
|
115
|
+
return "page"
|
|
116
|
+
|
|
117
|
+
def format(self) -> str:
|
|
118
|
+
return f"p{self.page}"
|
|
119
|
+
|
|
120
|
+
def __str__(self) -> str:
|
|
121
|
+
return self.format()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class ChunkLocator:
|
|
126
|
+
"""A chunk within a page: `p8.c3`. Both numbers are 1-based."""
|
|
127
|
+
|
|
128
|
+
page: int
|
|
129
|
+
ordinal: int
|
|
130
|
+
|
|
131
|
+
def __post_init__(self) -> None:
|
|
132
|
+
if self.page < 1:
|
|
133
|
+
raise MalformedTokenError(f"page numbers are 1-based: {self.page!r}")
|
|
134
|
+
if self.ordinal < 1:
|
|
135
|
+
raise MalformedTokenError(f"chunk ordinals are 1-based: {self.ordinal!r}")
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def kind(self) -> str:
|
|
139
|
+
return "chunk"
|
|
140
|
+
|
|
141
|
+
def format(self) -> str:
|
|
142
|
+
return f"p{self.page}.c{self.ordinal}"
|
|
143
|
+
|
|
144
|
+
def __str__(self) -> str:
|
|
145
|
+
return self.format()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class CellLocator:
|
|
150
|
+
"""A cell or rectangular range within a sheet: `rent-roll!B10`, `rent-roll!B10:C12`.
|
|
151
|
+
|
|
152
|
+
`kind` is `"cell"` when `end` is absent and `"range"` when it is present,
|
|
153
|
+
matching the `anchors.kind` column.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
sheet: str
|
|
157
|
+
cell: Cell
|
|
158
|
+
end: Cell | None = None
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
if not _SHEETREF_RE.fullmatch(self.sheet):
|
|
162
|
+
raise MalformedTokenError(f"invalid sheet reference: {self.sheet!r}")
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def kind(self) -> str:
|
|
166
|
+
return "range" if self.end is not None else "cell"
|
|
167
|
+
|
|
168
|
+
def format(self) -> str:
|
|
169
|
+
span = self.cell.format()
|
|
170
|
+
if self.end is not None:
|
|
171
|
+
span = f"{span}:{self.end.format()}"
|
|
172
|
+
return f"{self.sheet}!{span}"
|
|
173
|
+
|
|
174
|
+
def __str__(self) -> str:
|
|
175
|
+
return self.format()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
type Locator = PageLocator | ChunkLocator | CellLocator
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True, slots=True)
|
|
182
|
+
class Token:
|
|
183
|
+
"""A parsed citation token: `bd:<slug>:<locator>:<hash>`."""
|
|
184
|
+
|
|
185
|
+
slug: str
|
|
186
|
+
locator: Locator
|
|
187
|
+
hash: str
|
|
188
|
+
|
|
189
|
+
def __post_init__(self) -> None:
|
|
190
|
+
if not _SLUG_RE.fullmatch(self.slug):
|
|
191
|
+
raise MalformedTokenError(f"invalid slug: {self.slug!r}")
|
|
192
|
+
if not _HASH_RE.fullmatch(self.hash):
|
|
193
|
+
raise MalformedTokenError(f"invalid hash: {self.hash!r}")
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def kind(self) -> str:
|
|
197
|
+
"""The anchor kind this token names: `page` | `chunk` | `cell` | `range`."""
|
|
198
|
+
return self.locator.kind
|
|
199
|
+
|
|
200
|
+
def format(self) -> str:
|
|
201
|
+
return f"{PREFIX}{self.slug}:{self.locator.format()}:{self.hash}"
|
|
202
|
+
|
|
203
|
+
def __str__(self) -> str:
|
|
204
|
+
return self.format()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse(text: str) -> Token:
|
|
208
|
+
"""Parse a token. Strict: no whitespace tolerance, no canonicalization.
|
|
209
|
+
|
|
210
|
+
Raises `UnsupportedTokenError` for the reserved `bd:calc(...)` derivation
|
|
211
|
+
form and `MalformedTokenError` for everything else that is not a token.
|
|
212
|
+
"""
|
|
213
|
+
if not isinstance(text, str):
|
|
214
|
+
raise MalformedTokenError(f"token must be a string: {text!r}")
|
|
215
|
+
if not text.startswith(PREFIX):
|
|
216
|
+
raise MalformedTokenError(f"token must start with {PREFIX!r}: {text!r}")
|
|
217
|
+
body = text[len(PREFIX) :]
|
|
218
|
+
if body.startswith(_RESERVED_DERIVATION):
|
|
219
|
+
raise UnsupportedTokenError(
|
|
220
|
+
f"reserved derivation form 'bd:calc(...)' is not supported in v0: {text!r}"
|
|
221
|
+
)
|
|
222
|
+
slug, sep, rest = body.partition(":")
|
|
223
|
+
if not sep:
|
|
224
|
+
raise MalformedTokenError(f"token needs slug, locator and hash segments: {text!r}")
|
|
225
|
+
locator_text, sep, hash_text = rest.rpartition(":")
|
|
226
|
+
if not sep:
|
|
227
|
+
raise MalformedTokenError(f"token is missing its hash segment: {text!r}")
|
|
228
|
+
if not _SLUG_RE.fullmatch(slug):
|
|
229
|
+
raise MalformedTokenError(f"invalid slug {slug!r} in token: {text!r}")
|
|
230
|
+
if not _HASH_RE.fullmatch(hash_text):
|
|
231
|
+
raise MalformedTokenError(f"invalid hash {hash_text!r} in token: {text!r}")
|
|
232
|
+
return Token(slug=slug, locator=parse_locator(locator_text), hash=hash_text)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_locator(text: str) -> Locator:
|
|
236
|
+
"""Parse the locator segment of a token into a typed locator."""
|
|
237
|
+
if match := _CHUNK_RE.fullmatch(text):
|
|
238
|
+
return ChunkLocator(page=int(match["page"]), ordinal=int(match["ordinal"]))
|
|
239
|
+
if match := _PAGE_RE.fullmatch(text):
|
|
240
|
+
return PageLocator(page=int(match["page"]))
|
|
241
|
+
if match := _CELL_RE.fullmatch(text):
|
|
242
|
+
end = None
|
|
243
|
+
if match["col2"] is not None:
|
|
244
|
+
end = Cell(column=match["col2"], row=int(match["row2"]))
|
|
245
|
+
return CellLocator(
|
|
246
|
+
sheet=match["sheet"],
|
|
247
|
+
cell=Cell(column=match["col"], row=int(match["row"])),
|
|
248
|
+
end=end,
|
|
249
|
+
)
|
|
250
|
+
raise MalformedTokenError(f"invalid locator: {text!r}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def format_token(slug: str, locator: Locator, hash: str) -> str: # noqa: A002 - spec vocabulary
|
|
254
|
+
"""Format a token from its parts, validating them."""
|
|
255
|
+
return Token(slug=slug, locator=locator, hash=hash).format()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def format_locator(locator: Locator) -> str:
|
|
259
|
+
"""Format a locator exactly as it appears in a token."""
|
|
260
|
+
return locator.format()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def validate(text: str) -> bool:
|
|
264
|
+
"""True iff `text` is a token this version can parse.
|
|
265
|
+
|
|
266
|
+
The reserved `bd:calc(...)` form is *not* valid: it is recognized but
|
|
267
|
+
unsupported, and callers that must distinguish should catch the errors.
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
parse(text)
|
|
271
|
+
except (MalformedTokenError, UnsupportedTokenError):
|
|
272
|
+
return False
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def split_href(href: str) -> list[str]:
|
|
277
|
+
"""Split a markdown href into its citation candidates.
|
|
278
|
+
|
|
279
|
+
Multiple citations are `;`-separated. Each piece is stripped; empty pieces
|
|
280
|
+
(a stray or trailing `;`) carry no token and are dropped.
|
|
281
|
+
"""
|
|
282
|
+
return [piece for piece in (raw.strip() for raw in href.split(SEPARATOR)) if piece]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def is_token_href(href: str) -> bool:
|
|
286
|
+
"""True if any `;`-separated piece of `href` claims to be a backdraft token.
|
|
287
|
+
|
|
288
|
+
Deliberately shallow: an href with a `bd:` piece is a citation href even if
|
|
289
|
+
that piece is malformed, so bind reports it instead of ignoring it.
|
|
290
|
+
"""
|
|
291
|
+
return any(piece.startswith(PREFIX) for piece in split_href(href))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The registry: SQLite + FTS5 in `.backdraft/`, behind one object.
|
|
2
|
+
|
|
3
|
+
`Registry` is the only stateful thing in backdraft. The gate, bind and render
|
|
4
|
+
layers consume the surface in SPEC.md § Addendum A and implement none of it.
|
|
5
|
+
|
|
6
|
+
Export style (every package above the kernel uses it): this file re-exports
|
|
7
|
+
**names**, never submodules, and only the ones a consumer outside the package
|
|
8
|
+
imports — `store`'s own `__all__` is wider and is its internal contract. Reaching
|
|
9
|
+
past this surface (`registry.store.something`) is a signal that the something
|
|
10
|
+
belongs here.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .store import (
|
|
16
|
+
DIRECTORY,
|
|
17
|
+
EXPORT_FORMAT,
|
|
18
|
+
Registry,
|
|
19
|
+
RegistryError,
|
|
20
|
+
Resolution,
|
|
21
|
+
SearchHit,
|
|
22
|
+
SearchResults,
|
|
23
|
+
media_type_for,
|
|
24
|
+
sanitize_sheet_name,
|
|
25
|
+
slug_for,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DIRECTORY",
|
|
30
|
+
"EXPORT_FORMAT",
|
|
31
|
+
"Registry",
|
|
32
|
+
"RegistryError",
|
|
33
|
+
"Resolution",
|
|
34
|
+
"SearchHit",
|
|
35
|
+
"SearchResults",
|
|
36
|
+
"media_type_for",
|
|
37
|
+
"sanitize_sheet_name",
|
|
38
|
+
"slug_for",
|
|
39
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The ledger: every token minted into a writer's context, per session.
|
|
2
|
+
|
|
3
|
+
This is what lets bind distinguish "cited what you were shown" from "cited a
|
|
4
|
+
valid token you were never shown" — the hallucination class the app could not
|
|
5
|
+
express. The gate writes here on every emitted token; bind reads.
|
|
6
|
+
|
|
7
|
+
Plain functions over a connection rather than a second stateful object: the
|
|
8
|
+
Registry is the only stateful thing in the system, and it delegates here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Sequence
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ensure_session",
|
|
19
|
+
"record_shown",
|
|
20
|
+
"sessions",
|
|
21
|
+
"was_shown",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_session(
|
|
26
|
+
connection: sqlite3.Connection, session_id: str | None, label: str | None, now: str
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Return an existing or freshly created session id.
|
|
29
|
+
|
|
30
|
+
A `None` id mints one. NOTE: the spec says "caller-supplied or generated"
|
|
31
|
+
without fixing the generated form; a uuid4 hex is opaque, collision-free and
|
|
32
|
+
short enough to paste into `--session`.
|
|
33
|
+
"""
|
|
34
|
+
resolved = session_id or uuid.uuid4().hex
|
|
35
|
+
connection.execute(
|
|
36
|
+
"INSERT INTO sessions (id, label, started_at) VALUES (?, ?, ?) "
|
|
37
|
+
"ON CONFLICT(id) DO NOTHING",
|
|
38
|
+
(resolved, label, now),
|
|
39
|
+
)
|
|
40
|
+
return resolved
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def record_shown(
|
|
44
|
+
connection: sqlite3.Connection,
|
|
45
|
+
session_id: str,
|
|
46
|
+
anchor_ids: Sequence[int],
|
|
47
|
+
now: str,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Record that these anchors were shown in this session.
|
|
50
|
+
|
|
51
|
+
Idempotent: showing the same anchor twice keeps the first `shown_at`, because
|
|
52
|
+
what bind asks is whether the writer ever saw it.
|
|
53
|
+
"""
|
|
54
|
+
connection.executemany(
|
|
55
|
+
"INSERT INTO ledger (session_id, anchor_id, shown_at) VALUES (?, ?, ?) "
|
|
56
|
+
"ON CONFLICT(session_id, anchor_id) DO NOTHING",
|
|
57
|
+
[(session_id, int(anchor_id), now) for anchor_id in anchor_ids],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def was_shown(connection: sqlite3.Connection, session_id: str, token: str) -> bool:
|
|
62
|
+
"""True if any anchor named by `token` was shown in this session.
|
|
63
|
+
|
|
64
|
+
Matched by token, not by anchor id: a token that survived a re-ingest names a
|
|
65
|
+
new anchor row with the same name, and the writer did see it.
|
|
66
|
+
"""
|
|
67
|
+
row = connection.execute(
|
|
68
|
+
"SELECT 1 FROM ledger JOIN anchors ON anchors.id = ledger.anchor_id "
|
|
69
|
+
"WHERE ledger.session_id = ? AND anchors.token = ? LIMIT 1",
|
|
70
|
+
(session_id, token),
|
|
71
|
+
).fetchone()
|
|
72
|
+
return row is not None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sessions(connection: sqlite3.Connection) -> list[dict]:
|
|
76
|
+
"""Every session, oldest first."""
|
|
77
|
+
return [
|
|
78
|
+
{"id": row["id"], "label": row["label"], "started_at": row["started_at"]}
|
|
79
|
+
for row in connection.execute(
|
|
80
|
+
"SELECT id, label, started_at FROM sessions ORDER BY started_at, id"
|
|
81
|
+
)
|
|
82
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
-- The DDL. Single source of truth for registry shape (SPEC.md § Registry).
|
|
2
|
+
--
|
|
3
|
+
-- Applied by `Registry.open` against a fresh `.backdraft/registry.db`; every
|
|
4
|
+
-- statement is idempotent so opening an existing registry is a no-op.
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
7
|
+
id INTEGER PRIMARY KEY,
|
|
8
|
+
slug TEXT NOT NULL UNIQUE,
|
|
9
|
+
sha256 TEXT NOT NULL UNIQUE,
|
|
10
|
+
path TEXT NOT NULL, -- as given at ingest; informational
|
|
11
|
+
filename TEXT NOT NULL,
|
|
12
|
+
media_type TEXT NOT NULL, -- 'pdf' | 'xlsx' | 'text'
|
|
13
|
+
created_at TEXT NOT NULL -- ISO-8601 UTC, everywhere
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS extractions (
|
|
17
|
+
id INTEGER PRIMARY KEY,
|
|
18
|
+
document_id INTEGER NOT NULL REFERENCES documents(id),
|
|
19
|
+
extractor TEXT NOT NULL, -- 'pdf-text' | 'xlsx' | 'text' | 'vlm' | ...
|
|
20
|
+
extractor_version TEXT NOT NULL,
|
|
21
|
+
config_hash TEXT NOT NULL,
|
|
22
|
+
deterministic INTEGER NOT NULL,
|
|
23
|
+
is_current INTEGER NOT NULL DEFAULT 1, -- one current per document (partial unique index)
|
|
24
|
+
created_at TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- The "one current generation per document" rule, enforced by the database
|
|
28
|
+
-- rather than by the code that flips the flag.
|
|
29
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_extractions_current
|
|
30
|
+
ON extractions(document_id) WHERE is_current = 1;
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
33
|
+
id INTEGER PRIMARY KEY,
|
|
34
|
+
extraction_id INTEGER NOT NULL REFERENCES extractions(id),
|
|
35
|
+
number INTEGER NOT NULL, -- 1-based
|
|
36
|
+
kind TEXT NOT NULL, -- 'page' | 'sheet'
|
|
37
|
+
name TEXT, -- sheet name for kind='sheet'
|
|
38
|
+
text TEXT NOT NULL, -- the snapshot; receipts quote THIS
|
|
39
|
+
summary TEXT, -- optional, for TOC
|
|
40
|
+
UNIQUE (extraction_id, number)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS page_images (
|
|
44
|
+
extraction_id INTEGER NOT NULL REFERENCES extractions(id),
|
|
45
|
+
number INTEGER NOT NULL, -- matches pages.number
|
|
46
|
+
format TEXT NOT NULL, -- 'webp' | 'png' | 'jpeg'
|
|
47
|
+
width INTEGER NOT NULL,
|
|
48
|
+
height INTEGER NOT NULL,
|
|
49
|
+
data BLOB NOT NULL, -- the page as the extractor saw it
|
|
50
|
+
PRIMARY KEY (extraction_id, number)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS anchors (
|
|
54
|
+
id INTEGER PRIMARY KEY,
|
|
55
|
+
extraction_id INTEGER NOT NULL REFERENCES extractions(id),
|
|
56
|
+
page_number INTEGER NOT NULL,
|
|
57
|
+
kind TEXT NOT NULL, -- 'chunk' | 'cell' | 'range' | 'page'
|
|
58
|
+
locator TEXT NOT NULL, -- exactly the token's locator segment
|
|
59
|
+
snippet TEXT NOT NULL, -- verbatim from the snapshot (the receipt)
|
|
60
|
+
snippet_sha256 TEXT NOT NULL,
|
|
61
|
+
token TEXT NOT NULL, -- full bd:... string
|
|
62
|
+
start_off INTEGER, end_off INTEGER,
|
|
63
|
+
created_at TEXT NOT NULL,
|
|
64
|
+
UNIQUE (extraction_id, locator)
|
|
65
|
+
);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_anchors_token ON anchors(token); -- token repeats across generations when content unchanged
|
|
67
|
+
|
|
68
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
69
|
+
id TEXT PRIMARY KEY, -- caller-supplied or generated
|
|
70
|
+
label TEXT,
|
|
71
|
+
started_at TEXT NOT NULL
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
75
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
76
|
+
anchor_id INTEGER NOT NULL REFERENCES anchors(id),
|
|
77
|
+
shown_at TEXT NOT NULL,
|
|
78
|
+
PRIMARY KEY (session_id, anchor_id)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS bindings (
|
|
82
|
+
id INTEGER PRIMARY KEY,
|
|
83
|
+
doc_path TEXT NOT NULL,
|
|
84
|
+
session_id TEXT,
|
|
85
|
+
mode TEXT NOT NULL, -- 'frontwalk' | 'backfill'
|
|
86
|
+
report_json TEXT NOT NULL, -- full BindReport
|
|
87
|
+
bound_at TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(snippet, token UNINDEXED, slug UNINDEXED, page UNINDEXED);
|