Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff80359684 | ||
|
|
36976d9b53 | ||
|
|
8e6f67cc97 | ||
|
|
aca05626bd | ||
|
|
39ae2abe8d | ||
|
|
ba5cc3a6e2 | ||
|
|
15d59054ff | ||
|
|
3f30052ea8 | ||
|
|
c1ee920b0a | ||
|
|
cec0161ff6 | ||
|
|
46310f8773 | ||
|
|
7e0f3c9e06 | ||
|
|
f14025304e | ||
|
|
dd6d7e9395 | ||
|
|
68d478224f | ||
|
|
117f3c2cfd | ||
|
|
140d86a4b9 | ||
|
|
c96c393a4a | ||
|
|
3d0d95cf00 | ||
|
|
ed28fdbd2a | ||
|
|
7e511d5b78 | ||
|
|
0d26519fab | ||
|
|
a429b823e5 | ||
|
|
6847233183 | ||
|
|
6d7638ab73 | ||
|
|
bd1a91dab7 | ||
|
|
a5a1f91d95 | ||
|
|
80e3b7a1cf | ||
|
|
ef4189c72d | ||
|
|
60c20ed98d | ||
|
|
9f22380e82 | ||
|
|
e2eff3b9c7 |
@@ -77,12 +77,15 @@ bin/brain/index.go --rebuild # rebuil
|
||||
## Tools
|
||||
|
||||
```bash
|
||||
bin/facts/audit ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate
|
||||
bin/facts/crm [--dry-run] # proof person↔company/company↔project (ooCRM × corpus SoT)
|
||||
bin/facts/audit.go ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate
|
||||
bin/facts/crm.go [--dry-run] # proof person↔company/company↔project (ooCRM × corpus SoT)
|
||||
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 <id> [--body]
|
||||
bin/brain/get.go <id> [--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/brain/serve.go # HTTP :8630; GET /openapi.json POST /mcp
|
||||
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
|
||||
|
||||
@@ -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. |
|
||||
@@ -43,6 +43,7 @@ detective method: **a fact needs ≥2 independent sources or it is
|
||||
| D17 | assertion gate | Fact-check every *claim* (facts → info → live → web), not every edit. `bin/brain/search.go` adds a `web` block when there is no facts hit (`throttled`/`skipped`/`refused` ≠ absence). `--root` and `--no-web` stay local. Missing graph ≠ “does not exist”. |
|
||||
| D18 | reasoner | Pluggable OpenAI-compatible URL. RAM: Qwen3.5-9B. Quality: Bonsai-27B or Qwen3.6-27B. No official Qwen3.6-9B. |
|
||||
| D19 | git history | [go-git](https://github.com/go-git/go-git) via `bin/git/import.go`. No subprocess of the git binary. Conversion prints commit leafs; brain write is `bin/brain/index.go`. |
|
||||
| D20 | agent API | OpenAPI + MCP are generated from the same `internal/httpapi.Ops` table as `bin/brain/serve.go` handlers. `GET /openapi.json`, `POST /mcp` (JSON-RPC tools/list + tools/call). Tool names match OpenAPI paths (`search`/`get`/`stats`/`audit`). |
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -52,13 +53,13 @@ detective method: **a fact needs ≥2 independent sources or it is
|
||||
docs/ published docs (this conversation → docs/ as md)
|
||||
skills/ in-project skills (web-search, db-yaml, brain, diataxis-docs)
|
||||
bin/
|
||||
facts/extract auto-pair 2 sources → lexicon yaml + graph
|
||||
facts/audit ["self"|"facts"|"info"|"stale"] 2-source + staleness gate
|
||||
facts/extract.go audit.go crm.go # D14 shebang; Python implementation
|
||||
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)
|
||||
brain/serve.go HTTP API in-process + OpenAPI/MCP (D20)
|
||||
mail/import.go JSON → markdown (no brain write)
|
||||
markdown/import.go mistune leaves
|
||||
postgres/query.go read-only YAML (wraps bin/db/psql-yq)
|
||||
@@ -132,8 +133,10 @@ Common props on every node/edge: `root`, `confidence`, `evidence[]`, `how`,
|
||||
1. go vet + go test ./... (root module; packages without ladybug cgo)
|
||||
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)
|
||||
4. `bin/facts/audit self` (lexicon internal consistency; `bin/facts/audit.go` is the D14 wrapper)
|
||||
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
|
||||
|
||||
@@ -28,8 +28,8 @@ graph TB
|
||||
end
|
||||
|
||||
subgraph dph["2dph tools"]
|
||||
EX["bin/facts/extract<br/>2-source pairing"]
|
||||
AU["bin/facts/audit<br/>confidence + staleness"]
|
||||
EX["bin/facts/extract.go<br/>2-source pairing"]
|
||||
AU["bin/facts/audit.go<br/>confidence + staleness"]
|
||||
IDX["bin/brain/index.go<br/>chunk + embed"]
|
||||
MD["bin/markdown/import.go<br/>mistune leaves"]
|
||||
SR["bin/brain/search.go<br/>deduction"]
|
||||
@@ -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.
|
||||
@@ -140,14 +141,14 @@ bin/brain/search.go "invoice from last week" # same s
|
||||
`bin/{subject}/{method}.go` — self-describing: shebang on line 1, usage comment
|
||||
from line 2. Shared code in `internal/`. YAML default output, `--json` for
|
||||
machines. Tests gate every commit. HTTP: `bin/brain/serve.go` calls
|
||||
`internal/brain` in-process (`/health` `/search` `/get` `/stats` `/audit` `/ingest`).
|
||||
`internal/brain` in-process (`/health` `/search` `/get` `/stats` `/audit` `/ingest` `/openapi.json` `/mcp`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv venv .venv # Python 3.12, uv-managed
|
||||
uv pip install -r requirements.lock.txt # pinned toolchain
|
||||
bin/facts/audit self # lexicon consistency gate
|
||||
bin/facts/audit.go self # lexicon consistency gate
|
||||
go test ./... && python -m unittest discover -s bin/tools -t .
|
||||
```
|
||||
|
||||
|
||||
+6
-4
@@ -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:]))
|
||||
}
|
||||
|
||||
+6
-4
@@ -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 <id>
|
||||
// ./bin/brain/get.go <id> --body
|
||||
// ./bin/brain/get.go <id> --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:]))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// KB_ROOT=/path/to/2dph ./bin/brain/serve.go
|
||||
// KB_WORKERS=4 KB_PORT=8630 ./bin/brain/serve.go
|
||||
//
|
||||
// GET /openapi.json same Ops table as the handlers
|
||||
// POST /mcp JSON-RPC tools/list + tools/call
|
||||
//
|
||||
// Needs CGO + libladybug (same as bin/brain/search.go).
|
||||
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||
package main
|
||||
|
||||
+5
-4
@@ -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:]))
|
||||
}
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
//usr/bin/env go run -tags=facts_audit "$0" "$@"; exit
|
||||
//go:build facts_audit
|
||||
//
|
||||
// bin/facts/audit.go - 2-source + lexicon checks.
|
||||
//
|
||||
// ./bin/facts/audit.go self
|
||||
// ./bin/facts/audit.go db
|
||||
//
|
||||
// Python bin/facts/audit is the implementation (CI runs it directly).
|
||||
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/eSlider/2dph/internal/cmdbin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cmdbin.ExecFile("bin/facts/audit", os.Args[1:]))
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
//usr/bin/env go run -tags=facts_crm "$0" "$@"; exit
|
||||
//go:build facts_crm
|
||||
//
|
||||
// bin/facts/crm.go - prove person↔company / company↔project (ooCRM × corpus).
|
||||
//
|
||||
// ./bin/facts/crm.go [--dry-run] [--mismatches]
|
||||
//
|
||||
// Python bin/facts/crm is the implementation. Graph write stays Python.
|
||||
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/eSlider/2dph/internal/cmdbin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cmdbin.ExecFile("bin/facts/crm", os.Args[1:]))
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
//usr/bin/env go run -tags=facts_extract "$0" "$@"; exit
|
||||
//go:build facts_extract
|
||||
//
|
||||
// bin/facts/extract.go - acquire confirmed facts (2-source each).
|
||||
//
|
||||
// ./bin/facts/extract.go [--json] [--dry-run]
|
||||
//
|
||||
// Python bin/facts/extract is the implementation. Graph write stays Python.
|
||||
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/eSlider/2dph/internal/cmdbin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cmdbin.ExecFile("bin/facts/extract", os.Args[1:]))
|
||||
}
|
||||
@@ -77,6 +77,44 @@ 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_facts_methods_are_shebangs(self) -> None:
|
||||
for method in ("audit.go", "extract.go", "crm.go"):
|
||||
self._assert_shebang(f"bin/facts/{method}")
|
||||
text = (ROOT / "bin" / "facts" / method).read_text()
|
||||
self.assertIn("cmdbin.ExecFile", text)
|
||||
self.assertIn(f"bin/facts/{method.removesuffix('.go')}", text)
|
||||
|
||||
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()
|
||||
|
||||
@@ -59,6 +59,22 @@ 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_openapi_mcp_from_same_handlers(self) -> None:
|
||||
plan = (ROOT / "PLAN.md").read_text()
|
||||
self.assertIn("D20", plan)
|
||||
self.assertIn("/openapi.json", (ROOT / "README.md").read_text())
|
||||
self.assertIn("/mcp", (ROOT / "README.md").read_text())
|
||||
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
|
||||
self.assertIn("/mcp", skill)
|
||||
|
||||
def test_readme_search_escalates_web(self) -> None:
|
||||
text = (ROOT / "README.md").read_text()
|
||||
self.assertIn("--no-web", text)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# CRM association proof (oo CLI ↔ corpus)
|
||||
|
||||
Proven with `oo` (eslider/go-onlyoffice) against the OnlyOffice portal
|
||||
(`office.produktor.io`). Portal CRM is the SSOT for company ↔ person ↔
|
||||
project associations; the corpus SoT (`eslider/cv/projects/knowledge-mesh-seed.yaml`)
|
||||
is the second, independent source. Facts that can be backed by both are
|
||||
written to the brain under `root=facts` by `bin/facts/crm`.
|
||||
|
||||
## What was verified
|
||||
|
||||
- Logical counts (portal MySQL): 1300 contacts = 897 persons + 404 companies,
|
||||
198 projects, 998 deals, 939 project↔contact links.
|
||||
- Every client company linked to a project has ≥1 person underneath.
|
||||
- Every person `company_id` resolves to an existing company.
|
||||
- Corpus org list (9) maps 1:1 onto CRM companies:
|
||||
ProProdukt SL / produktor.io, Dyvenia, Immowelt AG, WhereGroup,
|
||||
Keynote SIGOS, D2S/SYSTEMS, GRID, Pack und Cup, Markets Platform.
|
||||
- 78 person↔company association facts written to the brain
|
||||
(`how=crm-crosscheck`, `type=association`). Recall@5 in `bin/kb/eval` = 1.0.
|
||||
|
||||
## Mistakes found
|
||||
|
||||
| # | Mistake | Fix |
|
||||
|---|---------|-----|
|
||||
| 1 | Duplicate legal entity `GoldenRatio.Exchange` (contact 759) vs `Golden Ratio Exchange` (763); 3 deals (211, 287, 559) were linked to 759 | `oo contacts merge 759 763` — 763 kept, 759 removed, deal links re-pointed to 763 |
|
||||
| 2 | `env/`-wide: OnlyOffice creds file used wrong UX (user `eslider`, password with `$2` suffix) making `oo` auth fail | `.env` fixed to `eslider@gmail.com` + clean password; `.env` stays gitignored |
|
||||
|
||||
## Gates after fix
|
||||
|
||||
- `uv run python -m unittest discover -s bin/tools -t .` → 26 tests OK
|
||||
- `bin/facts/audit self` + `bin/facts/audit db` → ok
|
||||
- `bin/kb/eval` → recall@5 = 1.0
|
||||
- `go test ./...` (bin/server + bin/watch) → ok
|
||||
+16
-1
@@ -61,4 +61,19 @@ 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).
|
||||
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`.
|
||||
|
||||
## Agent API (D20)
|
||||
|
||||
`bin/brain/serve.go` exposes the same `internal/httpapi.Ops` table as OpenAPI
|
||||
(`GET /openapi.json`) and MCP (`POST /mcp` JSON-RPC `tools/list` +
|
||||
`tools/call`). Tool names match paths: `search`, `get`, `stats`, `audit`.
|
||||
Agents should use these endpoints instead of shebang CLIs.
|
||||
@@ -0,0 +1,3 @@
|
||||
package brain
|
||||
|
||||
const ModelID = "minishlab/potion-multilingual-128M"
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <id> [--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
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type rpcReq struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type rpcErr struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, OpenAPI())
|
||||
}
|
||||
|
||||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST JSON-RPC"})
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "read body"})
|
||||
return
|
||||
}
|
||||
var req rpcReq
|
||||
if err := json.Unmarshal(raw, &req); err != nil {
|
||||
writeJSON(w, http.StatusOK, rpcResult(nil, nil, &rpcErr{-32700, "parse error"}))
|
||||
return
|
||||
}
|
||||
result, rpcErrv, callErr := s.mcpDispatch(r, req)
|
||||
if callErr != nil {
|
||||
writeJSON(w, http.StatusOK, rpcResult(req.ID, nil, &rpcErr{-32603, callErr.Error()}))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rpcResult(req.ID, result, rpcErrv))
|
||||
}
|
||||
|
||||
func (s *Server) mcpDispatch(r *http.Request, req rpcReq) (any, *rpcErr, error) {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return map[string]any{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": "2dph", "version": "1"},
|
||||
}, nil, nil
|
||||
case "notifications/initialized", "notifications/cancelled":
|
||||
return map[string]any{}, nil, nil
|
||||
case "tools/list":
|
||||
return map[string]any{"tools": MCPTools()}, nil, nil
|
||||
case "tools/call":
|
||||
out, err := s.mcpCall(r, req.Params)
|
||||
return out, nil, err
|
||||
case "ping":
|
||||
return map[string]any{}, nil, nil
|
||||
default:
|
||||
return nil, &rpcErr{-32601, "method not found"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) mcpCall(r *http.Request, params json.RawMessage) (any, error) {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, fmt.Errorf("params")
|
||||
}
|
||||
if p.Arguments == nil {
|
||||
p.Arguments = map[string]any{}
|
||||
}
|
||||
var (
|
||||
body []byte
|
||||
err error
|
||||
)
|
||||
switch p.Name {
|
||||
case "search":
|
||||
q := strings.TrimSpace(fmt.Sprint(p.Arguments["q"]))
|
||||
if q == "" || q == "<nil>" {
|
||||
return mcpText(`{"error":"q required"}`, true), nil
|
||||
}
|
||||
limit := 10
|
||||
if raw, ok := p.Arguments["n"]; ok {
|
||||
switch n := raw.(type) {
|
||||
case float64:
|
||||
limit = int(n)
|
||||
case string:
|
||||
if v, e := strconv.Atoi(n); e == nil {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
return mcpText(`{"error":"n must be int 1..100"}`, true), nil
|
||||
}
|
||||
if !s.tryAcquire(r) {
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
}
|
||||
defer s.release()
|
||||
body, err = s.api.Search(r.Context(), q, limit)
|
||||
case "get":
|
||||
id := strings.TrimSpace(fmt.Sprint(p.Arguments["id"]))
|
||||
if id == "" || id == "<nil>" {
|
||||
return mcpText(`{"error":"id required"}`, true), nil
|
||||
}
|
||||
full := false
|
||||
switch v := p.Arguments["body"].(type) {
|
||||
case bool:
|
||||
full = v
|
||||
case string:
|
||||
full = v == "1" || v == "true"
|
||||
}
|
||||
if !s.tryAcquire(r) {
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
}
|
||||
defer s.release()
|
||||
body, err = s.api.Get(r.Context(), id, full)
|
||||
case "stats":
|
||||
if !s.tryAcquire(r) {
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
}
|
||||
defer s.release()
|
||||
body, err = s.api.Stats(r.Context())
|
||||
case "audit":
|
||||
if !s.tryAcquire(r) {
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
}
|
||||
defer s.release()
|
||||
body, err = s.api.Audit(r.Context())
|
||||
case "ingest":
|
||||
if !s.tryAcquire(r) {
|
||||
return nil, fmt.Errorf("cancelled")
|
||||
}
|
||||
defer s.release()
|
||||
body, err = s.api.Ingest(r.Context())
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool %s", p.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return mcpText(err.Error(), true), nil
|
||||
}
|
||||
return mcpText(string(body), false), nil
|
||||
}
|
||||
|
||||
func mcpText(text string, isError bool) map[string]any {
|
||||
return map[string]any{
|
||||
"content": []any{map[string]any{"type": "text", "text": text}},
|
||||
"isError": isError,
|
||||
}
|
||||
}
|
||||
|
||||
type rpcResp struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcErr `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func rpcResult(id json.RawMessage, result any, err *rpcErr) rpcResp {
|
||||
out := rpcResp{JSONRPC: "2.0", ID: id}
|
||||
if len(id) == 0 {
|
||||
out.ID = []byte("null")
|
||||
}
|
||||
if err != nil {
|
||||
out.Error = err
|
||||
return out
|
||||
}
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
out.Result = result
|
||||
return out
|
||||
}
|
||||
@@ -48,18 +48,22 @@ func NewServer(api API, workers int) http.Handler {
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
case PathHealth:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
case "/search":
|
||||
case PathSearch:
|
||||
s.handleSearch(w, r)
|
||||
case "/get":
|
||||
case PathGet:
|
||||
s.handleGet(w, r)
|
||||
case "/stats":
|
||||
case PathStats:
|
||||
s.handleJSON(w, r, s.api.Stats)
|
||||
case "/audit":
|
||||
case PathAudit:
|
||||
s.handleJSON(w, r, s.api.Audit)
|
||||
case "/ingest":
|
||||
case PathIngest:
|
||||
s.handleJSON(w, r, s.api.Ingest)
|
||||
case PathOpenAPI:
|
||||
s.handleOpenAPI(w, r)
|
||||
case PathMCP:
|
||||
s.handleMCP(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
|
||||
}
|
||||
@@ -112,6 +116,16 @@ func (s *Server) handleJSON(w http.ResponseWriter, r *http.Request, fn func(cont
|
||||
writeAPI(w, body, err)
|
||||
}
|
||||
|
||||
func (s *Server) tryAcquire(r *http.Request) bool {
|
||||
return s.acquire(nopWriter{}, r)
|
||||
}
|
||||
|
||||
type nopWriter struct{}
|
||||
|
||||
func (nopWriter) Header() http.Header { return http.Header{} }
|
||||
func (nopWriter) Write([]byte) (int, error) { return 0, nil }
|
||||
func (nopWriter) WriteHeader(int) {}
|
||||
|
||||
func (s *Server) acquire(w http.ResponseWriter, r *http.Request) bool {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package httpapi
|
||||
|
||||
// Shared HTTP surface: OpenAPI paths and MCP tools are generated from Ops.
|
||||
// ServeHTTP must keep the same path strings.
|
||||
|
||||
type Param struct {
|
||||
Name, In, Type, Description string
|
||||
Required bool
|
||||
}
|
||||
|
||||
type Op struct {
|
||||
Path, Method, ID, Summary string
|
||||
Params []Param
|
||||
MCP bool
|
||||
}
|
||||
|
||||
const (
|
||||
PathHealth = "/health"
|
||||
PathSearch = "/search"
|
||||
PathGet = "/get"
|
||||
PathStats = "/stats"
|
||||
PathAudit = "/audit"
|
||||
PathIngest = "/ingest"
|
||||
PathOpenAPI = "/openapi.json"
|
||||
PathMCP = "/mcp"
|
||||
)
|
||||
|
||||
var Ops = []Op{
|
||||
{Path: PathHealth, Method: "get", ID: "health", Summary: "liveness"},
|
||||
{
|
||||
Path: PathSearch, Method: "get", ID: "search", Summary: "deduction search (facts → info → web)",
|
||||
MCP: true,
|
||||
Params: []Param{
|
||||
{Name: "q", In: "query", Type: "string", Description: "search query", Required: true},
|
||||
{Name: "n", In: "query", Type: "integer", Description: "hit limit 1..100 (default 10)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: PathGet, Method: "get", ID: "get", Summary: "read one leaf by id",
|
||||
MCP: true,
|
||||
Params: []Param{
|
||||
{Name: "id", In: "query", Type: "string", Description: "leaf id", Required: true},
|
||||
{Name: "body", In: "query", Type: "boolean", Description: "include full text"},
|
||||
},
|
||||
},
|
||||
{Path: PathStats, Method: "get", ID: "stats", Summary: "index health", MCP: true},
|
||||
{Path: PathAudit, Method: "get", ID: "audit", Summary: "facts confidence histogram", MCP: true},
|
||||
{Path: PathIngest, Method: "get", ID: "ingest", Summary: "rebuild hint (write is v2)", MCP: true},
|
||||
{Path: PathOpenAPI, Method: "get", ID: "openapi", Summary: "OpenAPI 3 document for this server"},
|
||||
}
|
||||
|
||||
func OpenAPI() map[string]any {
|
||||
paths := map[string]any{}
|
||||
for _, op := range Ops {
|
||||
params := make([]any, 0, len(op.Params))
|
||||
for _, p := range op.Params {
|
||||
params = append(params, map[string]any{
|
||||
"name": p.Name,
|
||||
"in": p.In,
|
||||
"required": p.Required,
|
||||
"description": p.Description,
|
||||
"schema": map[string]any{"type": p.Type},
|
||||
})
|
||||
}
|
||||
item := map[string]any{
|
||||
"operationId": op.ID,
|
||||
"summary": op.Summary,
|
||||
"responses": map[string]any{
|
||||
"200": map[string]any{
|
||||
"description": "JSON",
|
||||
"content": map[string]any{
|
||||
"application/json": map[string]any{
|
||||
"schema": map[string]any{"type": "object"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(params) > 0 {
|
||||
item["parameters"] = params
|
||||
}
|
||||
paths[op.Path] = map[string]any{op.Method: item}
|
||||
}
|
||||
return map[string]any{
|
||||
"openapi": "3.0.3",
|
||||
"info": map[string]any{
|
||||
"title": "2dph brain",
|
||||
"version": "1",
|
||||
"description": "Same handlers as bin/brain/serve.go. MCP tools at POST /mcp match these paths.",
|
||||
},
|
||||
"paths": paths,
|
||||
}
|
||||
}
|
||||
|
||||
type MCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
}
|
||||
|
||||
func MCPTools() []MCPTool {
|
||||
out := make([]MCPTool, 0, len(Ops))
|
||||
for _, op := range Ops {
|
||||
if !op.MCP {
|
||||
continue
|
||||
}
|
||||
props := map[string]any{}
|
||||
var required []string
|
||||
for _, p := range op.Params {
|
||||
props[p.Name] = map[string]any{"type": p.Type, "description": p.Description}
|
||||
if p.Required {
|
||||
required = append(required, p.Name)
|
||||
}
|
||||
}
|
||||
schema := map[string]any{"type": "object", "properties": props}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
out = append(out, MCPTool{
|
||||
Name: op.ID,
|
||||
Description: op.Summary,
|
||||
InputSchema: schema,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAPIIncludesCorePaths(t *testing.T) {
|
||||
doc := OpenAPI()
|
||||
raw, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths, _ := doc["paths"].(map[string]any)
|
||||
for _, p := range []string{"/search", "/get", "/stats", "/audit"} {
|
||||
if _, ok := paths[p]; !ok {
|
||||
t.Fatalf("openapi missing path %s (%s)", p, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolsMatchOpenAPIPaths(t *testing.T) {
|
||||
paths, _ := OpenAPI()["paths"].(map[string]any)
|
||||
tools := MCPTools()
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("no MCP tools")
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tool := range tools {
|
||||
names[tool.Name] = true
|
||||
path := "/" + tool.Name
|
||||
if _, ok := paths[path]; !ok {
|
||||
t.Fatalf("MCP tool %s has no OpenAPI path %s", tool.Name, path)
|
||||
}
|
||||
}
|
||||
for _, need := range []string{"search", "get", "stats", "audit"} {
|
||||
if !names[need] {
|
||||
t.Fatalf("MCP tools missing %s: %v", need, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIHTTP(t *testing.T) {
|
||||
h := NewServer(&fakeSearcher{}, 1)
|
||||
code, body := get(t, h, "/openapi.json")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("code = %d body=%s", code, body)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("not json: %v", err)
|
||||
}
|
||||
if doc["openapi"] == nil {
|
||||
t.Fatalf("missing openapi version: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolsListAndCall(t *testing.T) {
|
||||
h := NewServer(&fakeSearcher{}, 1)
|
||||
code, body := postJSON(t, h, "/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("list code = %d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"search"`) {
|
||||
t.Fatalf("tools/list missing search: %s", body)
|
||||
}
|
||||
code, body = postJSON(t, h, "/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"q":"matrix","n":3}}}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("call code = %d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "matrix") {
|
||||
t.Fatalf("search call body %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, h http.Handler, path, raw string) (int, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code, rec.Body.Bytes()
|
||||
}
|
||||
@@ -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 <id> --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
|
||||
@@ -44,4 +44,6 @@ are not wired yet); do not treat it as a graph walk.
|
||||
are not evidence of absence. `--root facts|info` and `--no-web` skip the web.
|
||||
- If recall looks wrong, run `bin/brain/eval.go`; it gates control questions and
|
||||
should stay at or above 95% recall@5.
|
||||
- Agents: `GET /openapi.json` and `POST /mcp` on `bin/brain/serve.go` (same
|
||||
handlers; tool names match paths `search`/`get`/`stats`/`audit`).
|
||||
- Never report an unconfirmed single-source local answer as fact.
|
||||
Reference in New Issue
Block a user