feat: write leafs incrementally without rebuilding the graph. (#29)
Ladybug 0.19 stays FTS/HNSW queryable on MERGE of new ids; DROP INDEX was the fatal path. bin/brain/add.go and POST /ingest land facts+info in one transaction so watch/mail/git can become leafs now (Gitea #14).
This commit is contained in:
+45
-1
@@ -4,7 +4,7 @@ Single embedded graph `var/kb.lbug`. Two roots: facts (assertions backed by
|
||||
>=2 independent sources) and info (narrative leafs). Hybrid retrieval: BM25
|
||||
(FTS extension) + HNSW cosine (VECTOR extension) + Cypher graph hops.
|
||||
|
||||
All access is read-only unless `--rebuild` is passed to kb/index.
|
||||
All access is read-only unless `--rebuild` (kb/index) or `kb/add`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -118,6 +118,50 @@ def upsert_leaf(conn: ladybug.Connection, *, text: str, root: str, confidence: s
|
||||
return lid
|
||||
|
||||
|
||||
def add_leafs(conn: ladybug.Connection, leafs: list[dict]) -> list[str]:
|
||||
"""Write facts+info leafs in one transaction. Safe while FTS/HNSW exist.
|
||||
|
||||
Each leaf dict: text, source, optional root/confidence/source_rev/how/loc/type/embedding.
|
||||
Does not delete the database file. Measured on Ladybug 0.19: MERGE of new
|
||||
ids (and updates) stays FTS+HNSW queryable; DROP INDEX is the fatal path.
|
||||
"""
|
||||
if not leafs:
|
||||
return []
|
||||
started = False
|
||||
try:
|
||||
conn.execute("BEGIN TRANSACTION")
|
||||
started = True
|
||||
except Exception:
|
||||
started = False
|
||||
ids: list[str] = []
|
||||
try:
|
||||
for lf in leafs:
|
||||
ids.append(
|
||||
upsert_leaf(
|
||||
conn,
|
||||
text=str(lf["text"]),
|
||||
root=str(lf.get("root") or ROOT_INFO),
|
||||
confidence=str(lf.get("confidence") or CONF_CONFIRMED),
|
||||
source=str(lf["source"]),
|
||||
source_rev=str(lf.get("source_rev") or "working-tree"),
|
||||
how=str(lf.get("how") or "brain/add"),
|
||||
loc=str(lf.get("loc") or lf.get("source") or ""),
|
||||
type_=str(lf.get("type") or lf.get("type_") or "reference"),
|
||||
embedding=lf.get("embedding"),
|
||||
)
|
||||
)
|
||||
if started:
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
if started:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
return ids
|
||||
|
||||
|
||||
def leaf_index_names(conn: ladybug.Connection) -> set[str]:
|
||||
"""Return index names on the Leaf table (e.g. {'id', 'Leaf_vec', '_PK'})."""
|
||||
rows = conn.execute("CALL SHOW_INDEXES() RETURN *").get_all()
|
||||
|
||||
@@ -75,9 +75,20 @@ class BinLayoutTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_brain_methods_are_shebangs(self) -> None:
|
||||
for method in ("index.go", "get.go", "stats.go", "eval.go", "watch.go"):
|
||||
for method in ("index.go", "add.go", "get.go", "stats.go", "eval.go", "watch.go"):
|
||||
self._assert_shebang(f"bin/brain/{method}")
|
||||
|
||||
def test_brain_add_is_python_write_not_rebuild(self) -> None:
|
||||
self._assert_shebang("bin/brain/add.go")
|
||||
text = (ROOT / "bin" / "brain" / "add.go").read_text()
|
||||
self.assertIn("cmdbin.ExecFile", text)
|
||||
self.assertIn("bin/kb/add", text)
|
||||
self.assertNotIn("--rebuild", text)
|
||||
py = (ROOT / "bin" / "kb" / "add").read_text()
|
||||
self.assertIn("add_leafs", py)
|
||||
self.assertIn("--json", py)
|
||||
self.assertNotIn("unlink", py.lower())
|
||||
|
||||
def test_brain_get_stats_eval_are_not_python_exec(self) -> None:
|
||||
for method in ("get.go", "stats.go", "eval.go"):
|
||||
text = (ROOT / "bin" / "brain" / method).read_text()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Incremental add writes leafs without deleting kb.lbug."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class KbAddCLITest(unittest.TestCase):
|
||||
def test_json_add_does_not_delete_db(self) -> None:
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
dbpath = tmp / "kb.lbug"
|
||||
py = sys.executable
|
||||
venv_py = ROOT / ".venv" / "bin" / "python"
|
||||
if venv_py.is_file():
|
||||
py = str(venv_py)
|
||||
payload = {
|
||||
"text": "cli zebra leaf",
|
||||
"root": "info",
|
||||
"source": "cli-test",
|
||||
"confidence": "confirmed",
|
||||
"how": "test",
|
||||
"loc": str(tmp),
|
||||
"type": "reference",
|
||||
"embedding": [0.0] * 256,
|
||||
}
|
||||
payload["embedding"][0] = 0.3
|
||||
proc = subprocess.run(
|
||||
[py, str(ROOT / "bin" / "kb" / "add"), "--db", str(dbpath), "--json"],
|
||||
cwd=ROOT,
|
||||
input=json.dumps(payload),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy(),
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertTrue(dbpath.exists(), "add must create the db, not skip write")
|
||||
out = json.loads(proc.stdout)
|
||||
self.assertEqual(out.get("mode"), "add")
|
||||
self.assertEqual(len(out.get("ids") or []), 1)
|
||||
again = subprocess.run(
|
||||
[py, str(ROOT / "bin" / "kb" / "add"), "--db", str(dbpath), "--json"],
|
||||
cwd=ROOT,
|
||||
input=json.dumps({
|
||||
**payload,
|
||||
"text": "second moose leaf",
|
||||
"source": "cli-test-2",
|
||||
}),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy(),
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(again.returncode, 0, again.stderr)
|
||||
self.assertTrue(dbpath.exists())
|
||||
second = json.loads(again.stdout)
|
||||
self.assertEqual(len(second.get("ids") or []), 1)
|
||||
self.assertNotEqual(out["ids"][0], second["ids"][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -71,6 +71,69 @@ class KblibTest(unittest.TestCase):
|
||||
self.assertTrue(hits)
|
||||
self.assertIn("Leaf_vec", kblib.leaf_index_names(self.conn))
|
||||
|
||||
def test_add_after_indexes_keeps_fts_queryable(self):
|
||||
"""Incremental add after FTS+HNSW must find the new leaf on both indexes."""
|
||||
kblib.upsert_leaf(self.conn, text="seed fox leaf", root="info",
|
||||
confidence="confirmed", source="s", source_rev="r1",
|
||||
how="test", loc="/tmp", type_="reference",
|
||||
embedding=make_emb(0.1))
|
||||
kblib.ensure_indexes(self.conn)
|
||||
ids = kblib.add_leafs(self.conn, [{
|
||||
"text": "added zebra after index",
|
||||
"root": "facts",
|
||||
"confidence": "confirmed",
|
||||
"source": "a.md x b.md",
|
||||
"source_rev": "r1",
|
||||
"how": "test",
|
||||
"loc": "/tmp",
|
||||
"type": "fact",
|
||||
"embedding": make_emb(0.9),
|
||||
}])
|
||||
self.assertEqual(len(ids), 1)
|
||||
fts = kblib.query_fts(self.conn, "zebra", 5)
|
||||
self.assertTrue(fts)
|
||||
self.assertIn("zebra", fts[0]["text"])
|
||||
self.assertEqual(fts[0]["root"], "facts")
|
||||
vec = kblib.query_vector(self.conn, make_emb(0.9), 5)
|
||||
self.assertTrue(any("zebra" in h["text"] for h in vec))
|
||||
fox = kblib.query_fts(self.conn, "fox", 5)
|
||||
self.assertTrue(fox)
|
||||
self.assertIn("fox", fox[0]["text"])
|
||||
|
||||
def test_add_facts_and_info_one_transaction(self):
|
||||
"""D12: facts and info land in the same transaction."""
|
||||
kblib.ensure_indexes(self.conn)
|
||||
ids = kblib.add_leafs(self.conn, [
|
||||
{
|
||||
"text": "tx fact leaf two-source",
|
||||
"root": "facts",
|
||||
"confidence": "confirmed",
|
||||
"source": "compose.yml x docker ps",
|
||||
"source_rev": "r1",
|
||||
"how": "test",
|
||||
"loc": "/tmp",
|
||||
"type": "fact",
|
||||
"embedding": make_emb(0.4),
|
||||
},
|
||||
{
|
||||
"text": "tx info narrative",
|
||||
"root": "info",
|
||||
"confidence": "confirmed",
|
||||
"source": "note.md",
|
||||
"source_rev": "r1",
|
||||
"how": "test",
|
||||
"loc": "/tmp",
|
||||
"type": "reference",
|
||||
"embedding": make_emb(0.5),
|
||||
},
|
||||
])
|
||||
self.assertEqual(len(ids), 2)
|
||||
stats = kblib.stats(self.conn)
|
||||
self.assertEqual(stats["by_root"].get("facts"), 1)
|
||||
self.assertEqual(stats["by_root"].get("info"), 1)
|
||||
self.assertTrue(kblib.query_fts(self.conn, "two-source", 5))
|
||||
self.assertTrue(kblib.query_fts(self.conn, "narrative", 5))
|
||||
|
||||
def test_drop_vector_then_create_raises_clear_error(self):
|
||||
"""DROP INDEX leaves ghost catalog; create_fts_and_vector must raise."""
|
||||
kblib.upsert_leaf(self.conn, text="seed", root="info",
|
||||
|
||||
Reference in New Issue
Block a user