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:
2026-08-10 21:58:25 +01:00
parent dcb00808c9
commit 4fdc0ef4a8
29 changed files with 1560 additions and 134 deletions
+224
View File
@@ -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:]))