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/cli.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""The CLI — the system's first substrate (SPEC.md § Addendum B).
|
|
2
|
+
|
|
3
|
+
This module owns the typer app, the `init` / `ingest` / `ls` / `export`
|
|
4
|
+
commands, and the mounts. The context every command shares — registry discovery,
|
|
5
|
+
session resolution, the exit codes and the error guard — lives in
|
|
6
|
+
`cli_context.py`, which the sub-apps import directly; the names are re-exported
|
|
7
|
+
here because `cli.find_root`, `cli.open_registry` and `cli.resolve_session` are
|
|
8
|
+
what the spec and the tests call the CLI's surface.
|
|
9
|
+
|
|
10
|
+
The gate, bind and render workstreams each ship an `app = typer.Typer()` that is
|
|
11
|
+
mounted here; each mount is guarded, so a partial checkout still runs the
|
|
12
|
+
commands it does have.
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 clean, 1 usage or environment error, 2 `bind` completing with
|
|
15
|
+
non-resolved citations (so a hook can gate on it).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Annotated, Iterable
|
|
23
|
+
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from .cli_context import (
|
|
27
|
+
DEFAULT_SESSION,
|
|
28
|
+
EXIT_UNRESOLVED,
|
|
29
|
+
EXIT_USAGE,
|
|
30
|
+
HOME_ENV,
|
|
31
|
+
SESSION_ENV,
|
|
32
|
+
UsageError,
|
|
33
|
+
find_root,
|
|
34
|
+
guard,
|
|
35
|
+
open_registry,
|
|
36
|
+
opened_registry,
|
|
37
|
+
resolve_session,
|
|
38
|
+
)
|
|
39
|
+
from .extract import vlm_ready
|
|
40
|
+
from .registry import DIRECTORY, Registry
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"app",
|
|
44
|
+
"main",
|
|
45
|
+
# re-exported from cli_context: this module is the CLI's front door
|
|
46
|
+
"DEFAULT_SESSION",
|
|
47
|
+
"EXIT_UNRESOLVED",
|
|
48
|
+
"EXIT_USAGE",
|
|
49
|
+
"HOME_ENV",
|
|
50
|
+
"SESSION_ENV",
|
|
51
|
+
"find_root",
|
|
52
|
+
"open_registry",
|
|
53
|
+
"resolve_session",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
app = typer.Typer(
|
|
57
|
+
name="backdraft",
|
|
58
|
+
help="Drop-in provenance for factual claims.",
|
|
59
|
+
no_args_is_help=True,
|
|
60
|
+
add_completion=False,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_ENV_TEMPLATE = """\
|
|
65
|
+
# Backdraft reads credentials only from here, BACKDRAFT_* variables, or --config.
|
|
66
|
+
# Ambient provider keys (OPENAI_API_KEY, ...) are never read.
|
|
67
|
+
BACKDRAFT_VLM_API_KEY=
|
|
68
|
+
BACKDRAFT_ENTAIL_API_KEY=
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# ---- commands ---------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def init(
|
|
76
|
+
directory: Annotated[
|
|
77
|
+
Path | None,
|
|
78
|
+
typer.Argument(help="Project root to initialize. Defaults to the current directory."),
|
|
79
|
+
] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Create `.backdraft/` here and print the registry's status."""
|
|
82
|
+
root = (directory or Path.cwd()).resolve()
|
|
83
|
+
with guard():
|
|
84
|
+
registry = Registry.open(root)
|
|
85
|
+
try:
|
|
86
|
+
documents = registry.documents()
|
|
87
|
+
finally:
|
|
88
|
+
registry.close()
|
|
89
|
+
env_path = root / DIRECTORY / "env"
|
|
90
|
+
if not env_path.exists():
|
|
91
|
+
env_path.write_text(_ENV_TEMPLATE, encoding="utf-8")
|
|
92
|
+
typer.echo(f"registry: {root / DIRECTORY}")
|
|
93
|
+
typer.echo(f"documents: {len(documents)}")
|
|
94
|
+
typer.echo(f"settings: {env_path} (credentials go here, deliberately)")
|
|
95
|
+
typer.echo(
|
|
96
|
+
"next: ingest sources, then have an agent write against them — "
|
|
97
|
+
"the backdraft skill (skills/backdraft) is the writing contract."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def ingest(
|
|
103
|
+
files: Annotated[list[Path], typer.Argument(help="Files to ingest.")],
|
|
104
|
+
extractor: Annotated[str, typer.Option("--extractor", help="Extractor name, or `auto`.")] = (
|
|
105
|
+
"auto"
|
|
106
|
+
),
|
|
107
|
+
slug: Annotated[
|
|
108
|
+
str | None, typer.Option("--slug", help="Slug for a new document. One file only.")
|
|
109
|
+
] = None,
|
|
110
|
+
config: Annotated[
|
|
111
|
+
list[str] | None,
|
|
112
|
+
typer.Option("--config", help="Extractor config as `key=value`. Repeatable."),
|
|
113
|
+
] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Snapshot files into the registry, minting their anchors."""
|
|
116
|
+
nudge_vlm = False
|
|
117
|
+
with guard():
|
|
118
|
+
if slug is not None and len(files) > 1:
|
|
119
|
+
raise UsageError("--slug names one document; pass one file")
|
|
120
|
+
settings = _parse_config(config or [])
|
|
121
|
+
with opened_registry() as registry:
|
|
122
|
+
for path in files:
|
|
123
|
+
document = registry.ingest(
|
|
124
|
+
path, extractor=extractor, slug=slug, config=settings
|
|
125
|
+
)
|
|
126
|
+
pages = registry.pages(document.slug)
|
|
127
|
+
typer.echo(
|
|
128
|
+
f"{document.slug} {document.filename} "
|
|
129
|
+
f"{document.media_type} {len(pages)} pages"
|
|
130
|
+
)
|
|
131
|
+
nudge_vlm = nudge_vlm or (
|
|
132
|
+
extractor == "auto"
|
|
133
|
+
and document.media_type == "pdf"
|
|
134
|
+
and not vlm_ready(settings)
|
|
135
|
+
)
|
|
136
|
+
if nudge_vlm:
|
|
137
|
+
# One line, once per invocation: `auto` fell back to the text layer,
|
|
138
|
+
# and the note names the condition that failed.
|
|
139
|
+
typer.echo(f"note: extracted with pdf-text (the embedded text layer). {_vlm_gap()}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("snapshot-pages")
|
|
143
|
+
def snapshot_pages(
|
|
144
|
+
slug: Annotated[str, typer.Argument(help="An ingested PDF's slug.")],
|
|
145
|
+
file: Annotated[
|
|
146
|
+
Path | None,
|
|
147
|
+
typer.Option("--file", help="The source PDF, when it moved since ingest."),
|
|
148
|
+
] = None,
|
|
149
|
+
dpi: Annotated[int, typer.Option("--dpi", help="Render resolution.")] = 200,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Backfill page snapshots for an already-ingested PDF. Local, no model calls.
|
|
152
|
+
|
|
153
|
+
Ingesting through the VLM extractor stores each page's image automatically;
|
|
154
|
+
this command adds them to registries created before that, or to text-layer
|
|
155
|
+
ingests, so `bind` can embed the cited pages into the artifact. Requires the
|
|
156
|
+
`[vlm]` extra for PDF rendering.
|
|
157
|
+
"""
|
|
158
|
+
with guard():
|
|
159
|
+
try:
|
|
160
|
+
import io
|
|
161
|
+
|
|
162
|
+
from PIL import Image # noqa: F401 (pdf2image needs it anyway)
|
|
163
|
+
from pdf2image import convert_from_path
|
|
164
|
+
except ImportError as error:
|
|
165
|
+
raise UsageError(
|
|
166
|
+
"snapshot-pages needs the [vlm] extra for PDF rendering: "
|
|
167
|
+
f"pip install 'backdraft[vlm]' ({error})"
|
|
168
|
+
) from error
|
|
169
|
+
from .extract.vlm_settings import MAX_IMAGE_HEIGHT, SNAPSHOT_QUALITY
|
|
170
|
+
|
|
171
|
+
with opened_registry() as registry:
|
|
172
|
+
document = registry.document(slug)
|
|
173
|
+
if document is None:
|
|
174
|
+
raise UsageError(f"no document with slug {slug!r}")
|
|
175
|
+
if document.media_type != "pdf":
|
|
176
|
+
raise UsageError(f"{slug} is {document.media_type}, not a PDF")
|
|
177
|
+
source = file or Path(document.path)
|
|
178
|
+
if not source.is_file():
|
|
179
|
+
raise UsageError(
|
|
180
|
+
f"source file not found at {source}; pass --file to point at it"
|
|
181
|
+
)
|
|
182
|
+
extraction_id = registry.current_extraction_id(slug)
|
|
183
|
+
if extraction_id is None:
|
|
184
|
+
raise UsageError(f"{slug} has no current extraction")
|
|
185
|
+
pages = registry.pages(slug)
|
|
186
|
+
for page in pages:
|
|
187
|
+
images = convert_from_path(
|
|
188
|
+
str(source), dpi=dpi, fmt="png",
|
|
189
|
+
first_page=page.number, last_page=page.number,
|
|
190
|
+
)
|
|
191
|
+
image = images[0]
|
|
192
|
+
if image.height > MAX_IMAGE_HEIGHT:
|
|
193
|
+
scale = MAX_IMAGE_HEIGHT / image.height
|
|
194
|
+
image = image.resize(
|
|
195
|
+
(max(1, round(image.width * scale)), MAX_IMAGE_HEIGHT)
|
|
196
|
+
)
|
|
197
|
+
buffer = io.BytesIO()
|
|
198
|
+
image.save(buffer, format="WEBP", quality=SNAPSHOT_QUALITY)
|
|
199
|
+
registry.save_page_image(
|
|
200
|
+
extraction_id, page.number,
|
|
201
|
+
data=buffer.getvalue(), format="webp",
|
|
202
|
+
width=image.width, height=image.height,
|
|
203
|
+
)
|
|
204
|
+
typer.echo(f"{slug} p{page.number} {image.width}x{image.height}")
|
|
205
|
+
typer.echo(f"stored {len(pages)} page snapshot(s)")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command()
|
|
209
|
+
def clean(
|
|
210
|
+
directory: Annotated[
|
|
211
|
+
Path | None,
|
|
212
|
+
typer.Argument(help="Directory to tidy. Defaults to the current directory."),
|
|
213
|
+
] = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Tidy a working directory: move stray records into `.backdraft/records/`
|
|
216
|
+
and delete leftover `.bound.md` projections.
|
|
217
|
+
|
|
218
|
+
Everything removed or moved is regenerable with `backdraft bind`; artifacts
|
|
219
|
+
(`.backdraft.html`) and authored documents are never touched.
|
|
220
|
+
"""
|
|
221
|
+
from .kernel.artifact import BOUND_SUFFIX, SIDECAR_SUFFIX, record_path
|
|
222
|
+
|
|
223
|
+
with guard():
|
|
224
|
+
base = (directory or Path.cwd()).resolve()
|
|
225
|
+
root = find_root(base)
|
|
226
|
+
touched = 0
|
|
227
|
+
for stray in sorted(base.glob(f"*{BOUND_SUFFIX}")):
|
|
228
|
+
stray.unlink()
|
|
229
|
+
typer.echo(f"removed {stray.name}")
|
|
230
|
+
touched += 1
|
|
231
|
+
for stray in sorted(base.glob(f"*{SIDECAR_SUFFIX}")):
|
|
232
|
+
doc_stem = stray.name.removesuffix(SIDECAR_SUFFIX)
|
|
233
|
+
target = record_path(root, base / f"{doc_stem}.md")
|
|
234
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
stray.replace(target)
|
|
236
|
+
typer.echo(f"moved {stray.name} -> {target.relative_to(root)}")
|
|
237
|
+
touched += 1
|
|
238
|
+
if not touched:
|
|
239
|
+
typer.echo("nothing to tidy")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command("ls")
|
|
243
|
+
def list_documents() -> None:
|
|
244
|
+
"""List the ingested documents: slug, filename, media type, page count."""
|
|
245
|
+
with opened_registry() as registry:
|
|
246
|
+
documents = registry.documents()
|
|
247
|
+
if not documents:
|
|
248
|
+
typer.echo("no documents ingested")
|
|
249
|
+
return
|
|
250
|
+
for document in documents:
|
|
251
|
+
pages = registry.pages(document.slug)
|
|
252
|
+
typer.echo(
|
|
253
|
+
f"{document.slug}\t{document.filename}\t{document.media_type}\t"
|
|
254
|
+
f"{len(pages)} pages"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command()
|
|
259
|
+
def export(
|
|
260
|
+
out: Annotated[
|
|
261
|
+
Path | None, typer.Option("--out", "-o", help="Write here instead of stdout.")
|
|
262
|
+
] = None,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Export the whole registry as JSON, every generation included."""
|
|
265
|
+
with opened_registry() as registry:
|
|
266
|
+
payload = json.dumps(registry.export_json(), indent=2, ensure_ascii=False)
|
|
267
|
+
if out is None:
|
|
268
|
+
typer.echo(payload)
|
|
269
|
+
else:
|
|
270
|
+
out.write_text(payload + "\n", encoding="utf-8")
|
|
271
|
+
typer.echo(f"wrote {out}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _vlm_gap() -> str:
|
|
275
|
+
"""Which condition keeps `auto` off the vision model — key, extra, or both."""
|
|
276
|
+
from .credentials import setting
|
|
277
|
+
from .extract.base import ExtractionError, get
|
|
278
|
+
|
|
279
|
+
has_key = bool(setting("BACKDRAFT_VLM_API_KEY"))
|
|
280
|
+
try:
|
|
281
|
+
get("vlm")
|
|
282
|
+
has_extra = True
|
|
283
|
+
except ExtractionError:
|
|
284
|
+
has_extra = False
|
|
285
|
+
if not has_key and not has_extra:
|
|
286
|
+
return (
|
|
287
|
+
"Glossy or scanned PDFs extract better through a vision model: "
|
|
288
|
+
"install backdraft[vlm] and set BACKDRAFT_VLM_API_KEY in .backdraft/env."
|
|
289
|
+
)
|
|
290
|
+
if not has_key:
|
|
291
|
+
return (
|
|
292
|
+
"The [vlm] extra is installed but BACKDRAFT_VLM_API_KEY is not set — "
|
|
293
|
+
"add it to .backdraft/env to use the vision model."
|
|
294
|
+
)
|
|
295
|
+
return (
|
|
296
|
+
"BACKDRAFT_VLM_API_KEY is set but the [vlm] extra is not installed — "
|
|
297
|
+
"install backdraft[vlm] to use the vision model."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _parse_config(pairs: Iterable[str]) -> dict:
|
|
302
|
+
"""`key=value` strings into a config dict. Values stay strings."""
|
|
303
|
+
settings: dict[str, str] = {}
|
|
304
|
+
for pair in pairs:
|
|
305
|
+
key, separator, value = pair.partition("=")
|
|
306
|
+
if not separator or not key:
|
|
307
|
+
raise UsageError(f"--config expects key=value, got {pair!r}")
|
|
308
|
+
settings[key] = value
|
|
309
|
+
return settings
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---- sub-app mounts ---------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _mount(module: str) -> bool:
|
|
316
|
+
"""Merge a workstream's sub-app into this one. False if it isn't built yet.
|
|
317
|
+
|
|
318
|
+
The spec's commands are flat — `backdraft read`, not `backdraft gate read` —
|
|
319
|
+
so a sub-app's commands are adopted rather than nested. A missing module is a
|
|
320
|
+
partial checkout, not an error.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
sub = __import__(module, fromlist=["app"]).app
|
|
324
|
+
except ImportError:
|
|
325
|
+
return False
|
|
326
|
+
app.registered_commands.extend(sub.registered_commands)
|
|
327
|
+
app.registered_groups.extend(sub.registered_groups)
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
for _module in ("backdraft.gate.cli", "backdraft.bind.cli", "backdraft.render.cli"):
|
|
332
|
+
_mount(_module)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def main() -> None:
|
|
336
|
+
"""Console-script entry point."""
|
|
337
|
+
app()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__": # pragma: no cover
|
|
341
|
+
main()
|
backdraft/cli_context.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""What every command needs before it can do anything: a registry and a session.
|
|
2
|
+
|
|
3
|
+
Discovery, session resolution, the exit codes and the one error guard live here
|
|
4
|
+
rather than in `cli.py` so that the sub-apps can import them the ordinary way.
|
|
5
|
+
The top level imports `gate.cli`, `bind.cli` and `render.cli` in order to mount
|
|
6
|
+
them (SPEC Addendum B), so anything they read off `cli.py` has to be fetched
|
|
7
|
+
lazily inside each command — which is how three copies of the same lookup grew.
|
|
8
|
+
This module breaks the cycle instead: it imports typer, the kernel and the
|
|
9
|
+
registry, and **never a sub-app**, so it is importable from anywhere.
|
|
10
|
+
|
|
11
|
+
`cli.py` re-exports these names, because `cli.find_root` / `cli.open_registry` /
|
|
12
|
+
`cli.resolve_session` are what the spec, the tests and the docs already call the
|
|
13
|
+
CLI's own surface. This module is the definition; `cli.py` is the front door.
|
|
14
|
+
|
|
15
|
+
Exit codes (SPEC § CLI): 0 clean, 1 usage or environment error, 2 bind completed
|
|
16
|
+
with non-resolved citations.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, NoReturn
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
|
|
28
|
+
from .kernel.errors import BackdraftError
|
|
29
|
+
from .registry import DIRECTORY, Registry
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Iterator
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DEFAULT_SESSION",
|
|
36
|
+
"EXIT_USAGE",
|
|
37
|
+
"EXIT_UNRESOLVED",
|
|
38
|
+
"HOME_ENV",
|
|
39
|
+
"SESSION_ENV",
|
|
40
|
+
"UsageError",
|
|
41
|
+
"fail",
|
|
42
|
+
"find_root",
|
|
43
|
+
"guard",
|
|
44
|
+
"open_registry",
|
|
45
|
+
"opened_registry",
|
|
46
|
+
"resolve_session",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
HOME_ENV = "BACKDRAFT_HOME"
|
|
50
|
+
SESSION_ENV = "BACKDRAFT_SESSION"
|
|
51
|
+
|
|
52
|
+
DEFAULT_SESSION = "default"
|
|
53
|
+
"""The auto-created session. Stable across invocations, so reads accumulate."""
|
|
54
|
+
|
|
55
|
+
EXIT_USAGE = 1
|
|
56
|
+
"""Usage or environment error."""
|
|
57
|
+
|
|
58
|
+
EXIT_UNRESOLVED = 2
|
|
59
|
+
"""`bind` completed and something did not resolve. Owned by `bind`; hooks gate on it."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UsageError(BackdraftError):
|
|
63
|
+
"""The command cannot run as asked: no registry, no such file, bad flag.
|
|
64
|
+
|
|
65
|
+
A domain error like any other, so it travels out of a helper and is turned
|
|
66
|
+
into a message and an exit code in exactly one place (`guard`).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---- discovery --------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_root(start: Path | None = None) -> Path | None:
|
|
74
|
+
"""The nearest directory containing `.backdraft/`, walking up from `start`.
|
|
75
|
+
|
|
76
|
+
`BACKDRAFT_HOME` overrides the walk. It may name either the project root or
|
|
77
|
+
the `.backdraft` directory itself — NOTE: the spec does not say which, and
|
|
78
|
+
guessing wrong is a confusing failure, so both are accepted.
|
|
79
|
+
"""
|
|
80
|
+
override = os.environ.get(HOME_ENV)
|
|
81
|
+
if override:
|
|
82
|
+
home = Path(override).expanduser()
|
|
83
|
+
return home.parent if home.name == DIRECTORY else home
|
|
84
|
+
current = (start or Path.cwd()).resolve()
|
|
85
|
+
for candidate in (current, *current.parents):
|
|
86
|
+
if (candidate / DIRECTORY).is_dir():
|
|
87
|
+
return candidate
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def open_registry(start: Path | None = None) -> Registry:
|
|
92
|
+
"""Open the discovered registry, or raise `UsageError` for `guard` to report."""
|
|
93
|
+
root = find_root(start)
|
|
94
|
+
if root is None:
|
|
95
|
+
raise UsageError(
|
|
96
|
+
f"no {DIRECTORY}/ found in this directory or any parent; run `backdraft init`"
|
|
97
|
+
)
|
|
98
|
+
return Registry.open(root)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_session(session: str | None = None, registry: Registry | None = None) -> str:
|
|
102
|
+
"""The session this invocation belongs to.
|
|
103
|
+
|
|
104
|
+
Precedence: `--session` flag, then `BACKDRAFT_SESSION`, then the default
|
|
105
|
+
session. Pure name resolution — pass `registry` to also create the session
|
|
106
|
+
row. The gate's reader/searcher ensure the session themselves, so their CLI
|
|
107
|
+
passes no registry. Shared with the gate and bind sub-apps so every command
|
|
108
|
+
agrees on which ledger it is writing to.
|
|
109
|
+
"""
|
|
110
|
+
chosen = session or os.environ.get(SESSION_ENV) or DEFAULT_SESSION
|
|
111
|
+
if registry is not None:
|
|
112
|
+
registry.ensure_session(chosen)
|
|
113
|
+
return chosen
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---- the error path ---------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fail(message: str, code: int = EXIT_USAGE) -> NoReturn:
|
|
120
|
+
"""One line on stderr, then exit. The only place a command prints an error."""
|
|
121
|
+
typer.echo(f"backdraft: {message}", err=True)
|
|
122
|
+
raise typer.Exit(code)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def guard(code: int = EXIT_USAGE) -> Iterator[None]:
|
|
127
|
+
"""Turn any `BackdraftError` raised inside into a message and an exit code.
|
|
128
|
+
|
|
129
|
+
Libraries raise; the CLI maps — once, here. Every subclass is covered, so a
|
|
130
|
+
`GateError`, an `ExtractionError`, a `RegistryError` and a `UsageError` all
|
|
131
|
+
reach the user as one line rather than a traceback, and a command that wants
|
|
132
|
+
a different exit code passes one instead of writing its own handler.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
yield
|
|
136
|
+
except BackdraftError as error:
|
|
137
|
+
fail(str(error), code)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@contextmanager
|
|
141
|
+
def opened_registry(start: Path | None = None) -> Iterator[Registry]:
|
|
142
|
+
"""The discovered registry, guarded and always closed.
|
|
143
|
+
|
|
144
|
+
The shape almost every command wants: discovery failures and domain errors
|
|
145
|
+
raised in the body both become exit 1, and the connection closes either way.
|
|
146
|
+
"""
|
|
147
|
+
with guard():
|
|
148
|
+
registry = open_registry(start)
|
|
149
|
+
try:
|
|
150
|
+
yield registry
|
|
151
|
+
finally:
|
|
152
|
+
registry.close()
|
backdraft/credentials.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Backdraft-scoped settings: the only way a credential reaches this tool.
|
|
2
|
+
|
|
3
|
+
The rule, decided after a real incident: **backdraft never reads a credential it
|
|
4
|
+
did not explicitly ask you to write.** Ambient provider variables — an
|
|
5
|
+
`OPENAI_API_KEY` exported for some other tool, an `ANTHROPIC_API_KEY` in a
|
|
6
|
+
dotfile — are never consulted. Presence of a generic key in the environment is
|
|
7
|
+
not consent to spend it or to send documents to its provider.
|
|
8
|
+
|
|
9
|
+
A setting is looked up in three places, most explicit first:
|
|
10
|
+
|
|
11
|
+
1. the `--config key=value` dict a command was invoked with;
|
|
12
|
+
2. a `BACKDRAFT_<NAME>` environment variable — still env, but exporting a
|
|
13
|
+
backdraft-prefixed variable is unambiguous intent *for this tool*;
|
|
14
|
+
3. a `.backdraft/env` file — plain `KEY=VALUE` lines, written deliberately,
|
|
15
|
+
living inside the state directory that is already gitignored.
|
|
16
|
+
|
|
17
|
+
Names in use: `BACKDRAFT_VLM_API_KEY`, `BACKDRAFT_VLM_MODEL`,
|
|
18
|
+
`BACKDRAFT_VLM_BASE_URL`, `BACKDRAFT_ENTAIL_API_KEY`, `BACKDRAFT_ENTAIL_MODEL`.
|
|
19
|
+
The same names are used verbatim in the env file.
|
|
20
|
+
|
|
21
|
+
Stdlib only, no imports from the package: this module sits beside the kernel so
|
|
22
|
+
that `extract` and `bind` can both use it without a sideways import.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
__all__ = ["setting", "env_file", "ENV_FILE_NAME"]
|
|
31
|
+
|
|
32
|
+
ENV_FILE_NAME = "env"
|
|
33
|
+
_STATE_DIR = ".backdraft"
|
|
34
|
+
_PREFIX = "BACKDRAFT_"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setting(
|
|
38
|
+
name: str,
|
|
39
|
+
config: dict | None = None,
|
|
40
|
+
*,
|
|
41
|
+
config_key: str | None = None,
|
|
42
|
+
start: Path | None = None,
|
|
43
|
+
) -> str | None:
|
|
44
|
+
"""The value of the backdraft-scoped setting `name`, or None.
|
|
45
|
+
|
|
46
|
+
`name` is the full variable name (`BACKDRAFT_VLM_API_KEY`). `config_key`
|
|
47
|
+
names the `--config` dict key that overrides it (`api_key`); omitted means
|
|
48
|
+
the setting has no per-invocation form. Precedence: config → environment →
|
|
49
|
+
`.backdraft/env` discovered from `start` (default: cwd, honoring
|
|
50
|
+
`BACKDRAFT_HOME`).
|
|
51
|
+
"""
|
|
52
|
+
if not name.startswith(_PREFIX): # pragma: no cover - caller bug, fail loud
|
|
53
|
+
raise ValueError(f"not a backdraft-scoped name: {name!r}")
|
|
54
|
+
if config_key is not None and config and config.get(config_key):
|
|
55
|
+
return str(config[config_key])
|
|
56
|
+
if value := os.environ.get(name):
|
|
57
|
+
return value
|
|
58
|
+
return _read_env_file(start).get(name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def env_file(start: Path | None = None) -> Path | None:
|
|
62
|
+
"""The `.backdraft/env` file that governs `start` (default cwd), if any.
|
|
63
|
+
|
|
64
|
+
`BACKDRAFT_HOME` overrides discovery, accepting either the project root or
|
|
65
|
+
the `.backdraft` directory itself — the same rule the CLI uses for the
|
|
66
|
+
registry.
|
|
67
|
+
"""
|
|
68
|
+
override = os.environ.get("BACKDRAFT_HOME")
|
|
69
|
+
if override:
|
|
70
|
+
home = Path(override).expanduser()
|
|
71
|
+
root = home.parent if home.name == _STATE_DIR else home
|
|
72
|
+
candidate = root / _STATE_DIR / ENV_FILE_NAME
|
|
73
|
+
return candidate if candidate.is_file() else None
|
|
74
|
+
current = (start or Path.cwd()).resolve()
|
|
75
|
+
for parent in (current, *current.parents):
|
|
76
|
+
candidate = parent / _STATE_DIR / ENV_FILE_NAME
|
|
77
|
+
if candidate.is_file():
|
|
78
|
+
return candidate
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_env_file(start: Path | None) -> dict[str, str]:
|
|
83
|
+
"""Parse the governing env file: `KEY=VALUE` lines, `#` comments, optional
|
|
84
|
+
single or double quotes around the value. Unreadable or absent → empty."""
|
|
85
|
+
path = env_file(start)
|
|
86
|
+
if path is None:
|
|
87
|
+
return {}
|
|
88
|
+
try:
|
|
89
|
+
text = path.read_text(encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
return {}
|
|
92
|
+
values: dict[str, str] = {}
|
|
93
|
+
for line in text.splitlines():
|
|
94
|
+
line = line.strip()
|
|
95
|
+
if not line or line.startswith("#"):
|
|
96
|
+
continue
|
|
97
|
+
key, separator, value = line.partition("=")
|
|
98
|
+
if not separator:
|
|
99
|
+
continue
|
|
100
|
+
key = key.strip()
|
|
101
|
+
value = value.strip()
|
|
102
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
103
|
+
value = value[1:-1]
|
|
104
|
+
if key.startswith(_PREFIX):
|
|
105
|
+
values[key] = value
|
|
106
|
+
return values
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Extractors: files in, ordered pages out.
|
|
2
|
+
|
|
3
|
+
The protocol and the registry live in `base`; the built-ins (`text`, `pdf-text`,
|
|
4
|
+
`xlsx`) and the `[vlm]` extra are imported on demand by `base.get`, so importing
|
|
5
|
+
this package costs nothing but the protocol.
|
|
6
|
+
|
|
7
|
+
Export style (shared by every package above the kernel): **names**, never
|
|
8
|
+
submodules, and only what a consumer outside the package imports.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .base import (
|
|
14
|
+
AUTO_ORDER,
|
|
15
|
+
EXTRACTORS,
|
|
16
|
+
ExtractedPage,
|
|
17
|
+
ExtractionError,
|
|
18
|
+
Extractor,
|
|
19
|
+
get,
|
|
20
|
+
names,
|
|
21
|
+
register,
|
|
22
|
+
select,
|
|
23
|
+
vlm_ready,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AUTO_ORDER",
|
|
28
|
+
"EXTRACTORS",
|
|
29
|
+
"ExtractedPage",
|
|
30
|
+
"Extractor",
|
|
31
|
+
"ExtractionError",
|
|
32
|
+
"get",
|
|
33
|
+
"names",
|
|
34
|
+
"register",
|
|
35
|
+
"select",
|
|
36
|
+
"vlm_ready",
|
|
37
|
+
]
|