scriptoria 0.8.1__tar.gz → 0.10.0__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.8.1 → scriptoria-0.10.0}/PKG-INFO +6 -3
- {scriptoria-0.8.1 → scriptoria-0.10.0}/README.md +4 -1
- {scriptoria-0.8.1 → scriptoria-0.10.0}/pyproject.toml +1 -1
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/__init__.py +1 -1
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/blocks.py +29 -27
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/cli.py +106 -6
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/facts.py +43 -12
- scriptoria-0.10.0/src/scrip/ontology.py +162 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_fact_cmd.py +130 -0
- scriptoria-0.10.0/tests/test_init_cmd.py +102 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/uv.lock +2 -2
- {scriptoria-0.8.1 → scriptoria-0.10.0}/.gitignore +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/pyrightconfig.json +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/anchors.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/embeddings.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/errors.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/frontmatter.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/graph.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/hashing.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/ingest.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/lock.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/manifest.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/pageindex_adapter.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/query.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/retrieval.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/src/scrip/similar.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/conftest.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_anchor_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_anchors.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_blocks.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_embeddings.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_facts.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_frontmatter.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_graph_status.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_hashing.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_index_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_ingest.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_json_shapes.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_lock.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_manifest.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_new_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_pageindex_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_query.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_retrieval.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_similar_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_span_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_stamp.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_status_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_unlock_cmd.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_verify.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_version.py +0 -0
- {scriptoria-0.8.1 → scriptoria-0.10.0}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: scriptoria
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
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
|
|
@@ -51,9 +51,11 @@ uv run --project scrip scrip --help
|
|
|
51
51
|
|
|
52
52
|
| Command | What it does |
|
|
53
53
|
|---|---|
|
|
54
|
+
| `scrip init [DIR]` | Create the root skeleton — `vault/{raw,facts,wiki}/` plus the `.kb/` cache and its manifest — in `DIR` (default: the current directory). Idempotent in an existing root; refuses to nest a new instance inside one. |
|
|
54
55
|
| `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
|
|
55
56
|
| `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
57
|
| `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
|
|
58
|
+
| `scrip ontology` | Validate and summarize optional `vault/ontology.yaml` vocabulary for fact predicates, entity kinds, and edge kinds. |
|
|
57
59
|
| `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
60
|
| `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
|
|
59
61
|
| `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
|
|
@@ -66,7 +68,8 @@ uv run --project scrip scrip --help
|
|
|
66
68
|
| `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
|
|
67
69
|
| `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
|
|
68
70
|
|
|
69
|
-
Every command accepts `--root DIR` and `--json
|
|
71
|
+
Every command accepts `--root DIR` and `--json` (`init` takes its target as a
|
|
72
|
+
positional path instead of `--root`).
|
|
70
73
|
|
|
71
74
|
The optional `scrip-harness` package adds the model-bearing commands, including
|
|
72
75
|
`scrip-harness answer "<question>"`, which gathers evidence with these primitives
|
|
@@ -20,9 +20,11 @@ uv run --project scrip scrip --help
|
|
|
20
20
|
|
|
21
21
|
| Command | What it does |
|
|
22
22
|
|---|---|
|
|
23
|
+
| `scrip init [DIR]` | Create the root skeleton — `vault/{raw,facts,wiki}/` plus the `.kb/` cache and its manifest — in `DIR` (default: the current directory). Idempotent in an existing root; refuses to nest a new instance inside one. |
|
|
23
24
|
| `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. |
|
|
24
25
|
| `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
26
|
| `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql "<duckdb>"`, `--where`, `--limit`. |
|
|
27
|
+
| `scrip ontology` | Validate and summarize optional `vault/ontology.yaml` vocabulary for fact predicates, entity kinds, and edge kinds. |
|
|
26
28
|
| `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
29
|
| `scrip new concept\|entity <slug> --from raw/...` | Scaffold a derived wiki page for the agent to fill. |
|
|
28
30
|
| `scrip anchor "<quote>" --source raw/<slug>` | Mint a content-anchored footnote for a verbatim source quote. |
|
|
@@ -35,7 +37,8 @@ uv run --project scrip scrip --help
|
|
|
35
37
|
| `scrip pageindex search "<question>" [--source raw/<slug>]` | Search cached PageIndex sections, returning only snippets mapped back to canonical `vault/raw/` text. |
|
|
36
38
|
| `scrip watch` / `scrip unlock` | Watch vault health in a poll loop; clear a stale advisory write lock. |
|
|
37
39
|
|
|
38
|
-
Every command accepts `--root DIR` and `--json
|
|
40
|
+
Every command accepts `--root DIR` and `--json` (`init` takes its target as a
|
|
41
|
+
positional path instead of `--root`).
|
|
39
42
|
|
|
40
43
|
The optional `scrip-harness` package adds the model-bearing commands, including
|
|
41
44
|
`scrip-harness answer "<question>"`, which gathers evidence with these primitives
|
|
@@ -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.10.0"
|
|
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.10.0"
|
|
17
17
|
|
|
18
18
|
# --- canonical vault layout ------------------------------------------------
|
|
19
19
|
# ``root`` is the repo/instance root: the directory containing ``vault/``.
|
|
@@ -38,43 +38,45 @@ def split_blocks(text: str) -> list[dict]:
|
|
|
38
38
|
# Use splitlines(keepends=True): its boundary set (form feed, NEL, U+2028/9,
|
|
39
39
|
# …) is part of the deterministic segmentation contract — io.StringIO only
|
|
40
40
|
# splits on \r/\n and would re-hash existing blocks that contain those chars.
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
# Emit blocks in a single pass: a running [cur_start, cur_end] paragraph is
|
|
42
|
+
# flushed at each boundary, so no intermediate `ranges` list is allocated.
|
|
43
|
+
out: list[dict] = []
|
|
44
|
+
seen: dict[str, int] = {}
|
|
45
|
+
|
|
46
|
+
def emit(s: int, e: int) -> None:
|
|
47
|
+
slice_text = text[s:e]
|
|
48
|
+
out.append(
|
|
49
|
+
{
|
|
50
|
+
"block_id": _block_id(slice_text, seen),
|
|
51
|
+
"span": [s, e],
|
|
52
|
+
"hash": hashing.sha256_text(slice_text),
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
cur_start: int | None = None
|
|
57
|
+
cur_end = 0
|
|
43
58
|
start = 0
|
|
44
59
|
|
|
45
60
|
for line in text.splitlines(keepends=True):
|
|
46
61
|
end = start + len(line)
|
|
47
62
|
if _is_blank(line):
|
|
48
|
-
if
|
|
49
|
-
|
|
50
|
-
|
|
63
|
+
if cur_start is not None:
|
|
64
|
+
emit(cur_start, cur_end)
|
|
65
|
+
cur_start = None
|
|
51
66
|
elif _is_heading(line):
|
|
52
|
-
if
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
if cur_start is not None:
|
|
68
|
+
emit(cur_start, cur_end)
|
|
69
|
+
cur_start = None
|
|
70
|
+
emit(start, end) # a heading is its own block
|
|
56
71
|
else:
|
|
57
|
-
if
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
cur[1] = end
|
|
72
|
+
if cur_start is None:
|
|
73
|
+
cur_start = start
|
|
74
|
+
cur_end = end
|
|
61
75
|
start = end
|
|
62
76
|
|
|
63
|
-
if
|
|
64
|
-
|
|
77
|
+
if cur_start is not None:
|
|
78
|
+
emit(cur_start, cur_end)
|
|
65
79
|
|
|
66
|
-
out: list[dict] = []
|
|
67
|
-
seen: dict[str, int] = {}
|
|
68
|
-
for s, e in ranges:
|
|
69
|
-
slice_text = text[s:e]
|
|
70
|
-
block_id = _block_id(slice_text, seen)
|
|
71
|
-
out.append(
|
|
72
|
-
{
|
|
73
|
-
"block_id": block_id,
|
|
74
|
-
"span": [s, e],
|
|
75
|
-
"hash": hashing.sha256_text(slice_text),
|
|
76
|
-
}
|
|
77
|
-
)
|
|
78
80
|
return out
|
|
79
81
|
|
|
80
82
|
|
|
@@ -35,12 +35,20 @@ def _safe_slug(slug: str, what: str = "slug") -> str:
|
|
|
35
35
|
# --------------------------------------------------------------------------- #
|
|
36
36
|
# Root resolution
|
|
37
37
|
# --------------------------------------------------------------------------- #
|
|
38
|
+
def _looks_like_root(cand: Path) -> bool:
|
|
39
|
+
"""True if ``cand`` looks like a scriptorium instance root: a directory
|
|
40
|
+
containing ``vault/`` plus either ``SPEC.md`` or ``.kb/``."""
|
|
41
|
+
return (cand / "vault").is_dir() and (
|
|
42
|
+
(cand / "SPEC.md").exists() or (cand / ".kb").is_dir()
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
38
46
|
def resolve_root(root_arg: str | None) -> Path:
|
|
39
47
|
"""Return the scriptorium root.
|
|
40
48
|
|
|
41
49
|
With ``--root`` the path is used verbatim (must be a directory). Otherwise we
|
|
42
|
-
walk up from the cwd for the nearest ancestor that looks like an instance
|
|
43
|
-
|
|
50
|
+
walk up from the cwd for the nearest ancestor that looks like an instance
|
|
51
|
+
(see :func:`_looks_like_root`).
|
|
44
52
|
"""
|
|
45
53
|
if root_arg:
|
|
46
54
|
root = Path(root_arg).expanduser().resolve()
|
|
@@ -50,13 +58,12 @@ def resolve_root(root_arg: str | None) -> Path:
|
|
|
50
58
|
|
|
51
59
|
cur = Path.cwd().resolve()
|
|
52
60
|
for cand in (cur, *cur.parents):
|
|
53
|
-
if (cand
|
|
54
|
-
(cand / "SPEC.md").exists() or (cand / ".kb").is_dir()
|
|
55
|
-
):
|
|
61
|
+
if _looks_like_root(cand):
|
|
56
62
|
return cand
|
|
57
63
|
raise errors.UsageError(
|
|
58
64
|
"could not locate a scriptorium root (looked for a parent with vault/ and "
|
|
59
|
-
"SPEC.md or .kb/).
|
|
65
|
+
"SPEC.md or .kb/). Run `scrip init` to start one here, or pass --root "
|
|
66
|
+
"explicitly."
|
|
60
67
|
)
|
|
61
68
|
|
|
62
69
|
|
|
@@ -68,6 +75,58 @@ def _emit(payload: dict) -> None:
|
|
|
68
75
|
# --------------------------------------------------------------------------- #
|
|
69
76
|
# Subcommand handlers -> return an int exit code
|
|
70
77
|
# --------------------------------------------------------------------------- #
|
|
78
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
79
|
+
from . import graph, manifest_path
|
|
80
|
+
|
|
81
|
+
target = Path(args.path or ".").expanduser().resolve()
|
|
82
|
+
if target.exists() and not target.is_dir():
|
|
83
|
+
raise errors.UsageError(f"init target is not a directory: {target}")
|
|
84
|
+
if _looks_like_root(target):
|
|
85
|
+
if args.json:
|
|
86
|
+
_emit({"root": str(target), "status": "exists", "created": []})
|
|
87
|
+
else:
|
|
88
|
+
print(f"already a scriptorium root: {target} (nothing to do)")
|
|
89
|
+
return 0
|
|
90
|
+
# Refuse to create a nested instance: an inner vault/ would shadow the outer
|
|
91
|
+
# root for every command run beneath it (resolve_root stops at the nearest
|
|
92
|
+
# ancestor).
|
|
93
|
+
for cand in target.parents:
|
|
94
|
+
if _looks_like_root(cand):
|
|
95
|
+
raise errors.UsageError(
|
|
96
|
+
f"refusing to create a nested scriptorium: {target} is inside the "
|
|
97
|
+
f"root at {cand}. Run `scrip init` at that root, or pick a "
|
|
98
|
+
f"directory outside it."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
created: list[str] = []
|
|
102
|
+
for rel in ("vault/raw", "vault/facts", "vault/wiki", ".kb"):
|
|
103
|
+
d = target / rel
|
|
104
|
+
if d.is_dir():
|
|
105
|
+
continue
|
|
106
|
+
if d.exists():
|
|
107
|
+
raise errors.UsageError(
|
|
108
|
+
f"cannot create {rel}/: a file already exists at {d}"
|
|
109
|
+
)
|
|
110
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
created.append(rel + "/")
|
|
112
|
+
# Same as `scrip status --rebuild-manifest`: scan the (empty) vault and write
|
|
113
|
+
# the .kb/manifest.json cache, so the instance starts warm and detectable.
|
|
114
|
+
graph.compute_status(target, rebuild=True)
|
|
115
|
+
created.append(str(manifest_path(target).relative_to(target)))
|
|
116
|
+
|
|
117
|
+
if args.json:
|
|
118
|
+
_emit({"root": str(target), "status": "initialized", "created": created})
|
|
119
|
+
else:
|
|
120
|
+
print(f"initialized scriptorium root at {target}")
|
|
121
|
+
for c in created:
|
|
122
|
+
print(f" + {c}")
|
|
123
|
+
print(
|
|
124
|
+
" next: add a source (`scrip ingest <url|file>`), compile a page "
|
|
125
|
+
"(`scrip new` + `scrip anchor` + `scrip stamp`), then `scrip verify`"
|
|
126
|
+
)
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
71
130
|
def cmd_status(args: argparse.Namespace) -> int:
|
|
72
131
|
from . import graph
|
|
73
132
|
|
|
@@ -493,6 +552,25 @@ def cmd_fact_add(args: argparse.Namespace) -> int:
|
|
|
493
552
|
return 1 if result["failures"] else 0
|
|
494
553
|
|
|
495
554
|
|
|
555
|
+
def cmd_ontology(args: argparse.Namespace) -> int:
|
|
556
|
+
from . import ontology
|
|
557
|
+
|
|
558
|
+
root = resolve_root(args.root)
|
|
559
|
+
ont = ontology.load(root)
|
|
560
|
+
result = {"path": str(ontology.ontology_path(root).relative_to(root)), **ont.summary()}
|
|
561
|
+
if args.json:
|
|
562
|
+
_emit(result)
|
|
563
|
+
else:
|
|
564
|
+
state = "active" if ont.active else "not present or empty"
|
|
565
|
+
print(f"ontology: {state}")
|
|
566
|
+
if ont.active:
|
|
567
|
+
print(f" entity kinds: {', '.join(result['entity_kinds']) or '(none)'}")
|
|
568
|
+
print(f" edge kinds: {', '.join(result['edge_kinds']) or '(none)'}")
|
|
569
|
+
print(f" claim predicates: {', '.join(result['claim_predicates']) or '(none)'}")
|
|
570
|
+
print(f" predicate aliases: {len(result['predicate_aliases'])}")
|
|
571
|
+
return 0
|
|
572
|
+
|
|
573
|
+
|
|
496
574
|
def cmd_ingest(args: argparse.Namespace) -> int:
|
|
497
575
|
from . import ingest, lock
|
|
498
576
|
|
|
@@ -536,6 +614,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
536
614
|
p.add_argument("--version", action="version", version=f"scrip {__version__}")
|
|
537
615
|
sub = p.add_subparsers(dest="command", required=True, metavar="<command>")
|
|
538
616
|
|
|
617
|
+
pinit = sub.add_parser(
|
|
618
|
+
"init",
|
|
619
|
+
help="create the root skeleton (vault/{raw,facts,wiki}/ + .kb/ cache) and its manifest",
|
|
620
|
+
)
|
|
621
|
+
pinit.add_argument(
|
|
622
|
+
"path",
|
|
623
|
+
nargs="?",
|
|
624
|
+
metavar="DIR",
|
|
625
|
+
help="directory to initialize (default: current directory; created if absent)",
|
|
626
|
+
)
|
|
627
|
+
pinit.add_argument(
|
|
628
|
+
"--json", action="store_true", help="machine-readable JSON output"
|
|
629
|
+
)
|
|
630
|
+
pinit.set_defaults(func=cmd_init)
|
|
631
|
+
|
|
539
632
|
ps = sub.add_parser(
|
|
540
633
|
"status",
|
|
541
634
|
parents=[common],
|
|
@@ -764,6 +857,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
764
857
|
)
|
|
765
858
|
pfa.set_defaults(func=cmd_fact_add)
|
|
766
859
|
|
|
860
|
+
po = sub.add_parser(
|
|
861
|
+
"ontology",
|
|
862
|
+
parents=[common],
|
|
863
|
+
help="validate and summarize optional vault/ontology.yaml vocabulary",
|
|
864
|
+
)
|
|
865
|
+
po.set_defaults(func=cmd_ontology)
|
|
866
|
+
|
|
767
867
|
pin = sub.add_parser(
|
|
768
868
|
"ingest",
|
|
769
869
|
parents=[common],
|
|
@@ -34,6 +34,8 @@ except ImportError:
|
|
|
34
34
|
|
|
35
35
|
from . import anchors, facts_dir, lock
|
|
36
36
|
from .errors import DataError, UsageError
|
|
37
|
+
from .ontology import Ontology
|
|
38
|
+
from .ontology import load as load_ontology
|
|
37
39
|
|
|
38
40
|
_POLARITIES = ("asserts", "denies", "qualifies")
|
|
39
41
|
|
|
@@ -52,7 +54,7 @@ _RECON_OWNED = ("reconciliation_id", "at")
|
|
|
52
54
|
_CLAIM_REQUIRED = ("quote", "source_id", "subject", "predicate", "object", "polarity", "confidence")
|
|
53
55
|
_CLAIM_ALLOWED = frozenset((*_CLAIM_REQUIRED, "claim_text", "tags"))
|
|
54
56
|
_ENTITY_REQUIRED = ("entity_id", "name", "kind")
|
|
55
|
-
_ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags"))
|
|
57
|
+
_ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags", "uri", "same_as", "external_ids"))
|
|
56
58
|
_EDGE_REQUIRED = ("src", "dst", "kind")
|
|
57
59
|
# An edge may optionally be *cited*: a verbatim ``quote`` + ``source_id`` whose
|
|
58
60
|
# ``anchor`` scrip mints+verifies exactly as for a claim. Bare edges stay
|
|
@@ -127,6 +129,26 @@ def _check_tags(rec: dict, index: int) -> None:
|
|
|
127
129
|
raise DataError(f"record {index}: 'tags' must be a list of strings")
|
|
128
130
|
|
|
129
131
|
|
|
132
|
+
def _check_string_list(rec: dict, key: str, index: int) -> None:
|
|
133
|
+
values = rec.get(key)
|
|
134
|
+
if values is None:
|
|
135
|
+
return
|
|
136
|
+
if not isinstance(values, list) or any(not isinstance(v, str) or not v.strip() for v in values):
|
|
137
|
+
raise DataError(f"record {index}: '{key}' must be a list of non-empty strings")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_string_map(rec: dict, key: str, index: int) -> None:
|
|
141
|
+
values = rec.get(key)
|
|
142
|
+
if values is None:
|
|
143
|
+
return
|
|
144
|
+
if (
|
|
145
|
+
not isinstance(values, dict)
|
|
146
|
+
or any(not isinstance(k, str) or not k.strip() for k in values)
|
|
147
|
+
or any(not isinstance(v, str) or not v.strip() for v in values.values())
|
|
148
|
+
):
|
|
149
|
+
raise DataError(f"record {index}: '{key}' must be a mapping of strings to strings")
|
|
150
|
+
|
|
151
|
+
|
|
130
152
|
def _check_shape(
|
|
131
153
|
rec: dict,
|
|
132
154
|
index: int,
|
|
@@ -148,13 +170,14 @@ def _check_shape(
|
|
|
148
170
|
raise DataError(f"record {index}: missing required field(s): {', '.join(missing)}")
|
|
149
171
|
|
|
150
172
|
|
|
151
|
-
def _validate(table: str, rec: dict, index: int) -> None:
|
|
173
|
+
def _validate(table: str, rec: dict, index: int, ont: Ontology) -> None:
|
|
152
174
|
if table == "claims":
|
|
153
175
|
_check_shape(rec, index, _CLAIM_REQUIRED, _CLAIM_ALLOWED)
|
|
154
176
|
# the quote's *emptiness* is a per-record finding, not a schema error
|
|
155
177
|
_check_str(rec, "quote", index, allow_blank=True)
|
|
156
178
|
for key in ("source_id", "subject", "predicate", "object"):
|
|
157
179
|
_check_str(rec, key, index)
|
|
180
|
+
rec["predicate"] = ont.canonical_claim_predicate(rec["predicate"], index)
|
|
158
181
|
if "claim_text" in rec:
|
|
159
182
|
_check_str(rec, "claim_text", index, allow_blank=True)
|
|
160
183
|
if rec["polarity"] not in _POLARITIES:
|
|
@@ -169,14 +192,20 @@ def _validate(table: str, rec: dict, index: int) -> None:
|
|
|
169
192
|
_check_shape(rec, index, _ENTITY_REQUIRED, _ENTITY_ALLOWED)
|
|
170
193
|
for key in _ENTITY_REQUIRED:
|
|
171
194
|
_check_str(rec, key, index)
|
|
195
|
+
ont.validate_entity_kind(rec["kind"], index)
|
|
172
196
|
eid = rec["entity_id"]
|
|
173
197
|
if not (eid.startswith("entity/") and _SLUG_RE.fullmatch(eid[len("entity/") :])):
|
|
174
198
|
raise DataError(f"record {index}: entity_id must look like entity/<slug>")
|
|
199
|
+
if "uri" in rec:
|
|
200
|
+
_check_str(rec, "uri", index)
|
|
201
|
+
_check_string_list(rec, "same_as", index)
|
|
202
|
+
_check_string_map(rec, "external_ids", index)
|
|
175
203
|
_check_tags(rec, index)
|
|
176
204
|
elif table == "edges":
|
|
177
205
|
_check_shape(rec, index, _EDGE_REQUIRED, _EDGE_ALLOWED)
|
|
178
206
|
for key in _EDGE_REQUIRED:
|
|
179
207
|
_check_str(rec, key, index)
|
|
208
|
+
ont.validate_edge_kind(rec["kind"], index)
|
|
180
209
|
if "quote" in rec or "source_id" in rec:
|
|
181
210
|
# both or neither: a quote needs a source to anchor against
|
|
182
211
|
missing = [k for k in ("quote", "source_id") if k not in rec]
|
|
@@ -383,10 +412,7 @@ def _load_meta(root: Path) -> dict:
|
|
|
383
412
|
|
|
384
413
|
def _write_meta(root: Path, data: dict, new_sources: list[str]) -> None:
|
|
385
414
|
derived = list(data.get("derived-from") or [])
|
|
386
|
-
|
|
387
|
-
if sid not in derived:
|
|
388
|
-
derived.append(sid)
|
|
389
|
-
data["derived-from"] = derived
|
|
415
|
+
data["derived-from"] = list(dict.fromkeys(derived + new_sources))
|
|
390
416
|
# Drop the stamp on EVERY append: with an unchanged derived-from the
|
|
391
417
|
# recomputed input-hash would still match the stored one, and status would
|
|
392
418
|
# report OK over facts nobody has blessed. Removing input-hash forces STALE
|
|
@@ -410,8 +436,9 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
410
436
|
"""
|
|
411
437
|
if table not in _FILES:
|
|
412
438
|
raise UsageError(f"unknown facts table: {table}")
|
|
439
|
+
ont = load_ontology(root)
|
|
413
440
|
for i, rec in enumerate(proposals):
|
|
414
|
-
_validate(table, rec, i)
|
|
441
|
+
_validate(table, rec, i, ont)
|
|
415
442
|
|
|
416
443
|
failures: list[dict] = []
|
|
417
444
|
resolved: list[dict | None] = [None] * len(proposals)
|
|
@@ -470,12 +497,16 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
470
497
|
appended.append(full)
|
|
471
498
|
elif table == "entities":
|
|
472
499
|
def canon(rec: dict) -> dict:
|
|
473
|
-
|
|
500
|
+
out = {
|
|
474
501
|
"entity_id": rec["entity_id"],
|
|
475
502
|
"name": rec["name"],
|
|
476
503
|
"kind": rec["kind"],
|
|
477
504
|
"tags": rec.get("tags") or [],
|
|
478
505
|
}
|
|
506
|
+
for key in ("uri", "same_as", "external_ids"):
|
|
507
|
+
if key in rec:
|
|
508
|
+
out[key] = rec[key]
|
|
509
|
+
return out
|
|
479
510
|
|
|
480
511
|
byid = {rec.get("entity_id"): canon(rec) for rec in existing if "entity_id" in rec
|
|
481
512
|
and isinstance(rec.get("name"), str) and isinstance(rec.get("kind"), str)}
|
|
@@ -562,12 +593,12 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict:
|
|
|
562
593
|
if not ends_with_newline:
|
|
563
594
|
f.write("\n")
|
|
564
595
|
f.write(payload)
|
|
565
|
-
|
|
596
|
+
new_sources_dict: dict[str, None] = {}
|
|
566
597
|
if table in ("claims", "edges"):
|
|
567
598
|
for rec in appended:
|
|
568
599
|
sid = rec.get("source_id") # cited edges carry one; bare edges don't
|
|
569
|
-
if sid
|
|
570
|
-
|
|
571
|
-
_write_meta(root, meta,
|
|
600
|
+
if sid:
|
|
601
|
+
new_sources_dict[sid] = None
|
|
602
|
+
_write_meta(root, meta, list(new_sources_dict.keys()))
|
|
572
603
|
|
|
573
604
|
return {"table": table, "appended": appended, "skipped": skipped, "failures": []}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Optional semantic vocabulary for the facts layer.
|
|
2
|
+
|
|
3
|
+
The ontology is deliberately small and file-native. If ``vault/ontology.yaml`` is
|
|
4
|
+
absent, facts behave exactly as before. If present, it constrains selected free
|
|
5
|
+
text fields and can canonicalize claim predicates via aliases.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from .errors import DataError
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from yaml import CSafeLoader as SafeLoader
|
|
20
|
+
except ImportError:
|
|
21
|
+
from yaml import SafeLoader
|
|
22
|
+
|
|
23
|
+
_ONTOLOGY_KEYS = frozenset(
|
|
24
|
+
("entity_kinds", "edge_kinds", "claim_predicates", "predicate_aliases")
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Ontology:
|
|
30
|
+
entity_kinds: frozenset[str] = frozenset()
|
|
31
|
+
edge_kinds: frozenset[str] = frozenset()
|
|
32
|
+
claim_predicates: frozenset[str] = frozenset()
|
|
33
|
+
predicate_aliases: dict[str, str] | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def active(self) -> bool:
|
|
37
|
+
return bool(
|
|
38
|
+
self.entity_kinds
|
|
39
|
+
or self.edge_kinds
|
|
40
|
+
or self.claim_predicates
|
|
41
|
+
or self.predicate_aliases
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def canonical_claim_predicate(self, predicate: str, index: int) -> str:
|
|
45
|
+
aliases = self.predicate_aliases or {}
|
|
46
|
+
canonical = aliases.get(predicate, predicate)
|
|
47
|
+
if self.claim_predicates and canonical not in self.claim_predicates:
|
|
48
|
+
raise DataError(
|
|
49
|
+
f"record {index}: predicate {predicate!r} is not in "
|
|
50
|
+
f"vault/ontology.yaml claim_predicates"
|
|
51
|
+
)
|
|
52
|
+
return canonical
|
|
53
|
+
|
|
54
|
+
def validate_entity_kind(self, kind: str, index: int) -> None:
|
|
55
|
+
if self.entity_kinds and kind not in self.entity_kinds:
|
|
56
|
+
raise DataError(
|
|
57
|
+
f"record {index}: entity kind {kind!r} is not in "
|
|
58
|
+
f"vault/ontology.yaml entity_kinds"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def validate_edge_kind(self, kind: str, index: int) -> None:
|
|
62
|
+
if self.edge_kinds and kind not in self.edge_kinds:
|
|
63
|
+
raise DataError(
|
|
64
|
+
f"record {index}: edge kind {kind!r} is not in "
|
|
65
|
+
f"vault/ontology.yaml edge_kinds"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def summary(self) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"active": self.active,
|
|
71
|
+
"entity_kinds": sorted(self.entity_kinds),
|
|
72
|
+
"edge_kinds": sorted(self.edge_kinds),
|
|
73
|
+
"claim_predicates": sorted(self.claim_predicates),
|
|
74
|
+
"predicate_aliases": dict(sorted((self.predicate_aliases or {}).items())),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ontology_path(root: Path) -> Path:
|
|
79
|
+
return root / "vault" / "ontology.yaml"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load(root: Path) -> Ontology:
|
|
83
|
+
path = ontology_path(root)
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return Ontology()
|
|
86
|
+
try:
|
|
87
|
+
data = yaml.load(path.read_text(encoding="utf-8"), Loader=SafeLoader)
|
|
88
|
+
except yaml.YAMLError as e:
|
|
89
|
+
raise DataError(f"invalid vault/ontology.yaml: {e}") from e
|
|
90
|
+
if data is None:
|
|
91
|
+
return Ontology()
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
raise DataError("invalid vault/ontology.yaml: expected a mapping")
|
|
94
|
+
_check_known_keys(data)
|
|
95
|
+
|
|
96
|
+
entity_kinds = _string_set(data, "entity_kinds")
|
|
97
|
+
edge_kinds = _string_set(data, "edge_kinds")
|
|
98
|
+
claim_predicates = _string_set(data, "claim_predicates")
|
|
99
|
+
predicate_aliases = _string_map(data, "predicate_aliases")
|
|
100
|
+
if claim_predicates:
|
|
101
|
+
for alias, target in predicate_aliases.items():
|
|
102
|
+
if target not in claim_predicates:
|
|
103
|
+
raise DataError(
|
|
104
|
+
"invalid vault/ontology.yaml: predicate_aliases target "
|
|
105
|
+
f"{target!r} for {alias!r} is not in claim_predicates"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return Ontology(
|
|
109
|
+
entity_kinds=frozenset(entity_kinds),
|
|
110
|
+
edge_kinds=frozenset(edge_kinds),
|
|
111
|
+
claim_predicates=frozenset(claim_predicates),
|
|
112
|
+
predicate_aliases=predicate_aliases,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_known_keys(data: dict) -> None:
|
|
117
|
+
unknown = [key for key in data if not isinstance(key, str) or key not in _ONTOLOGY_KEYS]
|
|
118
|
+
if unknown:
|
|
119
|
+
rendered = ", ".join(repr(key) for key in unknown)
|
|
120
|
+
expected = ", ".join(sorted(_ONTOLOGY_KEYS))
|
|
121
|
+
raise DataError(
|
|
122
|
+
f"invalid vault/ontology.yaml: unknown key(s): {rendered}; expected one of: "
|
|
123
|
+
f"{expected}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _string_set(data: dict, key: str) -> set[str]:
|
|
128
|
+
value = data.get(key, [])
|
|
129
|
+
if value is None:
|
|
130
|
+
return set()
|
|
131
|
+
if not isinstance(value, list):
|
|
132
|
+
raise DataError(f"invalid vault/ontology.yaml: {key} must be a list of strings")
|
|
133
|
+
out: set[str] = set()
|
|
134
|
+
for i, item in enumerate(value):
|
|
135
|
+
if not isinstance(item, str) or not item.strip():
|
|
136
|
+
raise DataError(
|
|
137
|
+
f"invalid vault/ontology.yaml: {key}[{i}] must be a non-empty string"
|
|
138
|
+
)
|
|
139
|
+
out.add(item.strip())
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _string_map(data: dict, key: str) -> dict[str, str]:
|
|
144
|
+
value = data.get(key, {})
|
|
145
|
+
if value is None:
|
|
146
|
+
return {}
|
|
147
|
+
if not isinstance(value, dict):
|
|
148
|
+
raise DataError(
|
|
149
|
+
f"invalid vault/ontology.yaml: {key} must be a mapping of strings to strings"
|
|
150
|
+
)
|
|
151
|
+
out: dict[str, str] = {}
|
|
152
|
+
for raw_k, raw_v in value.items():
|
|
153
|
+
if not isinstance(raw_k, str) or not raw_k.strip():
|
|
154
|
+
raise DataError(
|
|
155
|
+
f"invalid vault/ontology.yaml: {key} keys must be non-empty strings"
|
|
156
|
+
)
|
|
157
|
+
if not isinstance(raw_v, str) or not raw_v.strip():
|
|
158
|
+
raise DataError(
|
|
159
|
+
f"invalid vault/ontology.yaml: {key}.{raw_k} must be a non-empty string"
|
|
160
|
+
)
|
|
161
|
+
out[raw_k.strip()] = raw_v.strip()
|
|
162
|
+
return out
|
|
@@ -68,6 +68,17 @@ def _graph_lines(kb):
|
|
|
68
68
|
return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def _entities_lines(kb):
|
|
72
|
+
p = kb.root / "vault" / "facts" / "entities.ndjson"
|
|
73
|
+
if not p.exists():
|
|
74
|
+
return []
|
|
75
|
+
return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _write_ontology(kb, text):
|
|
79
|
+
(kb.root / "vault" / "ontology.yaml").write_text(text, encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
71
82
|
def _two_claims(kb):
|
|
72
83
|
"""Seed a contradiction pair to reconcile."""
|
|
73
84
|
kb.add_raw("s", SRC)
|
|
@@ -244,6 +255,38 @@ def test_fact_add_json_reports_appended_records(kb, capsys):
|
|
|
244
255
|
assert rec["anchor"].startswith("qh:")
|
|
245
256
|
|
|
246
257
|
|
|
258
|
+
def test_fact_add_ontology_canonicalizes_claim_predicate_aliases(kb):
|
|
259
|
+
kb.add_raw("s", SRC)
|
|
260
|
+
_write_ontology(
|
|
261
|
+
kb,
|
|
262
|
+
"""
|
|
263
|
+
claim_predicates:
|
|
264
|
+
- caches
|
|
265
|
+
predicate_aliases:
|
|
266
|
+
p: caches
|
|
267
|
+
""",
|
|
268
|
+
)
|
|
269
|
+
rc = _run_add(
|
|
270
|
+
kb,
|
|
271
|
+
_ndjson(_claim("Caching answers beats recomputing them.", predicate="p")),
|
|
272
|
+
)
|
|
273
|
+
assert rc == 0
|
|
274
|
+
assert _claims_lines(kb)[0]["predicate"] == "caches"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_fact_add_ontology_rejects_unknown_claim_predicate(kb):
|
|
278
|
+
kb.add_raw("s", SRC)
|
|
279
|
+
_write_ontology(kb, "claim_predicates:\n - caches\n")
|
|
280
|
+
|
|
281
|
+
rc = _run_add(
|
|
282
|
+
kb,
|
|
283
|
+
_ndjson(_claim("Caching answers beats recomputing them.", predicate="p")),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
assert rc == 3
|
|
287
|
+
assert _claims_lines(kb) == []
|
|
288
|
+
|
|
289
|
+
|
|
247
290
|
# --------------------------------------------------------------------------- #
|
|
248
291
|
# All-or-nothing batch + per-record failures (exit 1)
|
|
249
292
|
# --------------------------------------------------------------------------- #
|
|
@@ -483,6 +526,93 @@ def test_fact_add_entities_appends_and_conflicts(kb, capsys):
|
|
|
483
526
|
assert len(lines.splitlines()) == 1 # nothing else was appended
|
|
484
527
|
|
|
485
528
|
|
|
529
|
+
def test_fact_add_entities_accept_optional_uri_metadata(kb):
|
|
530
|
+
ent = {
|
|
531
|
+
"entity_id": "entity/duckdb",
|
|
532
|
+
"name": "DuckDB",
|
|
533
|
+
"kind": "tool",
|
|
534
|
+
"uri": "https://duckdb.org/",
|
|
535
|
+
"same_as": ["https://www.wikidata.org/wiki/Q118533698"],
|
|
536
|
+
"external_ids": {"wikidata": "Q118533698"},
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
assert _run_add(kb, _ndjson(ent), "--table", "entities") == 0
|
|
540
|
+
|
|
541
|
+
[stored] = _entities_lines(kb)
|
|
542
|
+
assert stored["uri"] == "https://duckdb.org/"
|
|
543
|
+
assert stored["same_as"] == ["https://www.wikidata.org/wiki/Q118533698"]
|
|
544
|
+
assert stored["external_ids"] == {"wikidata": "Q118533698"}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def test_fact_add_ontology_rejects_unknown_entity_kind_and_edge_kind(kb):
|
|
548
|
+
_write_ontology(
|
|
549
|
+
kb,
|
|
550
|
+
"""
|
|
551
|
+
entity_kinds:
|
|
552
|
+
- tool
|
|
553
|
+
edge_kinds:
|
|
554
|
+
- depends-on
|
|
555
|
+
""",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
assert (
|
|
559
|
+
_run_add(
|
|
560
|
+
kb,
|
|
561
|
+
_ndjson({"entity_id": "entity/a", "name": "A", "kind": "concept"}),
|
|
562
|
+
"--table",
|
|
563
|
+
"entities",
|
|
564
|
+
)
|
|
565
|
+
== 3
|
|
566
|
+
)
|
|
567
|
+
assert _run_add(kb, _ndjson(_edge(kind="relates-to")), "--table", "edges") == 3
|
|
568
|
+
assert _run_add(kb, _ndjson(_edge(kind="depends-on")), "--table", "edges") == 0
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def test_ontology_command_reports_optional_vocabulary(kb, capsys):
|
|
572
|
+
_write_ontology(
|
|
573
|
+
kb,
|
|
574
|
+
"""
|
|
575
|
+
entity_kinds:
|
|
576
|
+
- tool
|
|
577
|
+
edge_kinds:
|
|
578
|
+
- depends-on
|
|
579
|
+
claim_predicates:
|
|
580
|
+
- caches
|
|
581
|
+
predicate_aliases:
|
|
582
|
+
p: caches
|
|
583
|
+
""",
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
assert cli.main(["ontology", "--json", "--root", str(kb.root)]) == 0
|
|
587
|
+
|
|
588
|
+
data = json.loads(capsys.readouterr().out)
|
|
589
|
+
assert data["active"] is True
|
|
590
|
+
assert data["entity_kinds"] == ["tool"]
|
|
591
|
+
assert data["edge_kinds"] == ["depends-on"]
|
|
592
|
+
assert data["claim_predicates"] == ["caches"]
|
|
593
|
+
assert data["predicate_aliases"] == {"p": "caches"}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def test_ontology_rejects_alias_to_unknown_predicate(kb):
|
|
597
|
+
_write_ontology(
|
|
598
|
+
kb,
|
|
599
|
+
"""
|
|
600
|
+
claim_predicates:
|
|
601
|
+
- caches
|
|
602
|
+
predicate_aliases:
|
|
603
|
+
p: stores
|
|
604
|
+
""",
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
assert cli.main(["ontology", "--root", str(kb.root)]) == 3
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def test_ontology_rejects_unknown_top_level_key(kb):
|
|
611
|
+
_write_ontology(kb, "claim_predicate:\n - caches\n")
|
|
612
|
+
|
|
613
|
+
assert cli.main(["ontology", "--root", str(kb.root)]) == 3
|
|
614
|
+
|
|
615
|
+
|
|
486
616
|
def test_fact_add_edges_appends_and_skips_duplicates(kb):
|
|
487
617
|
edge = {"src": "entity/duckdb", "dst": "entity/motherduck", "kind": "made-by"}
|
|
488
618
|
assert _run_add(kb, _ndjson(edge), "--table", "edges") == 0
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""`scrip init` — create the root skeleton (vault/ layers + .kb/ cache) so a new
|
|
2
|
+
instance starts detectable and green. Idempotent in an existing root; refuses to
|
|
3
|
+
nest a new instance inside an existing one."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from scrip import cli, manifest_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_init_creates_skeleton_and_manifest(tmp_path, capsys):
|
|
11
|
+
root = tmp_path / "kb"
|
|
12
|
+
assert cli.main(["init", str(root)]) == 0
|
|
13
|
+
for rel in ("vault/raw", "vault/facts", "vault/wiki", ".kb"):
|
|
14
|
+
assert (root / rel).is_dir()
|
|
15
|
+
# same effect as `scrip status --rebuild-manifest`: the cache exists
|
|
16
|
+
assert manifest_path(root).exists()
|
|
17
|
+
out = capsys.readouterr().out
|
|
18
|
+
assert f"initialized scriptorium root at {root}" in out
|
|
19
|
+
assert "scrip ingest" in out # next-steps hint: ingest -> compile -> verify
|
|
20
|
+
assert "scrip verify" in out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_init_defaults_to_cwd(tmp_path, monkeypatch):
|
|
24
|
+
monkeypatch.chdir(tmp_path)
|
|
25
|
+
assert cli.main(["init"]) == 0
|
|
26
|
+
assert (tmp_path / "vault" / "raw").is_dir()
|
|
27
|
+
assert (tmp_path / ".kb").is_dir()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_init_then_commands_resolve_root_without_flags(tmp_path, monkeypatch):
|
|
31
|
+
"""The dogfooding gap this command closes: after init, `scrip status` and
|
|
32
|
+
`scrip verify` run green from inside the new root with no --root."""
|
|
33
|
+
root = tmp_path / "kb"
|
|
34
|
+
assert cli.main(["init", str(root)]) == 0
|
|
35
|
+
monkeypatch.chdir(root)
|
|
36
|
+
assert cli.main(["status"]) == 0
|
|
37
|
+
assert cli.main(["verify"]) == 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_init_existing_root_is_noop(tmp_path, capsys):
|
|
41
|
+
root = tmp_path / "kb"
|
|
42
|
+
assert cli.main(["init", str(root)]) == 0
|
|
43
|
+
manifest_before = manifest_path(root).read_text(encoding="utf-8")
|
|
44
|
+
capsys.readouterr()
|
|
45
|
+
assert cli.main(["init", str(root)]) == 0
|
|
46
|
+
out = capsys.readouterr().out
|
|
47
|
+
assert f"already a scriptorium root: {root}" in out
|
|
48
|
+
# a true no-op: the manifest was not rewritten
|
|
49
|
+
assert manifest_path(root).read_text(encoding="utf-8") == manifest_before
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_init_detects_spec_marker_root_as_existing(kb, capsys):
|
|
53
|
+
"""A root marked by SPEC.md (no .kb/ needed) counts as already initialized."""
|
|
54
|
+
assert cli.main(["init", str(kb.root)]) == 0
|
|
55
|
+
assert "already a scriptorium root" in capsys.readouterr().out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_init_inside_existing_root_refused_exit_2(tmp_path):
|
|
59
|
+
root = tmp_path / "kb"
|
|
60
|
+
assert cli.main(["init", str(root)]) == 0
|
|
61
|
+
nested = root / "vault" / "raw" / "sub"
|
|
62
|
+
assert cli.main(["init", str(nested)]) == 2
|
|
63
|
+
# nothing was created for the refused nested instance
|
|
64
|
+
assert not nested.exists()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_init_target_is_a_file_exit_2(tmp_path):
|
|
68
|
+
f = tmp_path / "notes.md"
|
|
69
|
+
f.write_text("hi\n", encoding="utf-8")
|
|
70
|
+
assert cli.main(["init", str(f)]) == 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_init_fills_in_missing_layers(tmp_path):
|
|
74
|
+
"""A partial layout (vault/ alone is not a detectable root) is completed
|
|
75
|
+
rather than refused."""
|
|
76
|
+
root = tmp_path / "kb"
|
|
77
|
+
(root / "vault" / "raw").mkdir(parents=True)
|
|
78
|
+
assert cli.main(["init", str(root)]) == 0
|
|
79
|
+
for rel in ("vault/facts", "vault/wiki", ".kb"):
|
|
80
|
+
assert (root / rel).is_dir()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_init_layer_path_is_a_file_refused_exit_2(tmp_path):
|
|
84
|
+
"""A file squatting a layer path is refused cleanly (exit 2), not an
|
|
85
|
+
uncaught FileExistsError mapped to the internal-error exit code."""
|
|
86
|
+
root = tmp_path / "kb"
|
|
87
|
+
(root / "vault").mkdir(parents=True)
|
|
88
|
+
(root / "vault" / "facts").write_text("oops\n", encoding="utf-8")
|
|
89
|
+
assert cli.main(["init", str(root)]) == 2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_init_json_shape(tmp_path, capsys):
|
|
93
|
+
root = tmp_path / "kb"
|
|
94
|
+
assert cli.main(["init", "--json", str(root)]) == 0
|
|
95
|
+
data = json.loads(capsys.readouterr().out)
|
|
96
|
+
assert set(data) == {"root", "status", "created"}
|
|
97
|
+
assert data["status"] == "initialized"
|
|
98
|
+
assert ".kb/manifest.json" in data["created"]
|
|
99
|
+
assert cli.main(["init", "--json", str(root)]) == 0
|
|
100
|
+
data = json.loads(capsys.readouterr().out)
|
|
101
|
+
assert data["status"] == "exists"
|
|
102
|
+
assert data["created"] == []
|
|
@@ -249,7 +249,7 @@ name = "exceptiongroup"
|
|
|
249
249
|
version = "1.3.1"
|
|
250
250
|
source = { registry = "https://pypi.org/simple" }
|
|
251
251
|
dependencies = [
|
|
252
|
-
{ name = "typing-extensions"
|
|
252
|
+
{ name = "typing-extensions" },
|
|
253
253
|
]
|
|
254
254
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
255
255
|
wheels = [
|
|
@@ -1141,7 +1141,7 @@ wheels = [
|
|
|
1141
1141
|
|
|
1142
1142
|
[[package]]
|
|
1143
1143
|
name = "scriptoria"
|
|
1144
|
-
version = "0.
|
|
1144
|
+
version = "0.10.0"
|
|
1145
1145
|
source = { editable = "." }
|
|
1146
1146
|
dependencies = [
|
|
1147
1147
|
{ name = "duckdb" },
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|