From 54201d8c22ec71f4c3782f2b85abf17d835879ef Mon Sep 17 00:00:00 2001 From: Andrey Oblivantsev Date: Thu, 13 Aug 2026 21:16:48 +0100 Subject: [PATCH] feat: brain get/stats/eval call internal/brain, not Python (#18) Read path is cgo like search. Control questions live in rank so CI can test them without ladybug. Python bin/kb/{get,stats,eval} stays the runner fallback. --- AGENTS.md | 4 +- PLAN.md | 9 +- README.md | 3 +- bin/brain/eval.go | 10 +- bin/brain/get.go | 10 +- bin/brain/stats.go | 9 +- bin/tools/test_bin_layout.py | 31 ++++ bin/tools/test_published_docs.py | 8 + docs/design.md | 10 +- internal/brain/modelid.go | 3 + internal/brain/rank/evalq.go | 16 ++ internal/brain/rank/evalq_test.go | 17 ++ internal/brain/read.go | 281 ++++++++++++++++++++++++++++++ skills/brain/SKILL.md | 2 +- 14 files changed, 394 insertions(+), 19 deletions(-) create mode 100644 internal/brain/modelid.go create mode 100644 internal/brain/rank/evalq.go create mode 100644 internal/brain/rank/evalq_test.go create mode 100644 internal/brain/read.go diff --git a/AGENTS.md b/AGENTS.md index 039e101..8bfa9f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,9 @@ bin/facts/crm [--dry-run] # proof person↔company/compa bin/kb/search "query" [--repo X] # deprecated wrapper → bin/brain/search.go bin/brain/search.go "query" [--root facts|info] # deduction search → YAML bin/brain/search.go "query" --no-web # local graph only -bin/brain/get.go [--body] +bin/brain/get.go [--body] [--json] # Go read; Python bin/kb/get CI fallback +bin/brain/stats.go [--json] +bin/brain/eval.go [--json] # recall@5; questions in internal/brain/rank bin/markdown/import.go [dir] # mistune leaves → YAML bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence diff --git a/PLAN.md b/PLAN.md index b448abc..e1c8305 100644 --- a/PLAN.md +++ b/PLAN.md @@ -29,7 +29,7 @@ detective method: **a fact needs ≥2 independent sources or it is | D3 | web search | Go client `bin/web/search.go` (`internal/websearch`). SearXNG URL is config (`BRAIN_SEARCH_URL`). Optional Compose profile `searxng` (sanitized settings). Do not run a second copy on a host that already has one. Empty/`throttled` ≠ “nothing exists”. | | D4 | embeddings | **model2vec** `minishlab/potion-multilingual-128M` instead of embeddinggemma. | | D5 | parser | **mistune** for MD → leaf extraction (duckdb-md documented as future optional SQL/export layer, not v1). | -| D6 | graph engine | **LadybugDB**. Go is the service (`bin/brain/search.go`, `bin/brain/serve.go` in-process, `internal/brain`); Python remains for index/write until the Go write path is safe. | +| D6 | graph engine | **LadybugDB**. Go is the service (`bin/brain/search.go`, `bin/brain/serve.go` in-process, `internal/brain`). Read path (`get.go` / `stats.go` / `eval.go`) is Go + cgo. Python `bin/kb/{get,stats,eval}` is the CI fallback (GitHub runners have no ladybug cgo). Index/write stays Python until the Go write path is safe. | | D7 | db access | `db-yaml`/`psql-yq`-style, read-only, YAML out. OnlyOffice Postgres via SSH tunnel (`127.0.0.1:5433`). | | D8 | evidence | detective method: ≥2 independent sources or `(not confirmed)`. Auto-pair docker ps × compose × ssh-config × docs. | | D9 | facts/goal model | Who / What / How / Where / When + evidence + confidence on every edge. | @@ -56,7 +56,8 @@ detective method: **a fact needs ≥2 independent sources or it is facts/audit ["self"|"facts"|"info"|"stale"] 2-source + staleness gate kb/index Python write path (called by bin/brain/index.go) brain/index.go rebuild FTS + HNSW (incl. --with-mail) - brain/get.go stats.go eval.go watch.go + brain/get.go stats.go eval.go # Go read (cgo); Python bin/kb/* CI fallback + brain/watch.go brain/search.go deduction: facts → info → web-search brain/serve.go HTTP API in-process (internal/httpapi + internal/brain) mail/import.go JSON → markdown (no brain write) @@ -133,7 +134,9 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`, 2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser) 3. python -m unittest discover -s bin/tools (includes published-docs SoT) 4. bin/facts/audit self (lexicon internal consistency) -5. bin/brain/eval.go (recall@5 ≥ 0.95, gates index regressions) +5. `bin/kb/eval` (recall@5 ≥ 0.95). Local SoT is `bin/brain/eval.go`; CI uses + the Python twin until the runner has ladybug cgo. Questions live in + `internal/brain/rank`. 6. md-docs build/lint if docs tooling arrives. Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as diff --git a/README.md b/README.md index 55f0e4f..6515ea3 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ bin/brain/search.go "invoice from last week" # same s - **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25 in one engine, embedded (no server), ACID, read-only-safe for concurrent - readers. **Never `DROP INDEX` FTS/VECTOR** on Ladybug 0.19: DROP leaves + readers. Read tools (`get` / `stats` / `eval`) are Go + cgo; Python + `bin/kb/{get,stats,eval}` is the CI fallback. **Never `DROP INDEX` FTS/VECTOR** on Ladybug 0.19: DROP leaves ghost catalog tables (`_0_Leaf_vec_UPPER`) so recreate fails while `SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` + `bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts. diff --git a/bin/brain/eval.go b/bin/brain/eval.go index 821c367..0082bdb 100755 --- a/bin/brain/eval.go +++ b/bin/brain/eval.go @@ -1,20 +1,22 @@ -//usr/bin/env go run -tags=brain_eval "$0" "$@"; exit -//go:build brain_eval +//usr/bin/env go run -tags=system_ladybug,brain_eval "$0" "$@"; exit +//go:build cgo && system_ladybug && brain_eval // // bin/brain/eval.go - recall@5 gate. // // ./bin/brain/eval.go // ./bin/brain/eval.go --json // +// Needs CGO + libladybug. Python bin/kb/eval is the CI fallback (no cgo). +// Control questions live in internal/brain/rank (cgo-free). // NOTE: never run `gofmt -w` on this file — it breaks the shebang. package main import ( "os" - "github.com/eSlider/2dph/internal/cmdbin" + "github.com/eSlider/2dph/internal/brain" ) func main() { - os.Exit(cmdbin.ExecFile("bin/kb/eval", os.Args[1:])) + os.Exit(brain.MainEval(os.Args[1:])) } diff --git a/bin/brain/get.go b/bin/brain/get.go index 75784fc..1b432ad 100755 --- a/bin/brain/get.go +++ b/bin/brain/get.go @@ -1,20 +1,22 @@ -//usr/bin/env go run -tags=brain_get "$0" "$@"; exit -//go:build brain_get +//usr/bin/env go run -tags=system_ladybug,brain_get "$0" "$@"; exit +//go:build cgo && system_ladybug && brain_get // // bin/brain/get.go - read one leaf by id. // // ./bin/brain/get.go // ./bin/brain/get.go --body +// ./bin/brain/get.go --json // +// Needs CGO + libladybug. Python bin/kb/get is the CI fallback (no cgo). // NOTE: never run `gofmt -w` on this file — it breaks the shebang. package main import ( "os" - "github.com/eSlider/2dph/internal/cmdbin" + "github.com/eSlider/2dph/internal/brain" ) func main() { - os.Exit(cmdbin.ExecFile("bin/kb/get", os.Args[1:])) + os.Exit(brain.MainGet(os.Args[1:])) } diff --git a/bin/brain/stats.go b/bin/brain/stats.go index 73e1868..29b3cec 100755 --- a/bin/brain/stats.go +++ b/bin/brain/stats.go @@ -1,20 +1,21 @@ -//usr/bin/env go run -tags=brain_stats "$0" "$@"; exit -//go:build brain_stats +//usr/bin/env go run -tags=system_ladybug,brain_stats "$0" "$@"; exit +//go:build cgo && system_ladybug && brain_stats // // bin/brain/stats.go - index health. // // ./bin/brain/stats.go // ./bin/brain/stats.go --json // +// Needs CGO + libladybug. Python bin/kb/stats is the CI fallback (no cgo). // NOTE: never run `gofmt -w` on this file — it breaks the shebang. package main import ( "os" - "github.com/eSlider/2dph/internal/cmdbin" + "github.com/eSlider/2dph/internal/brain" ) func main() { - os.Exit(cmdbin.ExecFile("bin/kb/stats", os.Args[1:])) + os.Exit(brain.MainStats(os.Args[1:])) } diff --git a/bin/tools/test_bin_layout.py b/bin/tools/test_bin_layout.py index db79ebc..81ae569 100644 --- a/bin/tools/test_bin_layout.py +++ b/bin/tools/test_bin_layout.py @@ -77,6 +77,37 @@ class BinLayoutTest(unittest.TestCase): for method in ("index.go", "get.go", "stats.go", "eval.go", "watch.go"): self._assert_shebang(f"bin/brain/{method}") + 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() + self.assertNotIn( + "ExecFile", + text, + f"bin/brain/{method} must call internal/brain, not ExecFile Python", + ) + self.assertNotIn( + "cmdbin", + text, + f"bin/brain/{method} must not import internal/cmdbin", + ) + self.assertIn( + "system_ladybug", + text.splitlines()[0], + f"bin/brain/{method} shebang must pass -tags=system_ladybug", + ) + self.assertIn( + "github.com/eSlider/2dph/internal/brain", + text, + ) + + def test_eval_control_questions_live_in_rank(self) -> None: + rank = (ROOT / "internal" / "brain" / "rank" / "evalq.go").read_text() + py = (ROOT / "bin" / "kb" / "eval").read_text() + for frag in ("BM25", "DevOps", "LadybugDB"): + self.assertIn(frag, rank) + self.assertIn(frag, py) + self.assertIn("0.95", rank) + def test_mail_import_is_shebang_not_brain_write(self) -> None: self._assert_shebang("bin/mail/import.go") index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text() diff --git a/bin/tools/test_published_docs.py b/bin/tools/test_published_docs.py index 68b884f..7af97b8 100644 --- a/bin/tools/test_published_docs.py +++ b/bin/tools/test_published_docs.py @@ -59,6 +59,14 @@ class PublishedDocsTest(unittest.TestCase): self.assertNotIn("password", settings.lower()) self.assertIn("json", settings) + def test_readme_read_path_is_go(self) -> None: + plan = (ROOT / "PLAN.md").read_text() + self.assertIn("get.go", plan) + self.assertIn("CI fallback", plan) + design = (ROOT / "docs" / "design.md").read_text() + self.assertIn("internal/brain/rank", design) + self.assertIn("They do not exec Python", design) + def test_readme_search_escalates_web(self) -> None: text = (ROOT / "README.md").read_text() self.assertIn("--no-web", text) diff --git a/docs/design.md b/docs/design.md index 5acb3fa..52002b1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -61,4 +61,12 @@ corpus HEAD. - C: narrative — READMEs, AGENTS.md, docs Confirmed = A×B or B×C agreement. Single source = hypothesis + `(not confirmed)`. -Conflicting pairings (≥2 yes vs ≥2 no) = hypothesis (OQ1 → v2 resolution). \ No newline at end of file +Conflicting pairings (≥2 yes vs ≥2 no) = hypothesis (OQ1 → v2 resolution). + +## Read path + +`bin/brain/get.go`, `stats.go`, and `eval.go` call `internal/brain` with cgo +(`system_ladybug`). They do not exec Python. Control questions for recall@5 +live in `internal/brain/rank` so CI can test the table without libladybug. +Python `bin/kb/{get,stats,eval}` remain for GitHub Actions until the runner +has ladybug cgo. Index/write is still `bin/kb/index`. \ No newline at end of file diff --git a/internal/brain/modelid.go b/internal/brain/modelid.go new file mode 100644 index 0000000..b948f32 --- /dev/null +++ b/internal/brain/modelid.go @@ -0,0 +1,3 @@ +package brain + +const ModelID = "minishlab/potion-multilingual-128M" diff --git a/internal/brain/rank/evalq.go b/internal/brain/rank/evalq.go new file mode 100644 index 0000000..68ff1d1 --- /dev/null +++ b/internal/brain/rank/evalq.go @@ -0,0 +1,16 @@ +package rank + +// Eval control questions (recall@5). Kept here so CI can test the gate +// table without ladybug cgo. The runner lives in internal/brain (cgo). +const EvalRecallThreshold = 0.95 + +type EvalQuestion struct { + Query string + Fragment string +} + +var EvalQuestions = []EvalQuestion{ + {"hybrid search fts and vector", "BM25"}, + {"eslider devops engineer", "DevOps"}, + {"ladybugdb graph engine storage", "LadybugDB"}, +} diff --git a/internal/brain/rank/evalq_test.go b/internal/brain/rank/evalq_test.go new file mode 100644 index 0000000..8e1918e --- /dev/null +++ b/internal/brain/rank/evalq_test.go @@ -0,0 +1,17 @@ +package rank + +import "testing" + +func TestEvalQuestionsAreThreeAndThreshold(t *testing.T) { + if EvalRecallThreshold != 0.95 { + t.Fatalf("threshold = %v", EvalRecallThreshold) + } + if len(EvalQuestions) != 3 { + t.Fatalf("questions = %d, want 3", len(EvalQuestions)) + } + for _, q := range EvalQuestions { + if q.Query == "" || q.Fragment == "" { + t.Fatalf("empty control: %+v", q) + } + } +} diff --git a/internal/brain/read.go b/internal/brain/read.go new file mode 100644 index 0000000..d5cdf4c --- /dev/null +++ b/internal/brain/read.go @@ -0,0 +1,281 @@ +//go:build cgo && system_ladybug + +package brain + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "unicode/utf8" + + "github.com/eSlider/2dph/internal/brain/rank" +) + +func MainGet(args []string) int { + id, body, jsonOut := "", false, false + for _, a := range args { + switch { + case a == "--body": + body = true + case a == "--json": + jsonOut = true + case a == "-h" || a == "--help": + fmt.Fprintln(os.Stderr, `usage: bin/brain/get.go [--body] [--json]`) + return 0 + case strings.HasPrefix(a, "-"): + fmt.Fprintf(os.Stderr, "brain/get: unknown flag %s\n", a) + return 2 + default: + id = a + } + } + if id == "" { + fmt.Fprintln(os.Stderr, "brain/get: id required") + return 2 + } + if err := openBrain(); err != nil { + fmt.Fprintf(os.Stderr, "open brain: %v\n", err) + return 1 + } + defer closeBrain() + meta, text, err := lookupLeaf(id) + if err != nil { + fmt.Fprintf(os.Stderr, "brain/get: %v\n", err) + return 1 + } + out := Dict{ + {"id", meta["id"]}, + {"root", meta["root"]}, + {"confidence", meta["confidence"]}, + {"source", meta["source"]}, + {"type", meta["type"]}, + } + if body { + out = append(out, KV{"text", text}) + } else { + out = append(out, KV{"snippet", clip(text, 280)}) + } + if jsonOut { + m := map[string]any{} + for _, kv := range out { + m[kv.K] = kv.V + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return b2i(enc.Encode(m)) + } + fmt.Print(toYAML(out, 0)) + return 0 +} + +func MainStats(args []string) int { + jsonOut := false + for _, a := range args { + switch a { + case "--json": + jsonOut = true + case "-h", "--help": + fmt.Fprintln(os.Stderr, `usage: bin/brain/stats.go [--json]`) + return 0 + default: + if strings.HasPrefix(a, "-") { + fmt.Fprintf(os.Stderr, "brain/stats: unknown flag %s\n", a) + return 2 + } + } + } + if err := openBrain(); err != nil { + fmt.Fprintf(os.Stderr, "open brain: %v\n", err) + return 1 + } + defer closeBrain() + s, err := leafStats() + if err != nil { + fmt.Fprintf(os.Stderr, "brain/stats: %v\n", err) + return 1 + } + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return b2i(enc.Encode(s)) + } + by := s["by_root"].(map[string]int) + keys := make([]string, 0, len(by)) + for k := range by { + keys = append(keys, k) + } + sort.Strings(keys) + byRoot := make(Dict, 0, len(keys)) + for _, k := range keys { + byRoot = append(byRoot, KV{k, by[k]}) + } + out := Dict{ + {"total", s["total"]}, + {"by_root", byRoot}, + {"db", s["db"]}, + {"model", s["model"]}, + } + fmt.Print(toYAML(out, 0)) + return 0 +} + +func MainEval(args []string) int { + jsonOut := false + for _, a := range args { + switch a { + case "--json": + jsonOut = true + case "-h", "--help": + fmt.Fprintln(os.Stderr, `usage: bin/brain/eval.go [--json]`) + return 0 + default: + if strings.HasPrefix(a, "-") { + fmt.Fprintf(os.Stderr, "brain/eval: unknown flag %s\n", a) + return 2 + } + } + } + if err := openBrain(); err != nil { + fmt.Fprintf(os.Stderr, "open brain: %v\n", err) + return 1 + } + defer closeBrain() + recalled := 0 + details := make([]any, 0, len(rank.EvalQuestions)) + jsDetails := make([]map[string]any, 0, len(rank.EvalQuestions)) + for _, q := range rank.EvalQuestions { + hits, err := queryFTS(q.Query, 5) + ok := false + if err == nil { + frag := strings.ToLower(q.Fragment) + for _, h := range hits { + if strings.Contains(strings.ToLower(h.Text), frag) { + ok = true + break + } + } + } + if ok { + recalled++ + } + details = append(details, Dict{ + {"q", q.Query}, + {"fragment", q.Fragment}, + {"in_top5", ok}, + }) + jsDetails = append(jsDetails, map[string]any{ + "q": q.Query, "fragment": q.Fragment, "in_top5": ok, + }) + } + n := len(rank.EvalQuestions) + recall := 0.0 + if n > 0 { + recall = float64(recalled) / float64(n) + } + passed := recall >= rank.EvalRecallThreshold + if jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + _ = enc.Encode(map[string]any{ + "recall@5": round3(recall), + "passed": passed, + "gate": n, + "details": jsDetails, + }) + } else { + out := Dict{ + {"recall@5", round3(recall)}, + {"passed", passed}, + {"gate", n}, + {"details", details}, + } + fmt.Print(toYAML(out, 0)) + } + if !passed { + return 2 + } + return 0 +} + +func lookupLeaf(id string) (map[string]string, string, error) { + if conn == nil { + return nil, "", fmt.Errorf("brain not open") + } + stmt, err := conn.Prepare( + "MATCH (l:Leaf {id:$id}) RETURN l.id, l.text, l.root, l.confidence, l.source, l.type", + ) + if err != nil { + return nil, "", err + } + defer stmt.Close() + res, err := conn.Execute(stmt, map[string]any{"id": id}) + if err != nil { + return nil, "", err + } + if !res.HasNext() { + return nil, "", fmt.Errorf("no leaf %s", id) + } + row, err := res.Next() + if err != nil { + return nil, "", err + } + vals, err := row.GetAsSlice() + if err != nil || len(vals) < 6 { + return nil, "", fmt.Errorf("leaf row") + } + meta := map[string]string{ + "id": fmt.Sprint(vals[0]), + "root": fmt.Sprint(vals[2]), + "confidence": fmt.Sprint(vals[3]), + "source": fmt.Sprint(vals[4]), + "type": fmt.Sprint(vals[5]), + } + return meta, fmt.Sprint(vals[1]), nil +} + +func leafStats() (map[string]any, error) { + if conn == nil { + return nil, fmt.Errorf("brain not open") + } + res, err := conn.Query("MATCH (l:Leaf) RETURN l.root, count(*)") + if err != nil { + return nil, err + } + byRoot := map[string]int{} + total := 0 + for res.HasNext() { + row, err := res.Next() + if err != nil { + return nil, err + } + vals, err := row.GetAsSlice() + if err != nil || len(vals) < 2 { + continue + } + n := int(asInt(vals[1])) + byRoot[fmt.Sprint(vals[0])] = n + total += n + } + return map[string]any{ + "total": total, + "by_root": byRoot, + "db": dbPath(), + "model": ModelID, + }, nil +} + +func clip(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + return string([]rune(s)[:n]) +} + +func round3(f float64) float64 { + return float64(int(f*1000+0.5)) / 1000 +} diff --git a/skills/brain/SKILL.md b/skills/brain/SKILL.md index cd33772..d71f4f2 100644 --- a/skills/brain/SKILL.md +++ b/skills/brain/SKILL.md @@ -28,7 +28,7 @@ bin/brain/search.go "onlyoffice postgres" --root facts # restrict to confirmed bin/brain/search.go "where is cs-lexicon" --json | yq '.[].ref' bin/brain/get.go --body # full chunk only when needed bin/brain/stats.go # index health -bin/brain/eval.go # recall@5 >= 0.95 gate +bin/brain/eval.go # recall@5 >= 0.95 gate (Go; Python bin/kb/eval is CI fallback) ``` `bin/kb/search` is a deprecated wrapper. `--hop` errors (File/FROM_FILE edges