Skip to content

Commit ec4c527

Browse files
committed
fix(compiler): YAML-safe brief in concept frontmatter (closes #67)
LLM-authored brief values often contain colons, quotes, or hashes; the existing f-string interpolation in _write_concept produced invalid frontmatter (e.g. "brief: Why X: Y" → mapping value not allowed). OpenKB's own reader uses string slicing so it never noticed, but external YAML-aware tools (VS Code preview, Obsidian, doc generators) reject the page. - New _yaml_kv_line helper routes the value through yaml.safe_dump so PyYAML auto-quotes when needed. - Both write sites in _write_concept (re.sub update path + create path) use the helper. re.sub now takes a lambda replacement to bypass backref interpretation when brief contains literal \1 / \g<…>. - New find_invalid_frontmatter check in lint.py runs yaml.safe_load on every wiki page's frontmatter and is wired into the run_structural_lint report; future regressions surface immediately instead of silently writing bad YAML. Repro and root-cause analysis by @invu557 (issue #67).
1 parent 7289cfe commit ec4c527

2 files changed

Lines changed: 63 additions & 3 deletions

File tree

openkb/agent/compiler.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from pathlib import Path
2828

2929
import litellm
30+
import yaml
3031

3132
from openkb.lint import list_existing_wiki_targets, strip_ghost_wikilinks
3233
from openkb.schema import get_agents_md
@@ -564,6 +565,19 @@ def _sanitize_concept_name(name: str) -> str:
564565
return sanitized or "unnamed-concept"
565566

566567

568+
def _yaml_kv_line(key: str, value: str) -> str:
569+
"""Render a single ``key: value`` line that round-trips through any YAML loader.
570+
571+
LLM-authored values often contain colons, quotes, ``#``, brackets, etc.;
572+
f-string interpolation produces invalid YAML for those. ``yaml.safe_dump``
573+
auto-quotes when needed and never wraps (``width=inf``).
574+
"""
575+
dumped = yaml.safe_dump(
576+
{key: value}, default_flow_style=False, width=10**9, allow_unicode=True,
577+
).strip()
578+
return dumped.split("\n", 1)[0]
579+
580+
567581
def _write_concept(wiki_dir: Path, name: str, content: str, source_file: str, is_update: bool, brief: str = "") -> None:
568582
"""Write or update a concept page, managing the sources frontmatter."""
569583
concepts_dir = wiki_dir / "concepts"
@@ -598,10 +612,13 @@ def _write_concept(wiki_dir: Path, name: str, content: str, source_file: str, is
598612
if end != -1:
599613
fm = existing[:end + 3]
600614
body = existing[end + 3:]
615+
brief_line = _yaml_kv_line("brief", brief)
601616
if "brief:" in fm:
602-
fm = re.sub(r"brief:.*", f"brief: {brief}", fm)
617+
# Lambda to bypass re.sub backref interpretation in the
618+
# replacement string (brief may contain \1, \g<…>, etc.).
619+
fm = re.sub(r"brief:.*", lambda _m: brief_line, fm)
603620
else:
604-
fm = fm.replace("---\n", f"---\nbrief: {brief}\n", 1)
621+
fm = fm.replace("---\n", f"---\n{brief_line}\n", 1)
605622
existing = fm + body
606623
path.write_text(existing, encoding="utf-8")
607624
else:
@@ -611,7 +628,7 @@ def _write_concept(wiki_dir: Path, name: str, content: str, source_file: str, is
611628
content = content[end + 3:].lstrip("\n")
612629
fm_lines = [f"sources: [{source_file}]"]
613630
if brief:
614-
fm_lines.append(f"brief: {brief}")
631+
fm_lines.append(_yaml_kv_line("brief", brief))
615632
frontmatter = "---\n" + "\n".join(fm_lines) + "\n---\n\n"
616633
path.write_text(frontmatter + content, encoding="utf-8")
617634

openkb/lint.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
- Orphaned pages — pages with no incoming or outgoing links
66
- Missing wiki entries — raw files without corresponding sources/summaries
77
- Index sync — index.md links vs actual files on disk
8+
- Invalid frontmatter — YAML that won't round-trip through safe_load
89
"""
910
from __future__ import annotations
1011

1112
import re
1213
import unicodedata
1314
from pathlib import Path
1415

16+
import yaml
17+
1518
# Matches [[wikilink]] or [[subdir/link]]
1619
_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
1720

@@ -402,6 +405,36 @@ def check_index_sync(wiki: Path) -> list[str]:
402405
return sorted(issues)
403406

404407

408+
def find_invalid_frontmatter(wiki: Path) -> list[str]:
409+
"""Return wiki pages whose YAML frontmatter fails ``yaml.safe_load``.
410+
411+
Catches the silent-write class of bug where an LLM-authored field
412+
(e.g. ``brief:``) ships unquoted and turns a colon-bearing value
413+
into invalid YAML that OpenKB itself reads with string slicing but
414+
external YAML-aware tools (VS Code, Obsidian, doc generators) reject.
415+
"""
416+
issues: list[str] = []
417+
if not wiki.exists():
418+
return issues
419+
for path in sorted(wiki.rglob("*.md")):
420+
if path.name in _EXCLUDED_FILES:
421+
continue
422+
text = _read_md(path)
423+
if not text.startswith("---"):
424+
continue
425+
end = text.find("\n---", 3)
426+
if end == -1:
427+
continue
428+
fm = text[3:end].strip("\n")
429+
try:
430+
yaml.safe_load(fm)
431+
except yaml.YAMLError as exc:
432+
rel = path.relative_to(wiki)
433+
msg = str(exc).splitlines()[0]
434+
issues.append(f"{rel}: {msg}")
435+
return issues
436+
437+
405438
def run_structural_lint(kb_dir: Path) -> str:
406439
"""Run all structural lint checks and return a formatted Markdown report.
407440
@@ -418,6 +451,7 @@ def run_structural_lint(kb_dir: Path) -> str:
418451
orphans = find_orphans(wiki)
419452
missing = find_missing_entries(raw, wiki)
420453
sync_issues = check_index_sync(wiki)
454+
bad_frontmatter = find_invalid_frontmatter(wiki)
421455

422456
lines = ["## Structural Lint Report\n"]
423457

@@ -455,5 +489,14 @@ def run_structural_lint(kb_dir: Path) -> str:
455489
lines.append(f"- {issue}")
456490
else:
457491
lines.append("Index is in sync.")
492+
lines.append("")
493+
494+
# Invalid frontmatter
495+
lines.append(f"### Invalid Frontmatter ({len(bad_frontmatter)})")
496+
if bad_frontmatter:
497+
for issue in bad_frontmatter:
498+
lines.append(f"- {issue}")
499+
else:
500+
lines.append("All frontmatter parses as valid YAML.")
458501

459502
return "\n".join(lines)

0 commit comments

Comments
 (0)