← back to Knowledge Layer Skill

knowledge-layer/scripts/kl_query.py

175 lines

#!/usr/bin/env python3
"""
kl_query.py — read-first retrieval for a Knowledge Layer (NO vectors, NO RAG).

Given a question or topic, return the handful of wiki entry files an agent
should LOAD before doing its task — so it "wakes up with full context." Ranking
is deterministic keyword overlap over each entry's title, aliases, tags, and
summary (the frontmatter + first lines), plus a small bonus for [[wikilink]]
neighbours of top hits. It prints paths (and optionally the entries), never a
guess — you read the files it names.

Usage:
    kl_query.py "<question or topic>" [<kl-root>] [-n N] [--print] [--json]

Options:
    -n N       max entries to return (default 6)
    --print    also print each entry's body (for direct context loading)
    --json     machine-readable {path, score, why}[]

Exit codes: 0 = hits, 1 = no hits, 2 = error.
"""
import argparse
import json
import re
import sys
from pathlib import Path

STOP = set("the a an of to and or for in on at by with from is are be as it "
           "this that these those we you i our your their his her its what how "
           "why when who whom which about into over under than then".split())
ENTRY_DIRS_SKIP = {".kl", "sources"}
FM_RE = re.compile(r"^---\s*$")


def tokenize(s: str):
    return [t for t in re.findall(r"[a-z0-9]+", s.lower()) if t and t not in STOP]


def parse_entry(p: Path):
    """Return (title, aliases[], tags[], summary, body, wikilinks[])."""
    text = p.read_text(errors="replace")
    lines = text.splitlines()
    fm = {}
    body_start = 0
    if lines and FM_RE.match(lines[0]):
        for i in range(1, len(lines)):
            if FM_RE.match(lines[i]):
                body_start = i + 1
                break
            m = re.match(r"([A-Za-z_]+):\s*(.*)$", lines[i])
            if m:
                fm[m.group(1).lower()] = m.group(2).strip()
    body = "\n".join(lines[body_start:])

    def listify(v):
        if not v:
            return []
        v = v.strip().strip("[]")
        return [x.strip().strip('"').strip("'") for x in v.split(",") if x.strip()]

    title = ""
    mtitle = re.search(r"^#\s+(.+)$", body, re.M)
    if mtitle:
        title = mtitle.group(1).strip()
    if not title:
        title = p.stem.replace("-", " ")

    summ = ""
    msum = re.search(r"\*\*Summary:\*\*\s*(.+)", body)
    if msum:
        summ = msum.group(1).strip()

    wikilinks = re.findall(r"\[\[([^\]]+)\]\]", body)
    return {
        "title": title,
        "aliases": listify(fm.get("aliases", "")),
        "tags": listify(fm.get("tags", "")),
        "type": fm.get("type", p.parent.name),
        "summary": summ,
        "body": body,
        "links": [w.strip().lower() for w in wikilinks],
    }


def collect_entries(root: Path):
    out = {}
    for p in sorted(root.rglob("*.md")):
        rel = p.relative_to(root)
        top = rel.parts[0]
        if top in ENTRY_DIRS_SKIP:
            continue
        if p.name in ("SCHEMA.md", "INDEX.md", "CONTRADICTIONS.md",
                      "SOURCES.md", "_about.md"):
            continue
        out[p] = parse_entry(p)
    return out


def score(qtokens, e):
    hay_strong = " ".join([e["title"]] + e["aliases"] + e["tags"]).lower()
    strong = set(tokenize(hay_strong))
    weak = set(tokenize(e["summary"] + " " + e["body"][:600]))
    s, why = 0, []
    for t in qtokens:
        if t in strong:
            s += 3
            why.append(t)
        elif t in weak:
            s += 1
    return s, why


def main() -> int:
    ap = argparse.ArgumentParser(description="Read-first retrieval (no vectors).")
    ap.add_argument("query")
    ap.add_argument("root", nargs="?", default="knowledge-layer")
    ap.add_argument("-n", type=int, default=6)
    ap.add_argument("--print", dest="show", action="store_true")
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    root = Path(args.root).resolve()
    if not (root / ".kl").is_dir():
        print(f"ERROR: {root} is not a Knowledge Layer (run kl_init.py first).",
              file=sys.stderr)
        return 2

    qtokens = tokenize(args.query)
    entries = collect_entries(root)
    scored = []
    stem_to_path = {p.stem.lower(): p for p in entries}
    for p, e in entries.items():
        s, why = score(qtokens, e)
        if s > 0:
            scored.append([p, e, s, why])

    # neighbour bonus: entries linked from a top hit get a small nudge.
    top_paths = {p for p, *_ in sorted(scored, key=lambda x: -x[2])[:args.n]}
    for p, e, s, why in scored:
        for lk in e["links"]:
            if lk in stem_to_path and stem_to_path[lk] in top_paths:
                s += 1
    scored.sort(key=lambda x: -x[2])
    scored = scored[:args.n]

    if not scored:
        print("No matching entries. The knowledge layer may not cover this yet — "
              "add a source to sources/ and compile.", file=sys.stderr)
        return 1

    if args.json:
        print(json.dumps([{
            "path": str(p.relative_to(root)),
            "score": s, "why": why, "summary": e["summary"]
        } for p, e, s, why in scored], indent=2))
        return 0

    print(f"Read these {len(scored)} entries first (query: {args.query!r}):\n")
    for p, e, s, why in scored:
        rel = p.relative_to(root)
        tag = f"[{e['type']}]"
        print(f"  {s:>3}  {rel}  {tag}")
        if e["summary"]:
            print(f"       {e['summary']}")
    if args.show:
        print("\n" + "=" * 60)
        for p, e, s, why in scored:
            print(f"\n### {p.relative_to(root)}\n")
            print(e["body"].strip())
    return 0


if __name__ == "__main__":
    sys.exit(main())