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>
225 lines
7.4 KiB
Python
Executable File
225 lines
7.4 KiB
Python
Executable File
#!/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:])) |