scriptoria 0.5.2__tar.gz → 0.6.1__tar.gz
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.
- {scriptoria-0.5.2 → scriptoria-0.6.1}/PKG-INFO +17 -3
- scriptoria-0.6.1/README.md +54 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/pyproject.toml +1 -1
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/__init__.py +1 -1
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/blocks.py +9 -11
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/cli.py +88 -4
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/facts.py +6 -1
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/frontmatter.py +7 -2
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/graph.py +7 -2
- scriptoria-0.6.1/src/scrip/pageindex_adapter.py +371 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/retrieval.py +6 -2
- scriptoria-0.6.1/tests/test_pageindex_cmd.py +186 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/uv.lock +1 -1
- scriptoria-0.5.2/README.md +0 -40
- {scriptoria-0.5.2 → scriptoria-0.6.1}/.gitignore +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/pyrightconfig.json +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/anchors.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/embeddings.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/errors.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/hashing.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/ingest.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/lock.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/manifest.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/query.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/src/scrip/similar.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/conftest.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_anchor_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_anchors.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_blocks.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_embeddings.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_fact_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_frontmatter.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_graph_status.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_hashing.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_index_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_ingest.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_json_shapes.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_lock.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_manifest.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_new_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_query.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_retrieval.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_similar_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_span_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_stamp.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_status_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_unlock_cmd.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_verify.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_version.py +0 -0
- {scriptoria-0.5.2 → scriptoria-0.6.1}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scriptoria
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base
|
|
5
5
|
Project-URL: Homepage, https://github.com/coredipper/scriptorium
|
|
6
6
|
Project-URL: Changelog, https://github.com/coredipper/scriptorium/blob/main/CHANGELOG.md
|
|
@@ -53,11 +53,25 @@ uv run --project scrip scrip --help
|
|
|
53
53
|
|---|---|
|
|
54
54
|
| `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
|
|
55
55
|
| `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
|
|
56
|
-
| `scrip query [claims\|entities\|edges\|contradictions]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
|
|
57
|
-
| `scrip
|
|
56
|
+
| `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
|
|
57
|
+
| `scrip ingest <url\|file>` | Fetch/read a source and write canonical `vault/raw/<slug>.md` plus sidecar metadata. HTML/PDF need the optional `[ingest]` extra. |
|
|
58
|
+
| `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
|
|
59
|
+
| `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
|
|
60
|
+
| `scrip fact add --table claims\|entities\|edges\|reconciliations` | Validate and append facts under the write lock; claims mint verified anchors, reconciliations mint ids/timestamps. |
|
|
61
|
+
| `scrip span --claim <id>` | Resolve a claim anchor and print the cited span. |
|
|
62
|
+
| `scrip similar --title ... --from ...` | Score overlap with existing wiki pages before PROMOTE. |
|
|
63
|
+
| `scrip search "<question>"` | Retrieve source blocks for a miss; uses embeddings if an index exists, otherwise lexical grep. Add `--long-docs pageindex` to try the PageIndex cache first. |
|
|
64
|
+
| `scrip index` | Build the optional embeddings index over `vault/raw/` when `scriptoria[embeddings]` is installed; otherwise exits cleanly and `search` falls back to grep. |
|
|
65
|
+
| `scrip pageindex build raw/<slug>` | Build the optional PageIndex cache for one raw source when a compatible backend is importable; otherwise exits cleanly. |
|
|
66
|
+
| `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
|
|
67
|
+
| `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
|
|
58
68
|
|
|
59
69
|
Every command accepts `--root DIR` and `--json`.
|
|
60
70
|
|
|
71
|
+
The optional `scrip-harness` package adds the model-bearing commands, including
|
|
72
|
+
`scrip-harness answer "<question>"`, which gathers evidence with these primitives
|
|
73
|
+
and verifies every citation before printing or saving an answer.
|
|
74
|
+
|
|
61
75
|
## Exit codes
|
|
62
76
|
|
|
63
77
|
| Code | Meaning |
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# scrip
|
|
2
|
+
|
|
3
|
+
The deterministic keeper of a [scriptorium](../README.md) knowledge base.
|
|
4
|
+
|
|
5
|
+
The agent owns judgment (synthesis, fact extraction, reconciliation, promotion).
|
|
6
|
+
`scrip` owns only what LLMs are unreliable or expensive at: **content hashing**,
|
|
7
|
+
**staleness** over a dependency graph, **provenance-anchor integrity**, and
|
|
8
|
+
**structured queries** over the facts layer. Files are the source of truth;
|
|
9
|
+
`.kb/manifest.json` is only a regenerable speed cache.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
uv tool install ./scrip # installs the `scrip` command
|
|
15
|
+
# or, without installing:
|
|
16
|
+
uv run --project scrip scrip --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
| Command | What it does |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
|
|
24
|
+
| `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
|
|
25
|
+
| `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
|
|
26
|
+
| `scrip ingest <url\|file>` | Fetch/read a source and write canonical `vault/raw/<slug>.md` plus sidecar metadata. HTML/PDF need the optional `[ingest]` extra. |
|
|
27
|
+
| `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
|
|
28
|
+
| `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
|
|
29
|
+
| `scrip fact add --table claims\|entities\|edges\|reconciliations` | Validate and append facts under the write lock; claims mint verified anchors, reconciliations mint ids/timestamps. |
|
|
30
|
+
| `scrip span --claim <id>` | Resolve a claim anchor and print the cited span. |
|
|
31
|
+
| `scrip similar --title ... --from ...` | Score overlap with existing wiki pages before PROMOTE. |
|
|
32
|
+
| `scrip search "<question>"` | Retrieve source blocks for a miss; uses embeddings if an index exists, otherwise lexical grep. Add `--long-docs pageindex` to try the PageIndex cache first. |
|
|
33
|
+
| `scrip index` | Build the optional embeddings index over `vault/raw/` when `scriptoria[embeddings]` is installed; otherwise exits cleanly and `search` falls back to grep. |
|
|
34
|
+
| `scrip pageindex build raw/<slug>` | Build the optional PageIndex cache for one raw source when a compatible backend is importable; otherwise exits cleanly. |
|
|
35
|
+
| `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
|
|
36
|
+
| `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
|
|
37
|
+
|
|
38
|
+
Every command accepts `--root DIR` and `--json`.
|
|
39
|
+
|
|
40
|
+
The optional `scrip-harness` package adds the model-bearing commands, including
|
|
41
|
+
`scrip-harness answer "<question>"`, which gathers evidence with these primitives
|
|
42
|
+
and verifies every citation before printing or saving an answer.
|
|
43
|
+
|
|
44
|
+
## Exit codes
|
|
45
|
+
|
|
46
|
+
| Code | Meaning |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `0` | clean / success |
|
|
49
|
+
| `1` | actionable finding (stale artifacts; broken citations) |
|
|
50
|
+
| `2` | usage error |
|
|
51
|
+
| `3` | data error (malformed frontmatter / NDJSON; missing source; duplicate id) |
|
|
52
|
+
| `4` | internal error |
|
|
53
|
+
|
|
54
|
+
Code `1` is an *expected* outcome the agent branches on, not a crash.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Distribution name on PyPI is `scriptoria` (scrip/scriptorium were taken); the
|
|
3
3
|
# CLI command and the import package both remain `scrip`.
|
|
4
4
|
name = "scriptoria"
|
|
5
|
-
version = "0.
|
|
5
|
+
version = "0.6.1"
|
|
6
6
|
description = "Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
requires-python = ">=3.10"
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
-
__version__ = "0.
|
|
16
|
+
__version__ = "0.6.1"
|
|
17
17
|
|
|
18
18
|
# --- canonical vault layout ------------------------------------------------
|
|
19
19
|
# ``root`` is the repo/instance root: the directory containing ``vault/``.
|
|
@@ -35,17 +35,13 @@ def _is_blank(line: str) -> bool:
|
|
|
35
35
|
|
|
36
36
|
def split_blocks(text: str) -> list[dict]:
|
|
37
37
|
"""Return a list of ``{"block_id", "span": [start, end], "hash"}``."""
|
|
38
|
-
#
|
|
39
|
-
|
|
38
|
+
# ⚡ Bolt: Single pass block segmentation to avoid intermediate O(N) allocation
|
|
39
|
+
ranges: list[list[int]] = [] # [start, end] per block
|
|
40
|
+
cur: list[int] | None = None
|
|
40
41
|
start = 0
|
|
42
|
+
|
|
41
43
|
for line in text.splitlines(keepends=True):
|
|
42
44
|
end = start + len(line)
|
|
43
|
-
spans.append((start, end, line))
|
|
44
|
-
start = end
|
|
45
|
-
|
|
46
|
-
ranges: list[list[int]] = [] # [start, end] per block
|
|
47
|
-
cur: list[int] | None = None
|
|
48
|
-
for s, e, line in spans:
|
|
49
45
|
if _is_blank(line):
|
|
50
46
|
if cur is not None:
|
|
51
47
|
ranges.append(cur)
|
|
@@ -54,12 +50,14 @@ def split_blocks(text: str) -> list[dict]:
|
|
|
54
50
|
if cur is not None:
|
|
55
51
|
ranges.append(cur)
|
|
56
52
|
cur = None
|
|
57
|
-
ranges.append([
|
|
53
|
+
ranges.append([start, end]) # a heading is its own block
|
|
58
54
|
else:
|
|
59
55
|
if cur is None:
|
|
60
|
-
cur = [
|
|
56
|
+
cur = [start, end]
|
|
61
57
|
else:
|
|
62
|
-
cur[1] =
|
|
58
|
+
cur[1] = end
|
|
59
|
+
start = end
|
|
60
|
+
|
|
63
61
|
if cur is not None:
|
|
64
62
|
ranges.append(cur)
|
|
65
63
|
|
|
@@ -224,15 +224,72 @@ def cmd_search(args: argparse.Namespace) -> int:
|
|
|
224
224
|
from . import retrieval
|
|
225
225
|
|
|
226
226
|
root = resolve_root(args.root)
|
|
227
|
-
out = retrieval.search(root, args.query, k=args.k)
|
|
227
|
+
out = retrieval.search(root, args.query, k=args.k, long_docs=args.long_docs)
|
|
228
228
|
if args.json:
|
|
229
229
|
_emit(out)
|
|
230
230
|
else:
|
|
231
|
-
|
|
231
|
+
stale_hint = "INDEX STALE, rebuild with `scrip index`"
|
|
232
|
+
if out["method"] == "pageindex":
|
|
233
|
+
stale_hint = "INDEX STALE, rebuild with `scrip pageindex build raw/<slug>`"
|
|
234
|
+
tag = out["method"] + (f" - {stale_hint}" if out["stale_index"] else "")
|
|
232
235
|
print(f"[{tag}] top {len(out['results'])} for: {args.query!r}")
|
|
233
236
|
for r in out["results"]:
|
|
234
|
-
|
|
235
|
-
|
|
237
|
+
score_value = r.get("score", "")
|
|
238
|
+
score = f"{score_value:.3f}" if isinstance(score_value, float) else str(score_value)
|
|
239
|
+
locator = r.get("block_id") or r.get("section_id") or "?"
|
|
240
|
+
print(f" {r['source_id']}#{locator} ({score})")
|
|
241
|
+
print(f" {r.get('snippet', '')}")
|
|
242
|
+
if not out["results"]:
|
|
243
|
+
print(" (no matches)")
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_pageindex_build(args: argparse.Namespace) -> int:
|
|
248
|
+
from . import pageindex_adapter
|
|
249
|
+
|
|
250
|
+
root = resolve_root(args.root)
|
|
251
|
+
out = pageindex_adapter.build_index(root, args.source)
|
|
252
|
+
if args.json:
|
|
253
|
+
_emit(out)
|
|
254
|
+
else:
|
|
255
|
+
if out["status"] == "built":
|
|
256
|
+
print(f"indexed {out['sections_indexed']} section(s) for {out['source_id']} into .kb/pageindex/")
|
|
257
|
+
else:
|
|
258
|
+
print(out["message"])
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_pageindex_search(args: argparse.Namespace) -> int:
|
|
263
|
+
from . import pageindex_adapter
|
|
264
|
+
|
|
265
|
+
root = resolve_root(args.root)
|
|
266
|
+
source_id = args.source
|
|
267
|
+
if source_id is not None and not source_id.startswith("raw/"):
|
|
268
|
+
source_id = f"raw/{source_id}"
|
|
269
|
+
out = pageindex_adapter.search(root, args.query, k=args.k, source_id=source_id)
|
|
270
|
+
if out is None:
|
|
271
|
+
payload = {
|
|
272
|
+
"status": "missing",
|
|
273
|
+
"message": "no usable PageIndex cache; run `scrip pageindex build raw/<slug>` first",
|
|
274
|
+
"method": "pageindex",
|
|
275
|
+
"stale_index": False,
|
|
276
|
+
"results": [],
|
|
277
|
+
}
|
|
278
|
+
if args.json:
|
|
279
|
+
_emit(payload)
|
|
280
|
+
else:
|
|
281
|
+
print(payload["message"])
|
|
282
|
+
return 0
|
|
283
|
+
if args.json:
|
|
284
|
+
_emit(out)
|
|
285
|
+
else:
|
|
286
|
+
stale_hint = " - INDEX STALE, rebuild with `scrip pageindex build raw/<slug>`"
|
|
287
|
+
tag = out["method"] + (stale_hint if out["stale_index"] else "")
|
|
288
|
+
print(f"[{tag}] top {len(out['results'])} for: {args.query!r}")
|
|
289
|
+
for r in out["results"]:
|
|
290
|
+
score_value = r.get("score", "")
|
|
291
|
+
score = f"{score_value:.3f}" if isinstance(score_value, float) else str(score_value)
|
|
292
|
+
print(f" {r['source_id']}#{r.get('section_id', '?')} ({score})")
|
|
236
293
|
print(f" {r['snippet']}")
|
|
237
294
|
if not out["results"]:
|
|
238
295
|
print(" (no matches)")
|
|
@@ -569,8 +626,35 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
569
626
|
)
|
|
570
627
|
psr.add_argument("query", help="the question / search text")
|
|
571
628
|
psr.add_argument("-k", type=int, default=5, help="number of results (default 5)")
|
|
629
|
+
psr.add_argument(
|
|
630
|
+
"--long-docs",
|
|
631
|
+
choices=["pageindex"],
|
|
632
|
+
help="try a long-document adapter cache before embeddings/grep",
|
|
633
|
+
)
|
|
572
634
|
psr.set_defaults(func=cmd_search)
|
|
573
635
|
|
|
636
|
+
ppidx = sub.add_parser(
|
|
637
|
+
"pageindex",
|
|
638
|
+
help="optional PageIndex long-document cache (regenerable, not source of truth)",
|
|
639
|
+
)
|
|
640
|
+
pidx_sub = ppidx.add_subparsers(dest="pageindex_command", required=True, metavar="<action>")
|
|
641
|
+
ppb = pidx_sub.add_parser(
|
|
642
|
+
"build",
|
|
643
|
+
parents=[common],
|
|
644
|
+
help="build a PageIndex cache for one raw source",
|
|
645
|
+
)
|
|
646
|
+
ppb.add_argument("source", metavar="raw/<slug>", help="raw source to index")
|
|
647
|
+
ppb.set_defaults(func=cmd_pageindex_build)
|
|
648
|
+
pps = pidx_sub.add_parser(
|
|
649
|
+
"search",
|
|
650
|
+
parents=[common],
|
|
651
|
+
help="search the PageIndex cache for verbatim raw-source snippets",
|
|
652
|
+
)
|
|
653
|
+
pps.add_argument("query", help="the question / search text")
|
|
654
|
+
pps.add_argument("--source", metavar="raw/<slug>", help="restrict to one cached source")
|
|
655
|
+
pps.add_argument("-k", type=int, default=5, help="number of results (default 5)")
|
|
656
|
+
pps.set_defaults(func=cmd_pageindex_search)
|
|
657
|
+
|
|
574
658
|
pul = sub.add_parser(
|
|
575
659
|
"unlock",
|
|
576
660
|
parents=[common],
|
|
@@ -26,6 +26,11 @@ from pathlib import Path
|
|
|
26
26
|
|
|
27
27
|
import yaml
|
|
28
28
|
|
|
29
|
+
try:
|
|
30
|
+
from yaml import CSafeLoader as SafeLoader
|
|
31
|
+
except ImportError:
|
|
32
|
+
from yaml import SafeLoader
|
|
33
|
+
|
|
29
34
|
from . import anchors, facts_dir, lock
|
|
30
35
|
from .errors import DataError, UsageError
|
|
31
36
|
|
|
@@ -337,7 +342,7 @@ def _load_meta(root: Path) -> dict:
|
|
|
337
342
|
"confidence": 0.0,
|
|
338
343
|
}
|
|
339
344
|
try:
|
|
340
|
-
data = yaml.
|
|
345
|
+
data = yaml.load(p.read_text(encoding="utf-8"), Loader=SafeLoader) or {}
|
|
341
346
|
except yaml.YAMLError as e:
|
|
342
347
|
raise DataError(f"invalid facts/_meta.yaml: {e}") from e
|
|
343
348
|
if not isinstance(data, dict):
|
|
@@ -12,6 +12,11 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
import yaml
|
|
14
14
|
|
|
15
|
+
try:
|
|
16
|
+
from yaml import CSafeLoader as SafeLoader
|
|
17
|
+
except ImportError:
|
|
18
|
+
from yaml import SafeLoader
|
|
19
|
+
|
|
15
20
|
from .errors import DataError
|
|
16
21
|
|
|
17
22
|
FENCE = "---"
|
|
@@ -28,7 +33,7 @@ def parse(text: str) -> tuple[dict, str]:
|
|
|
28
33
|
fm_text = "".join(lines[1:i])
|
|
29
34
|
body = "".join(lines[i + 1 :])
|
|
30
35
|
try:
|
|
31
|
-
meta = yaml.
|
|
36
|
+
meta = yaml.load(fm_text, Loader=SafeLoader)
|
|
32
37
|
except yaml.YAMLError as e:
|
|
33
38
|
raise DataError(f"invalid YAML frontmatter: {e}") from e
|
|
34
39
|
if meta is None:
|
|
@@ -53,7 +58,7 @@ def _read_frontmatter(f) -> tuple[bool, dict]:
|
|
|
53
58
|
raise DataError("unterminated frontmatter (missing closing '---')")
|
|
54
59
|
|
|
55
60
|
try:
|
|
56
|
-
meta = yaml.
|
|
61
|
+
meta = yaml.load("".join(fm_lines), Loader=SafeLoader)
|
|
57
62
|
except yaml.YAMLError as e:
|
|
58
63
|
raise DataError(f"invalid YAML frontmatter: {e}") from e
|
|
59
64
|
if meta is None:
|
|
@@ -13,6 +13,11 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
import yaml
|
|
15
15
|
|
|
16
|
+
try:
|
|
17
|
+
from yaml import CSafeLoader as SafeLoader
|
|
18
|
+
except ImportError:
|
|
19
|
+
from yaml import SafeLoader
|
|
20
|
+
|
|
16
21
|
from . import blocks as blocks_mod
|
|
17
22
|
from . import facts_dir, frontmatter, hashing, raw_dir, wiki_dir
|
|
18
23
|
from . import manifest as manifest_mod
|
|
@@ -92,7 +97,7 @@ def scan_derived(root: Path) -> dict:
|
|
|
92
97
|
fmeta = facts_dir(root) / "_meta.yaml"
|
|
93
98
|
if fmeta.exists():
|
|
94
99
|
try:
|
|
95
|
-
data = yaml.
|
|
100
|
+
data = yaml.load(fmeta.read_text(encoding="utf-8"), Loader=SafeLoader)
|
|
96
101
|
except yaml.YAMLError as e:
|
|
97
102
|
raise DataError(f"invalid facts/_meta.yaml: {e}") from e
|
|
98
103
|
if data is None:
|
|
@@ -246,7 +251,7 @@ def stamp_artifacts(root: Path, paths: list[str] | None = None) -> list[dict]:
|
|
|
246
251
|
ih = hashing.input_hash(deps)
|
|
247
252
|
path = root / d["path"]
|
|
248
253
|
if path.name == "_meta.yaml":
|
|
249
|
-
data = yaml.
|
|
254
|
+
data = yaml.load(path.read_text(encoding="utf-8"), Loader=SafeLoader) or {}
|
|
250
255
|
data["input-hash"] = ih
|
|
251
256
|
data["last-compiled"] = now
|
|
252
257
|
path.write_text(
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Optional PageIndex cache adapter for long-document retrieval.
|
|
2
|
+
|
|
3
|
+
PageIndex is treated like embeddings: a regenerable cache under ``.kb/``, never
|
|
4
|
+
the source of truth. The only durable text remains ``vault/raw/<slug>.md``; every
|
|
5
|
+
search result snippet persisted here must be copied verbatim from that raw text
|
|
6
|
+
so a later answer can still cite it with ``scrip anchor``.
|
|
7
|
+
|
|
8
|
+
The real backend is deliberately optional. Tests monkeypatch ``_get_backend``;
|
|
9
|
+
production can provide a module exposing ``build``/``build_index`` and optionally
|
|
10
|
+
``search``. Without a backend, the CLI reports an unavailable adapter and normal
|
|
11
|
+
``scrip search`` falls back to embeddings/grep.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import json
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import hashing, raw_dir
|
|
22
|
+
from .errors import DataError
|
|
23
|
+
|
|
24
|
+
INDEX_SCHEMA = 1
|
|
25
|
+
|
|
26
|
+
_backend = None
|
|
27
|
+
_backend_tried = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _pageindex_dir(root: Path) -> Path:
|
|
31
|
+
return root / ".kb" / "pageindex"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _source_id(source: str) -> str:
|
|
35
|
+
sid = source if source.startswith("raw/") else f"raw/{source}"
|
|
36
|
+
slug = sid[len("raw/") :]
|
|
37
|
+
if "/" in slug or "\\" in slug or slug.startswith(".") or ".." in slug:
|
|
38
|
+
raise DataError(f"unsafe source id: {source}")
|
|
39
|
+
return sid
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _source_path(root: Path, source_id: str) -> Path:
|
|
43
|
+
p = raw_dir(root) / f"{source_id[len('raw/'):]}.md"
|
|
44
|
+
if not p.exists():
|
|
45
|
+
raise DataError(f"source does not exist: {source_id}")
|
|
46
|
+
return p
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cache_dir(root: Path, source_id: str) -> Path:
|
|
50
|
+
return _pageindex_dir(root) / source_id[len("raw/") :]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _now() -> str:
|
|
54
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_backend():
|
|
58
|
+
"""Return an optional PageIndex backend module/object.
|
|
59
|
+
|
|
60
|
+
The adapter accepts either ``pageindex`` or ``page_index`` as import names.
|
|
61
|
+
A usable backend exposes ``build`` or ``build_index``; ``search`` is optional
|
|
62
|
+
because cached records can still be ranked lexically.
|
|
63
|
+
"""
|
|
64
|
+
global _backend, _backend_tried
|
|
65
|
+
if _backend_tried:
|
|
66
|
+
return _backend
|
|
67
|
+
_backend_tried = True
|
|
68
|
+
for name in ("pageindex", "page_index"):
|
|
69
|
+
try:
|
|
70
|
+
mod = importlib.import_module(name)
|
|
71
|
+
except Exception:
|
|
72
|
+
continue
|
|
73
|
+
if any(hasattr(mod, attr) for attr in ("build", "build_index")):
|
|
74
|
+
_backend = mod
|
|
75
|
+
break
|
|
76
|
+
return _backend
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def available() -> bool:
|
|
80
|
+
return _get_backend() is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _backend_name(backend) -> str:
|
|
84
|
+
return getattr(backend, "__name__", backend.__class__.__name__)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _backend_version(backend) -> str:
|
|
88
|
+
return str(getattr(backend, "__version__", "unknown"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _call_build(backend, *, source_id: str, text: str):
|
|
92
|
+
if hasattr(backend, "build_index"):
|
|
93
|
+
return backend.build_index(source_id=source_id, text=text)
|
|
94
|
+
if hasattr(backend, "build"):
|
|
95
|
+
return backend.build(source_id=source_id, text=text)
|
|
96
|
+
if callable(backend):
|
|
97
|
+
return backend(source_id=source_id, text=text)
|
|
98
|
+
raise RuntimeError("PageIndex backend does not expose build/build_index")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _call_search(backend, *, query: str, items: list[dict], k: int):
|
|
102
|
+
if backend is not None and hasattr(backend, "search"):
|
|
103
|
+
return backend.search(query=query, items=items, k=k)
|
|
104
|
+
return _lexical_rank(query, items, k)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _candidate_records(raw):
|
|
108
|
+
if raw is None:
|
|
109
|
+
return []
|
|
110
|
+
if isinstance(raw, list):
|
|
111
|
+
return raw
|
|
112
|
+
if isinstance(raw, dict):
|
|
113
|
+
for key in ("items", "sections", "nodes", "results"):
|
|
114
|
+
val = raw.get(key)
|
|
115
|
+
if isinstance(val, list):
|
|
116
|
+
return val
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _coerce_items(raw, *, source_id: str, text: str) -> list[dict]:
|
|
121
|
+
"""Normalize backend records and keep only snippets present in raw text."""
|
|
122
|
+
items: list[dict] = []
|
|
123
|
+
for i, rec in enumerate(_candidate_records(raw)):
|
|
124
|
+
if not isinstance(rec, dict):
|
|
125
|
+
continue
|
|
126
|
+
snippet = str(rec.get("snippet") or rec.get("text") or rec.get("content") or "")
|
|
127
|
+
span = rec.get("span_hint") or rec.get("span")
|
|
128
|
+
if (
|
|
129
|
+
isinstance(span, list)
|
|
130
|
+
and len(span) == 2
|
|
131
|
+
and all(isinstance(n, int) for n in span)
|
|
132
|
+
and 0 <= span[0] < span[1] <= len(text)
|
|
133
|
+
):
|
|
134
|
+
# Prefer the canonical raw slice. Backend summaries are not citable.
|
|
135
|
+
snippet = text[span[0] : span[1]].strip()
|
|
136
|
+
if not snippet:
|
|
137
|
+
continue
|
|
138
|
+
start = text.find(snippet)
|
|
139
|
+
if start < 0:
|
|
140
|
+
# The contract requires verbatim evidence; discard summaries.
|
|
141
|
+
continue
|
|
142
|
+
span_hint = [start, start + len(snippet)]
|
|
143
|
+
items.append(
|
|
144
|
+
{
|
|
145
|
+
"source_id": source_id,
|
|
146
|
+
"section_id": str(rec.get("section_id") or rec.get("id") or i),
|
|
147
|
+
"snippet": snippet,
|
|
148
|
+
"span_hint": span_hint,
|
|
149
|
+
"score": float(rec.get("score") or 0.0),
|
|
150
|
+
"method": "pageindex",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
return items
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _cached_item_maps(items: list[dict]) -> tuple[dict[tuple[str, str], dict], dict[str, list[dict]]]:
|
|
157
|
+
by_section: dict[tuple[str, str], dict] = {}
|
|
158
|
+
by_snippet: dict[str, list[dict]] = {}
|
|
159
|
+
for item in items:
|
|
160
|
+
source_id = item.get("source_id")
|
|
161
|
+
section_id = item.get("section_id")
|
|
162
|
+
snippet = item.get("snippet")
|
|
163
|
+
if isinstance(source_id, str) and section_id is not None:
|
|
164
|
+
by_section[(source_id, str(section_id))] = item
|
|
165
|
+
if isinstance(snippet, str):
|
|
166
|
+
by_snippet.setdefault(snippet, []).append(item)
|
|
167
|
+
return by_section, by_snippet
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _float_score(value, fallback: float) -> float:
|
|
171
|
+
try:
|
|
172
|
+
return float(value)
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
return fallback
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _match_cached_item(
|
|
178
|
+
rec: dict, by_section: dict[tuple[str, str], dict], by_snippet: dict[str, list[dict]]
|
|
179
|
+
) -> dict | None:
|
|
180
|
+
source_id = rec.get("source_id")
|
|
181
|
+
if isinstance(source_id, str):
|
|
182
|
+
try:
|
|
183
|
+
source_id = _source_id(source_id)
|
|
184
|
+
except DataError:
|
|
185
|
+
source_id = None
|
|
186
|
+
else:
|
|
187
|
+
source_id = None
|
|
188
|
+
section_id = rec.get("section_id") or rec.get("id")
|
|
189
|
+
if source_id is not None and section_id is not None:
|
|
190
|
+
item = by_section.get((source_id, str(section_id)))
|
|
191
|
+
if item is not None:
|
|
192
|
+
return item
|
|
193
|
+
snippet = rec.get("snippet") or rec.get("text") or rec.get("content")
|
|
194
|
+
if isinstance(snippet, str):
|
|
195
|
+
for item in by_snippet.get(snippet, []):
|
|
196
|
+
if source_id is None or item.get("source_id") == source_id:
|
|
197
|
+
if section_id is None or str(item.get("section_id")) == str(section_id):
|
|
198
|
+
return item
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _canonical_search_results(raw, items: list[dict], k: int) -> list[dict]:
|
|
203
|
+
by_section, by_snippet = _cached_item_maps(items)
|
|
204
|
+
results: list[dict] = []
|
|
205
|
+
seen: set[tuple[str, str, tuple[int, ...]]] = set()
|
|
206
|
+
for rec in _candidate_records(raw):
|
|
207
|
+
if not isinstance(rec, dict):
|
|
208
|
+
continue
|
|
209
|
+
item = _match_cached_item(rec, by_section, by_snippet)
|
|
210
|
+
if item is None:
|
|
211
|
+
continue
|
|
212
|
+
span = item.get("span_hint")
|
|
213
|
+
span_key = tuple(span) if isinstance(span, list) and all(isinstance(n, int) for n in span) else ()
|
|
214
|
+
key = (str(item.get("source_id")), str(item.get("section_id")), span_key)
|
|
215
|
+
if key in seen:
|
|
216
|
+
continue
|
|
217
|
+
seen.add(key)
|
|
218
|
+
out = dict(item)
|
|
219
|
+
out["score"] = _float_score(rec.get("score"), _float_score(item.get("score"), 0.0))
|
|
220
|
+
out["method"] = "pageindex"
|
|
221
|
+
results.append(out)
|
|
222
|
+
if len(results) >= k:
|
|
223
|
+
break
|
|
224
|
+
return results
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def build_index(root: Path, source: str) -> dict:
|
|
228
|
+
"""Build a PageIndex cache for one raw source.
|
|
229
|
+
|
|
230
|
+
Returns ``{"status": "built"|"unavailable", ...}`` rather than raising for
|
|
231
|
+
a missing backend so the CLI can fail soft like ``scrip index``.
|
|
232
|
+
"""
|
|
233
|
+
backend = _get_backend()
|
|
234
|
+
if backend is None:
|
|
235
|
+
return {
|
|
236
|
+
"status": "unavailable",
|
|
237
|
+
"message": (
|
|
238
|
+
"PageIndex backend not installed; see docs/pageindex-adapter.md. "
|
|
239
|
+
"Normal `scrip search` still falls back to embeddings/grep."
|
|
240
|
+
),
|
|
241
|
+
}
|
|
242
|
+
source_id = _source_id(source)
|
|
243
|
+
path = _source_path(root, source_id)
|
|
244
|
+
text = path.read_text(encoding="utf-8")
|
|
245
|
+
raw = _call_build(backend, source_id=source_id, text=text)
|
|
246
|
+
items = _coerce_items(raw, source_id=source_id, text=text)
|
|
247
|
+
d = _cache_dir(root, source_id)
|
|
248
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
raw_hash = hashing.content_hash_file(path)
|
|
250
|
+
(d / "tree.json").write_text(
|
|
251
|
+
json.dumps({"source_id": source_id, "items": items}, indent=2, ensure_ascii=False)
|
|
252
|
+
+ "\n",
|
|
253
|
+
encoding="utf-8",
|
|
254
|
+
)
|
|
255
|
+
(d / "meta.json").write_text(
|
|
256
|
+
json.dumps(
|
|
257
|
+
{
|
|
258
|
+
"source_id": source_id,
|
|
259
|
+
"raw_content_hash": raw_hash,
|
|
260
|
+
"backend": _backend_name(backend),
|
|
261
|
+
"backend_version": _backend_version(backend),
|
|
262
|
+
"schema": INDEX_SCHEMA,
|
|
263
|
+
"created_at": _now(),
|
|
264
|
+
},
|
|
265
|
+
indent=2,
|
|
266
|
+
ensure_ascii=False,
|
|
267
|
+
)
|
|
268
|
+
+ "\n",
|
|
269
|
+
encoding="utf-8",
|
|
270
|
+
)
|
|
271
|
+
return {"status": "built", "source_id": source_id, "sections_indexed": len(items)}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _load_cached(root: Path, source_id: str | None = None) -> tuple[list[dict], bool]:
|
|
275
|
+
base = _pageindex_dir(root)
|
|
276
|
+
if not base.is_dir():
|
|
277
|
+
return [], False
|
|
278
|
+
dirs = (
|
|
279
|
+
[_cache_dir(root, _source_id(source_id))]
|
|
280
|
+
if source_id
|
|
281
|
+
else sorted(p for p in base.iterdir() if p.is_dir())
|
|
282
|
+
)
|
|
283
|
+
items: list[dict] = []
|
|
284
|
+
stale = False
|
|
285
|
+
for d in dirs:
|
|
286
|
+
tree_p, meta_p = d / "tree.json", d / "meta.json"
|
|
287
|
+
if not tree_p.exists() or not meta_p.exists():
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
tree = json.loads(tree_p.read_text(encoding="utf-8"))
|
|
291
|
+
meta = json.loads(meta_p.read_text(encoding="utf-8"))
|
|
292
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
293
|
+
continue
|
|
294
|
+
sid = meta.get("source_id") or tree.get("source_id")
|
|
295
|
+
if not isinstance(sid, str):
|
|
296
|
+
continue
|
|
297
|
+
try:
|
|
298
|
+
source_path = _source_path(root, sid)
|
|
299
|
+
current = hashing.content_hash_file(source_path)
|
|
300
|
+
text = source_path.read_text(encoding="utf-8")
|
|
301
|
+
except DataError:
|
|
302
|
+
stale = True
|
|
303
|
+
continue
|
|
304
|
+
if meta.get("schema") != INDEX_SCHEMA:
|
|
305
|
+
stale = True
|
|
306
|
+
continue
|
|
307
|
+
if meta.get("raw_content_hash") != current:
|
|
308
|
+
stale = True
|
|
309
|
+
vals = tree.get("items") if isinstance(tree, dict) else None
|
|
310
|
+
if isinstance(vals, list):
|
|
311
|
+
items.extend(_revalidate_cached_items(vals, source_id=sid, text=text))
|
|
312
|
+
return items, stale
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _revalidate_cached_items(raw_items: list, *, source_id: str, text: str) -> list[dict]:
|
|
316
|
+
items: list[dict] = []
|
|
317
|
+
for item in raw_items:
|
|
318
|
+
if not isinstance(item, dict):
|
|
319
|
+
continue
|
|
320
|
+
cached_source = item.get("source_id")
|
|
321
|
+
if isinstance(cached_source, str):
|
|
322
|
+
try:
|
|
323
|
+
if _source_id(cached_source) != source_id:
|
|
324
|
+
continue
|
|
325
|
+
except DataError:
|
|
326
|
+
continue
|
|
327
|
+
snippet = item.get("snippet")
|
|
328
|
+
if not isinstance(snippet, str) or not snippet:
|
|
329
|
+
continue
|
|
330
|
+
start = text.find(snippet)
|
|
331
|
+
if start < 0:
|
|
332
|
+
continue
|
|
333
|
+
out = dict(item)
|
|
334
|
+
out["source_id"] = source_id
|
|
335
|
+
out["snippet"] = snippet
|
|
336
|
+
out["span_hint"] = [start, start + len(snippet)]
|
|
337
|
+
out["score"] = _float_score(item.get("score"), 0.0)
|
|
338
|
+
out["method"] = "pageindex"
|
|
339
|
+
items.append(out)
|
|
340
|
+
return items
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _terms(query: str) -> list[str]:
|
|
344
|
+
return [t for t in hashing.normalize(query).split() if t]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _lexical_rank(query: str, items: list[dict], k: int) -> list[dict]:
|
|
348
|
+
terms = _terms(query)
|
|
349
|
+
ranked: list[dict] = []
|
|
350
|
+
for item in items:
|
|
351
|
+
low = str(item.get("snippet") or "").lower()
|
|
352
|
+
score = sum(low.count(t) for t in terms)
|
|
353
|
+
if score <= 0:
|
|
354
|
+
continue
|
|
355
|
+
out = dict(item)
|
|
356
|
+
out["score"] = float(score)
|
|
357
|
+
out["method"] = "pageindex"
|
|
358
|
+
ranked.append(out)
|
|
359
|
+
ranked.sort(key=lambda r: (-float(r["score"]), str(r.get("source_id")), str(r.get("section_id"))))
|
|
360
|
+
return ranked[:k]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def search(root: Path, query: str, k: int = 5, source_id: str | None = None) -> dict | None:
|
|
364
|
+
"""Return ``{method, stale_index, results}``, or ``None`` with no cache."""
|
|
365
|
+
items, stale = _load_cached(root, source_id)
|
|
366
|
+
if not items:
|
|
367
|
+
return None
|
|
368
|
+
backend = _get_backend()
|
|
369
|
+
raw_results = _call_search(backend, query=query, items=items, k=k)
|
|
370
|
+
results = _canonical_search_results(raw_results, items, k)
|
|
371
|
+
return {"method": "pageindex", "stale_index": stale, "results": results}
|
|
@@ -11,7 +11,7 @@ import re
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
from . import blocks as blocks_mod
|
|
14
|
-
from . import embeddings, raw_dir
|
|
14
|
+
from . import embeddings, pageindex_adapter, raw_dir
|
|
15
15
|
|
|
16
16
|
_WORD = re.compile(r"\w+")
|
|
17
17
|
|
|
@@ -41,8 +41,12 @@ def grep_search(root: Path, query: str, k: int = 5) -> list[dict]:
|
|
|
41
41
|
return hits[:k]
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def search(root: Path, query: str, k: int = 5) -> dict:
|
|
44
|
+
def search(root: Path, query: str, k: int = 5, long_docs: str | None = None) -> dict:
|
|
45
45
|
"""Return ``{method, stale_index, results}``."""
|
|
46
|
+
if long_docs == "pageindex":
|
|
47
|
+
p = pageindex_adapter.search(root, query, k)
|
|
48
|
+
if p is not None:
|
|
49
|
+
return p
|
|
46
50
|
v = embeddings.vector_search(root, query, k)
|
|
47
51
|
if v is not None:
|
|
48
52
|
results, stale = v
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""PageIndex adapter tests. Hermetic: no real PageIndex dependency is installed;
|
|
2
|
+
the backend is monkeypatched with a deterministic tiny object."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from scrip import cli, pageindex_adapter, retrieval
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakePageIndex:
|
|
10
|
+
__version__ = "test"
|
|
11
|
+
|
|
12
|
+
def build_index(self, *, source_id, text):
|
|
13
|
+
start = text.index("Alpha section explains caching.")
|
|
14
|
+
end = start + len("Alpha section explains caching.")
|
|
15
|
+
return {
|
|
16
|
+
"sections": [
|
|
17
|
+
{"section_id": "alpha", "span_hint": [start, end], "score": 0.9},
|
|
18
|
+
# Summaries that are not verbatim raw text must not enter cache.
|
|
19
|
+
{"section_id": "summary", "snippet": "a generated summary absent from raw"},
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def search(self, *, query, items, k):
|
|
24
|
+
return [it for it in items if query.lower() in it["snippet"].lower()][:k]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fake_backend(monkeypatch):
|
|
28
|
+
monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: FakePageIndex())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_pageindex_build_unavailable_exits_cleanly(kb, capsys, monkeypatch):
|
|
32
|
+
monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: None)
|
|
33
|
+
kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
|
|
34
|
+
rc = cli.main(["pageindex", "build", "raw/paper", "--json", "--root", str(kb.root)])
|
|
35
|
+
assert rc == 0
|
|
36
|
+
data = json.loads(capsys.readouterr().out)
|
|
37
|
+
assert data["status"] == "unavailable"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_pageindex_build_persists_only_verbatim_raw_snippets(kb, capsys, monkeypatch):
|
|
41
|
+
_fake_backend(monkeypatch)
|
|
42
|
+
kb.add_raw(
|
|
43
|
+
"paper",
|
|
44
|
+
"# Paper\n\nAlpha section explains caching.\n\nBeta section covers indexing.\n",
|
|
45
|
+
)
|
|
46
|
+
rc = cli.main(["pageindex", "build", "raw/paper", "--json", "--root", str(kb.root)])
|
|
47
|
+
assert rc == 0
|
|
48
|
+
data = json.loads(capsys.readouterr().out)
|
|
49
|
+
assert data["status"] == "built"
|
|
50
|
+
assert data["sections_indexed"] == 1
|
|
51
|
+
|
|
52
|
+
tree = json.loads(
|
|
53
|
+
(kb.root / ".kb" / "pageindex" / "paper" / "tree.json").read_text(encoding="utf-8")
|
|
54
|
+
)
|
|
55
|
+
[item] = tree["items"]
|
|
56
|
+
assert item["source_id"] == "raw/paper"
|
|
57
|
+
assert item["section_id"] == "alpha"
|
|
58
|
+
assert item["snippet"] == "Alpha section explains caching."
|
|
59
|
+
assert "summary absent" not in json.dumps(tree)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_pageindex_search_cli_and_retrieval_integration(kb, capsys, monkeypatch):
|
|
63
|
+
_fake_backend(monkeypatch)
|
|
64
|
+
kb.add_raw(
|
|
65
|
+
"paper",
|
|
66
|
+
"# Paper\n\nAlpha section explains caching.\n\nBeta section covers indexing.\n",
|
|
67
|
+
)
|
|
68
|
+
assert cli.main(["pageindex", "build", "paper", "--root", str(kb.root)]) == 0
|
|
69
|
+
capsys.readouterr()
|
|
70
|
+
|
|
71
|
+
assert cli.main(["pageindex", "search", "Alpha", "--json", "--root", str(kb.root)]) == 0
|
|
72
|
+
out = json.loads(capsys.readouterr().out)
|
|
73
|
+
assert out["method"] == "pageindex"
|
|
74
|
+
assert out["results"][0]["snippet"] == "Alpha section explains caching."
|
|
75
|
+
|
|
76
|
+
routed = retrieval.search(kb.root, "Alpha", long_docs="pageindex")
|
|
77
|
+
assert routed["method"] == "pageindex"
|
|
78
|
+
assert routed["results"][0]["source_id"] == "raw/paper"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_search_long_docs_pageindex_cli_prints_section_results(kb, capsys, monkeypatch):
|
|
82
|
+
_fake_backend(monkeypatch)
|
|
83
|
+
kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
|
|
84
|
+
assert cli.main(["pageindex", "build", "paper", "--root", str(kb.root)]) == 0
|
|
85
|
+
capsys.readouterr()
|
|
86
|
+
|
|
87
|
+
rc = cli.main(["search", "Alpha", "--long-docs", "pageindex", "--root", str(kb.root)])
|
|
88
|
+
assert rc == 0
|
|
89
|
+
out = capsys.readouterr().out
|
|
90
|
+
assert "[pageindex]" in out
|
|
91
|
+
assert "raw/paper#alpha" in out
|
|
92
|
+
assert "Alpha section explains caching." in out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_pageindex_search_reuses_cached_verbatim_snippet(kb, monkeypatch):
|
|
96
|
+
class SummarySearchBackend(FakePageIndex):
|
|
97
|
+
def search(self, *, query, items, k):
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
"source_id": "raw/paper",
|
|
101
|
+
"section_id": "alpha",
|
|
102
|
+
"snippet": "fabricated summary absent from raw",
|
|
103
|
+
"score": 99,
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: SummarySearchBackend())
|
|
108
|
+
kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
|
|
109
|
+
pageindex_adapter.build_index(kb.root, "paper")
|
|
110
|
+
|
|
111
|
+
out = pageindex_adapter.search(kb.root, "Alpha")
|
|
112
|
+
assert out is not None
|
|
113
|
+
assert out["results"][0]["snippet"] == "Alpha section explains caching."
|
|
114
|
+
assert "fabricated summary" not in json.dumps(out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_pageindex_cache_revalidates_snippets_after_raw_change(kb, monkeypatch):
|
|
118
|
+
_fake_backend(monkeypatch)
|
|
119
|
+
kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
|
|
120
|
+
pageindex_adapter.build_index(kb.root, "paper")
|
|
121
|
+
kb.mutate_raw("paper", "# Paper\n\nNew intro.\n\nAlpha section explains caching.\n")
|
|
122
|
+
|
|
123
|
+
out = pageindex_adapter.search(kb.root, "Alpha")
|
|
124
|
+
assert out is not None
|
|
125
|
+
assert out["stale_index"] is True
|
|
126
|
+
assert out["results"][0]["snippet"] == "Alpha section explains caching."
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_pageindex_cache_drops_stale_snippets_absent_from_raw(kb, monkeypatch):
|
|
130
|
+
_fake_backend(monkeypatch)
|
|
131
|
+
kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
|
|
132
|
+
pageindex_adapter.build_index(kb.root, "paper")
|
|
133
|
+
kb.mutate_raw("paper", "# Paper\n\nAlpha section changed completely.\n")
|
|
134
|
+
|
|
135
|
+
assert pageindex_adapter.search(kb.root, "Alpha") is None
|
|
136
|
+
out = retrieval.search(kb.root, "Alpha", long_docs="pageindex")
|
|
137
|
+
assert out["method"] == "grep"
|
|
138
|
+
assert "changed completely" in out["results"][0]["snippet"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_pageindex_lexical_fallback_uses_query_time_scores(kb, monkeypatch):
|
|
142
|
+
class RankedBuildBackend:
|
|
143
|
+
__version__ = "test"
|
|
144
|
+
|
|
145
|
+
def build_index(self, *, source_id, text):
|
|
146
|
+
alpha_start = text.index("Alpha Alpha section.")
|
|
147
|
+
beta_start = text.index("Beta section.")
|
|
148
|
+
return {
|
|
149
|
+
"sections": [
|
|
150
|
+
{
|
|
151
|
+
"section_id": "alpha",
|
|
152
|
+
"span_hint": [alpha_start, alpha_start + len("Alpha Alpha section.")],
|
|
153
|
+
"score": 1,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"section_id": "beta",
|
|
157
|
+
"span_hint": [beta_start, beta_start + len("Beta section.")],
|
|
158
|
+
"score": 100,
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: RankedBuildBackend())
|
|
164
|
+
kb.add_raw("paper", "# Paper\n\nAlpha Alpha section.\n\nBeta section.\n")
|
|
165
|
+
pageindex_adapter.build_index(kb.root, "paper")
|
|
166
|
+
monkeypatch.setattr(pageindex_adapter, "_get_backend", lambda: None)
|
|
167
|
+
|
|
168
|
+
out = pageindex_adapter.search(kb.root, "Alpha section")
|
|
169
|
+
assert out is not None
|
|
170
|
+
assert [r["section_id"] for r in out["results"]] == ["alpha", "beta"]
|
|
171
|
+
assert [r["score"] for r in out["results"]] == [3.0, 1.0]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_search_long_docs_pageindex_falls_back_without_cache(kb):
|
|
175
|
+
kb.add_raw("paper", "# Paper\n\nAlpha section explains caching.\n")
|
|
176
|
+
out = retrieval.search(kb.root, "Alpha", long_docs="pageindex")
|
|
177
|
+
assert out["method"] == "grep"
|
|
178
|
+
assert out["results"][0]["source_id"] == "raw/paper"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_pageindex_search_missing_cache_json(kb, capsys):
|
|
182
|
+
rc = cli.main(["pageindex", "search", "anything", "--json", "--root", str(kb.root)])
|
|
183
|
+
assert rc == 0
|
|
184
|
+
out = json.loads(capsys.readouterr().out)
|
|
185
|
+
assert out["status"] == "missing"
|
|
186
|
+
assert out["results"] == []
|
scriptoria-0.5.2/README.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# scrip
|
|
2
|
-
|
|
3
|
-
The deterministic keeper of a [scriptorium](../README.md) knowledge base.
|
|
4
|
-
|
|
5
|
-
The agent owns judgment (synthesis, fact extraction, reconciliation, promotion).
|
|
6
|
-
`scrip` owns only what LLMs are unreliable or expensive at: **content hashing**,
|
|
7
|
-
**staleness** over a dependency graph, **provenance-anchor integrity**, and
|
|
8
|
-
**structured queries** over the facts layer. Files are the source of truth;
|
|
9
|
-
`.kb/manifest.json` is only a regenerable speed cache.
|
|
10
|
-
|
|
11
|
-
## Install
|
|
12
|
-
|
|
13
|
-
```sh
|
|
14
|
-
uv tool install ./scrip # installs the `scrip` command
|
|
15
|
-
# or, without installing:
|
|
16
|
-
uv run --project scrip scrip --help
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Commands
|
|
20
|
-
|
|
21
|
-
| Command | What it does |
|
|
22
|
-
|---|---|
|
|
23
|
-
| `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
|
|
24
|
-
| `scrip verify` | Check every provenance anchor still resolves to text in its source; check referenced sources exist and `claim_id`s are unique. Fails on `BROKEN` and `AMBIGUOUS` by default; `--allow-ambiguous` downgrades `AMBIGUOUS` to a warning. |
|
|
25
|
-
| `scrip query [claims\|entities\|edges\|contradictions]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
|
|
26
|
-
| `scrip index` | v0 stub for the embeddings retrieval rung. |
|
|
27
|
-
|
|
28
|
-
Every command accepts `--root DIR` and `--json`.
|
|
29
|
-
|
|
30
|
-
## Exit codes
|
|
31
|
-
|
|
32
|
-
| Code | Meaning |
|
|
33
|
-
|---|---|
|
|
34
|
-
| `0` | clean / success |
|
|
35
|
-
| `1` | actionable finding (stale artifacts; broken citations) |
|
|
36
|
-
| `2` | usage error |
|
|
37
|
-
| `3` | data error (malformed frontmatter / NDJSON; missing source; duplicate id) |
|
|
38
|
-
| `4` | internal error |
|
|
39
|
-
|
|
40
|
-
Code `1` is an *expected* outcome the agent branches on, not a crash.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|