Files
2dph/bin/facts/extract
T
eSliderandCursor b73b4d4f97 fix(kb): stop DROP INDEX killing HNSW via Ladybug ghost catalog
Ladybug 0.19 DROP INDEX leaves `_0_Leaf_vec_UPPER` / `0_id_docs` in catalog so
CREATE fails while SHOW_INDEXES omits the index; create_fts_and_vector used to
swallow that. Never drop FTS/VECTOR; ensure_indexes after upserts; rebuild =
delete kb.lbug. Add compose.edelweiss.yml + regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 16:05:14 +01:00

225 lines
7.4 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 / "bin" / "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 VAR, connect, ensure_indexes, init_schema, upsert_leaf
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)
# Upsert-with-index is safe; never DROP+recreate (ghost catalog kills HNSW).
ensure_indexes(conn)
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:]))