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,123 @@
|
|
|
1
|
+
"""The search side of the gate: FTS results that are themselves citable.
|
|
2
|
+
|
|
3
|
+
A searched snippet is minted exactly like a read one. This is the whole point of
|
|
4
|
+
routing search through the gate: in the app that preceded backdraft, search
|
|
5
|
+
returned text with nothing citable attached, so every search was followed by a
|
|
6
|
+
page read purely to obtain an anchor. Here the result *is* the anchor, and the
|
|
7
|
+
read hint below the results is an affordance, not a prerequisite.
|
|
8
|
+
|
|
9
|
+
Consumes the pinned registry surface (SPEC Addendum A) and nothing else.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from ..kernel.hashing import normalize
|
|
17
|
+
|
|
18
|
+
from .reader import GateError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
|
|
23
|
+
from ..registry.store import Registry, SearchHit
|
|
24
|
+
|
|
25
|
+
__all__ = ["EXCERPT_CHARS", "PHRASE_FALLBACK_NOTE", "search", "render_search"]
|
|
26
|
+
|
|
27
|
+
EXCERPT_CHARS = 160
|
|
28
|
+
"""How much of a snippet a result line shows before deferring to a page read."""
|
|
29
|
+
|
|
30
|
+
PHRASE_FALLBACK_NOTE = "(query retried as a phrase)"
|
|
31
|
+
"""Shown when the registry could not parse the query as FTS5 syntax.
|
|
32
|
+
|
|
33
|
+
The retry changes the question: `NOI 1.42x` as a boolean query asks for both
|
|
34
|
+
terms anywhere, as a phrase it asks for those tokens adjacent and in order. A
|
|
35
|
+
reader who is not told cannot distinguish "no such fact" from "not asked that
|
|
36
|
+
way", so the gate says it — once, on its own line, rather than as an error.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_ELLIPSIS = "..."
|
|
40
|
+
_DEFAULT_LIMIT = 20
|
|
41
|
+
"""Mirrors `Registry.search`'s own default, so the CLI and the store agree."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def search(
|
|
45
|
+
registry: Registry,
|
|
46
|
+
query: str,
|
|
47
|
+
*,
|
|
48
|
+
slug: str | None = None,
|
|
49
|
+
limit: int = _DEFAULT_LIMIT,
|
|
50
|
+
session: str | None = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Run `query` through the registry's FTS index and render the results.
|
|
53
|
+
|
|
54
|
+
Each hit is one line carrying its token, its document and page, and an
|
|
55
|
+
excerpt of its snippet; a read hint follows for every distinct page matched.
|
|
56
|
+
Every hit's anchor is recorded in `session` before the text is returned — a
|
|
57
|
+
result the writer saw is a result the writer may cite.
|
|
58
|
+
|
|
59
|
+
Raises `GateError` if `slug` names no document.
|
|
60
|
+
"""
|
|
61
|
+
if slug is not None and registry.document(slug) is None:
|
|
62
|
+
raise GateError(f"no such document: {slug!r}")
|
|
63
|
+
hits = registry.search(query, slug=slug, limit=limit)
|
|
64
|
+
_mint(registry, session, hits)
|
|
65
|
+
return render_search(query, hits, slug=slug)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def render_search(query: str, hits: Iterable[SearchHit], *, slug: str | None = None) -> str:
|
|
69
|
+
"""Render search results. Pure: minting happens in `search`.
|
|
70
|
+
|
|
71
|
+
NOTE: the `phrase_fallback` flag is read *before* `hits` is copied into a
|
|
72
|
+
plain list — it rides on the result object the registry returned.
|
|
73
|
+
"""
|
|
74
|
+
retried = bool(getattr(hits, "phrase_fallback", False))
|
|
75
|
+
hits = list(hits)
|
|
76
|
+
scope = f" in {slug}" if slug else ""
|
|
77
|
+
note = [PHRASE_FALLBACK_NOTE] if retried else []
|
|
78
|
+
if not hits:
|
|
79
|
+
return "\n".join(
|
|
80
|
+
[
|
|
81
|
+
f'No results for "{query}"{scope}.',
|
|
82
|
+
*note,
|
|
83
|
+
"",
|
|
84
|
+
"[List documents: backdraft read]",
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
count = f"{len(hits)} result" if len(hits) == 1 else f"{len(hits)} results"
|
|
89
|
+
lines = [f'{count} for "{query}"{scope}', *note, ""]
|
|
90
|
+
for hit in hits:
|
|
91
|
+
lines.append(f"[{hit.anchor.token}] {hit.slug} p{hit.page_number}")
|
|
92
|
+
lines.append(f" {_excerpt(hit.anchor.receipt.snippet)}")
|
|
93
|
+
lines.append("")
|
|
94
|
+
|
|
95
|
+
seen: list[tuple[str, int]] = []
|
|
96
|
+
for hit in hits:
|
|
97
|
+
if (hit.slug, hit.page_number) not in seen:
|
|
98
|
+
seen.append((hit.slug, hit.page_number))
|
|
99
|
+
lines += [f"[Read the page: backdraft read {s} p{n}]" for s, n in seen]
|
|
100
|
+
return "\n".join(line.rstrip() for line in lines).rstrip("\n")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _excerpt(snippet: str) -> str:
|
|
104
|
+
"""A snippet collapsed onto one line and cut to `EXCERPT_CHARS`.
|
|
105
|
+
|
|
106
|
+
NOTE: the cut is from the start rather than centred on the match. FTS5
|
|
107
|
+
decides what matched (stemming, phrase queries), and the gate does not
|
|
108
|
+
re-derive anything the registry owns; the token on the line above is the
|
|
109
|
+
thing to cite, and the read hint is the way to see the rest.
|
|
110
|
+
"""
|
|
111
|
+
text = normalize(snippet)
|
|
112
|
+
return text if len(text) <= EXCERPT_CHARS else text[:EXCERPT_CHARS] + _ELLIPSIS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _mint(registry: Registry, session: str | None, hits: Iterable[SearchHit]) -> None:
|
|
116
|
+
"""Record every result's anchor under the session."""
|
|
117
|
+
if session is None:
|
|
118
|
+
return
|
|
119
|
+
anchor_ids = sorted({hit.anchor.id for hit in hits if hit.anchor.id is not None})
|
|
120
|
+
if not anchor_ids:
|
|
121
|
+
return
|
|
122
|
+
session_id = registry.ensure_session(session)
|
|
123
|
+
registry.record_shown(session_id, anchor_ids)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The kernel: pure, stdlib-only, imports nothing else from the package.
|
|
2
|
+
|
|
3
|
+
Grammar, hashing, chunking, claim parsing, the artifact format and the vocabulary
|
|
4
|
+
of types. No I/O, no SQLite, no configuration. Everything above this layer —
|
|
5
|
+
registry, extract, gate, bind, render — imports the kernel; the kernel imports
|
|
6
|
+
none of them. `tests/test_invariants.py` enforces that by reading this source.
|
|
7
|
+
|
|
8
|
+
**The module paths are the API.** There is deliberately no flat re-export surface
|
|
9
|
+
here: callers write `from backdraft.kernel.tokens import parse`, not
|
|
10
|
+
`from backdraft.kernel import parse_token`. Two reasons. The module a name lives
|
|
11
|
+
in is information — `hashing.normalize` and a bare `normalize` are not equally
|
|
12
|
+
readable — and a flat surface has to invent disambiguating aliases (`parse`, in
|
|
13
|
+
three different modules, became `parse_token` / `parse_locator` /
|
|
14
|
+
`parse_citation`) that name nothing actually defined anywhere. Each submodule's
|
|
15
|
+
own `__all__` is its contract; this package has none.
|
|
16
|
+
"""
|
|
17
|
+
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The artifact format: its version string, its legend, its file names, its payload.
|
|
2
|
+
|
|
3
|
+
Kernel-owned because the format is shared vocabulary: bind writes the sidecar,
|
|
4
|
+
render reads and re-emits it, and neither may import the other. The prose spec
|
|
5
|
+
is `spec/artifact.md`; this module is its single in-code copy.
|
|
6
|
+
|
|
7
|
+
**Naming is part of the format.** What a bind run and a render run are called on
|
|
8
|
+
disk is how a reader (person, agent, or another implementation) finds the pieces
|
|
9
|
+
of one document's record, so the suffixes and the path math live here rather than
|
|
10
|
+
in whichever layer happens to write each file. `spec/artifact.md` § Naming is the
|
|
11
|
+
normative statement; the constants below are its in-code copy.
|
|
12
|
+
|
|
13
|
+
NOTE on kernel purity: `pathlib` is imported for path *math* only. Nothing here
|
|
14
|
+
touches the filesystem — these functions compute names, they do not read or write
|
|
15
|
+
(`tests/test_invariants.py` enforces that distinction).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .model import BindReport
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"FORMAT",
|
|
28
|
+
"LEGEND",
|
|
29
|
+
"ARTIFACT_SUFFIX",
|
|
30
|
+
"BOUND_SUFFIX",
|
|
31
|
+
"FOOTNOTES_SUFFIX",
|
|
32
|
+
"SIDECAR_SUFFIX",
|
|
33
|
+
"bound_path",
|
|
34
|
+
"sidecar_path",
|
|
35
|
+
"RECORDS_DIR",
|
|
36
|
+
"record_path",
|
|
37
|
+
"sidecar",
|
|
38
|
+
"dumps",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
FORMAT = "backdraft/artifact-v1"
|
|
42
|
+
"""The artifact format string. Readers match it exactly; see `LEGEND["version"]`."""
|
|
43
|
+
|
|
44
|
+
# ---- the naming family ------------------------------------------------------
|
|
45
|
+
#
|
|
46
|
+
# Every name is `<stem of the authored document>` + one of these suffixes, so a
|
|
47
|
+
# single `memo.md` produces `memo.bound.md`, `memo.backdraft.json`,
|
|
48
|
+
# `memo.backdraft.html` and `memo.footnotes.md`, and a directory listing sorts
|
|
49
|
+
# them together. The `.backdraft.*` pair is the artifact record proper — the same
|
|
50
|
+
# payload, machine-readable and human-readable; the other two are projections.
|
|
51
|
+
|
|
52
|
+
BOUND_SUFFIX = ".bound.md"
|
|
53
|
+
"""bind's rewritten document: `memo.md` -> `memo.bound.md`."""
|
|
54
|
+
|
|
55
|
+
SIDECAR_SUFFIX = ".backdraft.json"
|
|
56
|
+
"""A document's sidecar sits beside it: `memo.md` -> `memo.backdraft.json`."""
|
|
57
|
+
|
|
58
|
+
ARTIFACT_SUFFIX = ".backdraft.html"
|
|
59
|
+
"""The self-contained artifact: `memo.md` -> `memo.backdraft.html`."""
|
|
60
|
+
|
|
61
|
+
FOOTNOTES_SUFFIX = ".footnotes.md"
|
|
62
|
+
"""render's markdown projection: `memo.md` -> `memo.footnotes.md`.
|
|
63
|
+
|
|
64
|
+
Named for what it is rather than `.backdraft.md`, which would join the sidecar's
|
|
65
|
+
family without being the record, and rather than `.bound.md`, which is bind's
|
|
66
|
+
output and is a different document.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def bound_path(doc_path: Path) -> Path:
|
|
71
|
+
"""Where bind's rewritten document goes."""
|
|
72
|
+
return doc_path.with_name(doc_path.stem + BOUND_SUFFIX)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sidecar_path(doc_path: Path) -> Path:
|
|
76
|
+
"""The beside-the-document record path: `memo.md` -> `memo.backdraft.json`.
|
|
77
|
+
|
|
78
|
+
This is the *portable* location — the one a reader handed a document and
|
|
79
|
+
its record uses. A project with a registry stores records out of sight
|
|
80
|
+
instead; see `record_path`.
|
|
81
|
+
"""
|
|
82
|
+
return doc_path.with_name(doc_path.stem + SIDECAR_SUFFIX)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
RECORDS_DIR = "records"
|
|
86
|
+
"""Records live under `.backdraft/records/`, mirroring each document's path
|
|
87
|
+
relative to the project root — the authored directory shows only the document
|
|
88
|
+
and its artifact."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def record_path(root: Path, doc_path: Path) -> Path:
|
|
92
|
+
"""Where a project stores a document's record: out of the authored directory.
|
|
93
|
+
|
|
94
|
+
`<root>/.backdraft/records/<doc's path relative to root>` with the sidecar
|
|
95
|
+
suffix, so distinct documents can never collide. A document outside `root`
|
|
96
|
+
falls back to the beside-the-document path.
|
|
97
|
+
|
|
98
|
+
Pure path math (the kernel invariant): both arguments must already be
|
|
99
|
+
resolved to comparable absolute forms — callers hold the filesystem.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
relative = Path(doc_path).relative_to(Path(root))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return sidecar_path(doc_path)
|
|
105
|
+
return (
|
|
106
|
+
Path(root) / ".backdraft" / RECORDS_DIR / relative.parent
|
|
107
|
+
/ (relative.stem + SIDECAR_SUFFIX)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
LEGEND: dict[str, Any] = {
|
|
112
|
+
"what_this_is": (
|
|
113
|
+
"A bound document: every claim its author cited, recorded with the verbatim "
|
|
114
|
+
"evidence behind it. This legend describes the object around it so that a "
|
|
115
|
+
"reader who has never seen backdraft — person or model — can use the record "
|
|
116
|
+
"without any other file, any registry, and any network access."
|
|
117
|
+
),
|
|
118
|
+
"how_to_read": [
|
|
119
|
+
"`claims` is the record, in document order. Each claim is a span of the "
|
|
120
|
+
"authored document: `text` is the words the citations support, and "
|
|
121
|
+
"`start`/`end` are character offsets into the authored source, bounding the "
|
|
122
|
+
"whole markdown construct the author wrote.",
|
|
123
|
+
"Each entry in a claim's `citations` names one anchor by its `token` and "
|
|
124
|
+
"carries what binding found: a `status`, an `anchor` when one was found, "
|
|
125
|
+
"`drifted_from` when the source moved, `error` when the token did not parse, "
|
|
126
|
+
"and `verdicts` from whichever verification methods were switched on.",
|
|
127
|
+
"An `anchor` is the receipt: `slug` names the source document, `locator` "
|
|
128
|
+
"names the place inside it, `snippet` is the verbatim text that was quoted, "
|
|
129
|
+
"and `snippet_sha256` is that snippet's hash. The snippet is present in "
|
|
130
|
+
"full — you never need the source document to read the evidence.",
|
|
131
|
+
"A claim with `unmatched` true is one that binding could not anchor at all. "
|
|
132
|
+
"It is listed because it was written, not because it was verified.",
|
|
133
|
+
"Every citation whose `status` is not `resolved` is a failure that was kept "
|
|
134
|
+
"rather than hidden. Read those first; they are the interesting part.",
|
|
135
|
+
"`summary` is derived from `claims` and is never authoritative on its own. "
|
|
136
|
+
"If the two disagree, recount from `claims`.",
|
|
137
|
+
],
|
|
138
|
+
"token": (
|
|
139
|
+
"`bd:<slug>:<locator>:<hash>` is the textual name of an anchor. `hash` is the "
|
|
140
|
+
"leading 4 to 8 hex characters of sha256 over the normalized snippet, where "
|
|
141
|
+
"normalize is: Unicode NFC, then every whitespace run collapsed to one space, "
|
|
142
|
+
"then strip, case preserved."
|
|
143
|
+
),
|
|
144
|
+
"locator_forms": {
|
|
145
|
+
"p8": "page or sheet 8, whole",
|
|
146
|
+
"p8.c3": (
|
|
147
|
+
"chunk 3 within page 8 — chunks are deterministic paragraph-scale "
|
|
148
|
+
"subdivisions of a page, numbered from 1"
|
|
149
|
+
),
|
|
150
|
+
"rent-roll!B10": "sheet `rent-roll`, cell B10, A1 notation",
|
|
151
|
+
"rent-roll!B10:C12": "sheet `rent-roll`, rectangular range",
|
|
152
|
+
},
|
|
153
|
+
"citation_status": {
|
|
154
|
+
"resolved": (
|
|
155
|
+
"the anchor was found in the source document's current extraction; "
|
|
156
|
+
"`anchor.snippet` is what the source says now and what the author cited"
|
|
157
|
+
),
|
|
158
|
+
"drifted": (
|
|
159
|
+
"the anchor was found only in a superseded extraction — the source changed "
|
|
160
|
+
"after the claim was written. `drifted_from` is the snippet the author saw; "
|
|
161
|
+
"`anchor.snippet` is what stands there now. Compare them before trusting "
|
|
162
|
+
"the claim"
|
|
163
|
+
),
|
|
164
|
+
"not_shown": (
|
|
165
|
+
"a real anchor, but it was never shown to the writer in the recorded "
|
|
166
|
+
"session: the claim cites something its author did not read"
|
|
167
|
+
),
|
|
168
|
+
"unresolved": (
|
|
169
|
+
"a well-formed token naming no anchor in any generation of any source. "
|
|
170
|
+
"Treat the claim as uncited"
|
|
171
|
+
),
|
|
172
|
+
"malformed": (
|
|
173
|
+
"the citation text is not a token; `error` says why. Reported verbatim "
|
|
174
|
+
"rather than dropped"
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
"verdict_status": {
|
|
178
|
+
"pass": "the method ran and the claim survived it",
|
|
179
|
+
"fail": "the method ran and the claim did not survive it",
|
|
180
|
+
"partial": "the method ran and found partial support; `detail` says how much",
|
|
181
|
+
"skip": "the method did not apply to this claim and citation",
|
|
182
|
+
},
|
|
183
|
+
"verdicts_are_evidence": (
|
|
184
|
+
"Verification methods are opt-in switches, never gates. A method missing from "
|
|
185
|
+
"a citation's `verdicts` was not run — that is not a pass. `summary.by_method` "
|
|
186
|
+
"counts only what actually ran, as {method: {verdict status: count}}."
|
|
187
|
+
),
|
|
188
|
+
"verify_this_record": [
|
|
189
|
+
"1. For every citation carrying an `anchor`, recompute sha256 over the "
|
|
190
|
+
"normalized `snippet`: it must equal `anchor.snippet_sha256`, and the `hash` "
|
|
191
|
+
"segment of `token` must be a prefix of it.",
|
|
192
|
+
"2. The `slug` and `locator` segments of `token` must equal `anchor.slug` and "
|
|
193
|
+
"`anchor.locator`.",
|
|
194
|
+
"3. Recount `summary` from `claims`.",
|
|
195
|
+
"Steps 1 to 3 need nothing but this object. If you also hold the source "
|
|
196
|
+
"documents, look `locator` up in the document named by `slug` and compare its "
|
|
197
|
+
"text against `snippet`; that is the only check this record cannot make of "
|
|
198
|
+
"itself.",
|
|
199
|
+
],
|
|
200
|
+
"evidence": (
|
|
201
|
+
"Optional. Evidence context for the cited sources, bounded by what is cited "
|
|
202
|
+
"— never the whole corpus. `documents` maps slug to {filename, media_type}. "
|
|
203
|
+
"`pages` maps `slug:pN` to a page image {format, width, height, data} where "
|
|
204
|
+
"`data` is base64; for a vision-model extraction this is the page as the "
|
|
205
|
+
"model was shown it. `pagetexts` maps `slug:pN` to that page's extracted "
|
|
206
|
+
"text. `windows` maps `slug:<locator>` to a small cell grid around a cited "
|
|
207
|
+
"cell: {sheet, cited, cols, rows}. `sheets` maps `slug:<sheet>` to the full "
|
|
208
|
+
"cited sheet's values: {name, nrows, ncols, rows}. A record without "
|
|
209
|
+
"`evidence` is still complete — evidence is context, snippets are the proof. "
|
|
210
|
+
"The HTML artifact may omit `pages[*].data` from its embedded copy of this "
|
|
211
|
+
"object when the same bytes are present in the page as image elements."
|
|
212
|
+
),
|
|
213
|
+
"version": (
|
|
214
|
+
"The format string in `$format` is matched exactly, as an opaque string. A "
|
|
215
|
+
"reader that does not recognize `backdraft/artifact-v1` must not guess: later "
|
|
216
|
+
"versions may reuse these field names with different meanings. There is no "
|
|
217
|
+
"compatibility range and no minor-version negotiation."
|
|
218
|
+
),
|
|
219
|
+
}
|
|
220
|
+
"""Normative. The artifact teaches its own decoding; `spec/artifact.md` specifies it."""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def sidecar(report: BindReport) -> dict[str, Any]:
|
|
224
|
+
"""The sidecar payload: `$format`, `$legend`, then the report, in that order."""
|
|
225
|
+
return {"$format": FORMAT, "$legend": LEGEND, **report.to_dict()}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def dumps(report: BindReport) -> str:
|
|
229
|
+
"""The sidecar as text: UTF-8, two-space indent, trailing newline.
|
|
230
|
+
|
|
231
|
+
Deterministic — the same report always produces the same bytes, so artifacts
|
|
232
|
+
diff cleanly.
|
|
233
|
+
"""
|
|
234
|
+
return json.dumps(sidecar(report), indent=2, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The deterministic chunker (normative prose copy in spec/chunking.md).
|
|
2
|
+
|
|
3
|
+
`chunk(page_text) -> [Chunk(ordinal, text, start, end)]`. Pure and total: the
|
|
4
|
+
same page text always yields the same chunks, and no input raises.
|
|
5
|
+
|
|
6
|
+
Determinism is the whole point. A chunk's ordinal is half of its anchor identity
|
|
7
|
+
(extraction, page number, ordinal), so a chunker that rebalanced or capped chunk
|
|
8
|
+
counts would silently move every citation on the page. There is deliberately no
|
|
9
|
+
per-page rebalancing and no chunk-count cap.
|
|
10
|
+
|
|
11
|
+
The algorithm:
|
|
12
|
+
|
|
13
|
+
1. Split on blank lines (``\\n\\s*\\n``).
|
|
14
|
+
2. Merge forward: a segment shorter than 200 chars merges into the following
|
|
15
|
+
segment; the last segment merges backward instead.
|
|
16
|
+
3. Split long: a segment longer than 2400 chars splits at the sentence boundary
|
|
17
|
+
nearest each 1200-char multiple. A sentence boundary is a terminal ``.!?``
|
|
18
|
+
followed by whitespace and an uppercase letter or digit — no abbreviation
|
|
19
|
+
table, because splitting slightly wrong only shifts a boundary, and it does so
|
|
20
|
+
deterministically. A final piece under 200 chars merges backward: step 2 ran
|
|
21
|
+
before the split and could not see it.
|
|
22
|
+
4. Ordinals ``c1..cN`` in order.
|
|
23
|
+
|
|
24
|
+
Offsets are exact: ``page_text[chunk.start:chunk.end] == chunk.text`` for every
|
|
25
|
+
chunk. NOTE: a chunk's text is therefore the verbatim source region, including
|
|
26
|
+
any blank-line separators that merging swallowed; only the outer edges are
|
|
27
|
+
trimmed of whitespace. This keeps offsets meaningful for future span features and
|
|
28
|
+
costs nothing, since `hashing.normalize` collapses interior whitespace anyway.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
from .model import Chunk
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"MIN_CHARS",
|
|
39
|
+
"MAX_CHARS",
|
|
40
|
+
"TARGET_CHARS",
|
|
41
|
+
"chunk",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
MIN_CHARS = 200
|
|
45
|
+
"""Segments shorter than this merge into a neighbour."""
|
|
46
|
+
|
|
47
|
+
MAX_CHARS = 2400
|
|
48
|
+
"""Segments longer than this are split."""
|
|
49
|
+
|
|
50
|
+
TARGET_CHARS = 1200
|
|
51
|
+
"""Long segments split near each multiple of this."""
|
|
52
|
+
|
|
53
|
+
_BLANK_LINE = re.compile(r"\n\s*\n")
|
|
54
|
+
# NOTE: the spec says "terminal .!? + space"; we accept a run of whitespace so a
|
|
55
|
+
# double space or a wrapped line still reads as a boundary.
|
|
56
|
+
_SENTENCE_BOUNDARY = re.compile(r"[.!?]\s+(?=[A-Z0-9])")
|
|
57
|
+
|
|
58
|
+
type _Span = tuple[int, int]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def chunk(page_text: str) -> list[Chunk]:
|
|
62
|
+
"""Chunk one page's text. Returns `[]` for empty or whitespace-only pages."""
|
|
63
|
+
spans: list[_Span] = []
|
|
64
|
+
for span in _merge_short(_split_paragraphs(page_text)):
|
|
65
|
+
spans.extend(_split_long(page_text, span))
|
|
66
|
+
return [
|
|
67
|
+
Chunk(ordinal=ordinal, text=page_text[start:end], start=start, end=end)
|
|
68
|
+
for ordinal, (start, end) in enumerate(spans, start=1)
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _split_paragraphs(text: str) -> list[_Span]:
|
|
73
|
+
"""Blank-line separated regions, edge-trimmed, empties dropped."""
|
|
74
|
+
spans: list[_Span] = []
|
|
75
|
+
cursor = 0
|
|
76
|
+
for separator in _BLANK_LINE.finditer(text):
|
|
77
|
+
spans.append((cursor, separator.start()))
|
|
78
|
+
cursor = separator.end()
|
|
79
|
+
spans.append((cursor, len(text)))
|
|
80
|
+
return [span for span in (_trim(text, *raw) for raw in spans) if span[0] < span[1]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _merge_short(spans: list[_Span]) -> list[_Span]:
|
|
84
|
+
"""Merge forward while a group is under `MIN_CHARS`; the last merges backward.
|
|
85
|
+
|
|
86
|
+
NOTE: length is measured over the source region (`end - start`), which for a
|
|
87
|
+
single segment is exactly its character count and for a merged group also
|
|
88
|
+
counts the blank line it absorbed.
|
|
89
|
+
"""
|
|
90
|
+
groups: list[_Span] = []
|
|
91
|
+
index = 0
|
|
92
|
+
while index < len(spans):
|
|
93
|
+
start, end = spans[index]
|
|
94
|
+
index += 1
|
|
95
|
+
while end - start < MIN_CHARS and index < len(spans):
|
|
96
|
+
end = spans[index][1]
|
|
97
|
+
index += 1
|
|
98
|
+
groups.append((start, end))
|
|
99
|
+
if len(groups) > 1 and groups[-1][1] - groups[-1][0] < MIN_CHARS:
|
|
100
|
+
last = groups.pop()
|
|
101
|
+
start, _ = groups.pop()
|
|
102
|
+
groups.append((start, last[1]))
|
|
103
|
+
return groups
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _split_long(text: str, span: _Span) -> list[_Span]:
|
|
107
|
+
"""Split a span over `MAX_CHARS` at sentence boundaries near each target."""
|
|
108
|
+
start, end = span
|
|
109
|
+
length = end - start
|
|
110
|
+
if length <= MAX_CHARS:
|
|
111
|
+
return [span]
|
|
112
|
+
boundaries = [
|
|
113
|
+
start + match.end() for match in _SENTENCE_BOUNDARY.finditer(text[start:end])
|
|
114
|
+
]
|
|
115
|
+
if not boundaries:
|
|
116
|
+
return [span]
|
|
117
|
+
|
|
118
|
+
cuts: list[int] = []
|
|
119
|
+
previous = start
|
|
120
|
+
target = TARGET_CHARS
|
|
121
|
+
while target < length:
|
|
122
|
+
goal = start + target
|
|
123
|
+
# NOTE: nearest wins; ties go to the earlier boundary.
|
|
124
|
+
candidates = [offset for offset in boundaries if previous < offset < end]
|
|
125
|
+
if candidates:
|
|
126
|
+
best = min(candidates, key=lambda offset: (abs(offset - goal), offset))
|
|
127
|
+
cuts.append(best)
|
|
128
|
+
previous = best
|
|
129
|
+
target += TARGET_CHARS
|
|
130
|
+
|
|
131
|
+
edges = [start, *cuts, end]
|
|
132
|
+
pieces = [
|
|
133
|
+
_trim(text, edges[i], edges[i + 1]) for i in range(len(edges) - 1)
|
|
134
|
+
]
|
|
135
|
+
return _absorb_tail([piece for piece in pieces if piece[0] < piece[1]])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _absorb_tail(pieces: list[_Span]) -> list[_Span]:
|
|
139
|
+
"""Merge an undersized final piece backward, the way rule 2 merges backward.
|
|
140
|
+
|
|
141
|
+
Merging runs before splitting, so rule 2 cannot see the pieces rule 3 is
|
|
142
|
+
about to create: a 2500-char region splits near 1200 and leaves a ~100-char
|
|
143
|
+
tail that rule 2 would have absorbed had it been a paragraph. That tail is a
|
|
144
|
+
chunk in name only — too small to carry context, and it becomes an anchor
|
|
145
|
+
whose receipt is a sentence fragment. So rule 3 applies rule 2's backward
|
|
146
|
+
merge to its own output, which is where that rule already sends a trailing
|
|
147
|
+
short region. `pieces` is a fresh list and is mutated in place.
|
|
148
|
+
"""
|
|
149
|
+
if len(pieces) > 1 and pieces[-1][1] - pieces[-1][0] < MIN_CHARS:
|
|
150
|
+
last = pieces.pop()
|
|
151
|
+
start, _ = pieces.pop()
|
|
152
|
+
pieces.append((start, last[1]))
|
|
153
|
+
return pieces
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _trim(text: str, start: int, end: int) -> _Span:
|
|
157
|
+
"""Shrink a span past leading and trailing whitespace."""
|
|
158
|
+
while start < end and text[start].isspace():
|
|
159
|
+
start += 1
|
|
160
|
+
while end > start and text[end - 1].isspace():
|
|
161
|
+
end -= 1
|
|
162
|
+
return (start, end)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Reading claims out of authored markdown.
|
|
2
|
+
|
|
3
|
+
The wire form is one markdown link per claim span — `[claim text](bd:...)` — and
|
|
4
|
+
nothing else: no doc names, no footnotes, no display text. Which words an anchor
|
|
5
|
+
supports can never be recovered later and can always be projected away, so it is
|
|
6
|
+
captured here, at write time.
|
|
7
|
+
|
|
8
|
+
`parse_claims` is total: it never raises. A token that does not parse becomes a
|
|
9
|
+
`Citation` with status `malformed` carrying the reason, because bind must report
|
|
10
|
+
every citation the author wrote — including the ones it cannot use.
|
|
11
|
+
|
|
12
|
+
Scope, deliberately small (NOTE: the spec is silent on each of these):
|
|
13
|
+
|
|
14
|
+
* Link text may contain balanced brackets, other inline formatting, and newlines.
|
|
15
|
+
* Images (``) are not claims.
|
|
16
|
+
* An href is a citation href if any `;`-separated piece starts with `bd:`; the
|
|
17
|
+
pieces that do not are reported as malformed rather than dropped.
|
|
18
|
+
* Code spans and fenced blocks are not special-cased — a token written inside one
|
|
19
|
+
is still read as a claim. Recognizing fences would require a markdown parser,
|
|
20
|
+
and a document that quotes tokens in prose is not the case worth optimizing.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from .errors import MalformedTokenError, UnsupportedTokenError
|
|
28
|
+
from .model import Citation, CitationStatus, Claim
|
|
29
|
+
from .tokens import is_token_href, parse, split_href
|
|
30
|
+
|
|
31
|
+
__all__ = ["parse_claims", "parse_citation"]
|
|
32
|
+
|
|
33
|
+
_LINK_TITLE = re.compile(r"""\s+(?:"[^"]*"|'[^']*')$""")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_claims(source: str) -> list[Claim]:
|
|
37
|
+
"""Every claim in an authored document, in document order.
|
|
38
|
+
|
|
39
|
+
A claim's `start`/`end` bound the whole markdown link, so
|
|
40
|
+
`source[claim.start:claim.end]` is the construct bind rewrites; `claim.text`
|
|
41
|
+
is the link text alone.
|
|
42
|
+
"""
|
|
43
|
+
claims: list[Claim] = []
|
|
44
|
+
index = 0
|
|
45
|
+
length = len(source)
|
|
46
|
+
while index < length:
|
|
47
|
+
character = source[index]
|
|
48
|
+
if character == "\\":
|
|
49
|
+
index += 2
|
|
50
|
+
continue
|
|
51
|
+
if character != "[":
|
|
52
|
+
index += 1
|
|
53
|
+
continue
|
|
54
|
+
if index > 0 and source[index - 1] == "!":
|
|
55
|
+
index += 1
|
|
56
|
+
continue
|
|
57
|
+
link = _scan_link(source, index)
|
|
58
|
+
if link is None:
|
|
59
|
+
index += 1
|
|
60
|
+
continue
|
|
61
|
+
text_start, text_end, href, link_end = link
|
|
62
|
+
if not is_token_href(href):
|
|
63
|
+
index += 1
|
|
64
|
+
continue
|
|
65
|
+
claims.append(
|
|
66
|
+
Claim(
|
|
67
|
+
text=source[text_start:text_end],
|
|
68
|
+
start=index,
|
|
69
|
+
end=link_end,
|
|
70
|
+
citations=tuple(parse_citation(piece) for piece in split_href(href)),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
index = link_end
|
|
74
|
+
return claims
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_citation(token_text: str) -> Citation:
|
|
78
|
+
"""One citation from one token's text. Never raises.
|
|
79
|
+
|
|
80
|
+
Unparseable tokens — including the reserved `bd:calc(...)` derivation form,
|
|
81
|
+
which is recognized but unsupported in v0 — come back with status
|
|
82
|
+
`malformed` and the reason in `error`.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
parse(token_text)
|
|
86
|
+
except (MalformedTokenError, UnsupportedTokenError) as error:
|
|
87
|
+
return Citation(
|
|
88
|
+
token=token_text, status=CitationStatus.MALFORMED, error=str(error)
|
|
89
|
+
)
|
|
90
|
+
return Citation(token=token_text, status=CitationStatus.UNRESOLVED)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _scan_link(source: str, open_bracket: int) -> tuple[int, int, str, int] | None:
|
|
94
|
+
"""Scan `[text](href)` starting at `open_bracket`.
|
|
95
|
+
|
|
96
|
+
Returns `(text_start, text_end, href, link_end)`, or None if this bracket does
|
|
97
|
+
not open an inline link.
|
|
98
|
+
"""
|
|
99
|
+
text_start = open_bracket + 1
|
|
100
|
+
text_end = _scan_balanced(source, text_start, "[", "]")
|
|
101
|
+
if text_end is None:
|
|
102
|
+
return None
|
|
103
|
+
if text_end + 1 >= len(source) or source[text_end + 1] != "(":
|
|
104
|
+
return None
|
|
105
|
+
href_start = text_end + 2
|
|
106
|
+
href_end = _scan_balanced(source, href_start, "(", ")")
|
|
107
|
+
if href_end is None:
|
|
108
|
+
return None
|
|
109
|
+
return (text_start, text_end, _clean_href(source[href_start:href_end]), href_end + 1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _scan_balanced(source: str, start: int, opener: str, closer: str) -> int | None:
|
|
113
|
+
"""Index of the `closer` matching an already-consumed `opener`, or None."""
|
|
114
|
+
depth = 1
|
|
115
|
+
index = start
|
|
116
|
+
length = len(source)
|
|
117
|
+
while index < length:
|
|
118
|
+
character = source[index]
|
|
119
|
+
if character == "\\":
|
|
120
|
+
index += 2
|
|
121
|
+
continue
|
|
122
|
+
if character == opener:
|
|
123
|
+
depth += 1
|
|
124
|
+
elif character == closer:
|
|
125
|
+
depth -= 1
|
|
126
|
+
if depth == 0:
|
|
127
|
+
return index
|
|
128
|
+
index += 1
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _clean_href(raw: str) -> str:
|
|
133
|
+
"""The href as written, minus markdown's decorations.
|
|
134
|
+
|
|
135
|
+
NOTE: only a trailing quoted link title (`[x](bd:... "why")`) and enclosing
|
|
136
|
+
angle brackets are removed. Interior whitespace is kept, because the reserved
|
|
137
|
+
`bd:calc(...)` form contains it and a citation must be reported verbatim.
|
|
138
|
+
"""
|
|
139
|
+
href = raw.strip()
|
|
140
|
+
if href.startswith("<") and href.endswith(">"):
|
|
141
|
+
href = href[1:-1].strip()
|
|
142
|
+
return _LINK_TITLE.sub("", href).strip()
|