backdraft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,197 @@
1
+ """Provider resolution for the VLM extractor — importable without the extra.
2
+
3
+ The client is always the `openai` SDK; a router is injected through `base_url`,
4
+ never through a second SDK. This module is stdlib-only so `client_settings` can
5
+ be imported (and tested) without `backdraft[vlm]` installed; `vlm.py` re-exports
6
+ it next to the extractor that uses it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..credentials import setting
12
+ from .base import ExtractionError
13
+
14
+ __all__ = [
15
+ "DEFAULT_CONCURRENCY",
16
+ "DEFAULT_RETRIES",
17
+ "DEFAULT_TIMEOUT_SECONDS",
18
+ "MAX_IMAGE_HEIGHT",
19
+ "DEFAULT_MODEL",
20
+ "OPENROUTER_BASE_URL",
21
+ "client_settings",
22
+ "concurrency",
23
+ "is_retryable",
24
+ "retries",
25
+ "run_ordered",
26
+ "timeout_seconds",
27
+ "with_retries",
28
+ ]
29
+
30
+ DEFAULT_MODEL = "google/gemini-3.1-flash-lite-preview"
31
+ """The default: Gemini 3.1 Flash Lite through OpenRouter — what the HighRoad
32
+ pipeline runs in production. Overridden with `--config model=...` or
33
+ `BACKDRAFT_VLM_MODEL`."""
34
+
35
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
36
+
37
+
38
+ def client_settings(config: dict) -> tuple[str, str, str | None]:
39
+ """Resolve `(model, api_key, base_url)` from backdraft-scoped settings only.
40
+
41
+ The client is always the `openai` SDK; a router is injected through
42
+ `base_url`, never through a second SDK. There is one default provider —
43
+ OpenRouter running Gemini 3.1 Flash Lite — and one rule for credentials:
44
+ **ambient provider variables are never read.** `OPENAI_API_KEY` and
45
+ `OPENROUTER_API_KEY` exported for other tools do not exist as far as this
46
+ function is concerned; presence of a generic key is not consent to send
47
+ documents to its provider. See `backdraft.credentials`.
48
+
49
+ * `api_key`: `--config api_key=` → `BACKDRAFT_VLM_API_KEY` (env or
50
+ `.backdraft/env`). Required.
51
+ * `base_url`: `--config base_url=` → `BACKDRAFT_VLM_BASE_URL` → OpenRouter.
52
+ * `model`: `--config model=` → `BACKDRAFT_VLM_MODEL` → Gemini 3.1 Flash
53
+ Lite. Direct OpenAI (or any OpenAI-compatible endpoint) is explicit:
54
+ set the base_url and the model together.
55
+ """
56
+ api_key = setting("BACKDRAFT_VLM_API_KEY", config, config_key="api_key")
57
+ if not api_key:
58
+ raise ExtractionError(
59
+ "the vlm extractor needs a backdraft-scoped API key: set "
60
+ "BACKDRAFT_VLM_API_KEY (env or .backdraft/env), or pass "
61
+ "--config api_key=... — ambient OPENAI_API_KEY/OPENROUTER_API_KEY "
62
+ "are deliberately not read"
63
+ )
64
+ base_url = (
65
+ setting("BACKDRAFT_VLM_BASE_URL", config, config_key="base_url")
66
+ or OPENROUTER_BASE_URL
67
+ )
68
+ model = setting("BACKDRAFT_VLM_MODEL", config, config_key="model") or DEFAULT_MODEL
69
+ return str(model), str(api_key), str(base_url)
70
+
71
+
72
+ DEFAULT_CONCURRENCY = 15
73
+ """Concurrent page transcriptions — minimal_zerox's production value."""
74
+
75
+ DEFAULT_TIMEOUT_SECONDS = 120
76
+ """Per-page request timeout, as tested in production."""
77
+
78
+ DEFAULT_RETRIES = 4
79
+ """Attempts per page before the error propagates."""
80
+
81
+ MAX_IMAGE_HEIGHT = 1056
82
+ """Rendered page images are downscaled to this height — token-cost control,
83
+ ported from minimal_zerox (dpi buys legibility, height caps what a page costs)."""
84
+
85
+ SNAPSHOT_QUALITY = 85
86
+ """WebP quality for stored page snapshots (embed-ses generate-preview's
87
+ production setting). Lives here rather than vlm.py so `snapshot-pages` can
88
+ import it without pulling in the openai client."""
89
+
90
+
91
+ def _int_setting(name: str, config: dict, config_key: str, default: int) -> int:
92
+ raw = setting(name, config, config_key=config_key)
93
+ try:
94
+ return max(1, int(raw)) if raw else default
95
+ except ValueError:
96
+ return default
97
+
98
+
99
+ def concurrency(config: dict) -> int:
100
+ """`--config concurrency=` → `BACKDRAFT_VLM_CONCURRENCY` → 15."""
101
+ return _int_setting("BACKDRAFT_VLM_CONCURRENCY", config, "concurrency", DEFAULT_CONCURRENCY)
102
+
103
+
104
+ def timeout_seconds(config: dict) -> int:
105
+ """`--config timeout=` → `BACKDRAFT_VLM_TIMEOUT` → 120."""
106
+ return _int_setting("BACKDRAFT_VLM_TIMEOUT", config, "timeout", DEFAULT_TIMEOUT_SECONDS)
107
+
108
+
109
+ def retries(config: dict) -> int:
110
+ """`--config retries=` → `BACKDRAFT_VLM_RETRIES` → 4."""
111
+ return _int_setting("BACKDRAFT_VLM_RETRIES", config, "retries", DEFAULT_RETRIES)
112
+
113
+
114
+ def run_ordered(items, fn, *, workers, progress=None):
115
+ """Map `fn` over `items` concurrently, yielding results in input order.
116
+
117
+ `progress(done, total)` is called after each completion, in completion
118
+ order — the caller renders it however it likes. Exceptions propagate from
119
+ the yield of the item that raised, so ordering of failures is deterministic
120
+ too.
121
+ """
122
+ import threading
123
+ from concurrent.futures import ThreadPoolExecutor
124
+
125
+ items = list(items)
126
+ total = len(items)
127
+ done = 0
128
+ lock = threading.Lock()
129
+
130
+ def wrapped(item):
131
+ nonlocal done
132
+ try:
133
+ return fn(item)
134
+ finally:
135
+ with lock:
136
+ done += 1
137
+ if progress is not None:
138
+ progress(done, total)
139
+
140
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
141
+ futures = [pool.submit(wrapped, item) for item in items]
142
+ for future in futures:
143
+ yield future.result()
144
+
145
+
146
+ _TRANSIENT_MARKERS = (
147
+ "rate limit",
148
+ "too many requests",
149
+ "overloaded",
150
+ "overload",
151
+ "temporarily unavailable",
152
+ "timed out",
153
+ "timeout",
154
+ "connection reset",
155
+ "connection aborted",
156
+ "gateway",
157
+ "upstream",
158
+ "server disconnected",
159
+ )
160
+ """Message fragments that mark a transient upstream failure — minimal_zerox's
161
+ production-tested classification, ported verbatim."""
162
+
163
+
164
+ def is_retryable(error: Exception) -> bool:
165
+ """Whether a page transcription failure is worth another attempt.
166
+
167
+ Retryable: timeouts, HTTP 429/502/503/504 (via a `status_code` attribute,
168
+ the shape the openai SDK raises), transient upstream messages, and
169
+ connection-level failures. Everything else — auth, bad request, content —
170
+ fails fast; retrying those only spends money on the same answer.
171
+ """
172
+ if isinstance(error, TimeoutError):
173
+ return True
174
+ if getattr(error, "status_code", None) in {429, 502, 503, 504}:
175
+ return True
176
+ message = str(error).lower()
177
+ if any(marker in message for marker in _TRANSIENT_MARKERS):
178
+ return True
179
+ return isinstance(error, (ConnectionError, OSError))
180
+
181
+
182
+ def with_retries(fn, *, attempts, base_delay=1.0, sleep=None, jitter=None):
183
+ """Run `fn` with minimal_zerox's retry shape: classify, exponential backoff
184
+ plus jitter, raise the last error once attempts are spent or on the first
185
+ non-retryable one. `sleep`/`jitter` are injectable for tests."""
186
+ import random
187
+ import time
188
+
189
+ sleep = sleep or time.sleep
190
+ jitter = jitter or (lambda high: random.uniform(0, high))
191
+ for attempt in range(1, max(1, attempts) + 1):
192
+ try:
193
+ return fn()
194
+ except Exception as error: # noqa: BLE001 - classification decides
195
+ if not is_retryable(error) or attempt >= attempts:
196
+ raise
197
+ sleep(base_delay * (2 ** (attempt - 1)) + jitter(base_delay))
@@ -0,0 +1,244 @@
1
+ """Workbooks: one page per sheet, rendered as a markdown table with in-band refs.
2
+
3
+ This is the representation the HighRoad app proved — the only one whose sub-page
4
+ attribution worked, because the coordinates sit *in the text the model reads*:
5
+
6
+ ```
7
+ ## Sheet: rent-roll - Values View with cell references
8
+
9
+ | Row | A | B |
10
+ |---|---|---|
11
+ | 1 | [A1] Unit | [B1] Rent |
12
+ | 2 | [A2] 101 | [B2] 2400 |
13
+ ```
14
+
15
+ A model that copies `2400` out of that table has already seen `[B2]`, so the
16
+ registry can compose `bd:model:rent-roll!B2:9e2f` for it. Nothing about the
17
+ location travels in a side channel.
18
+
19
+ The app's hardening is kept because every bit of it was earned on real files:
20
+ hard row/column caps, an inflated-dimension placeholder page (Excel formats empty
21
+ cells and reports a million rows), trailing empty row/column trimming, and cell
22
+ truncation. NOTE: the caps mean a snapshot can be a *partial* view of a sheet;
23
+ the page title says so, which is the honest failure the spec's "failures are
24
+ data" principle asks for.
25
+
26
+ Reader choice: openpyxl alone. python-calamine tolerates more malformed files,
27
+ but openpyxl is already a dependency (column-letter arithmetic, fixtures) and one
28
+ reader means one representation to keep golden. NOTE: if malformed workbooks show
29
+ up in practice, calamine belongs behind an optional fallback, not a second
30
+ default.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from decimal import Decimal
36
+ from math import isfinite
37
+ from pathlib import Path
38
+ from typing import Any, Iterator
39
+
40
+ from openpyxl import load_workbook
41
+ from openpyxl.utils import get_column_letter
42
+
43
+ from ..kernel.model import CellValue
44
+ from .base import ExtractedPage, Extractor, ExtractionError, register
45
+
46
+ __all__ = ["MAX_COLS", "MAX_ROWS", "XlsxExtractor", "EXTRACTOR"]
47
+
48
+ MAX_ROWS = 2_000
49
+ """Hard cap on rows scanned per sheet."""
50
+
51
+ MAX_COLS = 200
52
+ """Hard cap on columns scanned per sheet."""
53
+
54
+ MAX_CELL_CHARS = 150
55
+ """A cell longer than this is truncated with an ellipsis."""
56
+
57
+ _MEDIA_TYPES = frozenset({"xlsx"})
58
+ _SUFFIXES = frozenset({".xlsx", ".xlsm", ".xltx", ".xltm"})
59
+
60
+
61
+ class XlsxExtractor:
62
+ """openpyxl workbook reader. Deterministic: values in, markdown out."""
63
+
64
+ name = "xlsx"
65
+ version = "1"
66
+ deterministic = True
67
+
68
+ def can_handle(self, path: Path, media_type: str) -> bool:
69
+ return media_type in _MEDIA_TYPES or path.suffix.lower() in _SUFFIXES
70
+
71
+ def extract(self, path: Path, config: dict) -> Iterator[ExtractedPage]:
72
+ """Yield one page per sheet, in workbook order.
73
+
74
+ Sheet names are yielded as the workbook spells them; the registry
75
+ sanitizes them to the sheetref charset when it mints tokens.
76
+ """
77
+ try:
78
+ workbook = load_workbook(path, data_only=True, read_only=True)
79
+ except Exception as error: # noqa: BLE001 - openpyxl raises broadly
80
+ raise ExtractionError(f"could not open {path}: {error}") from error
81
+ try:
82
+ for number, name in enumerate(workbook.sheetnames, start=1):
83
+ yield self._sheet(workbook[name], number, name)
84
+ finally:
85
+ workbook.close()
86
+
87
+ def _sheet(self, worksheet: Any, number: int, name: str) -> ExtractedPage:
88
+ reported_rows = worksheet.max_row or 0
89
+ reported_cols = worksheet.max_column or 0
90
+ if reported_rows > MAX_ROWS or reported_cols > MAX_COLS:
91
+ return ExtractedPage(
92
+ number=number,
93
+ kind="sheet",
94
+ name=name,
95
+ text=_placeholder(name, reported_rows, reported_cols),
96
+ cells=[],
97
+ )
98
+
99
+ grid = _read_grid(worksheet)
100
+ rows, cols = _bounds(grid)
101
+ if not rows or not cols:
102
+ return ExtractedPage(
103
+ number=number,
104
+ kind="sheet",
105
+ name=name,
106
+ text=_title(name, rows, cols, reported_rows, reported_cols),
107
+ cells=[],
108
+ )
109
+
110
+ table, cells = _render(grid, rows, cols)
111
+ title = _title(name, rows, cols, reported_rows, reported_cols)
112
+ return ExtractedPage(
113
+ number=number,
114
+ kind="sheet",
115
+ name=name,
116
+ text=f"{title}\n\n{table}",
117
+ cells=cells,
118
+ )
119
+
120
+
121
+ def _read_grid(worksheet: Any) -> list[list[Any]]:
122
+ """The sheet's values, capped. Read once: read-only sheets stream."""
123
+ return [
124
+ list(row)
125
+ for row in worksheet.iter_rows(
126
+ min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
127
+ )
128
+ ]
129
+
130
+
131
+ def _has_data(value: Any) -> bool:
132
+ """True if a cell holds data rather than formatting or emptiness."""
133
+ return value is not None and bool(str(value).strip())
134
+
135
+
136
+ def _bounds(grid: list[list[Any]]) -> tuple[int, int]:
137
+ """The (rows, cols) actually holding data — trailing empties trimmed off."""
138
+ rows = 0
139
+ cols = 0
140
+ for row_index, row in enumerate(grid, start=1):
141
+ for col_index, value in enumerate(row, start=1):
142
+ if _has_data(value):
143
+ rows = row_index
144
+ cols = max(cols, col_index)
145
+ return rows, cols
146
+
147
+
148
+ def _format_value(value: Any) -> str:
149
+ """A cell as text: integers unadorned, floats at full precision, dates ISO-8601.
150
+
151
+ Rounding here would be a correctness bug, not a display choice. The page text
152
+ is the snapshot and the snapshot is the receipt: a cap rate stored as
153
+ `0.0575` and written down as `0.058` cannot be value-traced against the claim
154
+ "5.75%" that was read off it, and the cell anchor's snippet — the thing a
155
+ reader is shown as proof — would disagree with the workbook.
156
+ """
157
+ if value is None:
158
+ return ""
159
+ if isinstance(value, bool):
160
+ # NOTE: before the int branch — bool is an int and `str(int(True))` is "1".
161
+ return str(value)
162
+ if isinstance(value, int):
163
+ return str(value)
164
+ if isinstance(value, float):
165
+ return _float_text(value)
166
+ if hasattr(value, "isoformat"):
167
+ return value.isoformat()
168
+ return str(value)
169
+
170
+
171
+ def _float_text(value: float) -> str:
172
+ """A float as the shortest text that reads back as the same float.
173
+
174
+ `repr` gives that shortest round-trip form, which is what keeps `0.0575` from
175
+ becoming `0.057499999999999996`. Routing it through `Decimal` only drops the
176
+ exponent, so a small rate reads as `0.000001` rather than `1e-06` — the form
177
+ it has in the workbook, and the form a claim would quote.
178
+ """
179
+ if not isfinite(value):
180
+ return repr(value)
181
+ if value.is_integer():
182
+ return str(int(value))
183
+ return format(Decimal(repr(value)), "f")
184
+
185
+
186
+ def _cell_text(value: Any) -> str:
187
+ """A formatted cell, truncated, with markdown's `|` neutralized."""
188
+ text = _format_value(value).replace("\n", " ").replace("|", "\\|")
189
+ if len(text) > MAX_CELL_CHARS:
190
+ text = text[: MAX_CELL_CHARS - 3] + "..."
191
+ return text
192
+
193
+
194
+ def _render(
195
+ grid: list[list[Any]], rows: int, cols: int
196
+ ) -> tuple[str, list[CellValue]]:
197
+ """The markdown table and the cell values it carries.
198
+
199
+ Every non-empty cell gets a `[B10]` prefix in the table and a `CellValue`
200
+ whose `value` is the cell text *as it appears in the table* — the receipt has
201
+ to be verbatim from the snapshot, truncation and all.
202
+ """
203
+ header = ["Row", *(get_column_letter(col) for col in range(1, cols + 1))]
204
+ lines = [
205
+ "| " + " | ".join(header) + " |",
206
+ "|" + "|".join(["---"] * len(header)) + "|",
207
+ ]
208
+ cells: list[CellValue] = []
209
+ for row_index in range(1, rows + 1):
210
+ row = grid[row_index - 1] if row_index <= len(grid) else []
211
+ rendered = [str(row_index)]
212
+ for col_index in range(1, cols + 1):
213
+ value = row[col_index - 1] if col_index <= len(row) else None
214
+ text = _cell_text(value)
215
+ if text:
216
+ ref = f"{get_column_letter(col_index)}{row_index}"
217
+ cells.append(CellValue(ref=ref, value=text))
218
+ text = f"[{ref}] {text}"
219
+ rendered.append(text)
220
+ lines.append("| " + " | ".join(rendered) + " |")
221
+ return "\n".join(lines), cells
222
+
223
+
224
+ def _title(name: str, rows: int, cols: int, reported_rows: int, reported_cols: int) -> str:
225
+ """The page's heading, which says when the view is partial."""
226
+ note = ""
227
+ if reported_rows > rows or reported_cols > cols:
228
+ note = f" (showing {rows}x{cols} of {reported_rows}x{reported_cols})"
229
+ return f"## Sheet: {name}{note} - Values View with cell references"
230
+
231
+
232
+ def _placeholder(name: str, rows: int, cols: int) -> str:
233
+ """The page a sheet gets when its reported dimensions are absurd."""
234
+ return (
235
+ f"## Sheet: {name}\n\n"
236
+ f"**Sheet could not be processed.**\n\n"
237
+ f"Reported dimensions ({rows:,} rows x {cols:,} columns) exceed processing "
238
+ f"limits ({MAX_ROWS:,} x {MAX_COLS:,}). This typically occurs when Excel "
239
+ f"applies formatting to empty cells, inflating the apparent sheet size."
240
+ )
241
+
242
+
243
+ EXTRACTOR: Extractor = XlsxExtractor()
244
+ register(EXTRACTOR)
@@ -0,0 +1,39 @@
1
+ """The gate: the only way source text reaches a writer's context.
2
+
3
+ `read` lists, tables-of-contents and pages; `search` queries the FTS index. Both
4
+ mint what they emit into the session ledger, which is what makes the design's
5
+ central claim true — the set of citable tokens is exactly the set the gate
6
+ emitted, and bind can tell a cited-what-you-saw token from a token the writer
7
+ never had.
8
+
9
+ `gate.cli` is mounted by the top-level CLI (SPEC Addendum B) and is imported
10
+ separately, so this package stays importable without typer.
11
+
12
+ Export style (shared by every package above the kernel): **names**, never
13
+ submodules, and only what a consumer outside the package imports.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .reader import (
19
+ GateError,
20
+ Selection,
21
+ read,
22
+ render_documents,
23
+ render_page_read,
24
+ render_toc,
25
+ select_pages,
26
+ )
27
+ from .searcher import render_search, search
28
+
29
+ __all__ = [
30
+ "GateError",
31
+ "Selection",
32
+ "read",
33
+ "render_documents",
34
+ "render_toc",
35
+ "render_page_read",
36
+ "select_pages",
37
+ "search",
38
+ "render_search",
39
+ ]
backdraft/gate/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """The gate's CLI surface: `read`, `search`, `session`.
2
+
3
+ Mounted by the top-level `cli.py` (SPEC Addendum B), which owns the typer app.
4
+ This module holds no logic of its own — it parses flags, calls `reader`/
5
+ `searcher`, prints, and lets `cli_context.guard` map a `GateError` to exit code 1
6
+ (usage/env error).
7
+
8
+ Discovery and session resolution come from `backdraft.cli_context`, imported at
9
+ module level: that module deliberately knows nothing about the sub-apps, so the
10
+ mount is not a cycle and there is no lazy lookup to keep in sync.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Annotated
17
+
18
+ import typer
19
+
20
+ from ..cli_context import SESSION_ENV, opened_registry, resolve_session
21
+ from .reader import cells as mint_cells
22
+ from .reader import read as read_pages
23
+ from .searcher import search as run_search
24
+
25
+ __all__ = ["app", "session_app"]
26
+
27
+ app = typer.Typer(help="Read and search ingested documents through the gate.")
28
+ session_app = typer.Typer(help="Inspect or start a ledger session.")
29
+ app.add_typer(session_app, name="session")
30
+
31
+
32
+ def _session_source(explicit: str | None) -> str:
33
+ """Which rule supplied the session id — shown by `session show`."""
34
+ if explicit:
35
+ return "--session"
36
+ if os.environ.get(SESSION_ENV):
37
+ return SESSION_ENV
38
+ return "default"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # commands
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _SessionOption = Annotated[
46
+ str | None,
47
+ typer.Option("--session", "-s", help="Ledger session to mint into."),
48
+ ]
49
+
50
+
51
+ @app.command()
52
+ def read(
53
+ slug: Annotated[str | None, typer.Argument(help="Document slug.")] = None,
54
+ selector: Annotated[
55
+ str | None, typer.Argument(help="Page (`p3`), range (`p3-5`), or sheet name.")
56
+ ] = None,
57
+ session: _SessionOption = None,
58
+ offset: Annotated[
59
+ int, typer.Option("--offset", help="Units to skip (chars for pages, rows for sheets).")
60
+ ] = 0,
61
+ limit: Annotated[
62
+ int | None, typer.Option("--limit", help="Units to show. Default: all of them.")
63
+ ] = None,
64
+ ) -> None:
65
+ """List documents, show a document's contents, or read pages from one."""
66
+ # The list and the table of contents emit no tokens, so they mint nothing.
67
+ minting = slug is not None and selector is not None
68
+ session_id = resolve_session(session) if minting else None
69
+ with opened_registry() as registry:
70
+ typer.echo(
71
+ read_pages(
72
+ registry, slug, selector, session=session_id, offset=offset, limit=limit
73
+ )
74
+ )
75
+
76
+
77
+ @app.command()
78
+ def cell(
79
+ slug: Annotated[str, typer.Argument(help="Document slug.")],
80
+ refs: Annotated[
81
+ list[str], typer.Argument(help="Cell locators, like rent-roll!B10. Repeatable.")
82
+ ],
83
+ session: _SessionOption = None,
84
+ ) -> None:
85
+ """Mint tokens for specific cells: the token, then the verbatim value.
86
+
87
+ The direct path to citing a cell you can see in a windowed sheet read —
88
+ no need to search for the cell's own value.
89
+ """
90
+ session_id = resolve_session(session)
91
+ with opened_registry() as registry:
92
+ typer.echo(mint_cells(registry, slug, refs, session=session_id))
93
+
94
+
95
+ @app.command()
96
+ def search(
97
+ query: Annotated[str, typer.Argument(help="FTS5 query.")],
98
+ in_: Annotated[
99
+ str | None, typer.Option("--in", help="Restrict to one document slug.")
100
+ ] = None,
101
+ session: _SessionOption = None,
102
+ limit: Annotated[int, typer.Option("--limit", help="Maximum results.")] = 20,
103
+ ) -> None:
104
+ """Search every anchor's snippet. Results are citable without a page read."""
105
+ session_id = resolve_session(session)
106
+ with opened_registry() as registry:
107
+ typer.echo(run_search(registry, query, slug=in_, limit=limit, session=session_id))
108
+
109
+
110
+ @session_app.command("start")
111
+ def session_start(
112
+ id_: Annotated[
113
+ str | None, typer.Option("--id", help="Use this id instead of a generated one.")
114
+ ] = None,
115
+ label: Annotated[str | None, typer.Option("--label", help="Human note.")] = None,
116
+ ) -> None:
117
+ """Start a ledger session and print the id to export."""
118
+ with opened_registry() as registry:
119
+ session_id = registry.ensure_session(id_, label)
120
+ typer.echo(f"session {session_id} started")
121
+ typer.echo(f"[Use it: export {SESSION_ENV}={session_id}]")
122
+
123
+
124
+ @session_app.command("show")
125
+ def session_show(session: _SessionOption = None) -> None:
126
+ """Show which session reads and searches would mint into."""
127
+ resolved = resolve_session(session)
128
+ with opened_registry() as registry:
129
+ typer.echo(
130
+ f"session {registry.ensure_session(resolved)} (from {_session_source(session)})"
131
+ )