Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f81b8e25e3 | ||
|
|
a8675ac33b | ||
|
|
de632ba6cc | ||
|
|
66c87842e2 | ||
|
|
20b78a9a20 | ||
|
|
5d4b3427a4 | ||
|
|
1c7db6d499 | ||
|
|
0786ddcb06 | ||
|
|
eeb5b79cf2 | ||
|
|
5990feb1f6 | ||
|
|
d27a738fee | ||
|
|
669e184cf6 | ||
|
|
ebc3f948c1 | ||
|
|
fe6a02024c | ||
|
|
4a065d9838 | ||
|
|
e3c6ef5684 | ||
|
|
98c14e23f1 | ||
|
|
ff1716de40 | ||
|
|
fec5325c7a | ||
|
|
27d9521e7f | ||
|
|
a7cb8d4c76 | ||
|
|
53cd00284d | ||
|
|
b73b4d4f97 | ||
|
|
1d1f6a90ff | ||
|
|
f220bcd95a | ||
|
|
678a1d1dba | ||
|
|
8781c0c3eb | ||
|
|
d6b17e8819 |
@@ -77,15 +77,11 @@ bin/brain/index.go --rebuild # rebuil
|
||||
## Tools
|
||||
|
||||
```bash
|
||||
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/facts/audit ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate
|
||||
bin/facts/crm [--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] [--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/brain/get.go <id> [--body]
|
||||
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`). 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -40,10 +40,9 @@ detective method: **a fact needs ≥2 independent sources or it is
|
||||
| D14 | tooling style | `bin/{subject}/{method}.go` shebang (e.g. `bin/brain/search.go`). Shared code in `internal/`. One root `go.mod` + `go.work`. No `bin/*/main.go`, no nested modules. |
|
||||
| D15 | repo | Gitea [`eSlider/2dph`](https://git.produktor.io/eSlider/2dph) is origin + [issues](https://git.produktor.io/eSlider/2dph/issues). GitHub `eSlider/2dph` is the public clone (PRs + Actions CI). No direct `main` pushes. TDD → PR → CI green → merge. |
|
||||
| D16 | contradictions | ≥2 yes vs ≥2 no → unrelated sources conflict → hypothesis → `(not confirmed)`. Resolution (authority, staleness adjudication) = **v2**, tracked as open question. |
|
||||
| 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”. |
|
||||
| D17 | assertion gate | Fact-check every *claim* (facts → info → live sources → web), not every edit. 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
|
||||
|
||||
@@ -51,15 +50,15 @@ detective method: **a fact needs ≥2 independent sources or it is
|
||||
2dph/
|
||||
PLAN.md / AGENTS.md
|
||||
docs/ published docs (this conversation → docs/ as md)
|
||||
skills/ in-project skills (web-search, postgres, brain, diataxis-docs)
|
||||
skills/ in-project skills (web-search, db-yaml, brain, diataxis-docs)
|
||||
bin/
|
||||
facts/extract.go audit.go crm.go # D14 shebang; Python implementation
|
||||
facts/extract auto-pair 2 sources → lexicon yaml + graph
|
||||
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 # Go read (cgo); Python bin/kb/* CI fallback
|
||||
brain/watch.go
|
||||
brain/get.go stats.go eval.go watch.go
|
||||
brain/search.go deduction: facts → info → web-search
|
||||
brain/serve.go HTTP API in-process + OpenAPI/MCP (D20)
|
||||
brain/serve.go HTTP API in-process (internal/httpapi + internal/brain)
|
||||
mail/import.go JSON → markdown (no brain write)
|
||||
markdown/import.go mistune leaves
|
||||
postgres/query.go read-only YAML (wraps bin/db/psql-yq)
|
||||
@@ -133,10 +132,8 @@ 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; `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`.
|
||||
4. bin/facts/audit self (lexicon internal consistency)
|
||||
5. bin/brain/eval.go (recall@5 ≥ 0.95, gates index regressions)
|
||||
6. md-docs build/lint if docs tooling arrives.
|
||||
|
||||
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
|
||||
@@ -146,7 +143,7 @@ Feedback loop: every commit → PR → CI → green/gate → merge. Same discipl
|
||||
|
||||
1. scaffold repo (:done after this file + AGENTS.md + .gitignore + ci)
|
||||
2. gh repo create eSlider/2dph --private + initial commit + CI
|
||||
3. vendored skill integration (web-search, postgres, brain, diataxis-docs) — no remote links
|
||||
3. vendored skill integration (web-search, db-yaml, brain, diataxis-docs) — no remote links
|
||||
4. .venv: ladybug + model2vec + mistune
|
||||
5. schema + tools with TDD (kb + md + facts + brain)
|
||||
6. ~/.config/brain config
|
||||
|
||||
@@ -28,8 +28,8 @@ graph TB
|
||||
end
|
||||
|
||||
subgraph dph["2dph tools"]
|
||||
EX["bin/facts/extract.go<br/>2-source pairing"]
|
||||
AU["bin/facts/audit.go<br/>confidence + staleness"]
|
||||
EX["bin/facts/extract<br/>2-source pairing"]
|
||||
AU["bin/facts/audit<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"]
|
||||
@@ -85,10 +85,9 @@ fact; conflicting sources or a single source → `hypothesis` → `(not confirme
|
||||
## Deduction search
|
||||
|
||||
```bash
|
||||
bin/brain/search.go "Matrix federation over HTTPS" # facts → info → web
|
||||
bin/brain/search.go "Matrix federation over HTTPS" # facts → info → web-search
|
||||
bin/brain/search.go "onlyoffice postgres" --root facts
|
||||
bin/brain/search.go "where is cs-lexicon" --json | yq '.'
|
||||
bin/brain/search.go "upstream flag" --no-web # local graph only
|
||||
bin/brain/get.go <id> --body # full chunk on demand
|
||||
bin/brain/stats.go # index health
|
||||
bin/brain/eval.go # recall@5 gate
|
||||
@@ -126,8 +125,7 @@ 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. 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
|
||||
readers. **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.
|
||||
@@ -141,14 +139,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` `/openapi.json` `/mcp`).
|
||||
`internal/brain` in-process (`/health` `/search` `/get` `/stats` `/audit` `/ingest`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
uv venv .venv # Python 3.12, uv-managed
|
||||
uv pip install -r requirements.lock.txt # pinned toolchain
|
||||
bin/facts/audit.go self # lexicon consistency gate
|
||||
bin/facts/audit self # lexicon consistency gate
|
||||
go test ./... && python -m unittest discover -s bin/tools -t .
|
||||
```
|
||||
|
||||
@@ -166,7 +164,7 @@ docker compose up brain-watch # auto re-index on change
|
||||
- [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier
|
||||
Neo4j + Qdrant + Matrix RAG brain
|
||||
- [agent-skills](https://github.com/eSlider/agent-skills) — upstream
|
||||
skills (`web-search`, `postgres`, …) that 2dph integrates
|
||||
skills (`web-search`, `db-yaml`, …) that 2dph integrates
|
||||
- detective method — the two-source method
|
||||
|
||||
Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues).
|
||||
|
||||
+4
-6
@@ -1,22 +1,20 @@
|
||||
//usr/bin/env go run -tags=system_ladybug,brain_eval "$0" "$@"; exit
|
||||
//go:build cgo && system_ladybug && brain_eval
|
||||
//usr/bin/env go run -tags=brain_eval "$0" "$@"; exit
|
||||
//go:build 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/brain"
|
||||
"github.com/eSlider/2dph/internal/cmdbin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(brain.MainEval(os.Args[1:]))
|
||||
os.Exit(cmdbin.ExecFile("bin/kb/eval", os.Args[1:]))
|
||||
}
|
||||
|
||||
+4
-6
@@ -1,22 +1,20 @@
|
||||
//usr/bin/env go run -tags=system_ladybug,brain_get "$0" "$@"; exit
|
||||
//go:build cgo && system_ladybug && brain_get
|
||||
//usr/bin/env go run -tags=brain_get "$0" "$@"; exit
|
||||
//go:build 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/brain"
|
||||
"github.com/eSlider/2dph/internal/cmdbin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(brain.MainGet(os.Args[1:]))
|
||||
os.Exit(cmdbin.ExecFile("bin/kb/get", os.Args[1:]))
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// bin/brain/search.go - deduction search over the 2dph brain.
|
||||
//
|
||||
// ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--json] [--no-web]
|
||||
// ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--json]
|
||||
// ./bin/brain/search.go serve [port]
|
||||
// ./bin/brain/search.go --list-model
|
||||
//
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
// 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
|
||||
|
||||
+4
-5
@@ -1,21 +1,20 @@
|
||||
//usr/bin/env go run -tags=system_ladybug,brain_stats "$0" "$@"; exit
|
||||
//go:build cgo && system_ladybug && brain_stats
|
||||
//usr/bin/env go run -tags=brain_stats "$0" "$@"; exit
|
||||
//go:build 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/brain"
|
||||
"github.com/eSlider/2dph/internal/cmdbin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(brain.MainStats(os.Args[1:]))
|
||||
os.Exit(cmdbin.ExecFile("bin/kb/stats", os.Args[1:]))
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
//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:]))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//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:]))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
//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,44 +77,6 @@ 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,31 +59,6 @@ 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)
|
||||
self.assertFalse((ROOT / "skills" / "db-yaml").exists())
|
||||
self.assertTrue((ROOT / "skills" / "postgres" / "SKILL.md").is_file())
|
||||
|
||||
def test_readme_search_escalates_web(self) -> None:
|
||||
text = (ROOT / "README.md").read_text()
|
||||
self.assertIn("--no-web", text)
|
||||
self.assertIn("D17", (ROOT / "PLAN.md").read_text())
|
||||
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
|
||||
self.assertIn("`web` block", skill)
|
||||
|
||||
def test_docs_do_not_claim_hop_walks(self) -> None:
|
||||
paths = [
|
||||
ROOT / "README.md",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Skills must name live commands; every bin/ path in SKILL.md must exist."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BIN_PATH = re.compile(r"(bin/[A-Za-z0-9_./-]+)")
|
||||
|
||||
|
||||
class SkillsTest(unittest.TestCase):
|
||||
def test_db_yaml_renamed_to_postgres(self) -> None:
|
||||
self.assertFalse(
|
||||
(ROOT / "skills" / "db-yaml").exists(),
|
||||
"skills/db-yaml must be skills/postgres",
|
||||
)
|
||||
self.assertTrue((ROOT / "skills" / "postgres" / "SKILL.md").is_file())
|
||||
text = (ROOT / "skills" / "postgres" / "SKILL.md").read_text()
|
||||
self.assertIn("bin/postgres/query.go", text)
|
||||
self.assertNotIn("search.ops.io", text)
|
||||
|
||||
def test_every_bin_path_in_skills_exists(self) -> None:
|
||||
missing: list[str] = []
|
||||
for path in (ROOT / "skills").rglob("SKILL.md"):
|
||||
text = path.read_text()
|
||||
for m in BIN_PATH.finditer(text):
|
||||
rel = m.group(1).rstrip(")`.,;")
|
||||
candidate = ROOT / rel
|
||||
if not candidate.exists():
|
||||
missing.append(f"{path.relative_to(ROOT)}: {rel}")
|
||||
self.assertEqual(missing, [], "skill bin paths must exist")
|
||||
|
||||
def test_brain_skill_lists_generated_tools(self) -> None:
|
||||
tools = (ROOT / "skills" / "brain" / "tools.md").read_text()
|
||||
skill = (ROOT / "skills" / "brain" / "SKILL.md").read_text()
|
||||
self.assertIn("tools.md", skill)
|
||||
for name in ("search", "get", "stats", "audit"):
|
||||
self.assertIn(f"`{name}`", tools)
|
||||
@@ -0,0 +1,33 @@
|
||||
# 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
|
||||
+1
-17
@@ -21,8 +21,7 @@ bin/brain/search.go "question"
|
||||
1. facts root — confirmed answers only → return with evidence links
|
||||
2. info root — supporting narrative → snippets, marked (not confirmed)
|
||||
3. web-search — second independent source → upgrade hypothesis to confirmed
|
||||
(`web` block from `bin/web/search.go` when no facts hit; status `throttled`
|
||||
is not evidence of absence; `--no-web` / `--root` skip it)
|
||||
(`bin/web/search.go`; status `throttled` is not evidence of absence)
|
||||
```
|
||||
|
||||
`--hop` is not implemented yet (needs File/FROM_FILE edges). The flag is an
|
||||
@@ -62,18 +61,3 @@ corpus HEAD.
|
||||
|
||||
Confirmed = A×B or B×C agreement. Single source = hypothesis + `(not confirmed)`.
|
||||
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.
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/eSlider/2dph/internal/brain/rank"
|
||||
)
|
||||
|
||||
// Ready opens the Ladybug file for the life of the serve process.
|
||||
@@ -19,7 +17,7 @@ func Ready() error {
|
||||
// HTTP is the in-process API used by bin/brain/serve.go.
|
||||
type HTTP struct{}
|
||||
|
||||
func (HTTP) Search(ctx context.Context, query string, limit int) ([]byte, error) {
|
||||
func (HTTP) Search(_ context.Context, query string, limit int) ([]byte, error) {
|
||||
hits, err := searchHits(query, "", "", limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -33,13 +31,10 @@ func (HTTP) Search(ctx context.Context, query string, limit int) ([]byte, error)
|
||||
hits[i].Snippet = string(runes)
|
||||
}
|
||||
}
|
||||
webOut := rank.Deduce(hits, query, "", false, func(q string) rank.SecondSource {
|
||||
return lookupWeb(ctx, q)
|
||||
})
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(toJSONOut(hits, query, "", webOut)); err != nil {
|
||||
if err := enc.Encode(toJSONOut(hits, query, "")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
package brain
|
||||
|
||||
const ModelID = "minishlab/potion-multilingual-128M"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json] [--no-web]
|
||||
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json]
|
||||
bin/brain/search.go serve [port]
|
||||
bin/brain/search.go --list-model`
|
||||
|
||||
@@ -17,7 +17,6 @@ type Options struct {
|
||||
Limit int
|
||||
JSONOut bool
|
||||
ListModel bool
|
||||
NoWeb bool
|
||||
}
|
||||
|
||||
// ParseArgs reads flags. Unknown flags are an error: silently dropping them
|
||||
@@ -55,8 +54,6 @@ func ParseArgs(args []string) (Options, error) {
|
||||
return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)")
|
||||
case "--json":
|
||||
opt.JSONOut = true
|
||||
case "--no-web":
|
||||
opt.NoWeb = true
|
||||
case "--list-model":
|
||||
opt.ListModel = true
|
||||
default:
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package rank
|
||||
|
||||
// SecondSource is the web-search block on a deduction answer.
|
||||
// Kept apart from graph hits so "ours" and "not ours" stay visible.
|
||||
type SecondSource struct {
|
||||
Status string `json:"status"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Cached bool `json:"cached,omitempty"`
|
||||
Results []SecondSourceHit `json:"results,omitempty"`
|
||||
}
|
||||
|
||||
type SecondSourceHit struct {
|
||||
Rank int `json:"rank"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
Engine string `json:"engine"`
|
||||
}
|
||||
|
||||
type WebFn func(query string) SecondSource
|
||||
|
||||
// ShouldEscalate is true when the default deduction path has no facts hit.
|
||||
// `--root facts|info` is a single-root ask: do not mix in the web.
|
||||
func ShouldEscalate(hits []Hit, rootFilter string) bool {
|
||||
if rootFilter != "" {
|
||||
return false
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h.Root == "facts" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Deduce returns the second-source block, or nil when web must not run.
|
||||
func Deduce(hits []Hit, query, rootFilter string, noWeb bool, web WebFn) *SecondSource {
|
||||
if noWeb || web == nil || !ShouldEscalate(hits, rootFilter) {
|
||||
return nil
|
||||
}
|
||||
out := web(query)
|
||||
return &out
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package rank
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldEscalateWhenNoFacts(t *testing.T) {
|
||||
if !ShouldEscalate(nil, "") {
|
||||
t.Fatal("empty local graph must escalate")
|
||||
}
|
||||
if !ShouldEscalate([]Hit{h("i", "info", "docs/a.md")}, "") {
|
||||
t.Fatal("info-only must escalate (not confirmed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldNotEscalateWhenFactsConfirm(t *testing.T) {
|
||||
hits := []Hit{h("f", "facts", "docker ps x compose"), h("i", "info", "docs/a.md")}
|
||||
if ShouldEscalate(hits, "") {
|
||||
t.Fatal("facts hit is already confirmed; do not mix web")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldNotEscalateWhenRootFilterSet(t *testing.T) {
|
||||
if ShouldEscalate(nil, "facts") {
|
||||
t.Fatal("--root facts must stay local")
|
||||
}
|
||||
if ShouldEscalate([]Hit{h("i", "info", "x")}, "info") {
|
||||
t.Fatal("--root info must stay local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeduceCallsWebOnlyWhenEscalating(t *testing.T) {
|
||||
called := 0
|
||||
web := func(q string) SecondSource {
|
||||
called++
|
||||
if q != "LadybugDB" {
|
||||
t.Fatalf("query = %q", q)
|
||||
}
|
||||
return SecondSource{Status: "ok", Results: []SecondSourceHit{{Title: "t", URL: "http://example.com"}}}
|
||||
}
|
||||
got := Deduce([]Hit{h("i", "info", "x")}, "LadybugDB", "", false, web)
|
||||
if called != 1 || got == nil || got.Status != "ok" {
|
||||
t.Fatalf("got %+v called=%d", got, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeduceNilWhenFactsOrNoWeb(t *testing.T) {
|
||||
web := func(string) SecondSource {
|
||||
t.Fatal("web must not run")
|
||||
return SecondSource{}
|
||||
}
|
||||
if Deduce([]Hit{h("f", "facts", "x")}, "q", "", false, web) != nil {
|
||||
t.Fatal("facts")
|
||||
}
|
||||
if Deduce([]Hit{h("i", "info", "x")}, "q", "", true, web) != nil {
|
||||
t.Fatal("--no-web")
|
||||
}
|
||||
if Deduce(nil, "q", "facts", false, web) != nil {
|
||||
t.Fatal("--root facts")
|
||||
}
|
||||
if Deduce(nil, "q", "", false, nil) != nil {
|
||||
t.Fatal("nil web fn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNoWeb(t *testing.T) {
|
||||
opt, err := ParseArgs([]string{"query", "--no-web", "--json"})
|
||||
if err != nil || !opt.NoWeb || !opt.JSONOut || opt.Query != "query" {
|
||||
t.Fatalf("got %+v err=%v", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageNamesNoWeb(t *testing.T) {
|
||||
if !strings.Contains(Usage, "--no-web") {
|
||||
t.Fatalf("usage must name --no-web, got:\n%s", Usage)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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"},
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -68,25 +68,18 @@ func runSearch(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
webOut := rank.Deduce(results, query, root, opt.NoWeb, func(q string) rank.SecondSource {
|
||||
return lookupWeb(context.Background(), q)
|
||||
})
|
||||
|
||||
out := Dict{
|
||||
{"query", query},
|
||||
{"root_filter", root},
|
||||
{"count", len(results)},
|
||||
{"results", resultsToDicts(results)},
|
||||
}
|
||||
if webOut != nil {
|
||||
out = append(out, KV{"web", secondToDict(*webOut)})
|
||||
}
|
||||
|
||||
if jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
return b2i(enc.Encode(toJSONOut(results, query, root, webOut)))
|
||||
return b2i(enc.Encode(toJSONOut(results, query, root)))
|
||||
}
|
||||
fmt.Print(toYAML(out, 0))
|
||||
return 0
|
||||
@@ -179,7 +172,6 @@ type jsonOut struct {
|
||||
RootFilter string `json:"root_filter"`
|
||||
Count int `json:"count"`
|
||||
Results []jsonHit `json:"results"`
|
||||
Web *rank.SecondSource `json:"web,omitempty"`
|
||||
}
|
||||
|
||||
type jsonHit struct {
|
||||
@@ -190,7 +182,7 @@ type jsonHit struct {
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
}
|
||||
|
||||
func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *jsonOut {
|
||||
func toJSONOut(hits []Hit, query, rootFilter string) *jsonOut {
|
||||
out := make([]jsonHit, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = jsonHit{
|
||||
@@ -206,7 +198,6 @@ func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *js
|
||||
RootFilter: rootFilter,
|
||||
Count: len(hits),
|
||||
Results: out,
|
||||
Web: web,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package brain
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/eSlider/2dph/internal/brain/rank"
|
||||
"github.com/eSlider/2dph/internal/websearch"
|
||||
)
|
||||
|
||||
func lookupWeb(ctx context.Context, query string) rank.SecondSource {
|
||||
o := websearch.Lookup(ctx, query, websearch.LookupOpt{Limit: 5})
|
||||
return toSecond(o)
|
||||
}
|
||||
|
||||
func toSecond(o websearch.Output) rank.SecondSource {
|
||||
hits := make([]rank.SecondSourceHit, 0, len(o.Results))
|
||||
for _, h := range o.Results {
|
||||
hits = append(hits, rank.SecondSourceHit{
|
||||
Rank: h.Rank,
|
||||
Title: h.Title,
|
||||
URL: h.URL,
|
||||
Snippet: h.Snippet,
|
||||
Engine: h.Engine,
|
||||
})
|
||||
}
|
||||
return rank.SecondSource{
|
||||
Status: o.Status,
|
||||
Note: o.Note,
|
||||
Cached: o.Cached,
|
||||
Results: hits,
|
||||
}
|
||||
}
|
||||
|
||||
func secondToDict(w rank.SecondSource) Dict {
|
||||
d := Dict{
|
||||
{"status", w.Status},
|
||||
}
|
||||
if w.Note != "" {
|
||||
d = append(d, KV{"note", w.Note})
|
||||
}
|
||||
if w.Cached {
|
||||
d = append(d, KV{"cached", true})
|
||||
}
|
||||
rows := make([]any, 0, len(w.Results))
|
||||
for _, h := range w.Results {
|
||||
rows = append(rows, Dict{
|
||||
{"rank", h.Rank},
|
||||
{"title", h.Title},
|
||||
{"url", h.URL},
|
||||
{"snippet", h.Snippet},
|
||||
{"engine", h.Engine},
|
||||
})
|
||||
}
|
||||
d = append(d, KV{"results", rows})
|
||||
return d
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
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,22 +48,18 @@ func NewServer(api API, workers int) http.Handler {
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case PathHealth:
|
||||
case "/health":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
case PathSearch:
|
||||
case "/search":
|
||||
s.handleSearch(w, r)
|
||||
case PathGet:
|
||||
case "/get":
|
||||
s.handleGet(w, r)
|
||||
case PathStats:
|
||||
case "/stats":
|
||||
s.handleJSON(w, r, s.api.Stats)
|
||||
case PathAudit:
|
||||
case "/audit":
|
||||
s.handleJSON(w, r, s.api.Audit)
|
||||
case PathIngest:
|
||||
case "/ingest":
|
||||
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"})
|
||||
}
|
||||
@@ -116,16 +112,6 @@ 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{}{}:
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import "strings"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// SkillMarkdown is the Cursor skill fragment generated from Ops/MCPTools.
|
||||
func SkillMarkdown() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# brain HTTP / MCP tools\n\n")
|
||||
b.WriteString("Generated from `internal/httpapi.Ops`. Do not edit by hand.\n\n")
|
||||
b.WriteString("Serve: `bin/brain/serve.go` (`GET /openapi.json`, `POST /mcp`).\n\n")
|
||||
for _, t := range MCPTools() {
|
||||
b.WriteString("- `")
|
||||
b.WriteString(t.Name)
|
||||
b.WriteString("` — ")
|
||||
b.WriteString(t.Description)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 TestSkillMarkdownMatchesCommittedFile(t *testing.T) {
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", "skills", "brain", "tools.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := SkillMarkdown()
|
||||
if got != string(want) {
|
||||
t.Fatalf("skills/brain/tools.md stale; regenerate from SkillMarkdown()\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusSkipped = "skipped"
|
||||
StatusRefused = "refused"
|
||||
)
|
||||
|
||||
type LookupOpt struct {
|
||||
Limit int
|
||||
Timeout time.Duration
|
||||
EnvPath string
|
||||
CachePath string
|
||||
Client *http.Client
|
||||
Now func() float64
|
||||
Sleep func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
func Lookup(ctx context.Context, query string, opt LookupOpt) Output {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if opt.Limit <= 0 {
|
||||
opt.Limit = DefaultLimit
|
||||
}
|
||||
if opt.Timeout <= 0 {
|
||||
opt.Timeout = 25 * time.Second
|
||||
}
|
||||
nowFn := opt.Now
|
||||
if nowFn == nil {
|
||||
nowFn = func() float64 { return float64(time.Now().Unix()) }
|
||||
}
|
||||
sleepFn := opt.Sleep
|
||||
if sleepFn == nil {
|
||||
sleepFn = func(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reason := PHIReason(query); reason != "" {
|
||||
return Output{Query: query, Status: StatusRefused, Note: reason}
|
||||
}
|
||||
|
||||
cachePath := opt.CachePath
|
||||
if cachePath == "" {
|
||||
cachePath = os.Getenv("BRAIN_SEARCH_CACHE")
|
||||
}
|
||||
if cachePath == "" {
|
||||
cachePath = os.Getenv("HOME") + "/.cache/brain/web-search.sqlite"
|
||||
}
|
||||
cache, err := OpenCache(cachePath)
|
||||
if err != nil {
|
||||
return Output{Query: query, Status: StatusSkipped, Note: "cache: " + err.Error()}
|
||||
}
|
||||
defer cache.Close()
|
||||
|
||||
key := CacheKey(query, nil)
|
||||
now := nowFn()
|
||||
if cached, err := cache.Get(key, CacheTTL, now); err == nil && cached != nil {
|
||||
out := Project(*cached, opt.Limit, DefaultSnippetChars)
|
||||
out.Cached = true
|
||||
return out
|
||||
}
|
||||
|
||||
envPath := opt.EnvPath
|
||||
if envPath == "" {
|
||||
envPath = os.Getenv("BRAIN_SEARCH_ENV")
|
||||
}
|
||||
if envPath == "" {
|
||||
envPath = os.Getenv("HOME") + "/.config/brain/search.env"
|
||||
}
|
||||
conf, err := LoadConfig(envPath)
|
||||
if err != nil {
|
||||
return Output{Query: query, Status: StatusSkipped, Note: "no BRAIN_SEARCH_URL; second source not consulted"}
|
||||
}
|
||||
|
||||
lock, err := os.OpenFile(cachePath+".lock", os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return Output{Query: query, Status: StatusSkipped, Note: "lock: " + err.Error()}
|
||||
}
|
||||
defer lock.Close()
|
||||
if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX); err != nil {
|
||||
return Output{Query: query, Status: StatusSkipped, Note: "lock: " + err.Error()}
|
||||
}
|
||||
defer unix.Flock(int(lock.Fd()), unix.LOCK_UN)
|
||||
|
||||
last, err := cache.LastCall()
|
||||
if err != nil {
|
||||
return Output{Query: query, Status: StatusSkipped, Note: "cache: " + err.Error()}
|
||||
}
|
||||
if delay := WaitFor(last, nowFn(), MinInterval); delay > 0 {
|
||||
if err := sleepFn(ctx, time.Duration(delay*float64(time.Second))); err != nil {
|
||||
return Output{Query: query, Status: StatusSkipped, Note: "cancelled"}
|
||||
}
|
||||
}
|
||||
_ = cache.MarkCall(nowFn())
|
||||
|
||||
payload, err := Fetch(opt.Client, conf, query, nil, opt.Timeout)
|
||||
if err != nil {
|
||||
return Output{Query: query, Status: StatusThrottled, Note: fmt.Sprintf("request failed: %v", err)}
|
||||
}
|
||||
if Classify(payload) == StatusOK {
|
||||
_ = cache.Put(key, payload, nowFn())
|
||||
}
|
||||
return Project(payload, opt.Limit, DefaultSnippetChars)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package websearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLookupRefusesPIIWithoutFetch(t *testing.T) {
|
||||
hits := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
hits++
|
||||
}))
|
||||
defer srv.Close()
|
||||
out := Lookup(context.Background(), "Personalnummer 12", LookupOpt{
|
||||
EnvPath: writeEnv(t, srv.URL),
|
||||
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
|
||||
Client: srv.Client(),
|
||||
Sleep: func(context.Context, time.Duration) error { return nil },
|
||||
})
|
||||
if out.Status != StatusRefused {
|
||||
t.Fatalf("status = %s", out.Status)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatal("PII query left the host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupSkipsWhenNoConfig(t *testing.T) {
|
||||
out := Lookup(context.Background(), "LadybugDB", LookupOpt{
|
||||
EnvPath: filepath.Join(t.TempDir(), "missing.env"),
|
||||
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
|
||||
Sleep: func(context.Context, time.Duration) error { return nil },
|
||||
})
|
||||
if out.Status != StatusSkipped {
|
||||
t.Fatalf("status = %s", out.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupFetchesOnceAndCaches(t *testing.T) {
|
||||
hits := 0
|
||||
payload := Payload{Query: "x", Results: []RawHit{{Title: "t", URL: "http://example.com", Content: "c", Engine: "bing"}}}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
opt := LookupOpt{
|
||||
EnvPath: writeEnv(t, srv.URL),
|
||||
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
|
||||
Client: srv.Client(),
|
||||
Now: func() float64 { return 1_000 },
|
||||
Sleep: func(context.Context, time.Duration) error { return nil },
|
||||
}
|
||||
a := Lookup(context.Background(), "LadybugDB", opt)
|
||||
b := Lookup(context.Background(), "LadybugDB", opt)
|
||||
if a.Status != StatusOK || b.Status != StatusOK {
|
||||
t.Fatalf("a=%s b=%s", a.Status, b.Status)
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("hits = %d, want 1 (second from cache)", hits)
|
||||
}
|
||||
if !b.Cached {
|
||||
t.Fatal("second lookup not cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupEmptyIsThrottled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"query":"x","results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
out := Lookup(context.Background(), "LadybugDB", LookupOpt{
|
||||
EnvPath: writeEnv(t, srv.URL),
|
||||
CachePath: filepath.Join(t.TempDir(), "c.sqlite"),
|
||||
Client: srv.Client(),
|
||||
Now: func() float64 { return 1_000 },
|
||||
Sleep: func(context.Context, time.Duration) error { return nil },
|
||||
})
|
||||
if out.Status != StatusThrottled {
|
||||
t.Fatalf("status = %s", out.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func writeEnv(t *testing.T, url string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "search.env")
|
||||
if err := os.WriteFile(p, []byte("BRAIN_SEARCH_URL="+url+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -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 (Go; Python bin/kb/eval is CI fallback)
|
||||
bin/brain/eval.go # recall@5 >= 0.95 gate
|
||||
```
|
||||
|
||||
`bin/kb/search` is a deprecated wrapper. `--hop` errors (File/FROM_FILE edges
|
||||
@@ -39,12 +39,8 @@ are not wired yet); do not treat it as a graph walk.
|
||||
- Search before you read. Never grep a repo for a concept the graph covers.
|
||||
- `--root facts` returns only confirmed evidence-linked answers. Default shows
|
||||
facts first, then info leafs clearly marked `(not confirmed)`.
|
||||
- If there is no facts hit, `bin/brain/search.go` consults SearXNG and adds a
|
||||
`web` block (kept apart from graph hits). `throttled` / `skipped` / `refused`
|
||||
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`). Generated
|
||||
list: [tools.md](tools.md).
|
||||
- Never report an unconfirmed single-source local answer as fact.
|
||||
- Escalate to `web-search` (the `web-search` skill) as the independent second
|
||||
source when both local roots cannot confirm; never report an unconfirmed
|
||||
single-source local answer as fact.
|
||||
@@ -1,11 +0,0 @@
|
||||
# brain HTTP / MCP tools
|
||||
|
||||
Generated from `internal/httpapi.Ops`. Do not edit by hand.
|
||||
|
||||
Serve: `bin/brain/serve.go` (`GET /openapi.json`, `POST /mcp`).
|
||||
|
||||
- `search` — deduction search (facts → info → web)
|
||||
- `get` — read one leaf by id
|
||||
- `stats` — index health
|
||||
- `audit` — facts confidence histogram
|
||||
- `ingest` — rebuild hint (write is v2)
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: db-yaml
|
||||
description: >-
|
||||
Read any Postgres as compact YAML through db/psql-yq, with a read-only guard and
|
||||
named profiles. Use when a task needs table contents, column types or a SELECT
|
||||
against cs_brain or another project database.
|
||||
---
|
||||
|
||||
# db-yaml
|
||||
|
||||
`bin/db/psql-yq` (vendored in this repo) talks to Postgres and returns YAML,
|
||||
which is far cheaper than a psql ASCII table and easy to slice with `yq`.
|
||||
|
||||
```bash
|
||||
bin/db/psql-yq --profile onlyoffice -s document_asset # column list
|
||||
bin/db/psql-yq --profile onlyoffice -t task_result -l 20 # sample rows as YAML
|
||||
bin/db/psql-yq --profile onlyoffice -c 'SELECT ...' # query -> YAML
|
||||
```
|
||||
|
||||
Ad-hoc targets without a profile:
|
||||
|
||||
```bash
|
||||
bin/db/psql-yq --container my-pg --db app -c 'SELECT 1'
|
||||
bin/db/psql-yq --dsn 'postgres://user@host:5432/db' -c 'SELECT 1'
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
Connection details live in `~/.config/brain/db-profiles.yml` (mode 600), never in a
|
||||
project repo. A profile names either a `container` or a `host`; passwords are read
|
||||
from a separate `password_env_file` and never appear in argv.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Read-only.** Any `insert|update|delete|drop|truncate|alter|create|grant|
|
||||
revoke|vacuum|copy` is rejected with exit 3. Do not work around it.
|
||||
- **PII.** `cs_brain` holds client data. Aggregate and count freely; never copy
|
||||
names or addresses into chat, issues or docs.
|
||||
- Use `-l` to keep samples small. Twenty rows answer most questions.
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: postgres
|
||||
description: >-
|
||||
Read Postgres as compact YAML through bin/postgres/query.go (read-only
|
||||
guard, named profiles). Use when a task needs table contents, column types,
|
||||
or a SELECT against an ops database.
|
||||
---
|
||||
|
||||
# postgres
|
||||
|
||||
`bin/postgres/query.go` wraps vendored `bin/db/psql-yq`. Output is YAML
|
||||
(cheaper than psql ASCII, easy to slice with `yq`).
|
||||
|
||||
```bash
|
||||
bin/postgres/query.go --profile onlyoffice -s document_asset # column list
|
||||
bin/postgres/query.go --profile onlyoffice -t task_result -l 20 # sample rows
|
||||
bin/postgres/query.go --profile onlyoffice -c 'SELECT ...' # query → YAML
|
||||
```
|
||||
|
||||
Ad-hoc targets without a profile:
|
||||
|
||||
```bash
|
||||
bin/postgres/query.go --container my-pg --db app -c 'SELECT 1'
|
||||
bin/postgres/query.go --dsn 'postgres://user@host:5432/db' -c 'SELECT 1'
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
Connection details live in `$HOME/.config/brain/db-profiles.yml` (mode 600),
|
||||
never in a project repo. A profile names either a `container` or a `host`;
|
||||
passwords are read from a separate `password_env_file` and never appear in argv.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Read-only.** Any `insert|update|delete|drop|truncate|alter|create|grant|
|
||||
revoke|vacuum|copy` is rejected with exit 3. Do not work around it.
|
||||
- **PII.** Client CRM databases: aggregate and count freely; never copy names
|
||||
or addresses into chat, issues or docs.
|
||||
- Use `-l` to keep samples small. Twenty rows answer most questions.
|
||||
Reference in New Issue
Block a user