feat: mirror tag-vocabulary summary and note-type validation
Ports two changes from the knowledge-base vault this skeleton is extracted from, keeping the shared scripts byte-identical across the pair: - `graph_summary` reports `by_tag` and `tag_count`, sized by `topTags`, and drops the `by_type`/`by_status` breakdowns. An MCP-only agent could not otherwise discover the tag spellings that `graph_search_nodes` matches verbatim. - `generate-graph.py --validate` now enforces a controlled vocabulary for a note's `type`, which is where the drift signal `by_type` used to give now lives. `AGENT_QUERY_GUIDE.md`, both skills and the-knowing-one quote the enforced enum. `tests/test_generate_graph.py` is new; `make test` is 50 tests. Regenerating the graph also drops a stale `GLOBAL_AGENTS` node for a file this skeleton does not carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
638cbe6b39
commit
1cb6369282
@@ -1,4 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build `index/graph.json` from the vault's Markdown, and validate it.
|
||||
|
||||
The graph is the derived navigation layer agents query instead of walking the
|
||||
tree: one node per note (frontmatter plus link counts) and one edge per typed
|
||||
markdown link. `--validate` is the gate that keeps it trustworthy — it fails on
|
||||
links that resolve to nothing, on edge relationship types or note `type` values
|
||||
outside their controlled vocabularies, and on legacy `[[wiki links]]`.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
@@ -36,6 +45,24 @@ RELATION_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
# Strict controlled vocabulary for a note's frontmatter `type`. A note without
|
||||
# one is a "note"; --validate fails on any value outside this set, which is what
|
||||
# keeps the facet meaningful as the vault grows.
|
||||
NOTE_TYPES = {
|
||||
"hub",
|
||||
"project",
|
||||
"area",
|
||||
"guideline",
|
||||
"policy",
|
||||
"feature",
|
||||
"roadmap",
|
||||
"decision",
|
||||
"note",
|
||||
"meta",
|
||||
"reference",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkRef:
|
||||
target: str # raw target text (resolution key: id / rel-path / title / stem, or a URL)
|
||||
@@ -402,7 +429,8 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument(
|
||||
"--validate",
|
||||
action="store_true",
|
||||
help="validate links: unresolved targets, edge relationship types, "
|
||||
help="validate the graph: unresolved targets, edge relationship types, "
|
||||
"note types, "
|
||||
"and legacy [[wiki links]] (all rejected)",
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -453,6 +481,25 @@ def print_invalid_types(invalid: List[Dict[str, object]]) -> None:
|
||||
print(f"- {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}")
|
||||
|
||||
|
||||
def find_invalid_node_types(graph: Dict[str, object]) -> List[Dict[str, object]]:
|
||||
return [
|
||||
n
|
||||
for n in graph.get("nodes", []) # type: ignore[union-attr]
|
||||
if n.get("type") not in NOTE_TYPES
|
||||
]
|
||||
|
||||
|
||||
def print_invalid_node_types(invalid: List[Dict[str, object]]) -> None:
|
||||
if not invalid:
|
||||
print("Validation passed: all node types are in the controlled vocabulary")
|
||||
return
|
||||
|
||||
print(f"Validation failed: {len(invalid)} note(s) with unknown type")
|
||||
print(f" allowed: {', '.join(sorted(NOTE_TYPES))}")
|
||||
for n in invalid:
|
||||
print(f"- {n.get('id')} ({n.get('path')}): type={n.get('type')!r}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
@@ -490,8 +537,10 @@ def main() -> None:
|
||||
print_unresolved(unresolved)
|
||||
invalid = find_invalid_types(graph)
|
||||
print_invalid_types(invalid)
|
||||
invalid_nodes = find_invalid_node_types(graph)
|
||||
print_invalid_node_types(invalid_nodes)
|
||||
print_wiki_links(wiki_uses)
|
||||
if unresolved or invalid or wiki_uses:
|
||||
if unresolved or invalid or invalid_nodes or wiki_uses:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
|
||||
+41
-19
@@ -25,6 +25,7 @@ import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
@@ -196,6 +197,11 @@ _WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
|
||||
_DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024
|
||||
_DOWNLOAD_TIMEOUT_SECONDS = 60
|
||||
|
||||
# graph_summary defaults: enough most-linked notes to spot the hubs, and enough
|
||||
# tags to cover the vocabulary an agent actually filters on.
|
||||
_DEFAULT_TOP_LINKED = 10
|
||||
_DEFAULT_TOP_TAGS = 50
|
||||
|
||||
|
||||
def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path:
|
||||
"""Turn a vault-relative path into an absolute path inside the repo root.
|
||||
@@ -245,12 +251,31 @@ def _tool_definitions() -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "graph_summary",
|
||||
"description": "Return aggregate counts from index/graph.json.",
|
||||
"description": (
|
||||
"Orientation for a cold start: node and edge counts, the repo "
|
||||
"scopes, the most-linked notes, and the tag vocabulary as "
|
||||
"`by_tag` (most frequent first) plus `tag_count`, the number of "
|
||||
"distinct tags. Read `by_tag` before filtering "
|
||||
"`graph_search_nodes` by `tag` \u2014 that filter matches a tag "
|
||||
"verbatim, so a guessed spelling returns nothing."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topLinked": {"type": "integer", "minimum": 1, "maximum": 50}
|
||||
"topLinked": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"description": "Most-linked notes to return (default 10).",
|
||||
},
|
||||
"topTags": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500,
|
||||
"description": "Tags to return, most frequent first (default 50).",
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -453,21 +478,16 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
|
||||
graph = _load_graph(state)
|
||||
nodes = graph.get("nodes", [])
|
||||
edges = graph.get("edges", [])
|
||||
top_n = int(args.get("topLinked", 10))
|
||||
top_n = int(args.get("topLinked", _DEFAULT_TOP_LINKED))
|
||||
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
|
||||
|
||||
by_repo: Dict[str, int] = {}
|
||||
by_type: Dict[str, int] = {}
|
||||
by_status: Dict[str, int] = {}
|
||||
for node in nodes:
|
||||
by_repo[str(node.get("repo", "unknown"))] = (
|
||||
by_repo.get(str(node.get("repo", "unknown")), 0) + 1
|
||||
)
|
||||
by_type[str(node.get("type", "unknown"))] = (
|
||||
by_type.get(str(node.get("type", "unknown")), 0) + 1
|
||||
)
|
||||
by_status[str(node.get("status", "unknown"))] = (
|
||||
by_status.get(str(node.get("status", "unknown")), 0) + 1
|
||||
)
|
||||
by_repo: Counter[str] = Counter(str(node.get("repo", "unknown")) for node in nodes)
|
||||
tag_counts: Counter[str] = Counter(
|
||||
str(tag) for node in nodes for tag in (node.get("tags") or [])
|
||||
)
|
||||
# Frequent tags first, alphabetical within a tie, so a truncated list is
|
||||
# both the most useful slice and stable across calls.
|
||||
ranked_tags = sorted(tag_counts.items(), key=lambda item: (-item[1], item[0]))
|
||||
|
||||
sorted_nodes = sorted(
|
||||
nodes,
|
||||
@@ -479,9 +499,11 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
|
||||
"generated_at": graph.get("generated_at"),
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
"by_repo": by_repo,
|
||||
"by_type": by_type,
|
||||
"by_status": by_status,
|
||||
"by_repo": dict(by_repo),
|
||||
"tag_count": len(tag_counts),
|
||||
"by_tag": [
|
||||
{"tag": tag, "count": count} for tag, count in ranked_tags[:top_tags]
|
||||
],
|
||||
"top_linked": [
|
||||
{
|
||||
"id": node.get("id"),
|
||||
|
||||
Reference in New Issue
Block a user