Skip to content

Commit 1afb2c5

Browse files
feat: Google OKF-conformant wiki frontmatter (#102)
* feat(compiler): capitalize entity type, rename brief→description Write entity frontmatter with type capitalized (e.g. "Organization"), use the key `description` instead of `brief` for the one-liner field. Readers normalize type to lowercase and prefer `description`, falling back to legacy `brief` for pre-migration pages. LLM prompts and JSON extraction updated to use the `description` key. * feat(compiler): add type: Concept and rename brief→description on concept pages * feat(compiler): add type+description to short-doc summary frontmatter * feat(tree_renderer): add type+description to PageIndex summary frontmatter * feat(compiler): backfill type+description on long-doc summaries during recompile * feat(lint): flag knowledge pages missing OKF type/description * docs(schema): document type/description frontmatter in AGENTS_MD * fix(compiler): strip legacy brief: on entity update (parity with concept pages) * fix(review): harden frontmatter writes (title-case type, quote full_text, backfill order + dirty-check, lint isinstance) * refactor(compiler): use shared frontmatter module; fix --- boundary + concept malformed fallback - Import openkb.frontmatter; replace local _yaml_kv_line / _yaml_list_line / _parse_yaml_list_value / _set_fm_line bodies with thin aliases to the shared module exports (call sites unchanged). - Replace all ad-hoc text.find("---", 3) splits with frontmatter.split(): _write_summary, _write_concept, _write_entity, _read_concept_briefs, _read_entity_briefs, compile_long_doc backfill, _gen_update, _gen_entity_update, summary-rewrite strip, _prepend_source_to_frontmatter, _remove_source_from_frontmatter, scan_affected_pages. - Add module-level _resolve_description(fm) helper; use it in both reader functions to consolidate description/brief fallback logic. - _read_concept_briefs and _read_entity_briefs now use frontmatter.parse() instead of manual yaml.safe_load. - Replace re.sub(r"^brief:.*\n?", ...) with frontmatter.drop_line() in both _write_concept and _write_entity update paths. - Add malformed-frontmatter fallback to _write_concept update path: when frontmatter.split returns None, rebuild valid block (type: Concept, sources, description) rather than writing a bare body. - Remove now-unused direct yaml import. - Add 3 regression tests: concept round-trip with "---" in brief, entity round-trip with "---" in brief, concept update on malformed frontmatter. * refactor(tree_renderer, lint): use shared frontmatter module * perf(lint): read wiki pages once and share across frontmatter checks Adds _load_wiki_pages() to walk wiki/*.md (excluding reports/, sources/, and _EXCLUDED_FILES) and return a {path: text} dict. Both find_invalid_frontmatter and find_missing_okf_fields accept an optional pages= kwarg; run_structural_lint builds it once and passes it to both, reducing 2N file reads to N. Standalone callers that pass only wiki continue to work unchanged. Also switches find_invalid_frontmatter to use frontmatter.split() for line-anchored delimiter detection.
1 parent 26f8b13 commit 1afb2c5

12 files changed

Lines changed: 897 additions & 254 deletions

openkb/agent/compiler.py

Lines changed: 167 additions & 188 deletions
Large diffs are not rendered by default.

openkb/frontmatter.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Shared helpers for the YAML frontmatter blocks of OpenKB wiki pages.
2+
3+
Single source of truth for building, splitting, parsing, and mutating the
4+
``---`` frontmatter used by summaries / concepts / entities. The closing
5+
delimiter is always matched at the start of a line (``\\n---``), so a ``---``
6+
that appears inside a quoted value never truncates the block — the failure
7+
mode that ad-hoc ``text.find("---", 3)`` parsing was prone to.
8+
"""
9+
from __future__ import annotations
10+
11+
import json
12+
import re
13+
14+
import yaml
15+
16+
17+
def kv_line(key: str, value: str) -> str:
18+
"""Render ``key: "value"`` with the value JSON-quoted (always single-line).
19+
20+
JSON strings are a strict subset of YAML: always single-line, always
21+
correctly escaped (newlines, quotes, colons, control chars), and never
22+
auto-promoted to a block scalar.
23+
"""
24+
return f"{key}: {json.dumps(value, ensure_ascii=False)}"
25+
26+
27+
def list_line(key: str, items) -> str:
28+
"""Render ``key: ["a", "b"]`` as JSON-style YAML (always single-line)."""
29+
return f"{key}: {json.dumps(list(items), ensure_ascii=False)}"
30+
31+
32+
def block(lines: list[str]) -> str:
33+
"""Assemble a complete frontmatter block (with delimiters + trailing blank)."""
34+
return "---\n" + "\n".join(lines) + "\n---\n\n"
35+
36+
37+
def parse_list_value(line: str) -> list[str] | None:
38+
"""Parse the right-hand side of ``key: [...]`` into a list of strings.
39+
40+
Returns ``None`` when the value cannot be interpreted as a list — callers
41+
treat that as "leave the frontmatter alone".
42+
"""
43+
colon = line.find(":")
44+
if colon == -1:
45+
return None
46+
try:
47+
parsed = yaml.safe_load(line[colon + 1:])
48+
except yaml.YAMLError:
49+
return None
50+
if not isinstance(parsed, list):
51+
return None
52+
return [str(x) for x in parsed]
53+
54+
55+
def split(text: str) -> tuple[str, str] | None:
56+
"""Split ``text`` into ``(frontmatter_block, body)``.
57+
58+
``frontmatter_block`` includes both ``---`` delimiters and the newline that
59+
ends the closing delimiter line; ``body`` is everything after, so
60+
``frontmatter_block + body == text`` exactly (lossless).
61+
62+
Returns ``None`` when ``text`` has no well-formed frontmatter: no leading
63+
``---`` or no line-anchored closing ``---``. Because the closing delimiter
64+
must start a line (``\\n---``), a ``---`` inside a quoted value is ignored.
65+
"""
66+
if not text.startswith("---"):
67+
return None
68+
nl = text.find("\n---", 3)
69+
if nl == -1:
70+
return None
71+
after = text.find("\n", nl + 1) # newline ending the closing '---' line
72+
if after == -1:
73+
return text, ""
74+
return text[:after + 1], text[after + 1:]
75+
76+
77+
def parse(text: str) -> dict:
78+
"""Return the frontmatter as a dict (``{}`` when absent or malformed)."""
79+
parts = split(text)
80+
if parts is None:
81+
return {}
82+
fm_block = parts[0]
83+
inner = fm_block[3:] # drop opening '---'
84+
close = inner.rfind("\n---") # drop closing '---' line
85+
if close != -1:
86+
inner = inner[:close]
87+
try:
88+
data = yaml.safe_load(inner)
89+
except yaml.YAMLError:
90+
return {}
91+
return data if isinstance(data, dict) else {}
92+
93+
94+
def set_line(fm_block: str, key: str, value: str) -> str:
95+
"""Set or insert a single scalar ``key:`` line in a frontmatter block.
96+
97+
Replaces an existing line for ``key``; otherwise inserts it right after the
98+
opening ``---``. A lambda replacement is used so values containing regex
99+
backrefs (``\\1``, ``\\g<…>``) are inserted literally.
100+
"""
101+
line = kv_line(key, value)
102+
if re.search(rf"^{re.escape(key)}:", fm_block, flags=re.MULTILINE):
103+
return re.sub(rf"^{re.escape(key)}:.*", lambda _m: line, fm_block,
104+
count=1, flags=re.MULTILINE)
105+
return fm_block.replace("---\n", f"---\n{line}\n", 1)
106+
107+
108+
def drop_line(fm_block: str, key: str) -> str:
109+
"""Remove any ``key:`` line from a frontmatter block (no-op if absent)."""
110+
return re.sub(rf"^{re.escape(key)}:.*\n?", "", fm_block, flags=re.MULTILINE)

openkb/indexer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def index_long_document(
174174
# Write wiki/summaries/ (no images, just summaries)
175175
summaries_dir = kb_dir / "wiki" / "summaries"
176176
summaries_dir.mkdir(parents=True, exist_ok=True)
177-
summary_md = render_summary_md(tree, source_name, doc_id)
177+
summary_md = render_summary_md(tree, source_name, doc_id, description=description)
178178
(summaries_dir / f"{source_name}.md").write_text(summary_md, encoding="utf-8")
179179

180180
return IndexResult(doc_id=doc_id, description=description, tree=tree)

openkb/lint.py

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import yaml
1717

18+
from openkb import frontmatter
1819
from openkb.schema import PAGE_CONTENT_DIRS
1920

2021
# Matches [[wikilink]] or [[subdir/link]]
@@ -449,42 +450,111 @@ def check_index_sync(wiki: Path) -> list[str]:
449450
return sorted(issues)
450451

451452

452-
def find_invalid_frontmatter(wiki: Path) -> list[str]:
453+
def _load_wiki_pages(wiki: Path) -> dict[Path, str]:
454+
"""Read all wiki .md files once and return a ``{path: text}`` mapping.
455+
456+
Applies the same scope as :func:`find_invalid_frontmatter`: walks the
457+
whole wiki, skips names in :data:`_EXCLUDED_FILES`, and skips any path
458+
whose first relative-to-wiki directory part is ``"reports"`` or
459+
``"sources"``. The result is the superset that both frontmatter checks
460+
iterate; :func:`find_missing_okf_fields` additionally filters down to
461+
:data:`PAGE_CONTENT_DIRS <openkb.schema.PAGE_CONTENT_DIRS>` paths.
462+
"""
463+
pages: dict[Path, str] = {}
464+
for path in wiki.rglob("*.md"):
465+
if path.name in _EXCLUDED_FILES:
466+
continue
467+
rel_parts = path.relative_to(wiki).parts
468+
if rel_parts and rel_parts[0] in ("reports", "sources"):
469+
continue
470+
pages[path] = _read_md(path)
471+
return pages
472+
473+
474+
def find_invalid_frontmatter(
475+
wiki: Path,
476+
pages: dict[Path, str] | None = None,
477+
) -> list[str]:
453478
"""Return wiki pages whose YAML frontmatter fails ``yaml.safe_load``.
454479
455480
Catches the silent-write class of bug where an LLM-authored field
456481
(e.g. ``brief:``) ships unquoted and turns a colon-bearing value
457482
into invalid YAML that OpenKB itself reads with string slicing but
458483
external YAML-aware tools (VS Code, Obsidian, doc generators) reject.
484+
485+
Args:
486+
wiki: Path to the wiki root directory.
487+
pages: Optional pre-loaded ``{path: text}`` mapping from
488+
:func:`_load_wiki_pages`. When ``None`` (the default), the
489+
mapping is built internally so existing callers that pass only
490+
``wiki`` keep working unchanged.
459491
"""
460492
issues: list[str] = []
461493
if not wiki.exists():
462494
return issues
463-
for path in sorted(wiki.rglob("*.md")):
464-
if path.name in _EXCLUDED_FILES:
465-
continue
466-
# Skip reports/ and sources/ — auto-generated / user-uploaded
467-
# content, not wiki pages we manage. Matches the convention in
468-
# find_broken_links / find_orphans.
469-
rel_parts = path.relative_to(wiki).parts
470-
if rel_parts and rel_parts[0] in ("reports", "sources"):
471-
continue
472-
text = _read_md(path)
473-
if not text.startswith("---"):
474-
continue
475-
end = text.find("\n---", 3)
476-
if end == -1:
495+
if pages is None:
496+
pages = _load_wiki_pages(wiki)
497+
for path, text in sorted(pages.items()):
498+
parts = frontmatter.split(text)
499+
if parts is None:
477500
continue
478-
fm = text[3:end].strip("\n")
501+
fm_block = parts[0]
502+
# Extract the raw YAML between the two ``---`` delimiters so that
503+
# yaml.safe_load can surface the exact error message. The closing
504+
# delimiter is line-anchored (frontmatter.split guarantees this),
505+
# so we strip the opening ``---\n`` and everything from the final
506+
# ``\n---`` onward.
507+
inner = fm_block[4:] # drop "---\n"
508+
close = inner.rfind("\n---")
509+
if close != -1:
510+
inner = inner[:close]
479511
try:
480-
yaml.safe_load(fm)
512+
yaml.safe_load(inner)
481513
except yaml.YAMLError as exc:
482514
rel = path.relative_to(wiki)
483515
msg = str(exc).splitlines()[0]
484516
issues.append(f"{rel}: {msg}")
485517
return issues
486518

487519

520+
def find_missing_okf_fields(
521+
wiki: Path,
522+
pages: dict[Path, str] | None = None,
523+
) -> list[str]:
524+
"""Return knowledge pages missing a non-empty ``type`` or ``description``.
525+
526+
OKF v0.1 requires every non-reserved knowledge page to carry a non-empty
527+
``type``; ``description`` is OpenKB's required one-liner. Only summaries/,
528+
concepts/, entities/ are checked — index.md, log.md and sources/ are exempt.
529+
530+
Args:
531+
wiki: Path to the wiki root directory.
532+
pages: Optional pre-loaded ``{path: text}`` mapping from
533+
:func:`_load_wiki_pages`. When ``None`` (the default), the
534+
mapping is built internally so existing callers that pass only
535+
``wiki`` keep working unchanged.
536+
"""
537+
issues: list[str] = []
538+
if not wiki.exists():
539+
return issues
540+
if pages is None:
541+
pages = _load_wiki_pages(wiki)
542+
for path, text in sorted(pages.items()):
543+
# find_missing_okf_fields covers only PAGE_CONTENT_DIRS subsets.
544+
rel_parts = path.relative_to(wiki).parts
545+
if not rel_parts or rel_parts[0] not in PAGE_CONTENT_DIRS:
546+
continue
547+
fm = frontmatter.parse(text)
548+
rel = path.relative_to(wiki)
549+
type_val = fm.get("type")
550+
if not (isinstance(type_val, str) and type_val.strip()):
551+
issues.append(f"{rel}: missing non-empty 'type'")
552+
desc_val = fm.get("description")
553+
if not (isinstance(desc_val, str) and desc_val.strip()):
554+
issues.append(f"{rel}: missing non-empty 'description'")
555+
return issues
556+
557+
488558
def run_structural_lint(kb_dir: Path) -> str:
489559
"""Run all structural lint checks and return a formatted Markdown report.
490560
@@ -497,11 +567,16 @@ def run_structural_lint(kb_dir: Path) -> str:
497567
wiki = kb_dir / "wiki"
498568
raw = kb_dir / "raw"
499569

570+
# Load all wiki pages once and share across both frontmatter checks
571+
# (avoids 2N disk reads when the wiki is large).
572+
wiki_pages = _load_wiki_pages(wiki) if wiki.exists() else {}
573+
500574
broken = find_broken_links(wiki)
501575
orphans = find_orphans(wiki)
502576
missing = find_missing_entries(raw, wiki, kb_dir=kb_dir)
503577
sync_issues = check_index_sync(wiki)
504-
bad_frontmatter = find_invalid_frontmatter(wiki)
578+
bad_frontmatter = find_invalid_frontmatter(wiki, pages=wiki_pages)
579+
missing_okf = find_missing_okf_fields(wiki, pages=wiki_pages)
505580

506581
lines = ["## Structural Lint Report\n"]
507582

@@ -548,5 +623,14 @@ def run_structural_lint(kb_dir: Path) -> str:
548623
lines.append(f"- {issue}")
549624
else:
550625
lines.append("All frontmatter parses as valid YAML.")
626+
lines.append("")
627+
628+
# OKF conformance
629+
lines.append(f"### OKF Conformance ({len(missing_okf)})")
630+
if missing_okf:
631+
for issue in missing_okf:
632+
lines.append(f"- {issue}")
633+
else:
634+
lines.append("All knowledge pages carry required OKF fields.")
551635

552636
return "\n".join(lines)

openkb/schema.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@
4848
- Use [[wikilink]] to link other wiki pages (e.g., [[concepts/attention]])
4949
- Standard Markdown heading hierarchy
5050
- Keep each page focused on a single topic
51-
- Do not include YAML frontmatter (---) in generated content; it is managed by code
51+
52+
## Frontmatter (managed by code — do NOT emit it in generated content)
53+
- Every summary/concept/entity page carries a non-empty `type:` — `Summary`,
54+
`Concept`, or a capitalized entity subtype (e.g. `Organization`). This is the
55+
one field OKF requires; consumers use it for routing/filtering/presentation.
56+
- `description:` — a single-sentence one-liner (the field formerly named `brief`).
57+
- Do not include YAML frontmatter (---) in generated content; it is managed by code.
5258
"""
5359

5460
# Backward compat alias

openkb/tree_renderer.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""Markdown renderers for PageIndex tree structures."""
22
from __future__ import annotations
33

4+
from openkb import frontmatter
45

5-
def _yaml_frontmatter(source_name: str, doc_id: str) -> str:
6-
"""Return a YAML frontmatter block for a PageIndex wiki page."""
7-
return (
8-
"---\n"
9-
"doc_type: pageindex\n"
10-
f"full_text: sources/{source_name}.json\n"
11-
"---\n"
12-
)
136

7+
def _yaml_frontmatter(source_name: str, doc_id: str, description: str = "") -> str:
8+
"""Return a YAML frontmatter block for a PageIndex wiki page."""
9+
lines = [frontmatter.kv_line("type", "Summary")]
10+
if description:
11+
lines.append(frontmatter.kv_line("description", description))
12+
lines.append("doc_type: pageindex")
13+
lines.append(frontmatter.kv_line("full_text", f"sources/{source_name}.json"))
14+
return "---\n" + "\n".join(lines) + "\n---\n"
1415

1516

1617
def _render_nodes_summary(nodes: list[dict], depth: int) -> str:
@@ -24,7 +25,7 @@ def _render_nodes_summary(nodes: list[dict], depth: int) -> str:
2425
summary = node.get("summary", "")
2526
children = node.get("nodes", [])
2627

27-
lines.append(f"{heading_prefix} {title} (pages {start}\u2013{end})\n")
28+
lines.append(f"{heading_prefix} {title} (pages {start}{end})\n")
2829
if summary:
2930
lines.append(f"Summary: {summary}\n")
3031
if children:
@@ -33,13 +34,15 @@ def _render_nodes_summary(nodes: list[dict], depth: int) -> str:
3334
return "\n".join(lines)
3435

3536

36-
37-
def render_summary_md(tree: dict, source_name: str, doc_id: str) -> str:
37+
def render_summary_md(tree: dict, source_name: str, doc_id: str,
38+
description: str = "") -> str:
3839
"""Render the summary Markdown page for a PageIndex tree.
3940
4041
Renders each node as a heading with page range and its summary text.
42+
Includes a YAML frontmatter block with ``type: "Summary"`` and an
43+
optional ``description`` field.
4144
"""
42-
frontmatter = _yaml_frontmatter(source_name, doc_id)
45+
frontmatter = _yaml_frontmatter(source_name, doc_id, description)
4346
structure = tree.get("structure", [])
4447
body = _render_nodes_summary(structure, depth=1)
4548
return frontmatter + "\n" + body

0 commit comments

Comments
 (0)