Files
2dph/bin/kb/index
T
eSlider 678a1d1dba feat(mail): full Gmail+OnlyOffice sync, import, and brain indexing
- bin/mail/sync.go: async Go sync engine (8 workers, paginated Gmail via
  API + OnlyOffice IMAP); Gmail attachments key off body.attachmentId, not
  MIME partId; ICS sidecars Latin-1->UTF-8 normalized (TestICSToMarkdownNormalizesLatin1)
- bin/mail/import: message.json -> markdown; PDFs via pdftotext -layout
  fast path with docling subprocess fallback for the ~5% textless files
- bin/mail/index_mail: fresh-rebuild indexer (repo corpus + mail) avoiding
  ladybug WAL corruption on bulk-insert into indexed DBs; split from import
- bin/kb/index: keep FTS/VECTOR indexes across incremental runs (drop+recreate
  leaves stale backing tables killing the vector index)
- docs: README/PLAN/AGENTS cover the mail pipeline

Result: 17,835 messages -> 28,918 info leafs, FTS+HNSW healthy.
2026-08-11 21:57:38 +01:00

145 lines
5.2 KiB
Python
Executable File

#!/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 / "bin" / "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)
# yaml seeds (knowledge-mesh, workspace catalogs) as plain info leafs
if root.is_dir():
for path in sorted(root.rglob("*.y*ml")):
try:
leafs.append({
"source": str(path), "repo": repo, "heading": path.stem,
"text": path.read_text(encoding="utf-8", errors="replace")[:20000],
"type": "seed", "status": "current", "related": "",
})
except OSError:
continue
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)
# Keep the FTS/VECTOR indexes in place across incremental runs: ladybug's
# DROP INDEX leaves the backing tables registered on migrated DBs, so a
# drop+recreate silently kills the vector index. Only create when missing.
embed = embedder()
done, total = index_leafs(conn, leafs, embed, a.limit)
if a.rebuild or not _has_indexes(conn):
create_fts_and_vector(conn, force=True)
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 _has_indexes(conn) -> bool:
try:
rows = conn.execute("CALL SHOW_INDEXES() RETURN *").get_all()
names = {row[1] for row in rows if row[0] == "Leaf"}
return "id" in names and "Leaf_vec" in names
except Exception:
return False
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))