feat(kb): brain tools, Go async serve, root-level docker
- tools/kblib.py: ladybug schema, embeddings, FTS+vector, hybrid RRF
- bin/kb/{index,search,get,stats,eval}: corpus indexing + deduction search
- bin/facts/{extract,audit}: 2-source evidence acquisition + gates
- serve/: async Go HTTP server (goroutines, bounded worker pool), TDD
- docker/ flattened to root: compose.yaml + Dockerfile (multi-stage Go)
- docker scripts -> bin/ shebang pattern (kb-watch, docker-entrypoint)
- bin/ci/semver + tools/semver.py: conventional-commit semver release
- ci.yml: go tests + shell checks; drop release-please (PR toggle blocked)
This commit is contained in:
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ci/semver - next semver from conventional commits since the last tag.
|
||||
|
||||
bin/ci/semver # last tag..HEAD
|
||||
bin/ci/semver v0.1.0 v0.1.0..HEAD # explicit tag + range
|
||||
prints: v0.1.1 | v0.2.0 | v1.0.0 | none
|
||||
|
||||
Bump rules (conventional commits):
|
||||
BREAKING CHANGE / feat! -> major
|
||||
feat: -> minor
|
||||
fix:, perf:, refactor:,... -> patch
|
||||
no commits in range -> none
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools"))
|
||||
|
||||
from semver import bump_type, bump_version # noqa: E402
|
||||
|
||||
|
||||
def subjects_for(range_: str) -> list[str]:
|
||||
args = ["git", "log", "--format=%s"]
|
||||
if range_ and range_ != "HEAD":
|
||||
args.append(range_)
|
||||
try:
|
||||
out = subprocess.run(args, capture_output=True, text=True, check=True).stdout
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def last_tag() -> str | None:
|
||||
try:
|
||||
out = subprocess.run(["git", "tag", "--sort=-v:refname"], capture_output=True, text=True, check=True).stdout
|
||||
tags = [t.strip() for t in out.splitlines() if t.strip().startswith("v")]
|
||||
return tags[0] if tags else None
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
tag = argv[1] if len(argv) > 1 else last_tag()
|
||||
range_ = argv[2] if len(argv) > 2 else (f"{tag}..HEAD" if tag else "HEAD")
|
||||
print(bump_version(tag, bump_type(subjects_for(range_))) or "none")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# bin/docker-entrypoint - run 2dph tools inside the container.
|
||||
#
|
||||
# brain shell (default)
|
||||
# brain search <q> bin/kb/search
|
||||
# brain index bin/kb/index
|
||||
# brain watch <dir> watchdog re-indexer
|
||||
# brain serve async Go HTTP server (serve/)
|
||||
#
|
||||
# Usage comment starts at line 2 (self-describing convention).
|
||||
set -euo pipefail
|
||||
|
||||
CMD="${1:-shell}"
|
||||
shift || true
|
||||
|
||||
case "$CMD" in
|
||||
shell) exec bash ;;
|
||||
search) exec "$KB_PY" /app/bin/kb/search "$@" ;;
|
||||
index) exec "$KB_PY" /app/bin/kb/index "$@" ;;
|
||||
watch) exec bash /app/bin/kb-watch "$@" ;;
|
||||
serve) exec /app/serve/serve "$@" ;;
|
||||
*) echo "unknown command: $CMD" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""facts/audit - evidence & lexicon checks for the 2dph brain.
|
||||
|
||||
bin/facts/audit self # lexicon: every fact in db has >=2 sources
|
||||
bin/facts/audit db # evidence gate: run against var/kb.lbug
|
||||
|
||||
`self` mode checks the repo itself (no network, no runtime deps). It greps
|
||||
for known-good two-source pairings and confirms the docs are consistent.
|
||||
`db` mode loads every Leaf with root=facts and asserts each has source_rev
|
||||
and a non-empty `loc` (the "where did you see it" evidence pointer) and that
|
||||
'confirmed' facts carry a two-source `source` field.
|
||||
|
||||
Exit 0 = all checks pass, 1 = audit failures, 2 = could not evaluate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
|
||||
def audit_db() -> list[str]:
|
||||
from kblib import connect, init_schema
|
||||
from kblib import VAR
|
||||
dbpath = VAR / "kb.lbug"
|
||||
if not dbpath.exists():
|
||||
return ["no database yet; run bin/kb/index first"]
|
||||
db, conn = connect(dbpath)
|
||||
init_schema(conn)
|
||||
r = conn.execute("MATCH (l:Leaf {root:'facts'}) RETURN l.id, l.source, l.loc, l.how, l.confidence")
|
||||
problems: list[str] = []
|
||||
for lid, source, loc, how, conf in r.get_all():
|
||||
if conf != "confirmed":
|
||||
problems.append(f"{lid}: facts require confidence='confirmed', got '{conf}'")
|
||||
if not source or " x " not in source:
|
||||
problems.append(f"{lid}: needs 2-source evidence in source, got '{source}'")
|
||||
if not loc:
|
||||
problems.append(f"{lid}: missing loc (evidence pointer)")
|
||||
if not how:
|
||||
problems.append(f"{lid}: missing how")
|
||||
conn.close()
|
||||
db.close()
|
||||
return problems
|
||||
|
||||
|
||||
def audit_self() -> list[str]:
|
||||
problems: list[str] = []
|
||||
plan = (ROOT / "PLAN.md").read_text()
|
||||
|
||||
if "recall@5" not in plan:
|
||||
problems.append("PLAN.md missing recall@5 gate")
|
||||
if re.search(r"(?i)facts must have.*2 sources|2.source", plan) is None:
|
||||
problems.append("PLAN.md missing the two-source evidence rule for facts")
|
||||
if re.search(r"(?i)HNSW|BM25|deduction", (ROOT / "README.md").read_text()) is None:
|
||||
problems.append("README.md missing search/retrieval description")
|
||||
return problems
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="evidence & lexicon audit")
|
||||
p.add_argument("mode", choices=("self", "db"))
|
||||
p.add_argument("--json", action="store_true")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
problems = audit_self() if a.mode == "self" else audit_db()
|
||||
out = {"mode": a.mode, "ok": not problems, "problems": problems}
|
||||
if a.json:
|
||||
print(json.dumps(out, indent=2))
|
||||
else:
|
||||
from yamlout import to_yaml
|
||||
print(to_yaml(out))
|
||||
return 0 if not problems else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""facts/extract - acquire confirmed facts from the ops stack (2-source each).
|
||||
|
||||
bin/facts/extract [--json] [--dry-run] [--ssh PATH] [--compose PATH]
|
||||
|
||||
The deduction rule: a fact is only stored under root=facts if it is backed by
|
||||
>=2 independent sources. Sources here:
|
||||
|
||||
S1 runtime : docker ps (running containers) or ~/.ssh/config (hosts)
|
||||
S2 declared : docker-compose files or PLAN.md/README.md mentions
|
||||
|
||||
Extracted facts are written into var/kb.lbug (root=facts, confidence=confirmed).
|
||||
--dry-run prints the proposed facts without touching the database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
COMPOSE_FILES = [ROOT / "docker" / "compose.yaml", ROOT / "compose.yaml"]
|
||||
DOC_MARKERS = ["README.md", "PLAN.md", "AGENTS.md"]
|
||||
SSH_CONFIG = Path.home() / ".ssh" / "config"
|
||||
REPO = "eSlider/2dph"
|
||||
|
||||
|
||||
def read_docker_ps() -> list[str]:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["docker", "ps", "--format", "{{.Names}}"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
return [n.strip() for n in out.stdout.splitlines() if n.strip()]
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
|
||||
|
||||
def compose_files_in(dirname: Path) -> list[Path]:
|
||||
out = []
|
||||
for name in ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"):
|
||||
p = dirname / name
|
||||
if p.exists():
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def container_compose_dir(name: str) -> Path | None:
|
||||
"""Find the compose project dir a running container belongs to."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["docker", "inspect", name,
|
||||
"--format", "{{ index .Config.Labels \"com.docker.compose.project.working_dir\"}}"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
path = out.stdout.strip()
|
||||
return Path(path) if path and path != "<no value>" else None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def read_compose_services(compose: Path) -> list[str]:
|
||||
if not compose.exists():
|
||||
return []
|
||||
try:
|
||||
import yaml
|
||||
return list(yaml.safe_load(compose.read_text()).get("services", {}).keys())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def read_ssh_hosts(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
hosts = []
|
||||
for line in path.read_text().splitlines():
|
||||
m = re.match(r"^\s*Host\s+(.+)$", line)
|
||||
if m:
|
||||
hosts.extend(h for h in m.group(1).split() if h not in ("*",))
|
||||
return hosts
|
||||
|
||||
|
||||
def mentions(term: str, files: list[Path]) -> bool:
|
||||
for path in files:
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
if re.search(rf"\b{re.escape(term)}\b", path.read_text(), re.I):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def build_facts() -> list[dict]:
|
||||
facts: list[dict] = []
|
||||
doc_files = [ROOT / m for m in DOC_MARKERS]
|
||||
|
||||
running = read_docker_ps()
|
||||
# Pair each running container against its own compose file (2 independent
|
||||
# sources: runtime state docker ps × declared state compose).
|
||||
runtime_facts = 0
|
||||
for name in running:
|
||||
cdir = container_compose_dir(name)
|
||||
for cfile in compose_files_in(cdir) if cdir else []:
|
||||
if name in read_compose_services(cfile):
|
||||
facts.append({
|
||||
"text": f"container '{name}' is running and declared in {cfile.name}",
|
||||
"source": f"docker ps x compose:{cfile.name}",
|
||||
"loc": f"{cfile}:{name}",
|
||||
"how": "facts/extract",
|
||||
})
|
||||
runtime_facts += 1
|
||||
break
|
||||
if runtime_facts:
|
||||
print(f"facts/extract: paired {runtime_facts}/{len(running)} running containers to compose", file=sys.stderr)
|
||||
|
||||
compose = [Path(p) for p in COMPOSE_FILES]
|
||||
compose_services = set()
|
||||
for c in compose:
|
||||
compose_services.update(read_compose_services(c))
|
||||
if compose_services and running:
|
||||
overlap = sorted(compose_services & set(running))
|
||||
for name in overlap:
|
||||
facts.append({
|
||||
"text": f"container '{name}' is running and declared in compose",
|
||||
"source": f"docker ps x {compose[0].name}",
|
||||
"loc": "docker ps; docker compose config",
|
||||
"how": "facts/extract",
|
||||
})
|
||||
|
||||
hosts = read_ssh_hosts(SSH_CONFIG)
|
||||
for host in hosts:
|
||||
if mentions(host, doc_files):
|
||||
facts.append({
|
||||
"text": f"host '{host}' is configured in ~/.ssh/config and referenced in this repo",
|
||||
"source": f"ssh config x docs({', '.join(DOC_MARKERS)})",
|
||||
"loc": f"~/.ssh/config:{host}",
|
||||
"how": "facts/extract",
|
||||
})
|
||||
|
||||
# Single-docker-container facts still need 2 sources: running + hostname hint
|
||||
for name in running:
|
||||
known_hosts = set(hosts)
|
||||
if not known_hosts:
|
||||
break
|
||||
# a running container name that also matches a configured host
|
||||
if name in known_hosts:
|
||||
facts.append({
|
||||
"text": f"container '{name}' is running and matches configured host '{name}'",
|
||||
"source": "docker ps x ssh config",
|
||||
"loc": f"docker ps:{name}; ~/.ssh/config:{name}",
|
||||
"how": "facts/extract",
|
||||
})
|
||||
return facts
|
||||
|
||||
|
||||
def dedupe(facts: list[dict]) -> list[dict]:
|
||||
seen = set()
|
||||
out = []
|
||||
for f in facts:
|
||||
key = f["text"]
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
|
||||
def write_facts(facts: list[dict]) -> None:
|
||||
from kblib import connect, init_schema, upsert_leaf
|
||||
from kblib import VAR
|
||||
VAR.mkdir(exist_ok=True)
|
||||
db, conn = connect(VAR / "kb.lbug", read_only=False)
|
||||
init_schema(conn)
|
||||
from model2vec import StaticModel
|
||||
model = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
for f in facts:
|
||||
emb = model.encode([f["text"]])[0].astype(float).tolist()
|
||||
upsert_leaf(conn, text=f["text"], root="facts", confidence="confirmed",
|
||||
source=f["source"], source_rev=REPO, how=f["how"],
|
||||
loc=f["loc"], type_="fact", embedding=emb)
|
||||
conn.close()
|
||||
db.close()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
global COMPOSE_FILES, SSH_CONFIG
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="acquire confirmed facts from ops sources")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
p.add_argument("--ssh", default=str(SSH_CONFIG))
|
||||
p.add_argument("--compose", action="append")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
if a.compose:
|
||||
COMPOSE_FILES = [Path(c) for c in a.compose]
|
||||
if a.ssh:
|
||||
SSH_CONFIG = Path(a.ssh)
|
||||
|
||||
facts = dedupe(build_facts())
|
||||
if not a.dry_run and facts:
|
||||
write_facts(facts)
|
||||
|
||||
out = {"count": len(facts), "facts": facts}
|
||||
if a.json:
|
||||
print(json.dumps(out, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
from yamlout import to_yaml
|
||||
print(to_yaml(out))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# kb-watch - re-index 2dph when corpus files change.
|
||||
#
|
||||
# kb-watch [dir...] [interval_seconds]
|
||||
#
|
||||
# Polls mtimes (no inotify deps); cheap and reliable in containers. Defaults:
|
||||
# dirs = /corpus (compose) or . ; interval = 30s.
|
||||
set -euo pipefail
|
||||
|
||||
DEFAULT_DIRS="${KB_WATCH_DIRS:-/corpus}"
|
||||
DIRS=("$@")
|
||||
[[ ${#DIRS[@]} -eq 0 ]] && DIRS=(${DEFAULT_DIRS})
|
||||
INTERVAL="${KB_WATCH_INTERVAL:-30}"
|
||||
|
||||
index() { "${KB_PY:-python3}" /app/bin/kb/index; }
|
||||
|
||||
LAST_STAMP=""
|
||||
while true; do
|
||||
STAMP=$(find "${DIRS[@]}" -type f -newermt "-${INTERVAL} seconds" 2>/dev/null \
|
||||
| head -1 | md5sum)
|
||||
if [[ -n "$STAMP" && "$STAMP" != "$LAST_STAMP" ]]; then
|
||||
echo "kb-watch: changes detected, re-indexing" >&2
|
||||
index || echo "kb-watch: index failed; will retry" >&2
|
||||
LAST_STAMP="$STAMP"
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kb/eval - recall@5 gate for the brain.
|
||||
|
||||
bin/kb/eval [--json]
|
||||
|
||||
Control questions are answered from the graph; recall@5 >= 0.95 gates CI.
|
||||
Each question maps to leaf ids that MUST appear in the top 5 hits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from kblib import open_readonly, query_fts # noqa: E402
|
||||
from yamlout import to_yaml # noqa: E402
|
||||
|
||||
RECALL_THRESHOLD = 0.95
|
||||
|
||||
# (query, expected leaf id)
|
||||
CONTROL_QUESTIONS: list[tuple[str, str]] = [
|
||||
("which database does the brain use", "facts:ladybug"),
|
||||
("hybrid search weights fts and vector equally", "info:hybrid"),
|
||||
]
|
||||
|
||||
|
||||
def hit_ids_of(query: str, conn, limit: int = 5) -> list[str]:
|
||||
try:
|
||||
hits = query_fts(conn, query, limit)
|
||||
return [h["id"] for h in hits]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="recall@5 gate")
|
||||
p.add_argument("--json", action="store_true")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
db, conn = open_readonly()
|
||||
recalled = 0
|
||||
detail = []
|
||||
for query, expected in CONTROL_QUESTIONS:
|
||||
hits = hit_ids_of(query, conn)
|
||||
ok = any(expected in h or h in expected for h in hits)
|
||||
recalled += int(ok)
|
||||
detail.append({"q": query, "expected": expected, "in_top5": ok, "hits": hits[:5]})
|
||||
recall = recalled / len(CONTROL_QUESTIONS) if CONTROL_QUESTIONS else 1.0
|
||||
passed = recall >= RECALL_THRESHOLD
|
||||
out = {"recall@5": round(recall, 3), "passed": passed, "gate": len(CONTROL_QUESTIONS), "details": detail}
|
||||
print(json.dumps(out, indent=2) if a.json else to_yaml(out))
|
||||
conn.close()
|
||||
db.close()
|
||||
return 0 if passed else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kb/get - read one leaf by id.
|
||||
|
||||
bin/kb/get <id> # metadata + snippet
|
||||
bin/kb/get <id> --body # full text
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from kblib import open_readonly # noqa: E402
|
||||
from yamlout import to_yaml # noqa: E402
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="read one leaf by id")
|
||||
p.add_argument("id")
|
||||
p.add_argument("--body", action="store_true")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
db, conn = open_readonly()
|
||||
r = conn.execute(
|
||||
"MATCH (l:Leaf {id:$id}) RETURN l.id, l.text, l.root, l.confidence, l.source, l.type",
|
||||
parameters={"id": a.id},
|
||||
)
|
||||
rows = r.get_all()
|
||||
if not rows:
|
||||
print(f"kb/get: no leaf {a.id}", file=sys.stderr)
|
||||
conn.close()
|
||||
db.close()
|
||||
return 1
|
||||
row = rows[0]
|
||||
out = {"id": row[0], "root": row[2], "confidence": row[3], "source": row[4], "type": row[5]}
|
||||
if a.body:
|
||||
out["text"] = row[1]
|
||||
else:
|
||||
out["snippet"] = row[1][:280]
|
||||
print(to_yaml(out))
|
||||
conn.close()
|
||||
db.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kb/index - build the 2dph brain from markdown + factual leafs.
|
||||
|
||||
bin/kb/index [--corpus DIR] [--rebuild] [--limit N]
|
||||
bin/kb/index --json # emit stats as JSON
|
||||
|
||||
Reads every .md under the corpus (default: repo root docs, skills, READMEs)
|
||||
as `info` leafs, embeds them with model2vec (potion-multilingual-128M), and
|
||||
writes them into var/kb.lbug with FTS + HNSW indexes. `facts` leafs come
|
||||
from bin/facts/extract (docker x compose x ssh-config pairing).
|
||||
|
||||
--rebuild drops the database file and indexes from scratch. Without it a run
|
||||
is idempotent (MERGE by (source,text) id).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from kblib import ( # noqa: E402
|
||||
connect, create_fts_and_vector, init_schema, upsert_leaf,
|
||||
open_readonly, stats,
|
||||
)
|
||||
from mdleaves import read_markdown, to_all, walk_markdown # noqa: E402
|
||||
|
||||
CORPUS_DEFAULTS = ["README.md", "PLAN.md", "AGENTS.md", "docs", "skills"]
|
||||
|
||||
|
||||
def load_corpus(root: Path) -> list[dict]:
|
||||
files: list[Path] = []
|
||||
for entry in CORPUS_DEFAULTS:
|
||||
p = root / entry
|
||||
if p.is_file():
|
||||
files.append(p)
|
||||
elif p.is_dir():
|
||||
files.extend(walk_markdown(p))
|
||||
leafs: list[dict] = []
|
||||
for path in files:
|
||||
try:
|
||||
leafs.extend(to_all(read_markdown(path), path, repo="eSlider/2dph"))
|
||||
except OSError as e:
|
||||
print(f"kb/index: skip {path}: {e}", file=sys.stderr)
|
||||
return leafs
|
||||
|
||||
|
||||
def load_corpus_glob(source: str) -> list[dict]:
|
||||
"""Add arbitrary markdown dirs/files as corpus roots (repo=dirname)."""
|
||||
root = Path(source)
|
||||
if not root.exists():
|
||||
print(f"kb/index: skip missing corpus {source}", file=sys.stderr)
|
||||
return []
|
||||
files = [root] if root.is_file() else walk_markdown(root)
|
||||
repo = root.name if root.is_dir() else root.parent.name
|
||||
leafs: list[dict] = []
|
||||
for path in files:
|
||||
try:
|
||||
leafs.extend(to_all(read_markdown(path), path, repo=repo))
|
||||
except OSError as e:
|
||||
print(f"kb/index: skip {path}: {e}", file=sys.stderr)
|
||||
return leafs
|
||||
|
||||
|
||||
def index_leafs(conn, leafs: list[dict], embed_fn, limit: int) -> tuple[int, int]:
|
||||
count = 0
|
||||
for lf in leafs[:limit] if limit else leafs:
|
||||
query = f"{lf['heading']}\n\n{lf['text']}"
|
||||
emb = embed_fn(lf["text"]) if lf["text"] else None
|
||||
upsert_leaf(conn, text=query, root="info", confidence="confirmed",
|
||||
source=lf["source"], source_rev="working-tree",
|
||||
how="kb/index", loc=lf["source"], type_=lf.get("type", "reference"),
|
||||
embedding=emb)
|
||||
count += 1
|
||||
return count, len(leafs)
|
||||
|
||||
|
||||
def embedder():
|
||||
from model2vec import StaticModel
|
||||
model = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
return lambda text: model.encode([text])[0].astype(float).tolist()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="build the 2dph brain index")
|
||||
p.add_argument("--corpus", action="append", help="extra markdown dir/file to index (may repeat)")
|
||||
p.add_argument("--rebuild", action="store_true", help="fresh db + indexes")
|
||||
p.add_argument("--limit", type=int, default=0, help="max leafs to embed")
|
||||
p.add_argument("--json", action="store_true")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
from kblib import DB_PATH, VAR
|
||||
VAR.mkdir(exist_ok=True)
|
||||
if a.rebuild and DB_PATH.exists():
|
||||
DB_PATH.unlink()
|
||||
|
||||
leafs = load_corpus(ROOT)
|
||||
if a.corpus:
|
||||
for source in a.corpus:
|
||||
leafs.extend(load_corpus_glob(source))
|
||||
|
||||
db, conn = connect(DB_PATH, read_only=False)
|
||||
init_schema(conn)
|
||||
|
||||
if not (a.rebuild or _already_indexed(conn)):
|
||||
create_fts_and_vector(conn, force=True)
|
||||
embed = embedder()
|
||||
done, total = index_leafs(conn, leafs, embed, a.limit)
|
||||
create_fts_and_vector(conn, force=(done > 0 or a.rebuild))
|
||||
s = stats(conn)
|
||||
conn.close()
|
||||
db.close()
|
||||
|
||||
result = {"indexed": done, "corpus_total": total, **{k: v for k, v in s.items() if k in ("total", "by_root")}}
|
||||
print(json.dumps(result, indent=2) if a.json else f"indexed {done}/{total} leafs; db total {s['total']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _already_indexed(conn) -> bool:
|
||||
try:
|
||||
return conn.execute("MATCH (l:Leaf) RETURN count(*)").get_all()[0][0] > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kb/search - deduction search over the 2dph brain.
|
||||
|
||||
bin/kb/search "query" # hybrid facts+info, YAML out
|
||||
bin/kb/search "query" --root facts # confirmed facts only
|
||||
bin/kb/search "query" --hop 1 # follow graph edges after hitting
|
||||
bin/kb/search "query" --json | yq '.'
|
||||
bin/kb/search "query" -n 5 # more results
|
||||
|
||||
Deduction order: facts root first (confirmed answers with evidence links),
|
||||
then info root (marked `(not confirmed)`). --root restricts to one root.
|
||||
--hop N walks FROM_FILE edges (sibling leafs in the same source file).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from kblib import connect, hybrid_search, init_schema, open_readonly, query_fts # noqa: E402
|
||||
from yamlout import to_yaml # noqa: E402
|
||||
import ladybug # noqa: E402
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="deduction search over the brain")
|
||||
p.add_argument("query")
|
||||
p.add_argument("--root", choices=("facts", "info", None), default=None)
|
||||
p.add_argument("--hop", type=int, default=0)
|
||||
p.add_argument("-n", "--limit", type=int, default=10)
|
||||
p.add_argument("--json", action="store_true")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
try:
|
||||
db, conn = open_readonly()
|
||||
except FileNotFoundError as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
from model2vec import StaticModel
|
||||
model = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
||||
emb = model.encode([a.query])[0].astype(float).tolist()
|
||||
|
||||
rhs: list[dict] = []
|
||||
try:
|
||||
rhs = query_fts(conn, a.query, a.limit * 2)
|
||||
except Exception:
|
||||
rhs = []
|
||||
|
||||
results = hybrid_search(conn, emb, rhs, a.limit)
|
||||
if a.root:
|
||||
results = [h for h in results if h["root"] == a.root]
|
||||
|
||||
for hit in results:
|
||||
hit.pop("rrf", None)
|
||||
if hit.get("text"):
|
||||
hit["snippet"] = hit["text"][:280]
|
||||
|
||||
out = {"query": a.query, "root_filter": a.root or "facts+info",
|
||||
"count": len(results), "results": results}
|
||||
print(json.dumps(out, indent=2, ensure_ascii=False) if a.json else to_yaml(out))
|
||||
conn.close()
|
||||
db.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kb/stats - index health for the 2dph brain.
|
||||
|
||||
bin/kb/stats # leaf counts by root, db size, model
|
||||
bin/kb/stats --json # machine-readable
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
|
||||
from kblib import open_readonly, stats # noqa: E402
|
||||
from yamlout import to_yaml # noqa: E402
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="brain index health")
|
||||
p.add_argument("--json", action="store_true")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
try:
|
||||
db, conn = open_readonly()
|
||||
except FileNotFoundError as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
s = stats(conn)
|
||||
conn.close()
|
||||
db.close()
|
||||
print(json.dumps(s, indent=2) if a.json else to_yaml(s))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
import lib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from mdleaves import leaves_to_json, read_markdown, to_all, walk_markdown # noqa: E402
|
||||
from yamlout import to_yaml # noqa: E402
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description="md/import - split markdown corpus into leafs")
|
||||
p.add_argument("root", nargs="?", default=".", help="directory to walk for .md files")
|
||||
p.add_argument("--files", action="store", help="comma-separated file list")
|
||||
p.add_argument("--json", action="store_true")
|
||||
a = p.parse_args(argv)
|
||||
|
||||
paths: list[Path] = []
|
||||
if a.files:
|
||||
paths = [Path(f) for f in a.files.split(",")]
|
||||
else:
|
||||
root = Path(a.root)
|
||||
if not root.exists():
|
||||
print(f"md/import: no such path {root}", file=sys.stderr)
|
||||
return 2
|
||||
paths = [root] if root.is_file() else walk_markdown(root)
|
||||
|
||||
if not paths:
|
||||
print("md/import: no markdown files", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
all_leafs: list[dict] = []
|
||||
for path in paths:
|
||||
try:
|
||||
text = read_markdown(path)
|
||||
except OSError as e:
|
||||
print(f"md/import: {path}: {e}", file=sys.stderr)
|
||||
continue
|
||||
all_leafs.extend(to_all(text, path))
|
||||
|
||||
out = leaves_to_json(all_leafs)
|
||||
print(out if a.json else to_yaml(__import__("json").loads(out)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user