diff --git a/AGENT.md b/AGENT.md index eb77121..632b428 100644 --- a/AGENT.md +++ b/AGENT.md @@ -65,6 +65,10 @@ The data contract these steps assume is normative in [SPEC.md](SPEC.md). (`--table edges`): `{src,dst,kind}` by default; an edge may optionally be **cited** with a verbatim `quote` + `source_id`, and scrip mints+verifies its `anchor` just like a claim (an unverifiable quote fails the batch, exit 1). + - if `vault/ontology.yaml` exists, `scrip fact add` also validates entity + kinds, edge kinds, and claim predicates against it, and canonicalizes claim + predicate aliases before storage. Use `scrip ontology` to check the optional + vocabulary directly. 2. `scrip stamp vault/facts/_meta.yaml` — every append (any table) drops the set's `input-hash`, so it deliberately shows STALE until you stamp it. 3. `scrip verify` (anchors resolve) and `scrip query contradictions` (catch @@ -92,10 +96,11 @@ reconciliations. `scrip-harness answer ""` runs the safe form of this ladder: it refuses stale artifacts, broken anchors, and open contradictions by default; gathers -facts/wiki evidence first; falls back to `scrip search` when compiled evidence is -thin; and accepts the model answer only after every claim citation resolves via -`scrip span` or every raw quote mints via `scrip anchor`. `--save` writes the -verified answer to `wiki/explorations/`. +facts/wiki evidence first; includes bounded graph context when relevant; falls +back to `scrip search` when compiled evidence is thin; and accepts the model +answer only after every claim citation resolves via `scrip span` or every raw +quote mints via `scrip anchor`. Graph context and wiki pages are not citable by +themselves. `--save` writes the verified answer to `wiki/explorations/`. ## PROMOTE — turn a good answer into a page @@ -157,6 +162,7 @@ caveat is left to the read-only view layer rather than mutating a stamped page. | record provenance hashes after compiling | `scrip stamp [path…]` | | check every citation still resolves | `scrip verify` | | query the facts layer | `scrip query claims \| entities \| edges \| contradictions \| --sql "…"` | +| validate optional semantic vocabulary | `scrip ontology` | | retrieve source blocks (rung 4) | `scrip search ""` | | build the semantic index (optional) | `scrip index` *(needs the `[embeddings]` extra)* | | answer with verified citations | `scrip-harness answer "" [--save]` | diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b841e..dc9e6ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ reference CLI. The file **contract** is versioned separately in ## [Unreleased] +### Added +- **Optional ontology vocabulary.** `vault/ontology.yaml` can constrain claim + predicates, entity kinds, and edge kinds; `scrip ontology` validates and + summarizes it; and claim predicate aliases canonicalize before storage. +- **External entity identity metadata.** Entity rows may carry optional `uri`, + `same_as`, and `external_ids` fields while keeping local `entity/` ids as + the stable contract identity. +- **Ontology-aware harness drafting.** `scrip-harness extract` and + `scrip-harness graph` feed the active ontology summary into model prompts so + drafts prefer local vocabulary before `scrip fact add` validates them. +- **Graph-guided answer context.** `scrip-harness answer` includes bounded, + ranked entity/edge context when relevant; this context is not citable, so final + citations remain verified claim ids or raw quotes. + +### Changed +- **scrip-harness now requires `scriptoria>=0.9.0`.** The harness ontology + prompts rely on deterministic ontology enforcement in the keeper. + ## [0.8.1] — 2026-07-07 scriptoria moves to 0.8.1 for a lower-allocation NDJSON parser. (Released diff --git a/README.md b/README.md index e91300d..47d90b4 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ scrip query claims --where "list_contains(tags, 'caching')" scrip query contradictions scrip query --sql "SELECT source_id, count(*) AS n FROM claims GROUP BY 1 ORDER BY n DESC" +# optional semantic vocabulary: validates fact predicates/kinds when present +scrip ontology + # rung 4 — retrieve source blocks for an uncompiled question (grep by default) scrip search "what makes adding one document expensive?" @@ -79,7 +82,8 @@ scrip similar --title "Compilation over retrieval" --from raw/karpathy-llm-wiki ### Optional: answer demo The optional harness can answer from a green vault with model output constrained -to verified claim ids or raw quotes. A sanitized fixture is included for demos: +to verified claim ids or raw quotes; graph neighborhoods may guide the answer +but are not citable by themselves. A sanitized fixture is included for demos: ```sh scripts/demo_answer.sh --provider openai diff --git a/SPEC.md b/SPEC.md index 371bdf3..3aa7300 100644 --- a/SPEC.md +++ b/SPEC.md @@ -44,6 +44,7 @@ vault/ raw/ immutable sources (+ .meta.yaml sidecars) ← never edited after ingest facts/ structured extractions, queryable as data ← derived from raw/ wiki/ synthesized prose (concepts, entities) ← derived from raw/ + ontology.yaml optional semantic vocabulary ← validates facts if present ``` ### 2.1 `raw/` @@ -59,6 +60,9 @@ vault/ ### 2.2 `facts/` Newline-delimited JSON (one record per line), queryable directly as data: - `facts/entities.ndjson` — entities (people, works, organizations, systems). + Entities have local `entity/` ids and may optionally carry external + identity metadata (`uri`, `same_as`, `external_ids`) without changing their + local contract identity. - `facts/claims.ndjson` — the claims table (§5). - `facts/graph.ndjson` — edges between entities/sources (citation/idea graph). An edge is `{src, dst, kind}`; it may optionally be **cited** — carrying a verbatim @@ -80,6 +84,31 @@ appends without rewrites and diffs line-by-line in git. - `wiki/log.md` — an append-only journal of compiles, answers, reconciliations (not a tracked derived artifact). +### 2.4 `ontology.yaml` (optional) + +`vault/ontology.yaml` is an optional semantic vocabulary. If absent, facts remain +validated only by their base schema. If present, `scrip fact add` uses it to +constrain selected free-text fields and canonicalize claim predicates via aliases: + +```yaml +entity_kinds: [tool, concept, person, organization] +edge_kinds: [depends-on, part-of, cites, alternative-to] +claim_predicates: [caches, depends-on, contradicts] +predicate_aliases: + stores: caches +``` + +- `entity_kinds` constrains `facts/entities.ndjson` `kind`. +- `edge_kinds` constrains `facts/graph.ndjson` `kind`. +- `claim_predicates` constrains `facts/claims.ndjson` `predicate`. +- `predicate_aliases` maps proposed predicates to canonical predicates before + storage; alias targets must be listed in `claim_predicates` when that list is + present. + +The ontology is a checked source file, not a cache. It does not affect staleness +hashes or provenance anchors; it only makes fact writes stricter and more +canonical when operators opt in. + --- ## 3. Identifiers @@ -94,6 +123,11 @@ appends without rewrites and diffs line-by-line in git. A `raw/` id maps to the file `vault/raw/.md`. Block-scoped ids append `#` (§7.2), e.g. `raw/friston-2010#b3f9a1c2d4e5`. +Entity ids remain local, stable `entity/` identifiers even when an entity +also carries an external `uri`, `same_as`, or `external_ids` mapping. External +identity enriches integration; it does not replace the local id used by facts, +wiki pages, and graph edges. + --- ## 4. Frontmatter schema (derived artifacts) @@ -257,7 +291,8 @@ source of truth; deleting `.kb/` and recomputing from `vault/` must be a no-op. Answering a question is a descent through rungs; stop at the first that applies: 1. **Consult compiled** — look in `wiki/` and query `facts/` first - (index-before-search). + (index-before-search). Entity/edge neighborhoods may guide context, but they + are not citations by themselves. 2. **Hit & fresh** (`scrip status` clean) → answer from the compiled layer and cite anchors. *Cheapest path; no re-derivation.* 3. **Hit & stale** → recompile only the stale artifact from its sources @@ -332,6 +367,12 @@ behaviours; their exit codes are part of the contract surface it (§6), so `scrip verify` covers it. Backward-compatible: bare `{src,dst,kind}` edges are unaffected, and the optional fields do not change block ids, so the manifest `version` stays `2`. +- **Optional ontology vocabulary (additive).** `vault/ontology.yaml` may constrain + entity kinds, edge kinds, and claim predicates, and may canonicalize claim + predicate aliases before append. Instances without the file are unaffected. + Entity rows may also carry optional `uri`, `same_as`, and `external_ids` + metadata. These are backward-compatible schema additions and do not change + block ids, so the manifest `version` stays `2`. - **v1 → v2 (block ids).** Block ids became content-derived (a digest of the normalized block text) instead of positional `b0,b1,…`, making block-precise dependencies insertion-stable (§7.2). The id *form* is the only change: a diff --git a/docs/roadmap.md b/docs/roadmap.md index bea807f..4ad9d4d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,3 +57,33 @@ Keep comparisons honest: Future work should integrate with those layers where useful, rather than rebuilding their entire product surface. + +## Phase 5 — Semantic Layer + +Status: initial implementation started. + +The Knowledge Graph Guys' recurring lesson maps cleanly onto the next layer of +scriptorium: a graph becomes more useful when its identifiers and relationships +are constrained by explicit semantics, not just free-text labels. + +Implemented: + +- Optional `vault/ontology.yaml` vocabulary. +- `scrip ontology` validates and summarizes that vocabulary. +- `scrip fact add` validates claim predicates, entity kinds, and edge kinds when + the ontology exists. +- Claim predicate aliases canonicalize before storage, improving deterministic + contradiction grouping without adding model judgment. +- Entity rows may carry optional external identity metadata (`uri`, `same_as`, + `external_ids`) while retaining local `entity/` ids as the contract + identity. +- Harness EXTRACT and GRAPH prompts include the active ontology summary, so model + drafts prefer local vocabulary before deterministic validation runs. +- `scrip-harness answer` includes a bounded, ranked `graph_context` packet from + relevant entities and edges. This is context-only: final citations still have + to be claim ids or verified raw quotes. + +Next: + +- Add focused views over the graph rather than whole-graph visualization, avoiding + the usual unreadable graph hairball. diff --git a/harness/README.md b/harness/README.md index 55bda11..3a5766d 100644 --- a/harness/README.md +++ b/harness/README.md @@ -76,8 +76,8 @@ So the model owns *what to say*; `scrip` owns *what is true on disk*. contradictions` must be clean by default. Stale artifacts, broken anchors, or open contradiction pairs stop the answer before any model call. 2. **Gather** — the harness ranks claims from `facts/`, reads relevant compiled - wiki pages as context, and falls back to `scrip search` when compiled evidence - is thin. + wiki pages and graph neighborhoods as context, and falls back to `scrip search` + when compiled evidence is thin. Wiki/graph context cannot be cited directly. 3. **Draft** — the selected provider answers from that bounded evidence packet and returns structured citation records: either an existing `claim_id` or a verbatim raw quote. diff --git a/harness/pyproject.toml b/harness/pyproject.toml index 0a5f71d..ff7ad79 100644 --- a/harness/pyproject.toml +++ b/harness/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrip-harness" -version = "0.10.2" +version = "0.11.0" description = "Runnable AGENT.md compile loop for scriptorium: drives `scrip` via subprocess + a model provider. Not part of the deterministic scrip core." readme = "README.md" requires-python = ">=3.10" @@ -19,9 +19,9 @@ dependencies = [ "pydantic>=2", # Version floor (not the dev path source below) is what ships in the wheel's # Requires-Dist, so an installed scrip-harness pulls scriptoria from PyPI. - # >=0.8.1: includes cited-edge support plus the LF-only NDJSON parser fix the - # harness shells through when it submits fact batches. - "scriptoria>=0.8.1", + # >=0.9.0: includes optional ontology vocabulary enforcement used by the + # harness prompt hints and the deterministic fact writer. + "scriptoria>=0.9.0", ] [project.optional-dependencies] diff --git a/harness/src/scrip_harness/__init__.py b/harness/src/scrip_harness/__init__.py index dc2abbb..9404981 100644 --- a/harness/src/scrip_harness/__init__.py +++ b/harness/src/scrip_harness/__init__.py @@ -11,4 +11,4 @@ way. The harness is optional and lives outside the deterministic core. """ -__version__ = "0.10.2" +__version__ = "0.11.0" diff --git a/harness/src/scrip_harness/answer.py b/harness/src/scrip_harness/answer.py index b558cdf..db5ba97 100644 --- a/harness/src/scrip_harness/answer.py +++ b/harness/src/scrip_harness/answer.py @@ -24,8 +24,8 @@ "Avoid generic table headers; quote enough source words to be unique.\n" "- Keep the body free of footnote definitions; the harness mints and appends " "verified definitions after your draft.\n" - "- Do not cite wiki pages directly. They are context; final citations must be " - "claim ids or raw quotes." + "- Do not cite wiki pages or graph context directly. They are context; final " + "citations must be claim ids or raw quotes." ) diff --git a/harness/src/scrip_harness/cli.py b/harness/src/scrip_harness/cli.py index f836ddf..f50a338 100644 --- a/harness/src/scrip_harness/cli.py +++ b/harness/src/scrip_harness/cli.py @@ -11,7 +11,7 @@ import argparse import sys from pathlib import Path -from typing import cast +from typing import Any, cast def _resolve_root(root_arg: str | None) -> Path: @@ -35,6 +35,22 @@ def _normalize_sources(raw: str) -> list[str]: ] +def _ontology_for_model(root: Path) -> dict[str, Any] | None: + try: + import scrip.ontology as ontology_mod + from scrip.errors import DataError + except ImportError as e: + raise RuntimeError( + "ontology-aware harness commands require scriptoria>=0.9.0" + ) from e + + try: + ont = ontology_mod.load(root) + except DataError as e: + raise RuntimeError(str(e)) from e + return ont.summary() if ont.active else None + + def _add_model_args(parser: argparse.ArgumentParser, *, include_provider: bool = True) -> None: if include_provider: parser.add_argument( @@ -204,6 +220,14 @@ def main(argv: list[str] | None = None) -> int: chosen_model = args.model chosen_provider = cast(model_mod.Provider, getattr(args, "provider", "auto")) api_key_file = cast(str | None, getattr(args, "api_key_file", None)) + needs_ontology = args.command in {"extract", "graph"} or ( + args.command == "ingest" and args.through in {"extract", "graph"} + ) + try: + local_ontology = _ontology_for_model(root) if needs_ontology else None + except RuntimeError as e: + print(f"scrip-harness: {e}", file=sys.stderr) + return 1 if args.command == "ingest": def _compile_draft(text: str, *, source_id: str, failures=None): @@ -216,12 +240,14 @@ def _extract_draft(text: str, *, source_id: str, failures=None): return model_mod.draft_extraction( text, source_id=source_id, provider=chosen_provider, model=chosen_model, failures=failures, api_key_file=api_key_file, + ontology=local_ontology, ) def _graph_draft(text: str, *, source_id: str): return model_mod.draft_graph( text, source_id=source_id, provider=chosen_provider, model=chosen_model, api_key_file=api_key_file, + ontology=local_ontology, ) clean_fn = None @@ -365,6 +391,7 @@ def extract_draft_fn(text: str, *, source_id: str, failures=None): model=chosen_model, failures=failures, api_key_file=api_key_file, + ontology=local_ontology, ) extract_sources = None @@ -404,6 +431,7 @@ def graph_draft_fn(text: str, *, source_id: str): provider=chosen_provider, model=chosen_model, api_key_file=api_key_file, + ontology=local_ontology, ) try: @@ -419,7 +447,7 @@ def graph_draft_fn(text: str, *, source_id: str): ) extras = [] if result["dropped_edges"]: - extras.append(f"{len(result['dropped_edges'])} edge(s) dropped (unknown endpoint)") + extras.append(f"{len(result['dropped_edges'])} edge(s) dropped") if result["skipped_entities"]: extras.append(f"{len(result['skipped_entities'])} entity name(s) skipped (no slug)") if extras: diff --git a/harness/src/scrip_harness/extract.py b/harness/src/scrip_harness/extract.py index d3c2db2..cfb4d44 100644 --- a/harness/src/scrip_harness/extract.py +++ b/harness/src/scrip_harness/extract.py @@ -5,7 +5,8 @@ from __future__ import annotations import json -from typing import Literal +from collections.abc import Mapping +from typing import Any, Literal from pydantic import BaseModel @@ -53,15 +54,36 @@ class DraftExtraction(BaseModel): claims: list[DraftFact] -def build_extract_prompt(source_text: str) -> str: +def _ontology_guidance(ontology: Mapping[str, Any] | None) -> str: + if not ontology or not ontology.get("active"): + return "" + lines = ["\n\nLOCAL ONTOLOGY:"] + predicates = ontology.get("claim_predicates") or [] + aliases = ontology.get("predicate_aliases") or {} + if predicates: + lines.append("- Use claim `predicate` values only from: " + ", ".join(predicates) + ".") + if aliases: + rendered = ", ".join(f"{alias} -> {target}" for alias, target in sorted(aliases.items())) + lines.append("- Prefer canonical predicates; accepted aliases map as: " + rendered + ".") + return "\n".join(lines) + + +def build_extract_prompt(source_text: str, ontology: Mapping[str, Any] | None = None) -> str: return ( "Extract the atomic factual claims from the source below as structured " "records. Each claim needs a verbatim `quote`, a subject/predicate/object " - "triple, and a polarity.\n\n----- SOURCE -----\n" + source_text + "triple, and a polarity." + + _ontology_guidance(ontology) + + "\n\n----- SOURCE -----\n" + + source_text ) -def build_extract_retry_prompt(source_text: str, failures: list[dict]) -> str: +def build_extract_retry_prompt( + source_text: str, + failures: list[dict], + ontology: Mapping[str, Any] | None = None, +) -> str: """Ask for a replacement for each failed quote, in the reported order. An empty replacement quote tells the runner to drop that claim.""" listing = "\n".join( @@ -77,7 +99,10 @@ def build_extract_retry_prompt(source_text: str, failures: list[dict]) -> str: "Return exactly one replacement claim per failed quote, in the same " "order, with the corrected verbatim `quote` and the claim's triple/" "polarity. If a claim cannot be supported by a verbatim quote, return it " - "with an empty `quote` to drop it.\n\n----- SOURCE -----\n" + source_text + "with an empty `quote` to drop it." + + _ontology_guidance(ontology) + + "\n\n----- SOURCE -----\n" + + source_text ) diff --git a/harness/src/scrip_harness/graph.py b/harness/src/scrip_harness/graph.py index 7e5bc94..0b2c730 100644 --- a/harness/src/scrip_harness/graph.py +++ b/harness/src/scrip_harness/graph.py @@ -14,6 +14,8 @@ import json import re +from collections.abc import Mapping +from typing import Any from pydantic import BaseModel @@ -84,12 +86,27 @@ def entity_id(name: str) -> str: return f"entity/{s}" if s else "" -def build_graph_prompt(source_text: str) -> str: +def _ontology_guidance(ontology: Mapping[str, Any] | None) -> str: + if not ontology or not ontology.get("active"): + return "" + lines = ["\n\nLOCAL ONTOLOGY:"] + entity_kinds = ontology.get("entity_kinds") or [] + edge_kinds = ontology.get("edge_kinds") or [] + if entity_kinds: + lines.append("- Use entity `kind` values only from: " + ", ".join(entity_kinds) + ".") + if edge_kinds: + lines.append("- Use edge `kind` values only from: " + ", ".join(edge_kinds) + ".") + return "\n".join(lines) + + +def build_graph_prompt(source_text: str, ontology: Mapping[str, Any] | None = None) -> str: return ( "Draft the entities and the typed relationships among them from the source " "below. Every edge's `src`/`dst` must be the `name` of an entity you also " "list. Where a single verbatim span states a relationship, copy it into the " - "edge's `quote`; otherwise leave `quote` empty.\n\n----- SOURCE -----\n" + "edge's `quote`; otherwise leave `quote` empty." + + _ontology_guidance(ontology) + + "\n\n----- SOURCE -----\n" + source_text ) diff --git a/harness/src/scrip_harness/model.py b/harness/src/scrip_harness/model.py index 5568561..0a0b4a5 100644 --- a/harness/src/scrip_harness/model.py +++ b/harness/src/scrip_harness/model.py @@ -606,15 +606,16 @@ def draft_extraction( client=None, failures: list[dict] | None = None, api_key_file: str | None = None, + ontology: dict[str, Any] | None = None, ) -> DraftExtraction: """Ask a model to extract structured claims from ``source_text``. With ``failures`` (the per-record findings from ``scrip fact add``), asks instead for one replacement claim per failure, in order — the retry half of the extract loop.""" prompt = ( - build_extract_prompt(source_text) + build_extract_prompt(source_text, ontology) if failures is None - else build_extract_retry_prompt(source_text, failures) + else build_extract_retry_prompt(source_text, failures, ontology) ) out = _complete_structured( EXTRACT_SYSTEM, @@ -639,6 +640,7 @@ def draft_graph( provider: Provider | None = None, client=None, api_key_file: str | None = None, + ontology: dict[str, Any] | None = None, ) -> DraftGraph: """Ask a model to draft the entities and typed edges a source describes. Returns a validated :class:`DraftGraph`. Entities/edges carry no anchors, so @@ -646,7 +648,7 @@ def draft_graph( unsluggable entities instead.""" out = _complete_structured( GRAPH_SYSTEM, - build_graph_prompt(source_text), + build_graph_prompt(source_text, ontology), DraftGraph, provider=provider, model=model, diff --git a/harness/src/scrip_harness/runner.py b/harness/src/scrip_harness/runner.py index 98a8a69..a5527cc 100644 --- a/harness/src/scrip_harness/runner.py +++ b/harness/src/scrip_harness/runner.py @@ -13,11 +13,14 @@ import re import subprocess import sys -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence from datetime import datetime, timezone from pathlib import Path +from scrip.errors import DataError + from scrip import frontmatter # reuse the deterministic frontmatter helper +from scrip import ontology as ontology_mod from .answer import DraftAnswer, overlap_score, tokenize from .compile import DraftPage, assemble_body, extract_markers, format_sources @@ -606,6 +609,10 @@ def draft_graph_facts( source_text = (root / "vault" / "raw" / f"{slug}.md").read_text(encoding="utf-8") except OSError as e: raise GraphError(f"cannot read {source_id}: {e}") from e + try: + ont = ontology_mod.load(root) + except DataError as e: + raise GraphError(str(e)) from e draft = draft_fn(source_text, source_id=source_id) @@ -625,6 +632,10 @@ def draft_graph_facts( if not eid or not e.kind.strip(): skipped_entities.append(e.name) continue + try: + ont.validate_entity_kind(e.kind, len(kept_entities)) + except DataError as e: + raise GraphError(str(e)) from e kept_entities.append(e) name_to_id[e.name] = eid for name, eid in _existing_entity_ids(root).items(): @@ -637,10 +648,17 @@ def draft_graph_facts( kept_edges: list[DraftEdge] = [] dropped_edges: list[dict] = [] for edge in draft.edges: - if edge.src in name_to_id and edge.dst in name_to_id and edge.kind.strip(): - kept_edges.append(edge) - else: + if edge.src not in name_to_id or edge.dst not in name_to_id or not edge.kind.strip(): dropped_edges.append({"src": edge.src, "dst": edge.dst, "kind": edge.kind}) + continue + try: + ont.validate_edge_kind(edge.kind, len(kept_edges)) + except DataError: + dropped_edges.append( + {"src": edge.src, "dst": edge.dst, "kind": edge.kind, "reason": "invalid kind"} + ) + continue + kept_edges.append(edge) if not kept_entities and not kept_edges: raise GraphError(f"the draft proposed no entities or edges for {source_id}") @@ -677,7 +695,7 @@ def draft_graph_facts( f"{source_id}: +{n_ent} entit{'y' if n_ent == 1 else 'ies'}, +{n_edge} edge(s)" ) if dropped_edges: - note += f", {len(dropped_edges)} edge(s) dropped (unknown endpoint)" + note += f", {len(dropped_edges)} edge(s) dropped (invalid endpoint/kind)" if degraded_edges: note += f", {len(degraded_edges)} edge(s) degraded (unverifiable quote)" if skipped_entities: @@ -886,6 +904,157 @@ def _rank_claims(question: str, claims: list[dict], top: int) -> list[dict]: return ranked[:top] +def _iter_lf_lines(text: str) -> Iterator[str]: + start = 0 + while True: + end = text.find("\n", start) + if end == -1: + yield text[start:] + return + yield text[start:end] + start = end + 1 + + +def _read_ndjson(path: Path, label: str) -> list[dict]: + if not path.exists(): + return [] + rows: list[dict] = [] + for lineno, line in enumerate(_iter_lf_lines(path.read_text(encoding="utf-8")), start=1): + if not line.strip(): + continue + try: + rec = json.loads(line) + except json.JSONDecodeError as e: + raise AnswerError(f"{label}:{lineno}: invalid JSON: {e}") from e + if not isinstance(rec, dict): + raise AnswerError(f"{label}:{lineno}: expected a JSON object") + rows.append(rec) + return rows + + +def _flatten_text(value) -> list[str]: + if value is None: + return [] + if isinstance(value, dict): + out: list[str] = [] + for k, v in value.items(): + out.append(str(k)) + out.extend(_flatten_text(v)) + return out + if isinstance(value, list): + out: list[str] = [] + for item in value: + out.extend(_flatten_text(item)) + return out + return [str(value)] + + +def _token_overlap(seed: set[str], *values) -> int: + text = " ".join(part for value in values for part in _flatten_text(value)) + return len(tokenize(text) & seed) + + +def _entity_context(rec: dict, score: int) -> dict: + out = { + "entity_id": rec.get("entity_id"), + "name": rec.get("name"), + "kind": rec.get("kind"), + "tags": rec.get("tags") or [], + "score": score, + } + for key in ("uri", "same_as", "external_ids"): + if rec.get(key): + out[key] = rec[key] + return out + + +def _graph_answer_context( + root: Path, + question: str, + ranked_claims: list[dict], + *, + top: int, +) -> dict: + entities = _read_ndjson(root / "vault" / "facts" / "entities.ndjson", "entities.ndjson") + edges = _read_ndjson(root / "vault" / "facts" / "graph.ndjson", "graph.ndjson") + if not entities and not edges: + return {"entities": [], "edges": []} + + seed_text = [question] + for claim in ranked_claims: + seed_text.extend(_flatten_text(claim.get("text"))) + seed_text.extend(_flatten_text(claim.get("triple"))) + seed_text.extend(_flatten_text(claim.get("tags"))) + seed = tokenize(" ".join(seed_text)) + if not seed: + return {"entities": [], "edges": []} + + ranked_entities: list[tuple[int, str, dict]] = [] + for rec in entities: + score = _token_overlap( + seed, + rec.get("entity_id"), + rec.get("name"), + rec.get("kind"), + rec.get("tags"), + rec.get("uri"), + rec.get("same_as"), + rec.get("external_ids"), + ) + if score > 0: + ranked_entities.append((score, str(rec.get("entity_id") or ""), rec)) + ranked_entities.sort(key=lambda item: (-item[0], item[1])) + selected_entities = ranked_entities[:top] + selected_entity_ids = { + str(rec.get("entity_id")) for _, _, rec in selected_entities if rec.get("entity_id") + } + entity_by_id = { + str(rec.get("entity_id")): rec for rec in entities if isinstance(rec.get("entity_id"), str) + } + + ranked_edges: list[tuple[int, str, str, str, dict]] = [] + for rec in edges: + src = str(rec.get("src") or "") + dst = str(rec.get("dst") or "") + src_ent = entity_by_id.get(src, {}) + dst_ent = entity_by_id.get(dst, {}) + score = _token_overlap( + seed, + src, + dst, + rec.get("kind"), + src_ent.get("name"), + src_ent.get("kind"), + dst_ent.get("name"), + dst_ent.get("kind"), + ) + if src in selected_entity_ids or dst in selected_entity_ids: + score += 2 + if score > 0: + ranked_edges.append((score, src, dst, str(rec.get("kind") or ""), rec)) + ranked_edges.sort(key=lambda item: (-item[0], item[1], item[2], item[3])) + + context_edges: list[dict] = [] + for score, src, dst, _kind, rec in ranked_edges[:top]: + edge = { + "src": src, + "dst": dst, + "kind": rec.get("kind"), + "src_name": entity_by_id.get(src, {}).get("name"), + "dst_name": entity_by_id.get(dst, {}).get("name"), + "score": score, + } + for key in ("source_id", "anchor"): + if rec.get(key): + edge[key] = rec[key] + context_edges.append(edge) + + return { + "entities": [_entity_context(rec, score) for score, _, rec in selected_entities], + "edges": context_edges, + } + + def _gather_answer_evidence( root: Path, question: str, @@ -905,6 +1074,7 @@ def _gather_answer_evidence( claims = rows ranked_claims = _rank_claims(question, claims, k) pages = _read_wiki_pages(root, question, k) + graph_context = _graph_answer_context(root, question, ranked_claims, top=k) raw_blocks: list[dict] = [] # Wiki pages are context-only: the model may read them, but final citations @@ -934,11 +1104,13 @@ def _gather_answer_evidence( return { "claims": ranked_claims, "wiki_pages": pages, + "graph_context": graph_context, "raw_blocks": raw_blocks, "policy": { "claim_citations": "cite by claim_id", "raw_citations": "cite by source_id plus verbatim quote", "wiki_pages": "context only; do not cite directly", + "graph_context": "context only; do not cite directly", }, } diff --git a/harness/tests/test_answer.py b/harness/tests/test_answer.py index e1bd4b9..f1e8d34 100644 --- a/harness/tests/test_answer.py +++ b/harness/tests/test_answer.py @@ -13,7 +13,7 @@ build_answer_prompt, overlap_score, ) -from scrip_harness.runner import AnswerError, answer_question +from scrip_harness.runner import AnswerError, _read_ndjson, answer_question from scrip import anchors, frontmatter, hashing @@ -66,6 +66,17 @@ def test_overlap_score_counts_question_terms(): assert overlap_score("cached answers", "Answers are cached by default") == 2 +def test_graph_context_ndjson_reader_preserves_unicode_line_separator(tmp_path): + path = tmp_path / "entities.ndjson" + path.write_text( + json.dumps({"entity_id": "entity/a", "name": "Alpha\u2028Beta"}, ensure_ascii=False) + + "\n", + encoding="utf-8", + ) + + assert _read_ndjson(path, "entities.ndjson")[0]["name"] == "Alpha\u2028Beta" + + def test_answer_uses_verified_claim_and_raw_citations(tmp_path): root = _vault(tmp_path) _raw( @@ -157,6 +168,75 @@ def stub(question, *, evidence): answer_question(root, "What about compiled knowledge?", draft_fn=stub) +def test_answer_includes_relevant_graph_context_but_does_not_cite_it(tmp_path): + root = _vault(tmp_path) + _raw( + root, + "topic", + "# Topic\n\nPageIndex is an alternative to a vector DB.\n", + ) + _claim( + root, + "clm_0001", + "topic", + "PageIndex is an alternative to a vector DB.", + subject="pageindex", + predicate="alternative-to", + ) + (root / "vault" / "facts" / "entities.ndjson").write_text( + "\n".join( + json.dumps(row) + for row in [ + { + "entity_id": "entity/pageindex", + "name": "PageIndex", + "kind": "tool", + "tags": ["retrieval"], + "uri": "https://example.test/pageindex", + "same_as": ["https://example.test/page-index"], + "external_ids": {"wikidata": "Q123"}, + }, + { + "entity_id": "entity/vector-db", + "name": "Vector DB", + "kind": "tool", + "tags": ["retrieval"], + }, + ] + ) + + "\n", + encoding="utf-8", + ) + (root / "vault" / "facts" / "graph.ndjson").write_text( + json.dumps( + { + "src": "entity/pageindex", + "dst": "entity/vector-db", + "kind": "alternative-to", + } + ) + + "\n", + encoding="utf-8", + ) + + def stub(question, *, evidence): + graph_context = evidence["graph_context"] + by_id = {entity["entity_id"]: entity for entity in graph_context["entities"]} + assert evidence["policy"]["graph_context"] == "context only; do not cite directly" + assert by_id["entity/pageindex"]["uri"] == "https://example.test/pageindex" + assert by_id["entity/pageindex"]["external_ids"] == {"wikidata": "Q123"} + assert graph_context["edges"][0]["kind"] == "alternative-to" + assert graph_context["edges"][0]["src_name"] == "PageIndex" + return DraftAnswer( + body="PageIndex is framed as an alternative to vector databases.[^a1]", + citations=[AnswerCitation(marker="a1", kind="claim", claim_id="clm_0001")], + ) + + result = answer_question(root, "How does PageIndex relate to vector databases?", draft_fn=stub) + + assert "claim=clm_0001" in result["answer"] + + def test_answer_falls_back_to_raw_when_only_wiki_context_matches(tmp_path): root = _vault(tmp_path) raw_text = "# Topic\n\nCompiled knowledge compounds over time.\n" diff --git a/harness/tests/test_cli.py b/harness/tests/test_cli.py index 840914c..e7597e7 100644 --- a/harness/tests/test_cli.py +++ b/harness/tests/test_cli.py @@ -29,9 +29,15 @@ def test_graph_command_drafts_entities_and_edges(tmp_path, monkeypatch, capsys): for d in ("vault/raw", "vault/wiki/concepts", "vault/facts", ".kb"): (tmp_path / d).mkdir(parents=True) (tmp_path / "SPEC.md").write_text("marker\n", encoding="utf-8") + (tmp_path / "vault" / "ontology.yaml").write_text( + "entity_kinds:\n - tool\nedge_kinds:\n - alternative-to\n", + encoding="utf-8", + ) (tmp_path / "vault" / "raw" / "topic.md").write_text("# T\n\nText.\n", encoding="utf-8") + seen = {} def fake_draft_graph(text, *, source_id, **kw): + seen["ontology"] = kw.get("ontology") return DraftGraph( entities=[ DraftEntity(name="PageIndex", kind="tool"), @@ -45,6 +51,8 @@ def fake_draft_graph(text, *, source_id, **kw): rc = cli.main(["graph", "topic", "--root", str(tmp_path)]) assert rc == 0 + assert seen["ontology"]["entity_kinds"] == ["tool"] + assert seen["ontology"]["edge_kinds"] == ["alternative-to"] assert "drafted 2 entities and 1 edge(s)" in capsys.readouterr().out ents = (tmp_path / "vault" / "facts" / "entities.ndjson").read_text(encoding="utf-8") assert "entity/pageindex" in ents and "entity/vector-db" in ents @@ -92,6 +100,46 @@ def fake_draft_extraction(text, *, source_id, failures=None, **kw): assert '"source_id": "raw/a"' in recs and '"source_id": "raw/b"' in recs +def test_extract_command_passes_local_ontology_to_model(tmp_path, monkeypatch, capsys): + from scrip_harness.extract import DraftExtraction, DraftFact + + for d in ("vault/raw", "vault/wiki/concepts", "vault/facts", ".kb"): + (tmp_path / d).mkdir(parents=True) + (tmp_path / "SPEC.md").write_text("marker\n", encoding="utf-8") + (tmp_path / "vault" / "ontology.yaml").write_text( + "claim_predicates:\n - caches\npredicate_aliases:\n cached-by: caches\n", + encoding="utf-8", + ) + (tmp_path / "vault" / "raw" / "topic.md").write_text( + "# Topic\n\nCached answers avoid recomputation.\n", encoding="utf-8" + ) + seen = {} + + def fake_draft_extraction(text, *, source_id, failures=None, **kw): + seen["ontology"] = kw.get("ontology") + return DraftExtraction( + claims=[ + DraftFact( + quote="Cached answers avoid recomputation.", + subject="answers", + predicate="cached-by", + object="work", + ) + ] + ) + + monkeypatch.setattr(model, "draft_extraction", fake_draft_extraction) + + rc = cli.main(["extract", "topic", "--root", str(tmp_path)]) + + assert rc == 0 + assert seen["ontology"]["claim_predicates"] == ["caches"] + assert seen["ontology"]["predicate_aliases"] == {"cached-by": "caches"} + assert "extracted 1 claim(s)" in capsys.readouterr().out + recs = (tmp_path / "vault" / "facts" / "claims.ndjson").read_text(encoding="utf-8") + assert '"predicate": "caches"' in recs + + def test_promote_resynthesize_command_redrafts_target(tmp_path, monkeypatch, capsys): from scrip_harness.compile import DraftClaim, DraftPage diff --git a/harness/tests/test_extract.py b/harness/tests/test_extract.py index 7b52ff2..22dae18 100644 --- a/harness/tests/test_extract.py +++ b/harness/tests/test_extract.py @@ -7,7 +7,13 @@ import subprocess import pytest -from scrip_harness.extract import DraftExtraction, DraftFact, to_ndjson +from scrip_harness.extract import ( + DraftExtraction, + DraftFact, + build_extract_prompt, + build_extract_retry_prompt, + to_ndjson, +) from scrip_harness.runner import ExtractError, extract_facts @@ -52,6 +58,35 @@ def test_to_ndjson_serializes_proposals_for_scrip(): assert "anchor" not in line and "claim_id" not in line and "extracted_at" not in line +def test_build_extract_prompt_includes_local_ontology_guidance(): + ontology = { + "active": True, + "claim_predicates": ["alternative-to", "builds-on"], + "predicate_aliases": {"competes-with": "alternative-to"}, + } + + prompt = build_extract_prompt("PageIndex is an alternative.", ontology) + + assert "LOCAL ONTOLOGY" in prompt + assert "alternative-to, builds-on" in prompt + assert "competes-with -> alternative-to" in prompt + assert "PageIndex is an alternative." in prompt + + +def test_build_extract_retry_prompt_keeps_local_ontology_guidance(): + ontology = {"active": True, "claim_predicates": ["caches"]} + + prompt = build_extract_retry_prompt( + "Cached answers avoid recomputation.", + [{"status": "BROKEN", "quote": "Cached answer", "detail": "not present"}], + ontology, + ) + + assert "LOCAL ONTOLOGY" in prompt + assert "Use claim `predicate` values only from: caches." in prompt + assert "BROKEN" in prompt + + # --------------------------------------------------------------------------- # # Happy path # --------------------------------------------------------------------------- # diff --git a/harness/tests/test_graph.py b/harness/tests/test_graph.py index 3ecdfaf..e47f2d4 100644 --- a/harness/tests/test_graph.py +++ b/harness/tests/test_graph.py @@ -12,6 +12,7 @@ DraftEdge, DraftEntity, DraftGraph, + build_graph_prompt, edge_records, edges_to_ndjson, entities_to_ndjson, @@ -91,6 +92,21 @@ def test_edge_records_attaches_quote_and_source_only_for_cited_edges(): assert records[2] == {"src": "entity/a", "dst": "entity/b", "kind": "weak"} +def test_build_graph_prompt_includes_local_ontology_guidance(): + ontology = { + "active": True, + "entity_kinds": ["tool", "concept"], + "edge_kinds": ["alternative-to", "part-of"], + } + + prompt = build_graph_prompt("PageIndex relates to Vector DB.", ontology) + + assert "LOCAL ONTOLOGY" in prompt + assert "Use entity `kind` values only from: tool, concept." in prompt + assert "Use edge `kind` values only from: alternative-to, part-of." in prompt + assert "PageIndex relates to Vector DB." in prompt + + # --------------------------------------------------------------------------- # # Happy path (real scrip) # --------------------------------------------------------------------------- # @@ -285,6 +301,33 @@ def stub(source_text, *, source_id): assert _status_rc(root) == 0 # entities + the one good edge committed, vault green +def test_graph_drops_ontology_invalid_edge_kind_before_appending(tmp_path): + root = _vault(tmp_path) + (root / "vault" / "ontology.yaml").write_text( + "entity_kinds:\n - concept\nedge_kinds:\n - relates-to\n", + encoding="utf-8", + ) + (root / "vault" / "raw" / "topic.md").write_text("# T\n\nA made B.\n", encoding="utf-8") + + def stub(source_text, *, source_id): + return DraftGraph( + entities=[DraftEntity(name="A", kind="concept"), DraftEntity(name="B", kind="concept")], + edges=[DraftEdge(src="A", dst="B", kind="made-by")], + ) + + result = draft_graph_facts(root, "topic", draft_fn=stub) + + assert result["dropped_edges"] == [ + {"src": "A", "dst": "B", "kind": "made-by", "reason": "invalid kind"} + ] + assert {e["entity_id"] for e in _rows(root, "entities.ndjson")} == { + "entity/a", + "entity/b", + } + assert _rows(root, "graph.ndjson") == [] + assert _status_rc(root) == 0 + + def test_graph_skips_entities_with_blank_kind(tmp_path): root = _vault(tmp_path) (root / "vault" / "raw" / "topic.md").write_text("# T\n\nText.\n", encoding="utf-8") diff --git a/harness/uv.lock b/harness/uv.lock index 4082c2f..333f08b 100644 --- a/harness/uv.lock +++ b/harness/uv.lock @@ -270,7 +270,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] 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" } wheels = [ @@ -994,7 +994,7 @@ wheels = [ [[package]] name = "scrip-harness" -version = "0.10.2" +version = "0.11.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -1026,7 +1026,7 @@ dev = [{ name = "pytest", specifier = ">=8" }] [[package]] name = "scriptoria" -version = "0.8.1" +version = "0.9.0" source = { directory = "../scrip" } dependencies = [ { name = "duckdb" }, diff --git a/scrip/README.md b/scrip/README.md index f3b18fc..ca007b4 100644 --- a/scrip/README.md +++ b/scrip/README.md @@ -23,6 +23,7 @@ uv run --project scrip scrip --help | `scrip status` | Report `STALE` / `OK` / `UNCOMPILED` artifacts from the dependency graph. `--no-cache` recomputes from files; `--rebuild-manifest` regenerates the cache. | | `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. | | `scrip query [claims\|entities\|edges\|contradictions\|reconciliations]` | Structured query over `vault/facts/*.ndjson` via DuckDB. `--sql ""`, `--where`, `--limit`. | +| `scrip ontology` | Validate and summarize optional `vault/ontology.yaml` vocabulary for fact predicates, entity kinds, and edge kinds. | | `scrip ingest ` | Fetch/read a source and write canonical `vault/raw/.md` plus sidecar metadata. HTML/PDF need the optional `[ingest]` extra. | | `scrip new concept\|entity --from raw/...` | Scaffold a derived wiki page for the agent to fill. | | `scrip anchor "" --source raw/` | Mint a content-anchored footnote for a verbatim source quote. | diff --git a/scrip/pyproject.toml b/scrip/pyproject.toml index 1225b04..93e68ca 100644 --- a/scrip/pyproject.toml +++ b/scrip/pyproject.toml @@ -2,7 +2,7 @@ # Distribution name on PyPI is `scriptoria` (scrip/scriptorium were taken); the # CLI command and the import package both remain `scrip`. name = "scriptoria" -version = "0.8.1" +version = "0.9.0" description = "Deterministic scriptorium-keeper (the `scrip` CLI): staleness, provenance integrity, and fact queries for an agent-compiled knowledge base" readme = "README.md" requires-python = ">=3.10" diff --git a/scrip/src/scrip/__init__.py b/scrip/src/scrip/__init__.py index d8e4fdc..ae82346 100644 --- a/scrip/src/scrip/__init__.py +++ b/scrip/src/scrip/__init__.py @@ -13,7 +13,7 @@ from pathlib import Path -__version__ = "0.8.1" +__version__ = "0.9.0" # --- canonical vault layout ------------------------------------------------ # ``root`` is the repo/instance root: the directory containing ``vault/``. diff --git a/scrip/src/scrip/cli.py b/scrip/src/scrip/cli.py index 50806d7..0c7616d 100644 --- a/scrip/src/scrip/cli.py +++ b/scrip/src/scrip/cli.py @@ -493,6 +493,25 @@ def cmd_fact_add(args: argparse.Namespace) -> int: return 1 if result["failures"] else 0 +def cmd_ontology(args: argparse.Namespace) -> int: + from . import ontology + + root = resolve_root(args.root) + ont = ontology.load(root) + result = {"path": str(ontology.ontology_path(root).relative_to(root)), **ont.summary()} + if args.json: + _emit(result) + else: + state = "active" if ont.active else "not present or empty" + print(f"ontology: {state}") + if ont.active: + print(f" entity kinds: {', '.join(result['entity_kinds']) or '(none)'}") + print(f" edge kinds: {', '.join(result['edge_kinds']) or '(none)'}") + print(f" claim predicates: {', '.join(result['claim_predicates']) or '(none)'}") + print(f" predicate aliases: {len(result['predicate_aliases'])}") + return 0 + + def cmd_ingest(args: argparse.Namespace) -> int: from . import ingest, lock @@ -764,6 +783,13 @@ def build_parser() -> argparse.ArgumentParser: ) pfa.set_defaults(func=cmd_fact_add) + po = sub.add_parser( + "ontology", + parents=[common], + help="validate and summarize optional vault/ontology.yaml vocabulary", + ) + po.set_defaults(func=cmd_ontology) + pin = sub.add_parser( "ingest", parents=[common], diff --git a/scrip/src/scrip/facts.py b/scrip/src/scrip/facts.py index d3c00a0..3ab3454 100644 --- a/scrip/src/scrip/facts.py +++ b/scrip/src/scrip/facts.py @@ -34,6 +34,8 @@ from . import anchors, facts_dir, lock from .errors import DataError, UsageError +from .ontology import Ontology +from .ontology import load as load_ontology _POLARITIES = ("asserts", "denies", "qualifies") @@ -52,7 +54,7 @@ _CLAIM_REQUIRED = ("quote", "source_id", "subject", "predicate", "object", "polarity", "confidence") _CLAIM_ALLOWED = frozenset((*_CLAIM_REQUIRED, "claim_text", "tags")) _ENTITY_REQUIRED = ("entity_id", "name", "kind") -_ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags")) +_ENTITY_ALLOWED = frozenset((*_ENTITY_REQUIRED, "tags", "uri", "same_as", "external_ids")) _EDGE_REQUIRED = ("src", "dst", "kind") # An edge may optionally be *cited*: a verbatim ``quote`` + ``source_id`` whose # ``anchor`` scrip mints+verifies exactly as for a claim. Bare edges stay @@ -127,6 +129,26 @@ def _check_tags(rec: dict, index: int) -> None: raise DataError(f"record {index}: 'tags' must be a list of strings") +def _check_string_list(rec: dict, key: str, index: int) -> None: + values = rec.get(key) + if values is None: + return + if not isinstance(values, list) or any(not isinstance(v, str) or not v.strip() for v in values): + raise DataError(f"record {index}: '{key}' must be a list of non-empty strings") + + +def _check_string_map(rec: dict, key: str, index: int) -> None: + values = rec.get(key) + if values is None: + return + if ( + not isinstance(values, dict) + or any(not isinstance(k, str) or not k.strip() for k in values) + or any(not isinstance(v, str) or not v.strip() for v in values.values()) + ): + raise DataError(f"record {index}: '{key}' must be a mapping of strings to strings") + + def _check_shape( rec: dict, index: int, @@ -148,13 +170,14 @@ def _check_shape( raise DataError(f"record {index}: missing required field(s): {', '.join(missing)}") -def _validate(table: str, rec: dict, index: int) -> None: +def _validate(table: str, rec: dict, index: int, ont: Ontology) -> None: if table == "claims": _check_shape(rec, index, _CLAIM_REQUIRED, _CLAIM_ALLOWED) # the quote's *emptiness* is a per-record finding, not a schema error _check_str(rec, "quote", index, allow_blank=True) for key in ("source_id", "subject", "predicate", "object"): _check_str(rec, key, index) + rec["predicate"] = ont.canonical_claim_predicate(rec["predicate"], index) if "claim_text" in rec: _check_str(rec, "claim_text", index, allow_blank=True) if rec["polarity"] not in _POLARITIES: @@ -169,14 +192,20 @@ def _validate(table: str, rec: dict, index: int) -> None: _check_shape(rec, index, _ENTITY_REQUIRED, _ENTITY_ALLOWED) for key in _ENTITY_REQUIRED: _check_str(rec, key, index) + ont.validate_entity_kind(rec["kind"], index) eid = rec["entity_id"] if not (eid.startswith("entity/") and _SLUG_RE.fullmatch(eid[len("entity/") :])): raise DataError(f"record {index}: entity_id must look like entity/") + if "uri" in rec: + _check_str(rec, "uri", index) + _check_string_list(rec, "same_as", index) + _check_string_map(rec, "external_ids", index) _check_tags(rec, index) elif table == "edges": _check_shape(rec, index, _EDGE_REQUIRED, _EDGE_ALLOWED) for key in _EDGE_REQUIRED: _check_str(rec, key, index) + ont.validate_edge_kind(rec["kind"], index) if "quote" in rec or "source_id" in rec: # both or neither: a quote needs a source to anchor against missing = [k for k in ("quote", "source_id") if k not in rec] @@ -410,8 +439,9 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict: """ if table not in _FILES: raise UsageError(f"unknown facts table: {table}") + ont = load_ontology(root) for i, rec in enumerate(proposals): - _validate(table, rec, i) + _validate(table, rec, i, ont) failures: list[dict] = [] resolved: list[dict | None] = [None] * len(proposals) @@ -470,12 +500,16 @@ def add(root: Path, table: str, proposals: list[dict]) -> dict: appended.append(full) elif table == "entities": def canon(rec: dict) -> dict: - return { + out = { "entity_id": rec["entity_id"], "name": rec["name"], "kind": rec["kind"], "tags": rec.get("tags") or [], } + for key in ("uri", "same_as", "external_ids"): + if key in rec: + out[key] = rec[key] + return out byid = {rec.get("entity_id"): canon(rec) for rec in existing if "entity_id" in rec and isinstance(rec.get("name"), str) and isinstance(rec.get("kind"), str)} diff --git a/scrip/src/scrip/ontology.py b/scrip/src/scrip/ontology.py new file mode 100644 index 0000000..02aa7ef --- /dev/null +++ b/scrip/src/scrip/ontology.py @@ -0,0 +1,162 @@ +"""Optional semantic vocabulary for the facts layer. + +The ontology is deliberately small and file-native. If ``vault/ontology.yaml`` is +absent, facts behave exactly as before. If present, it constrains selected free +text fields and can canonicalize claim predicates via aliases. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from .errors import DataError + +try: + from yaml import CSafeLoader as SafeLoader +except ImportError: + from yaml import SafeLoader + +_ONTOLOGY_KEYS = frozenset( + ("entity_kinds", "edge_kinds", "claim_predicates", "predicate_aliases") +) + + +@dataclass(frozen=True) +class Ontology: + entity_kinds: frozenset[str] = frozenset() + edge_kinds: frozenset[str] = frozenset() + claim_predicates: frozenset[str] = frozenset() + predicate_aliases: dict[str, str] | None = None + + @property + def active(self) -> bool: + return bool( + self.entity_kinds + or self.edge_kinds + or self.claim_predicates + or self.predicate_aliases + ) + + def canonical_claim_predicate(self, predicate: str, index: int) -> str: + aliases = self.predicate_aliases or {} + canonical = aliases.get(predicate, predicate) + if self.claim_predicates and canonical not in self.claim_predicates: + raise DataError( + f"record {index}: predicate {predicate!r} is not in " + f"vault/ontology.yaml claim_predicates" + ) + return canonical + + def validate_entity_kind(self, kind: str, index: int) -> None: + if self.entity_kinds and kind not in self.entity_kinds: + raise DataError( + f"record {index}: entity kind {kind!r} is not in " + f"vault/ontology.yaml entity_kinds" + ) + + def validate_edge_kind(self, kind: str, index: int) -> None: + if self.edge_kinds and kind not in self.edge_kinds: + raise DataError( + f"record {index}: edge kind {kind!r} is not in " + f"vault/ontology.yaml edge_kinds" + ) + + def summary(self) -> dict[str, Any]: + return { + "active": self.active, + "entity_kinds": sorted(self.entity_kinds), + "edge_kinds": sorted(self.edge_kinds), + "claim_predicates": sorted(self.claim_predicates), + "predicate_aliases": dict(sorted((self.predicate_aliases or {}).items())), + } + + +def ontology_path(root: Path) -> Path: + return root / "vault" / "ontology.yaml" + + +def load(root: Path) -> Ontology: + path = ontology_path(root) + if not path.exists(): + return Ontology() + try: + data = yaml.load(path.read_text(encoding="utf-8"), Loader=SafeLoader) + except yaml.YAMLError as e: + raise DataError(f"invalid vault/ontology.yaml: {e}") from e + if data is None: + return Ontology() + if not isinstance(data, dict): + raise DataError("invalid vault/ontology.yaml: expected a mapping") + _check_known_keys(data) + + entity_kinds = _string_set(data, "entity_kinds") + edge_kinds = _string_set(data, "edge_kinds") + claim_predicates = _string_set(data, "claim_predicates") + predicate_aliases = _string_map(data, "predicate_aliases") + if claim_predicates: + for alias, target in predicate_aliases.items(): + if target not in claim_predicates: + raise DataError( + "invalid vault/ontology.yaml: predicate_aliases target " + f"{target!r} for {alias!r} is not in claim_predicates" + ) + + return Ontology( + entity_kinds=frozenset(entity_kinds), + edge_kinds=frozenset(edge_kinds), + claim_predicates=frozenset(claim_predicates), + predicate_aliases=predicate_aliases, + ) + + +def _check_known_keys(data: dict) -> None: + unknown = [key for key in data if not isinstance(key, str) or key not in _ONTOLOGY_KEYS] + if unknown: + rendered = ", ".join(repr(key) for key in unknown) + expected = ", ".join(sorted(_ONTOLOGY_KEYS)) + raise DataError( + f"invalid vault/ontology.yaml: unknown key(s): {rendered}; expected one of: " + f"{expected}" + ) + + +def _string_set(data: dict, key: str) -> set[str]: + value = data.get(key, []) + if value is None: + return set() + if not isinstance(value, list): + raise DataError(f"invalid vault/ontology.yaml: {key} must be a list of strings") + out: set[str] = set() + for i, item in enumerate(value): + if not isinstance(item, str) or not item.strip(): + raise DataError( + f"invalid vault/ontology.yaml: {key}[{i}] must be a non-empty string" + ) + out.add(item.strip()) + return out + + +def _string_map(data: dict, key: str) -> dict[str, str]: + value = data.get(key, {}) + if value is None: + return {} + if not isinstance(value, dict): + raise DataError( + f"invalid vault/ontology.yaml: {key} must be a mapping of strings to strings" + ) + out: dict[str, str] = {} + for raw_k, raw_v in value.items(): + if not isinstance(raw_k, str) or not raw_k.strip(): + raise DataError( + f"invalid vault/ontology.yaml: {key} keys must be non-empty strings" + ) + if not isinstance(raw_v, str) or not raw_v.strip(): + raise DataError( + f"invalid vault/ontology.yaml: {key}.{raw_k} must be a non-empty string" + ) + out[raw_k.strip()] = raw_v.strip() + return out diff --git a/scrip/tests/test_fact_cmd.py b/scrip/tests/test_fact_cmd.py index 7094044..2d06fe3 100644 --- a/scrip/tests/test_fact_cmd.py +++ b/scrip/tests/test_fact_cmd.py @@ -68,6 +68,17 @@ def _graph_lines(kb): return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()] +def _entities_lines(kb): + p = kb.root / "vault" / "facts" / "entities.ndjson" + if not p.exists(): + return [] + return [json.loads(s) for s in p.read_text(encoding="utf-8").splitlines() if s.strip()] + + +def _write_ontology(kb, text): + (kb.root / "vault" / "ontology.yaml").write_text(text, encoding="utf-8") + + def _two_claims(kb): """Seed a contradiction pair to reconcile.""" kb.add_raw("s", SRC) @@ -244,6 +255,38 @@ def test_fact_add_json_reports_appended_records(kb, capsys): assert rec["anchor"].startswith("qh:") +def test_fact_add_ontology_canonicalizes_claim_predicate_aliases(kb): + kb.add_raw("s", SRC) + _write_ontology( + kb, + """ +claim_predicates: + - caches +predicate_aliases: + p: caches +""", + ) + rc = _run_add( + kb, + _ndjson(_claim("Caching answers beats recomputing them.", predicate="p")), + ) + assert rc == 0 + assert _claims_lines(kb)[0]["predicate"] == "caches" + + +def test_fact_add_ontology_rejects_unknown_claim_predicate(kb): + kb.add_raw("s", SRC) + _write_ontology(kb, "claim_predicates:\n - caches\n") + + rc = _run_add( + kb, + _ndjson(_claim("Caching answers beats recomputing them.", predicate="p")), + ) + + assert rc == 3 + assert _claims_lines(kb) == [] + + # --------------------------------------------------------------------------- # # All-or-nothing batch + per-record failures (exit 1) # --------------------------------------------------------------------------- # @@ -483,6 +526,93 @@ def test_fact_add_entities_appends_and_conflicts(kb, capsys): assert len(lines.splitlines()) == 1 # nothing else was appended +def test_fact_add_entities_accept_optional_uri_metadata(kb): + ent = { + "entity_id": "entity/duckdb", + "name": "DuckDB", + "kind": "tool", + "uri": "https://duckdb.org/", + "same_as": ["https://www.wikidata.org/wiki/Q118533698"], + "external_ids": {"wikidata": "Q118533698"}, + } + + assert _run_add(kb, _ndjson(ent), "--table", "entities") == 0 + + [stored] = _entities_lines(kb) + assert stored["uri"] == "https://duckdb.org/" + assert stored["same_as"] == ["https://www.wikidata.org/wiki/Q118533698"] + assert stored["external_ids"] == {"wikidata": "Q118533698"} + + +def test_fact_add_ontology_rejects_unknown_entity_kind_and_edge_kind(kb): + _write_ontology( + kb, + """ +entity_kinds: + - tool +edge_kinds: + - depends-on +""", + ) + + assert ( + _run_add( + kb, + _ndjson({"entity_id": "entity/a", "name": "A", "kind": "concept"}), + "--table", + "entities", + ) + == 3 + ) + assert _run_add(kb, _ndjson(_edge(kind="relates-to")), "--table", "edges") == 3 + assert _run_add(kb, _ndjson(_edge(kind="depends-on")), "--table", "edges") == 0 + + +def test_ontology_command_reports_optional_vocabulary(kb, capsys): + _write_ontology( + kb, + """ +entity_kinds: + - tool +edge_kinds: + - depends-on +claim_predicates: + - caches +predicate_aliases: + p: caches +""", + ) + + assert cli.main(["ontology", "--json", "--root", str(kb.root)]) == 0 + + data = json.loads(capsys.readouterr().out) + assert data["active"] is True + assert data["entity_kinds"] == ["tool"] + assert data["edge_kinds"] == ["depends-on"] + assert data["claim_predicates"] == ["caches"] + assert data["predicate_aliases"] == {"p": "caches"} + + +def test_ontology_rejects_alias_to_unknown_predicate(kb): + _write_ontology( + kb, + """ +claim_predicates: + - caches +predicate_aliases: + p: stores +""", + ) + + assert cli.main(["ontology", "--root", str(kb.root)]) == 3 + + +def test_ontology_rejects_unknown_top_level_key(kb): + _write_ontology(kb, "claim_predicate:\n - caches\n") + + assert cli.main(["ontology", "--root", str(kb.root)]) == 3 + + def test_fact_add_edges_appends_and_skips_duplicates(kb): edge = {"src": "entity/duckdb", "dst": "entity/motherduck", "kind": "made-by"} assert _run_add(kb, _ndjson(edge), "--table", "edges") == 0 diff --git a/scrip/uv.lock b/scrip/uv.lock index 52fb40f..a7404fd 100644 --- a/scrip/uv.lock +++ b/scrip/uv.lock @@ -249,7 +249,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] 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" } wheels = [ @@ -1141,7 +1141,7 @@ wheels = [ [[package]] name = "scriptoria" -version = "0.8.1" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "duckdb" },