backdraft 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backdraft/__init__.py +13 -0
- backdraft/bind/__init__.py +37 -0
- backdraft/bind/binder.py +653 -0
- backdraft/bind/cli.py +133 -0
- backdraft/bind/evidence.py +232 -0
- backdraft/bind/verify/__init__.py +30 -0
- backdraft/bind/verify/base.py +70 -0
- backdraft/bind/verify/entail.py +156 -0
- backdraft/bind/verify/overlap.py +114 -0
- backdraft/bind/verify/recompute.py +45 -0
- backdraft/bind/verify/value_trace.py +352 -0
- backdraft/cli.py +341 -0
- backdraft/cli_context.py +152 -0
- backdraft/credentials.py +106 -0
- backdraft/extract/__init__.py +37 -0
- backdraft/extract/base.py +184 -0
- backdraft/extract/pdf_text.py +124 -0
- backdraft/extract/text.py +44 -0
- backdraft/extract/vlm.py +175 -0
- backdraft/extract/vlm_settings.py +197 -0
- backdraft/extract/xlsx.py +244 -0
- backdraft/gate/__init__.py +39 -0
- backdraft/gate/cli.py +131 -0
- backdraft/gate/reader.py +592 -0
- backdraft/gate/searcher.py +123 -0
- backdraft/kernel/__init__.py +17 -0
- backdraft/kernel/artifact.py +234 -0
- backdraft/kernel/chunking.py +162 -0
- backdraft/kernel/claims.py +142 -0
- backdraft/kernel/errors.py +35 -0
- backdraft/kernel/hashing.py +89 -0
- backdraft/kernel/model.py +336 -0
- backdraft/kernel/tokens.py +291 -0
- backdraft/registry/__init__.py +39 -0
- backdraft/registry/ledger.py +82 -0
- backdraft/registry/schema.sql +90 -0
- backdraft/registry/store.py +915 -0
- backdraft/render/__init__.py +31 -0
- backdraft/render/_text.py +69 -0
- backdraft/render/cli.py +107 -0
- backdraft/render/footnotes.py +172 -0
- backdraft/render/html.py +1211 -0
- backdraft/render/markdown.py +313 -0
- backdraft/render/placement.py +105 -0
- backdraft/render/sidecar.py +164 -0
- backdraft-0.2.0.dist-info/METADATA +253 -0
- backdraft-0.2.0.dist-info/RECORD +50 -0
- backdraft-0.2.0.dist-info/WHEEL +4 -0
- backdraft-0.2.0.dist-info/entry_points.txt +2 -0
- backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
backdraft/bind/binder.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""bind: resolve → verify → rewrite → report.
|
|
2
|
+
|
|
3
|
+
The postprocess. It takes the document as written — claim spans carrying raw
|
|
4
|
+
tokens — and turns every token back into the receipt it names: a doc name, a
|
|
5
|
+
locator, and the verbatim quote the writer saw. What it cannot resolve it
|
|
6
|
+
reports; what it reports it never drops.
|
|
7
|
+
|
|
8
|
+
Three rules hold everywhere in this module:
|
|
9
|
+
|
|
10
|
+
* **Never edit a claim's text.** Bind rewrites the citation *around* a claim
|
|
11
|
+
(`[claim](bd:…)` → `[claim](#cite-1)`); the words are the author's.
|
|
12
|
+
* **Never drop a citation.** Every token the author wrote gets a number, a
|
|
13
|
+
References entry, and a line in the report — malformed ones included.
|
|
14
|
+
* **Verification never gates.** The exit-code contract keys off citation
|
|
15
|
+
resolution alone; verdicts are evidence recorded beside it.
|
|
16
|
+
|
|
17
|
+
Outputs, per bind run over `notes.md`:
|
|
18
|
+
|
|
19
|
+
* `notes.bound.md` — the rewritten document plus a generated References section.
|
|
20
|
+
A separate file, not an in-place edit: the authored document with its tokens
|
|
21
|
+
is the source of truth and bind is re-runnable against it.
|
|
22
|
+
* `notes.backdraft.json` — the sidecar: the self-describing artifact payload
|
|
23
|
+
(`$format` + `$legend` wrapped around `BindReport.to_dict()`), which is what a
|
|
24
|
+
reader who has only this file needs. The row saved to `bindings.report_json`
|
|
25
|
+
is the bare `BindReport.to_dict()` instead: the registry already knows what
|
|
26
|
+
format it stores, so the legend would be noise in a database column.
|
|
27
|
+
|
|
28
|
+
NOTE: both filenames belong to the artifact format, not to this module —
|
|
29
|
+
`kernel/artifact.py` owns the suffixes and the path math (spec/artifact.md
|
|
30
|
+
§ Naming), so bind, render and any other implementation agree on where a bound
|
|
31
|
+
document's pieces live. They are re-exported here because this module's callers
|
|
32
|
+
already ask bind where it wrote.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import re
|
|
39
|
+
from collections.abc import Sequence
|
|
40
|
+
from datetime import UTC, datetime
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import TYPE_CHECKING
|
|
43
|
+
|
|
44
|
+
from ..kernel.artifact import ( # noqa: F401 (re-exported: naming is kernel-owned)
|
|
45
|
+
BOUND_SUFFIX,
|
|
46
|
+
SIDECAR_SUFFIX,
|
|
47
|
+
bound_path,
|
|
48
|
+
record_path,
|
|
49
|
+
sidecar_path,
|
|
50
|
+
)
|
|
51
|
+
from ..kernel.artifact import dumps as artifact_dumps
|
|
52
|
+
from ..kernel.claims import parse_claims
|
|
53
|
+
from ..kernel.model import (
|
|
54
|
+
Anchor,
|
|
55
|
+
BindMode,
|
|
56
|
+
BindReport,
|
|
57
|
+
Citation,
|
|
58
|
+
CitationStatus,
|
|
59
|
+
Claim,
|
|
60
|
+
)
|
|
61
|
+
from ..kernel.tokens import format_locator
|
|
62
|
+
from . import evidence as evidence_module
|
|
63
|
+
from .verify.base import Verifier, selected
|
|
64
|
+
from .verify.value_trace import extract_values
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING: # pragma: no cover - bind consumes the registry, never extends it
|
|
67
|
+
from ..registry.store import Registry
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"bind",
|
|
71
|
+
"bound_path",
|
|
72
|
+
"record_path",
|
|
73
|
+
"record_target",
|
|
74
|
+
"sidecar_path",
|
|
75
|
+
"propose_anchors",
|
|
76
|
+
"search_query",
|
|
77
|
+
"BOUND_SUFFIX",
|
|
78
|
+
"SIDECAR_SUFFIX",
|
|
79
|
+
"PROPOSAL_LIMIT",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# BOUND_SUFFIX, SIDECAR_SUFFIX, `bound_path` and `sidecar_path` come from
|
|
83
|
+
# `kernel.artifact`: the names of a bind run's outputs, like the format inside
|
|
84
|
+
# them, are shared vocabulary between bind (the writer) and render (the reader).
|
|
85
|
+
|
|
86
|
+
PROPOSAL_LIMIT = 3
|
|
87
|
+
"""Search hits proposed per unmatched claim in backfill mode."""
|
|
88
|
+
|
|
89
|
+
QUERY_TERMS = 5
|
|
90
|
+
"""Distinctive words kept from a claim when searching for its anchor."""
|
|
91
|
+
|
|
92
|
+
QUERY_VALUES = 2
|
|
93
|
+
"""Numbers from a claim carried into the query as terms of their own."""
|
|
94
|
+
|
|
95
|
+
_REFERENCES_HEADING = "## References"
|
|
96
|
+
_UNMATCHED_HEADING = "## Unmatched claims"
|
|
97
|
+
|
|
98
|
+
_WORD = re.compile(r"[A-Za-z][A-Za-z'’]*")
|
|
99
|
+
_VALUE = re.compile(r"\d[\d,]*(?:\.\d+)?")
|
|
100
|
+
|
|
101
|
+
_STOPWORDS = frozenset(
|
|
102
|
+
"""
|
|
103
|
+
a an and are as at be been but by can could did do does for from had has
|
|
104
|
+
have how in into is it its may might must no not of off on one only or other
|
|
105
|
+
our out over per shall should since so some such than that the their them
|
|
106
|
+
then there these they this those through to under until up upon was were
|
|
107
|
+
what when where which while who whom why will with within would you your
|
|
108
|
+
also being both each either every less more most much nor same still
|
|
109
|
+
thus very
|
|
110
|
+
""".split()
|
|
111
|
+
)
|
|
112
|
+
"""Words that carry no discriminating power in a corpus of analyst prose.
|
|
113
|
+
|
|
114
|
+
Deliberately not the full NLTK list: a term dropped here is a term the proposal
|
|
115
|
+
cannot rank on, and words like `net`, `total` and `gross` earn their place in
|
|
116
|
+
this domain even though a general-purpose list would bin them.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def bind(
|
|
121
|
+
doc_path: Path,
|
|
122
|
+
registry: Registry,
|
|
123
|
+
*,
|
|
124
|
+
mode: BindMode = "frontwalk",
|
|
125
|
+
session_id: str | None = None,
|
|
126
|
+
checks: Sequence[str] = (),
|
|
127
|
+
write: bool = True,
|
|
128
|
+
lean: bool = False,
|
|
129
|
+
bound: bool = False,
|
|
130
|
+
) -> BindReport:
|
|
131
|
+
"""Bind one authored document. Returns the report; writes the artifacts.
|
|
132
|
+
|
|
133
|
+
`checks` names the verification methods to run (`--check`); empty means no
|
|
134
|
+
verifier runs and the report carries no verdict rows. `session_id` is what
|
|
135
|
+
`not_shown` is judged against — without one, frontwalk mode cannot tell a
|
|
136
|
+
cited-what-you-saw token from a valid token that was never shown, so it
|
|
137
|
+
does not claim to. `write=False` computes the report without touching the
|
|
138
|
+
filesystem or the registry. `lean` skips page images in the evidence block
|
|
139
|
+
(text windows and sheet values are always included). `bound` also writes
|
|
140
|
+
the rewritten-markdown projection (`memo.bound.md`) — off by default, so
|
|
141
|
+
the standard working set is three files: the authored document, the
|
|
142
|
+
record, the artifact.
|
|
143
|
+
"""
|
|
144
|
+
source = doc_path.read_text(encoding="utf-8")
|
|
145
|
+
verifiers = selected(checks)
|
|
146
|
+
claims = [
|
|
147
|
+
_resolve_claim(claim, registry, mode=mode, session_id=session_id)
|
|
148
|
+
for claim in parse_claims(source)
|
|
149
|
+
]
|
|
150
|
+
proposals: dict[int, tuple[Anchor, ...]] = {}
|
|
151
|
+
if mode == "backfill":
|
|
152
|
+
claims = _backfill(claims, source, registry, proposals)
|
|
153
|
+
claims = _verify(claims, verifiers)
|
|
154
|
+
report = BindReport(
|
|
155
|
+
doc_path=str(doc_path),
|
|
156
|
+
mode=mode,
|
|
157
|
+
bound_at=_now(),
|
|
158
|
+
claims=tuple(claims),
|
|
159
|
+
session_id=session_id,
|
|
160
|
+
evidence=evidence_module.assemble(registry, claims, lean=lean),
|
|
161
|
+
)
|
|
162
|
+
if write:
|
|
163
|
+
if bound:
|
|
164
|
+
bound_path(doc_path).write_text(
|
|
165
|
+
rewrite(source, report, registry, proposals), encoding="utf-8"
|
|
166
|
+
)
|
|
167
|
+
# The record is the self-describing artifact payload ($format + $legend
|
|
168
|
+
# around the report). With a rooted registry it lives out of the
|
|
169
|
+
# authored directory (.backdraft/records/); a fake or rootless registry
|
|
170
|
+
# keeps the portable beside-the-document form. The registry row carries
|
|
171
|
+
# the bare report — the evidence block is heavy and reproducible.
|
|
172
|
+
target = record_target(doc_path, registry)
|
|
173
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
target.write_text(artifact_dumps(report), encoding="utf-8")
|
|
175
|
+
registry.save_binding(
|
|
176
|
+
doc_path=str(doc_path),
|
|
177
|
+
session_id=session_id,
|
|
178
|
+
mode=mode,
|
|
179
|
+
report_json=json.dumps(
|
|
180
|
+
report.to_dict(include_evidence=False), indent=2, ensure_ascii=False
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
return report
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def record_target(doc_path: Path, registry) -> Path: # noqa: ANN001
|
|
187
|
+
"""Where this bind writes its record: the registry's records dir, or beside."""
|
|
188
|
+
root = getattr(registry, "root", None)
|
|
189
|
+
if root is None:
|
|
190
|
+
return sidecar_path(doc_path)
|
|
191
|
+
return record_path(Path(root).resolve(), doc_path.resolve())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def propose_anchors(
|
|
195
|
+
registry: Registry, text: str, *, limit: int = PROPOSAL_LIMIT
|
|
196
|
+
) -> tuple[Anchor, ...]:
|
|
197
|
+
"""Search hits bind offers for an unanchored claim. Proposals only.
|
|
198
|
+
|
|
199
|
+
Backfill proposes; it never attaches. An anchor bind chose for a claim the
|
|
200
|
+
author did not cite would be a citation nobody made.
|
|
201
|
+
"""
|
|
202
|
+
query = search_query(text)
|
|
203
|
+
if not query:
|
|
204
|
+
return ()
|
|
205
|
+
try:
|
|
206
|
+
hits = registry.search(query, limit=limit)
|
|
207
|
+
except Exception: # noqa: BLE001 - a search that fails proposes nothing
|
|
208
|
+
return ()
|
|
209
|
+
return tuple(hit.anchor for hit in hits[:limit])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def search_query(text: str) -> str:
|
|
213
|
+
"""The FTS5 query a claim's own words make. Empty when it has none to give.
|
|
214
|
+
|
|
215
|
+
A claim sentence is not a query. Handed to FTS5 verbatim, an ordinary one —
|
|
216
|
+
*"Real estate taxes of $412,300 are the largest single expense line."* —
|
|
217
|
+
fails to parse on the `$`, the `,` and the `.`, falls through to the quoted-
|
|
218
|
+
phrase retry, and matches nothing at all, because no snippet contains that
|
|
219
|
+
exact sentence. Backfill's proposals were therefore always empty or driven by
|
|
220
|
+
stopword overlap.
|
|
221
|
+
|
|
222
|
+
So the claim is reduced to what distinguishes it:
|
|
223
|
+
|
|
224
|
+
* stopwords and one/two-letter fragments go;
|
|
225
|
+
* an all-caps acronym (`NOI`, `DSCR`, `LTV`) outranks everything, because a
|
|
226
|
+
corpus of prose has few of them and analysts write in them;
|
|
227
|
+
* otherwise longer words rank first — a crude stand-in for rarity, but the
|
|
228
|
+
right crudeness, since it prefers `reassessment` over `year`;
|
|
229
|
+
* the top `QUERY_TERMS` survive, joined with `OR` so ranking sorts by how
|
|
230
|
+
many matched rather than requiring all of them;
|
|
231
|
+
* up to `QUERY_VALUES` numbers ride along as their own terms — `412,300` is
|
|
232
|
+
the single most distinctive thing in that sentence.
|
|
233
|
+
|
|
234
|
+
Every term is quoted, which both keeps punctuation out of FTS5's grammar and
|
|
235
|
+
makes it impossible for a claim containing the word "or" to become an
|
|
236
|
+
operator.
|
|
237
|
+
"""
|
|
238
|
+
# Deduplicated on case, but the first spelling is kept: `NOI` has to still
|
|
239
|
+
# look like an acronym when the sentence later says `noi`.
|
|
240
|
+
seen: dict[str, str] = {}
|
|
241
|
+
for word in _WORD.findall(text):
|
|
242
|
+
if _is_content(word):
|
|
243
|
+
seen.setdefault(word.lower(), word)
|
|
244
|
+
# `sorted` is stable, so words of equal rank stay in the order they were
|
|
245
|
+
# written — the query is a pure function of the claim, as proposals must be.
|
|
246
|
+
ranked = sorted(seen.values(), key=_distinctiveness)
|
|
247
|
+
values = sorted(dict.fromkeys(_VALUE.findall(text)), key=len, reverse=True)
|
|
248
|
+
terms = [*ranked[:QUERY_TERMS], *values[:QUERY_VALUES]]
|
|
249
|
+
return " OR ".join(f'"{term.lower()}"' for term in terms)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _is_content(word: str) -> bool:
|
|
253
|
+
"""A word worth searching for: not a stopword, not a two-letter fragment."""
|
|
254
|
+
return len(word) > 2 and word.lower() not in _STOPWORDS
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _distinctiveness(word: str) -> tuple[int, int]:
|
|
258
|
+
"""Sort key: acronyms first, then longest first."""
|
|
259
|
+
return (0 if word.isupper() else 1, -len(word))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --- resolution ------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _resolve_claim(
|
|
266
|
+
claim: Claim, registry: Registry, *, mode: BindMode, session_id: str | None
|
|
267
|
+
) -> Claim:
|
|
268
|
+
citations = tuple(
|
|
269
|
+
_resolve_citation(citation, registry, mode=mode, session_id=session_id)
|
|
270
|
+
for citation in claim.citations
|
|
271
|
+
)
|
|
272
|
+
return Claim(
|
|
273
|
+
text=claim.text,
|
|
274
|
+
start=claim.start,
|
|
275
|
+
end=claim.end,
|
|
276
|
+
citations=citations,
|
|
277
|
+
unmatched=claim.unmatched,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _current_counterpart(registry: Registry, anchor: Anchor) -> Anchor | None:
|
|
282
|
+
"""The current generation's anchor at `anchor`'s locator, if it survived."""
|
|
283
|
+
if anchor.page_number is None:
|
|
284
|
+
return None
|
|
285
|
+
for candidate in registry.anchors_for_page(anchor.slug, anchor.page_number):
|
|
286
|
+
if candidate.locator == anchor.locator:
|
|
287
|
+
return candidate
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _resolve_citation(
|
|
292
|
+
citation: Citation, registry: Registry, *, mode: BindMode, session_id: str | None
|
|
293
|
+
) -> Citation:
|
|
294
|
+
"""One citation's status, from the closed set.
|
|
295
|
+
|
|
296
|
+
`malformed` is already decided by the kernel and is never revisited: a
|
|
297
|
+
token that does not parse cannot be looked up.
|
|
298
|
+
"""
|
|
299
|
+
if citation.status is CitationStatus.MALFORMED:
|
|
300
|
+
return citation
|
|
301
|
+
resolution = registry.resolve(citation.token)
|
|
302
|
+
if resolution is None:
|
|
303
|
+
return Citation(token=citation.token, status=CitationStatus.UNRESOLVED)
|
|
304
|
+
anchor = resolution.anchor
|
|
305
|
+
if not resolution.current:
|
|
306
|
+
# The drift contract (kernel fixture, spec/artifact.md): `drifted_from`
|
|
307
|
+
# is the snippet the writer cited; `anchor` is what stands at that
|
|
308
|
+
# locator now, found via `anchors_for_page` on the current generation.
|
|
309
|
+
# When the locator itself is gone, the cited anchor stands in and the
|
|
310
|
+
# two sides of the diff agree — still `drifted`, because the token no
|
|
311
|
+
# longer resolves against the current generation.
|
|
312
|
+
current = _current_counterpart(registry, anchor)
|
|
313
|
+
return Citation(
|
|
314
|
+
token=citation.token,
|
|
315
|
+
status=CitationStatus.DRIFTED,
|
|
316
|
+
anchor=current or anchor,
|
|
317
|
+
drifted_from=anchor.receipt.snippet,
|
|
318
|
+
)
|
|
319
|
+
if mode == "frontwalk" and session_id is not None:
|
|
320
|
+
if not registry.was_shown(session_id, citation.token):
|
|
321
|
+
return Citation(
|
|
322
|
+
token=citation.token, status=CitationStatus.NOT_SHOWN, anchor=anchor
|
|
323
|
+
)
|
|
324
|
+
return Citation(token=citation.token, status=CitationStatus.RESOLVED, anchor=anchor)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# --- backfill --------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _backfill(
|
|
331
|
+
claims: list[Claim],
|
|
332
|
+
source: str,
|
|
333
|
+
registry: Registry,
|
|
334
|
+
proposals: dict[int, tuple[Anchor, ...]],
|
|
335
|
+
) -> list[Claim]:
|
|
336
|
+
"""Mark and propose for every claim backfill could not anchor.
|
|
337
|
+
|
|
338
|
+
Two kinds land here. A claim the author cited whose citations all failed —
|
|
339
|
+
the token is there, the anchor is not. And a factual sentence carrying no
|
|
340
|
+
citation at all, which is the whole backfill case: an existing document
|
|
341
|
+
plus ingested sources, where the report has to name what is still
|
|
342
|
+
unattributed rather than say nothing.
|
|
343
|
+
"""
|
|
344
|
+
marked: list[Claim] = []
|
|
345
|
+
for claim in claims:
|
|
346
|
+
if any(citation.anchor is not None for citation in claim.citations):
|
|
347
|
+
marked.append(claim)
|
|
348
|
+
continue
|
|
349
|
+
marked.append(
|
|
350
|
+
Claim(
|
|
351
|
+
text=claim.text,
|
|
352
|
+
start=claim.start,
|
|
353
|
+
end=claim.end,
|
|
354
|
+
citations=claim.citations,
|
|
355
|
+
unmatched=True,
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
covered = [(claim.start, claim.end) for claim in claims]
|
|
359
|
+
for start, end in _candidate_spans(source, covered):
|
|
360
|
+
marked.append(Claim(text=source[start:end], start=start, end=end, unmatched=True))
|
|
361
|
+
marked.sort(key=lambda claim: claim.start)
|
|
362
|
+
for index, claim in enumerate(marked):
|
|
363
|
+
if claim.unmatched:
|
|
364
|
+
proposals[index] = propose_anchors(registry, claim.text)
|
|
365
|
+
return marked
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _candidate_spans(source: str, covered: Sequence[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
369
|
+
"""Sentences that look like factual claims and carry no citation.
|
|
370
|
+
|
|
371
|
+
NOTE: the spec does not say what an uncited claim *is*, so this takes the
|
|
372
|
+
narrowest reading that still makes backfill useful: a sentence, outside a
|
|
373
|
+
fenced code block or a heading, that contains at least one value. A claim
|
|
374
|
+
with a number or a date is the claim class backfill can actually search
|
|
375
|
+
for; treating every sentence as a claim would bury the report it is meant
|
|
376
|
+
to be.
|
|
377
|
+
"""
|
|
378
|
+
spans: list[tuple[int, int]] = []
|
|
379
|
+
for start, end in _sentences(source):
|
|
380
|
+
if any(start < c_end and c_start < end for c_start, c_end in covered):
|
|
381
|
+
continue
|
|
382
|
+
if not extract_values(source[start:end]):
|
|
383
|
+
continue
|
|
384
|
+
spans.append((start, end))
|
|
385
|
+
return spans
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _sentences(source: str) -> list[tuple[int, int]]:
|
|
389
|
+
"""Sentence spans in prose lines, as `(start, end)` offsets into `source`."""
|
|
390
|
+
spans: list[tuple[int, int]] = []
|
|
391
|
+
offset = 0
|
|
392
|
+
fenced = False
|
|
393
|
+
for line in source.splitlines(keepends=True):
|
|
394
|
+
stripped = line.strip()
|
|
395
|
+
if stripped.startswith("```"):
|
|
396
|
+
fenced = not fenced
|
|
397
|
+
offset += len(line)
|
|
398
|
+
continue
|
|
399
|
+
if fenced or not stripped or stripped.startswith("#"):
|
|
400
|
+
offset += len(line)
|
|
401
|
+
continue
|
|
402
|
+
for start, end in _split_sentences(line):
|
|
403
|
+
spans.append((offset + start, offset + end))
|
|
404
|
+
offset += len(line)
|
|
405
|
+
return spans
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _split_sentences(line: str) -> list[tuple[int, int]]:
|
|
409
|
+
"""Sentence spans within one line: terminal `.!?` followed by a space."""
|
|
410
|
+
spans: list[tuple[int, int]] = []
|
|
411
|
+
start = 0
|
|
412
|
+
index = 0
|
|
413
|
+
length = len(line)
|
|
414
|
+
while index < length:
|
|
415
|
+
if line[index] in ".!?" and (index + 1 >= length or line[index + 1].isspace()):
|
|
416
|
+
end = index + 1
|
|
417
|
+
if line[start:end].strip():
|
|
418
|
+
spans.append((start + _lead(line[start:end]), end))
|
|
419
|
+
start = end
|
|
420
|
+
index += 1
|
|
421
|
+
tail = line[start:]
|
|
422
|
+
if tail.strip():
|
|
423
|
+
spans.append((start + _lead(tail), start + len(tail.rstrip())))
|
|
424
|
+
return spans
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _lead(text: str) -> int:
|
|
428
|
+
return len(text) - len(text.lstrip())
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# --- verification ----------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _verify(claims: list[Claim], verifiers: Sequence[Verifier]) -> list[Claim]:
|
|
435
|
+
"""Run enabled verifiers over every citation that reached an anchor.
|
|
436
|
+
|
|
437
|
+
A citation with no anchor has no snippet to check against, so no method
|
|
438
|
+
applies to it — `unresolved` and `malformed` carry no verdicts. A verifier
|
|
439
|
+
whose `applies` is False writes no row: the report never implies a method
|
|
440
|
+
looked at a claim it never read.
|
|
441
|
+
"""
|
|
442
|
+
if not verifiers:
|
|
443
|
+
return claims
|
|
444
|
+
pairs = [
|
|
445
|
+
(claim, citation, citation.anchor)
|
|
446
|
+
for claim in claims
|
|
447
|
+
for citation in claim.citations
|
|
448
|
+
if citation.anchor is not None
|
|
449
|
+
]
|
|
450
|
+
for verifier in verifiers:
|
|
451
|
+
prepare = getattr(verifier, "prepare", None)
|
|
452
|
+
if callable(prepare):
|
|
453
|
+
applicable = [
|
|
454
|
+
(claim, citation, anchor)
|
|
455
|
+
for claim, citation, anchor in pairs
|
|
456
|
+
if verifier.applies(claim, citation)
|
|
457
|
+
]
|
|
458
|
+
if applicable:
|
|
459
|
+
prepare(applicable)
|
|
460
|
+
verified: list[Claim] = []
|
|
461
|
+
for claim in claims:
|
|
462
|
+
citations = tuple(
|
|
463
|
+
_verify_citation(claim, citation, verifiers) for citation in claim.citations
|
|
464
|
+
)
|
|
465
|
+
verified.append(
|
|
466
|
+
Claim(
|
|
467
|
+
text=claim.text,
|
|
468
|
+
start=claim.start,
|
|
469
|
+
end=claim.end,
|
|
470
|
+
citations=citations,
|
|
471
|
+
unmatched=claim.unmatched,
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
return verified
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _verify_citation(
|
|
478
|
+
claim: Claim, citation: Citation, verifiers: Sequence[Verifier]
|
|
479
|
+
) -> Citation:
|
|
480
|
+
if citation.anchor is None:
|
|
481
|
+
return citation
|
|
482
|
+
verdicts = tuple(
|
|
483
|
+
verifier.verify(claim, citation, citation.anchor)
|
|
484
|
+
for verifier in verifiers
|
|
485
|
+
if verifier.applies(claim, citation)
|
|
486
|
+
)
|
|
487
|
+
return Citation(
|
|
488
|
+
token=citation.token,
|
|
489
|
+
status=citation.status,
|
|
490
|
+
anchor=citation.anchor,
|
|
491
|
+
drifted_from=citation.drifted_from,
|
|
492
|
+
verdicts=verdicts,
|
|
493
|
+
error=citation.error,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --- rewrite ---------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def rewrite(
|
|
501
|
+
source: str,
|
|
502
|
+
report: BindReport,
|
|
503
|
+
registry: Registry,
|
|
504
|
+
proposals: dict[int, tuple[Anchor, ...]] | None = None,
|
|
505
|
+
) -> str:
|
|
506
|
+
"""The authored document with tokens replaced by readable citations.
|
|
507
|
+
|
|
508
|
+
Citations are numbered 1..N in document order, **one number per distinct
|
|
509
|
+
token**: three claims citing the same anchor share number 4 and the anchor's
|
|
510
|
+
quote appears once. Numbering per citation instead would repeat a 1200-char
|
|
511
|
+
snippet down the page once per citing claim, which is how a References
|
|
512
|
+
section stops being readable — and a reader who sees `[4]` twice has learned
|
|
513
|
+
something true, that both claims rest on the same evidence.
|
|
514
|
+
|
|
515
|
+
A claim's span becomes `[claim](#cite-n)`; a claim carrying more than one
|
|
516
|
+
citation gets the extra numbers appended as their own links, so no citation
|
|
517
|
+
loses its anchor and the claim's own words stay exactly as written. Uncited
|
|
518
|
+
backfill claims are left untouched — bind has nothing to attach to them.
|
|
519
|
+
|
|
520
|
+
NOTE: this is a rendering rule only. The report and the sidecar still carry
|
|
521
|
+
every citation the author wrote, individually.
|
|
522
|
+
"""
|
|
523
|
+
proposals = proposals or {}
|
|
524
|
+
numbers, entries = _numbering(report)
|
|
525
|
+
pieces: list[str] = []
|
|
526
|
+
cursor = 0
|
|
527
|
+
for index, claim in enumerate(report.claims):
|
|
528
|
+
if not claim.citations:
|
|
529
|
+
continue
|
|
530
|
+
pieces.append(source[cursor : claim.start])
|
|
531
|
+
pieces.append(_rewrite_claim(claim, numbers[index]))
|
|
532
|
+
cursor = claim.end
|
|
533
|
+
pieces.append(source[cursor:])
|
|
534
|
+
body = "".join(pieces).rstrip("\n")
|
|
535
|
+
sections = [body, _references(report, entries, registry)]
|
|
536
|
+
unmatched = _unmatched_section(report, proposals, registry)
|
|
537
|
+
if unmatched:
|
|
538
|
+
sections.append(unmatched)
|
|
539
|
+
return "\n\n".join(sections) + "\n"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _numbering(report: BindReport) -> tuple[dict[int, list[int]], list[Citation]]:
|
|
543
|
+
"""Citation numbers per claim index, and the citation each number names.
|
|
544
|
+
|
|
545
|
+
Numbers are 1-based over the whole document and keyed on the token, so a
|
|
546
|
+
token cited from three places is numbered once, at its first appearance.
|
|
547
|
+
Returns `({claim index: [numbers]}, [citation for 1, citation for 2, ...])`.
|
|
548
|
+
"""
|
|
549
|
+
assigned: dict[str, int] = {}
|
|
550
|
+
entries: list[Citation] = []
|
|
551
|
+
numbers: dict[int, list[int]] = {}
|
|
552
|
+
for index, claim in enumerate(report.claims):
|
|
553
|
+
row: list[int] = []
|
|
554
|
+
for citation in claim.citations:
|
|
555
|
+
if citation.token not in assigned:
|
|
556
|
+
assigned[citation.token] = len(entries) + 1
|
|
557
|
+
entries.append(citation)
|
|
558
|
+
number = assigned[citation.token]
|
|
559
|
+
# A claim that cites one token twice links to it once; the report
|
|
560
|
+
# still carries both citations.
|
|
561
|
+
if number not in row:
|
|
562
|
+
row.append(number)
|
|
563
|
+
numbers[index] = row
|
|
564
|
+
return numbers, entries
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _rewrite_claim(claim: Claim, numbers: Sequence[int]) -> str:
|
|
568
|
+
first, *rest = numbers
|
|
569
|
+
out = f"[{claim.text}](#cite-{first})"
|
|
570
|
+
for number in rest:
|
|
571
|
+
out += f"[{number}](#cite-{number})"
|
|
572
|
+
return out
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _references(report: BindReport, entries: Sequence[Citation], registry: Registry) -> str:
|
|
576
|
+
"""The generated References section: doc name, locator, verbatim quote.
|
|
577
|
+
|
|
578
|
+
One entry per distinct token — `entries` is already deduplicated by
|
|
579
|
+
`_numbering`, and its index is the citation number.
|
|
580
|
+
"""
|
|
581
|
+
lines = [_REFERENCES_HEADING, ""]
|
|
582
|
+
if report.summary["citations"] == 0:
|
|
583
|
+
lines.append("_No citations._")
|
|
584
|
+
for number, citation in enumerate(entries, start=1):
|
|
585
|
+
lines.append(f'<a id="cite-{number}"></a>**[{number}]** {_entry(citation, registry)}')
|
|
586
|
+
lines.append("")
|
|
587
|
+
if citation.anchor is not None:
|
|
588
|
+
lines.append(_quote(citation.anchor.receipt.snippet))
|
|
589
|
+
lines.append("")
|
|
590
|
+
return "\n".join(lines).rstrip("\n")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _entry(citation: Citation, registry: Registry) -> str:
|
|
594
|
+
"""One reference line: what the citation names, and how it resolved."""
|
|
595
|
+
status = str(citation.status)
|
|
596
|
+
if citation.anchor is None:
|
|
597
|
+
detail = f" — {citation.error}" if citation.error else ""
|
|
598
|
+
return f"`{citation.token}` — {status}{detail}"
|
|
599
|
+
anchor = citation.anchor
|
|
600
|
+
name = _doc_name(registry, anchor.slug)
|
|
601
|
+
locator = format_locator(anchor.locator)
|
|
602
|
+
note = ""
|
|
603
|
+
if citation.status is CitationStatus.DRIFTED:
|
|
604
|
+
note = " (snippet from a superseded extraction)"
|
|
605
|
+
elif citation.status is CitationStatus.NOT_SHOWN:
|
|
606
|
+
note = " (not in the session ledger)"
|
|
607
|
+
return f"{name} — `{locator}` — {status}{note}"
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _unmatched_section(
|
|
611
|
+
report: BindReport, proposals: dict[int, tuple[Anchor, ...]], registry: Registry
|
|
612
|
+
) -> str:
|
|
613
|
+
"""Backfill's open list: claims bind could not anchor, and what it found.
|
|
614
|
+
|
|
615
|
+
NOTE: `BindReport` carries the `unmatched` flag but has no field for
|
|
616
|
+
proposals, and the kernel is not bind's to extend — so the proposals live
|
|
617
|
+
here, in the document a human reads, while the sidecar carries the flag.
|
|
618
|
+
"""
|
|
619
|
+
entries = [
|
|
620
|
+
(index, claim) for index, claim in enumerate(report.claims) if claim.unmatched
|
|
621
|
+
]
|
|
622
|
+
if not entries:
|
|
623
|
+
return ""
|
|
624
|
+
lines = [_UNMATCHED_HEADING, ""]
|
|
625
|
+
for index, claim in entries:
|
|
626
|
+
lines.append(f"- {claim.text.strip()}")
|
|
627
|
+
for anchor in proposals.get(index, ()):
|
|
628
|
+
name = _doc_name(registry, anchor.slug)
|
|
629
|
+
lines.append(f" - proposed: `{anchor.token}` — {name}")
|
|
630
|
+
if not proposals.get(index):
|
|
631
|
+
lines.append(" - proposed: none")
|
|
632
|
+
lines.append("")
|
|
633
|
+
return "\n".join(lines).rstrip("\n")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _doc_name(registry: Registry, slug: str) -> str:
|
|
637
|
+
"""The document's human handle: its filename, or the slug if unknown."""
|
|
638
|
+
try:
|
|
639
|
+
document = registry.document(slug)
|
|
640
|
+
except Exception: # noqa: BLE001 - a name lookup never fails a bind
|
|
641
|
+
return slug
|
|
642
|
+
return document.filename if document is not None else slug
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _quote(snippet: str) -> str:
|
|
646
|
+
"""The verbatim snippet as a markdown blockquote, line for line."""
|
|
647
|
+
lines = snippet.splitlines() or [""]
|
|
648
|
+
return "\n".join(f"> {line}".rstrip() for line in lines)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _now() -> str:
|
|
652
|
+
"""ISO-8601 UTC, matching the DDL's `TEXT` timestamps."""
|
|
653
|
+
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|