#!/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 != "" 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:]))