Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a05803f4b | ||
|
|
ad83e2a12f | ||
|
|
d894c6609f | ||
|
|
ff80359684 | ||
|
|
36976d9b53 | ||
|
|
8e6f67cc97 | ||
|
|
aca05626bd |
@@ -15,6 +15,9 @@ Read first: [PLAN](PLAN.md) → [docs](docs/).
|
|||||||
- `info` root = descriptive/narrative leafs, searchable, never asserted as fact.
|
- `info` root = descriptive/narrative leafs, searchable, never asserted as fact.
|
||||||
- Search is deduction: `facts` → `info` → `web-search` (second independent
|
- Search is deduction: `facts` → `info` → `web-search` (second independent
|
||||||
source). An answer is `confirmed` only if it comes off the facts root.
|
source). An answer is `confirmed` only if it comes off the facts root.
|
||||||
|
- Fact-check every *claim* (facts → info → live → web), not every edit or
|
||||||
|
syntax tweak. PicoClaw: `search` then `get` then `audit` before a factual
|
||||||
|
reply (`skills/picoclaw/SKILL.md`). `throttled` is not a negative finding.
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
@@ -77,11 +80,15 @@ bin/brain/index.go --rebuild # rebuil
|
|||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bin/facts/audit ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate
|
bin/facts/audit.go ["self"|"facts"|"info"|"stale"] # 2-source + staleness gate
|
||||||
bin/facts/crm [--dry-run] # proof person↔company/company↔project (ooCRM × corpus SoT)
|
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/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" [--root facts|info] # deduction search → YAML
|
||||||
bin/brain/get.go <id> [--body]
|
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/markdown/import.go [dir] # mistune leaves → YAML
|
bin/markdown/import.go [dir] # mistune leaves → YAML
|
||||||
bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs
|
bin/git/import.go [REPO] [--json] [--limit N] # go-git history → commit leafs
|
||||||
bin/web/search.go "query" [--json] # SearXNG; throttled ≠ absence
|
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”. |
|
| 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. |
|
| 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). |
|
| 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`). |
|
| 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. |
|
| 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. |
|
| D9 | facts/goal model | Who / What / How / Where / When + evidence + confidence on every edge. |
|
||||||
@@ -40,9 +40,10 @@ 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. |
|
| 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. |
|
| 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. |
|
| 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 sources → web), not every edit. Missing graph ≠ “does not exist”. |
|
| 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. |
|
| 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`. |
|
| 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
|
## Architecture
|
||||||
|
|
||||||
@@ -50,15 +51,15 @@ detective method: **a fact needs ≥2 independent sources or it is
|
|||||||
2dph/
|
2dph/
|
||||||
PLAN.md / AGENTS.md
|
PLAN.md / AGENTS.md
|
||||||
docs/ published docs (this conversation → docs/ as md)
|
docs/ published docs (this conversation → docs/ as md)
|
||||||
skills/ in-project skills (web-search, db-yaml, brain, diataxis-docs)
|
skills/ in-project skills (web-search, postgres, brain, picoclaw, diataxis-docs)
|
||||||
bin/
|
bin/
|
||||||
facts/extract auto-pair 2 sources → lexicon yaml + graph
|
facts/extract.go audit.go crm.go # D14 shebang; Python implementation
|
||||||
facts/audit ["self"|"facts"|"info"|"stale"] 2-source + staleness gate
|
|
||||||
kb/index Python write path (called by bin/brain/index.go)
|
kb/index Python write path (called by bin/brain/index.go)
|
||||||
brain/index.go rebuild FTS + HNSW (incl. --with-mail)
|
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/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); compose profile picoclaw
|
||||||
mail/import.go JSON → markdown (no brain write)
|
mail/import.go JSON → markdown (no brain write)
|
||||||
markdown/import.go mistune leaves
|
markdown/import.go mistune leaves
|
||||||
postgres/query.go read-only YAML (wraps bin/db/psql-yq)
|
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)
|
1. go vet + go test ./... (root module; packages without ladybug cgo)
|
||||||
2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser)
|
2. `go test ./internal/brain/rank` (cgo-free ranking + flag parser)
|
||||||
3. python -m unittest discover -s bin/tools (includes published-docs SoT)
|
3. python -m unittest discover -s bin/tools (includes published-docs SoT)
|
||||||
4. bin/facts/audit self (lexicon internal consistency)
|
4. `bin/facts/audit self` (lexicon internal consistency; `bin/facts/audit.go` is the D14 wrapper)
|
||||||
5. bin/brain/eval.go (recall@5 ≥ 0.95, gates index regressions)
|
5. `bin/kb/eval` (recall@5 ≥ 0.95). Local SoT is `bin/brain/eval.go`; CI uses
|
||||||
|
the Python twin until the runner has ladybug cgo. Questions live in
|
||||||
|
`internal/brain/rank`.
|
||||||
6. md-docs build/lint if docs tooling arrives.
|
6. md-docs build/lint if docs tooling arrives.
|
||||||
|
|
||||||
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
|
Feedback loop: every commit → PR → CI → green/gate → merge. Same discipline as
|
||||||
@@ -143,7 +146,7 @@ Feedback loop: every commit → PR → CI → green/gate → merge. Same discipl
|
|||||||
|
|
||||||
1. scaffold repo (:done after this file + AGENTS.md + .gitignore + ci)
|
1. scaffold repo (:done after this file + AGENTS.md + .gitignore + ci)
|
||||||
2. gh repo create eSlider/2dph --private + initial commit + CI
|
2. gh repo create eSlider/2dph --private + initial commit + CI
|
||||||
3. vendored skill integration (web-search, db-yaml, brain, diataxis-docs) — no remote links
|
3. vendored skill integration (web-search, postgres, brain, diataxis-docs) — no remote links
|
||||||
4. .venv: ladybug + model2vec + mistune
|
4. .venv: ladybug + model2vec + mistune
|
||||||
5. schema + tools with TDD (kb + md + facts + brain)
|
5. schema + tools with TDD (kb + md + facts + brain)
|
||||||
6. ~/.config/brain config
|
6. ~/.config/brain config
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ graph TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph dph["2dph tools"]
|
subgraph dph["2dph tools"]
|
||||||
EX["bin/facts/extract<br/>2-source pairing"]
|
EX["bin/facts/extract.go<br/>2-source pairing"]
|
||||||
AU["bin/facts/audit<br/>confidence + staleness"]
|
AU["bin/facts/audit.go<br/>confidence + staleness"]
|
||||||
IDX["bin/brain/index.go<br/>chunk + embed"]
|
IDX["bin/brain/index.go<br/>chunk + embed"]
|
||||||
MD["bin/markdown/import.go<br/>mistune leaves"]
|
MD["bin/markdown/import.go<br/>mistune leaves"]
|
||||||
SR["bin/brain/search.go<br/>deduction"]
|
SR["bin/brain/search.go<br/>deduction"]
|
||||||
@@ -85,9 +85,10 @@ fact; conflicting sources or a single source → `hypothesis` → `(not confirme
|
|||||||
## Deduction search
|
## Deduction search
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bin/brain/search.go "Matrix federation over HTTPS" # facts → info → web-search
|
bin/brain/search.go "Matrix federation over HTTPS" # facts → info → web
|
||||||
bin/brain/search.go "onlyoffice postgres" --root facts
|
bin/brain/search.go "onlyoffice postgres" --root facts
|
||||||
bin/brain/search.go "where is cs-lexicon" --json | yq '.'
|
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/get.go <id> --body # full chunk on demand
|
||||||
bin/brain/stats.go # index health
|
bin/brain/stats.go # index health
|
||||||
bin/brain/eval.go # recall@5 gate
|
bin/brain/eval.go # recall@5 gate
|
||||||
@@ -125,7 +126,8 @@ bin/brain/search.go "invoice from last week" # same s
|
|||||||
|
|
||||||
- **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25
|
- **LadybugDB** — single `var/kb.lbug`, Cypher property graph, HNSW + BM25
|
||||||
in one engine, embedded (no server), ACID, read-only-safe for concurrent
|
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
|
ghost catalog tables (`_0_Leaf_vec_UPPER`) so recreate fails while
|
||||||
`SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` +
|
`SHOW_INDEXES` omits HNSW. Fresh indexes = delete `var/kb.lbug` +
|
||||||
`bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts.
|
`bin/brain/index.go --rebuild`. Use `ensure_indexes()` after upserts.
|
||||||
@@ -139,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
|
`bin/{subject}/{method}.go` — self-describing: shebang on line 1, usage comment
|
||||||
from line 2. Shared code in `internal/`. YAML default output, `--json` for
|
from line 2. Shared code in `internal/`. YAML default output, `--json` for
|
||||||
machines. Tests gate every commit. HTTP: `bin/brain/serve.go` calls
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv venv .venv # Python 3.12, uv-managed
|
uv venv .venv # Python 3.12, uv-managed
|
||||||
uv pip install -r requirements.lock.txt # pinned toolchain
|
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 .
|
go test ./... && python -m unittest discover -s bin/tools -t .
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -156,6 +158,7 @@ Docker (optional, cached model + var volumes):
|
|||||||
docker compose run --rm brain index # (re)index corpus
|
docker compose run --rm brain index # (re)index corpus
|
||||||
docker compose run --rm brain search "query" # one-shot query
|
docker compose run --rm brain search "query" # one-shot query
|
||||||
docker compose run --rm brain serve # bin/brain/serve.go
|
docker compose run --rm brain serve # bin/brain/serve.go
|
||||||
|
docker compose --profile picoclaw up brain-mcp # MCP on 127.0.0.1:8630
|
||||||
docker compose up brain-watch # auto re-index on change
|
docker compose up brain-watch # auto re-index on change
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -164,7 +167,7 @@ docker compose up brain-watch # auto re-index on change
|
|||||||
- [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier
|
- [go-second-brain](https://github.com/eSlider/go-second-brain) — the earlier
|
||||||
Neo4j + Qdrant + Matrix RAG brain
|
Neo4j + Qdrant + Matrix RAG brain
|
||||||
- [agent-skills](https://github.com/eSlider/agent-skills) — upstream
|
- [agent-skills](https://github.com/eSlider/agent-skills) — upstream
|
||||||
skills (`web-search`, `db-yaml`, …) that 2dph integrates
|
skills (`web-search`, `postgres`, …) that 2dph integrates
|
||||||
- detective method — the two-source method
|
- detective method — the two-source method
|
||||||
|
|
||||||
Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues).
|
Work board (issues): [git.produktor.io/eSlider/2dph/issues](https://git.produktor.io/eSlider/2dph/issues).
|
||||||
|
|||||||
+6
-4
@@ -1,20 +1,22 @@
|
|||||||
//usr/bin/env go run -tags=brain_eval "$0" "$@"; exit
|
//usr/bin/env go run -tags=system_ladybug,brain_eval "$0" "$@"; exit
|
||||||
//go:build brain_eval
|
//go:build cgo && system_ladybug && brain_eval
|
||||||
//
|
//
|
||||||
// bin/brain/eval.go - recall@5 gate.
|
// bin/brain/eval.go - recall@5 gate.
|
||||||
//
|
//
|
||||||
// ./bin/brain/eval.go
|
// ./bin/brain/eval.go
|
||||||
// ./bin/brain/eval.go --json
|
// ./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.
|
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/eSlider/2dph/internal/cmdbin"
|
"github.com/eSlider/2dph/internal/brain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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
|
//usr/bin/env go run -tags=system_ladybug,brain_get "$0" "$@"; exit
|
||||||
//go:build brain_get
|
//go:build cgo && system_ladybug && brain_get
|
||||||
//
|
//
|
||||||
// bin/brain/get.go - read one leaf by id.
|
// bin/brain/get.go - read one leaf by id.
|
||||||
//
|
//
|
||||||
// ./bin/brain/get.go <id>
|
// ./bin/brain/get.go <id>
|
||||||
// ./bin/brain/get.go <id> --body
|
// ./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.
|
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/eSlider/2dph/internal/cmdbin"
|
"github.com/eSlider/2dph/internal/brain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(cmdbin.ExecFile("bin/kb/get", os.Args[1:]))
|
os.Exit(brain.MainGet(os.Args[1:]))
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
//
|
//
|
||||||
// bin/brain/search.go - deduction search over the 2dph brain.
|
// bin/brain/search.go - deduction search over the 2dph brain.
|
||||||
//
|
//
|
||||||
// ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--json]
|
// ./bin/brain/search.go "query" [--root facts|info] [--repo P] [-n N] [--json] [--no-web]
|
||||||
// ./bin/brain/search.go serve [port]
|
// ./bin/brain/search.go serve [port]
|
||||||
// ./bin/brain/search.go --list-model
|
// ./bin/brain/search.go --list-model
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
// KB_ROOT=/path/to/2dph ./bin/brain/serve.go
|
// KB_ROOT=/path/to/2dph ./bin/brain/serve.go
|
||||||
// KB_WORKERS=4 KB_PORT=8630 ./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).
|
// Needs CGO + libladybug (same as bin/brain/search.go).
|
||||||
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||||
package main
|
package main
|
||||||
|
|||||||
+5
-4
@@ -1,20 +1,21 @@
|
|||||||
//usr/bin/env go run -tags=brain_stats "$0" "$@"; exit
|
//usr/bin/env go run -tags=system_ladybug,brain_stats "$0" "$@"; exit
|
||||||
//go:build brain_stats
|
//go:build cgo && system_ladybug && brain_stats
|
||||||
//
|
//
|
||||||
// bin/brain/stats.go - index health.
|
// bin/brain/stats.go - index health.
|
||||||
//
|
//
|
||||||
// ./bin/brain/stats.go
|
// ./bin/brain/stats.go
|
||||||
// ./bin/brain/stats.go --json
|
// ./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.
|
// NOTE: never run `gofmt -w` on this file — it breaks the shebang.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/eSlider/2dph/internal/cmdbin"
|
"github.com/eSlider/2dph/internal/brain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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"):
|
for method in ("index.go", "get.go", "stats.go", "eval.go", "watch.go"):
|
||||||
self._assert_shebang(f"bin/brain/{method}")
|
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:
|
def test_mail_import_is_shebang_not_brain_write(self) -> None:
|
||||||
self._assert_shebang("bin/mail/import.go")
|
self._assert_shebang("bin/mail/import.go")
|
||||||
index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text()
|
index_mail = (ROOT / "bin" / "mail" / "index_mail").read_text()
|
||||||
|
|||||||
@@ -59,6 +59,43 @@ class PublishedDocsTest(unittest.TestCase):
|
|||||||
self.assertNotIn("password", settings.lower())
|
self.assertNotIn("password", settings.lower())
|
||||||
self.assertIn("json", settings)
|
self.assertIn("json", settings)
|
||||||
|
|
||||||
|
def test_picoclaw_compose_profile_has_mcp_example(self) -> None:
|
||||||
|
compose = (ROOT / "compose.yaml").read_text()
|
||||||
|
self.assertIn('profiles: ["picoclaw"]', compose)
|
||||||
|
self.assertIn("127.0.0.1:8630", compose)
|
||||||
|
example = (ROOT / "deploy" / "picoclaw" / "mcp.json.example").read_text()
|
||||||
|
self.assertIn("127.0.0.1:8630/mcp", example)
|
||||||
|
self.assertNotIn("password", example.lower())
|
||||||
|
self.assertNotIn("token", example.lower())
|
||||||
|
docs = (ROOT / "docs" / "picoclaw.md").read_text()
|
||||||
|
self.assertIn("search", docs)
|
||||||
|
self.assertIn("throttled", docs)
|
||||||
|
|
||||||
|
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:
|
def test_docs_do_not_claim_hop_walks(self) -> None:
|
||||||
paths = [
|
paths = [
|
||||||
ROOT / "README.md",
|
ROOT / "README.md",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
def test_picoclaw_lists_tool_order(self) -> None:
|
||||||
|
skill = (ROOT / "skills" / "picoclaw" / "SKILL.md").read_text()
|
||||||
|
agents = (ROOT / "AGENTS.md").read_text()
|
||||||
|
self.assertIn("**`search`**", skill)
|
||||||
|
self.assertIn("**`get`**", skill)
|
||||||
|
self.assertIn("**`audit`**", skill)
|
||||||
|
self.assertIn("throttled", skill.lower())
|
||||||
|
self.assertIn("not a negative finding", agents)
|
||||||
|
self.assertIn("Fact-check every", agents)
|
||||||
@@ -76,6 +76,25 @@ services:
|
|||||||
- ./deploy/searxng/limiter.toml:/etc/searxng/limiter.toml:ro
|
- ./deploy/searxng/limiter.toml:/etc/searxng/limiter.toml:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# MCP endpoint for an external agent (PicoClaw is not shipped here).
|
||||||
|
# docker compose --profile picoclaw up brain-mcp
|
||||||
|
# Point the agent at http://127.0.0.1:8630/mcp (see deploy/picoclaw/).
|
||||||
|
brain-mcp:
|
||||||
|
profiles: ["picoclaw"]
|
||||||
|
image: ghcr.io/eslider/2dph:latest
|
||||||
|
environment: *env
|
||||||
|
volumes:
|
||||||
|
- kb-model:/data/hf
|
||||||
|
- kb-var:/data
|
||||||
|
- ~/.config/brain:/secret:ro
|
||||||
|
command: ["brain", "serve"]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8630:8630"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
kb-model:
|
kb-model:
|
||||||
kb-var:
|
kb-var:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"2dph": {
|
||||||
|
"url": "http://127.0.0.1:8630/mcp",
|
||||||
|
"description": "2dph fact gate. Tool order: search → get → audit. throttled is not absence."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-1
@@ -21,7 +21,8 @@ bin/brain/search.go "question"
|
|||||||
1. facts root — confirmed answers only → return with evidence links
|
1. facts root — confirmed answers only → return with evidence links
|
||||||
2. info root — supporting narrative → snippets, marked (not confirmed)
|
2. info root — supporting narrative → snippets, marked (not confirmed)
|
||||||
3. web-search — second independent source → upgrade hypothesis to confirmed
|
3. web-search — second independent source → upgrade hypothesis to confirmed
|
||||||
(`bin/web/search.go`; status `throttled` is not evidence of absence)
|
(`web` block from `bin/web/search.go` when no facts hit; status `throttled`
|
||||||
|
is not evidence of absence; `--no-web` / `--root` skip it)
|
||||||
```
|
```
|
||||||
|
|
||||||
`--hop` is not implemented yet (needs File/FROM_FILE edges). The flag is an
|
`--hop` is not implemented yet (needs File/FROM_FILE edges). The flag is an
|
||||||
@@ -61,3 +62,18 @@ corpus HEAD.
|
|||||||
|
|
||||||
Confirmed = A×B or B×C agreement. Single source = hypothesis + `(not confirmed)`.
|
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,18 @@
|
|||||||
|
# PicoClaw profile (reference agent)
|
||||||
|
|
||||||
|
2dph is the memory/fact gate. PicoClaw (or any MCP client) is the agent loop
|
||||||
|
and is **not** shipped in this repo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile picoclaw up brain-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
The API listens on `127.0.0.1:8630`. Point the agent at
|
||||||
|
`http://127.0.0.1:8630/mcp` using [deploy/picoclaw/mcp.json.example](../deploy/picoclaw/mcp.json.example).
|
||||||
|
|
||||||
|
OpenAPI: `GET http://127.0.0.1:8630/openapi.json`.
|
||||||
|
|
||||||
|
Before a factual reply: `search` → `get` → `audit`. `throttled` is not a
|
||||||
|
negative finding. See `skills/picoclaw/SKILL.md`.
|
||||||
|
|
||||||
|
No Cursor required. A live PicoClaw binary/image is an operator choice.
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/eSlider/2dph/internal/brain/rank"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ready opens the Ladybug file for the life of the serve process.
|
// Ready opens the Ladybug file for the life of the serve process.
|
||||||
@@ -17,7 +19,7 @@ func Ready() error {
|
|||||||
// HTTP is the in-process API used by bin/brain/serve.go.
|
// HTTP is the in-process API used by bin/brain/serve.go.
|
||||||
type HTTP struct{}
|
type HTTP struct{}
|
||||||
|
|
||||||
func (HTTP) Search(_ context.Context, query string, limit int) ([]byte, error) {
|
func (HTTP) Search(ctx context.Context, query string, limit int) ([]byte, error) {
|
||||||
hits, err := searchHits(query, "", "", limit)
|
hits, err := searchHits(query, "", "", limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -31,10 +33,13 @@ func (HTTP) Search(_ context.Context, query string, limit int) ([]byte, error) {
|
|||||||
hits[i].Snippet = string(runes)
|
hits[i].Snippet = string(runes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
webOut := rank.Deduce(hits, query, "", false, func(q string) rank.SecondSource {
|
||||||
|
return lookupWeb(ctx, q)
|
||||||
|
})
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
enc := json.NewEncoder(&buf)
|
enc := json.NewEncoder(&buf)
|
||||||
enc.SetEscapeHTML(false)
|
enc.SetEscapeHTML(false)
|
||||||
if err := enc.Encode(toJSONOut(hits, query, "")); err != nil {
|
if err := enc.Encode(toJSONOut(hits, query, "", webOut)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package brain
|
||||||
|
|
||||||
|
const ModelID = "minishlab/potion-multilingual-128M"
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json]
|
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json] [--no-web]
|
||||||
bin/brain/search.go serve [port]
|
bin/brain/search.go serve [port]
|
||||||
bin/brain/search.go --list-model`
|
bin/brain/search.go --list-model`
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ type Options struct {
|
|||||||
Limit int
|
Limit int
|
||||||
JSONOut bool
|
JSONOut bool
|
||||||
ListModel bool
|
ListModel bool
|
||||||
|
NoWeb bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseArgs reads flags. Unknown flags are an error: silently dropping them
|
// ParseArgs reads flags. Unknown flags are an error: silently dropping them
|
||||||
@@ -54,6 +55,8 @@ func ParseArgs(args []string) (Options, error) {
|
|||||||
return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)")
|
return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)")
|
||||||
case "--json":
|
case "--json":
|
||||||
opt.JSONOut = true
|
opt.JSONOut = true
|
||||||
|
case "--no-web":
|
||||||
|
opt.NoWeb = true
|
||||||
case "--list-model":
|
case "--list-model":
|
||||||
opt.ListModel = true
|
opt.ListModel = true
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -68,18 +68,25 @@ 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{
|
out := Dict{
|
||||||
{"query", query},
|
{"query", query},
|
||||||
{"root_filter", root},
|
{"root_filter", root},
|
||||||
{"count", len(results)},
|
{"count", len(results)},
|
||||||
{"results", resultsToDicts(results)},
|
{"results", resultsToDicts(results)},
|
||||||
}
|
}
|
||||||
|
if webOut != nil {
|
||||||
|
out = append(out, KV{"web", secondToDict(*webOut)})
|
||||||
|
}
|
||||||
|
|
||||||
if jsonOut {
|
if jsonOut {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
enc.SetEscapeHTML(false)
|
enc.SetEscapeHTML(false)
|
||||||
return b2i(enc.Encode(toJSONOut(results, query, root)))
|
return b2i(enc.Encode(toJSONOut(results, query, root, webOut)))
|
||||||
}
|
}
|
||||||
fmt.Print(toYAML(out, 0))
|
fmt.Print(toYAML(out, 0))
|
||||||
return 0
|
return 0
|
||||||
@@ -168,10 +175,11 @@ func rowsToHits(res *lbug.QueryResult) ([]Hit, error) {
|
|||||||
|
|
||||||
// JSON output types
|
// JSON output types
|
||||||
type jsonOut struct {
|
type jsonOut struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
RootFilter string `json:"root_filter"`
|
RootFilter string `json:"root_filter"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
Results []jsonHit `json:"results"`
|
Results []jsonHit `json:"results"`
|
||||||
|
Web *rank.SecondSource `json:"web,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonHit struct {
|
type jsonHit struct {
|
||||||
@@ -182,7 +190,7 @@ type jsonHit struct {
|
|||||||
Snippet string `json:"snippet,omitempty"`
|
Snippet string `json:"snippet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toJSONOut(hits []Hit, query, rootFilter string) *jsonOut {
|
func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *jsonOut {
|
||||||
out := make([]jsonHit, len(hits))
|
out := make([]jsonHit, len(hits))
|
||||||
for i, h := range hits {
|
for i, h := range hits {
|
||||||
out[i] = jsonHit{
|
out[i] = jsonHit{
|
||||||
@@ -198,6 +206,7 @@ func toJSONOut(hits []Hit, query, rootFilter string) *jsonOut {
|
|||||||
RootFilter: rootFilter,
|
RootFilter: rootFilter,
|
||||||
Count: len(hits),
|
Count: len(hits),
|
||||||
Results: out,
|
Results: out,
|
||||||
|
Web: web,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -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) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/health":
|
case PathHealth:
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||||
case "/search":
|
case PathSearch:
|
||||||
s.handleSearch(w, r)
|
s.handleSearch(w, r)
|
||||||
case "/get":
|
case PathGet:
|
||||||
s.handleGet(w, r)
|
s.handleGet(w, r)
|
||||||
case "/stats":
|
case PathStats:
|
||||||
s.handleJSON(w, r, s.api.Stats)
|
s.handleJSON(w, r, s.api.Stats)
|
||||||
case "/audit":
|
case PathAudit:
|
||||||
s.handleJSON(w, r, s.api.Audit)
|
s.handleJSON(w, r, s.api.Audit)
|
||||||
case "/ingest":
|
case PathIngest:
|
||||||
s.handleJSON(w, r, s.api.Ingest)
|
s.handleJSON(w, r, s.api.Ingest)
|
||||||
|
case PathOpenAPI:
|
||||||
|
s.handleOpenAPI(w, r)
|
||||||
|
case PathMCP:
|
||||||
|
s.handleMCP(w, r)
|
||||||
default:
|
default:
|
||||||
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
|
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)
|
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 {
|
func (s *Server) acquire(w http.ResponseWriter, r *http.Request) bool {
|
||||||
select {
|
select {
|
||||||
case s.semaphore <- struct{}{}:
|
case s.semaphore <- struct{}{}:
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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/search.go "where is cs-lexicon" --json | yq '.[].ref'
|
||||||
bin/brain/get.go <id> --body # full chunk only when needed
|
bin/brain/get.go <id> --body # full chunk only when needed
|
||||||
bin/brain/stats.go # index health
|
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
|
`bin/kb/search` is a deprecated wrapper. `--hop` errors (File/FROM_FILE edges
|
||||||
@@ -39,8 +39,12 @@ 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.
|
- Search before you read. Never grep a repo for a concept the graph covers.
|
||||||
- `--root facts` returns only confirmed evidence-linked answers. Default shows
|
- `--root facts` returns only confirmed evidence-linked answers. Default shows
|
||||||
facts first, then info leafs clearly marked `(not confirmed)`.
|
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
|
- If recall looks wrong, run `bin/brain/eval.go`; it gates control questions and
|
||||||
should stay at or above 95% recall@5.
|
should stay at or above 95% recall@5.
|
||||||
- Escalate to `web-search` (the `web-search` skill) as the independent second
|
- Agents: `GET /openapi.json` and `POST /mcp` on `bin/brain/serve.go` (same
|
||||||
source when both local roots cannot confirm; never report an unconfirmed
|
handlers; tool names match paths `search`/`get`/`stats`/`audit`). Generated
|
||||||
single-source local answer as fact.
|
list: [tools.md](tools.md).
|
||||||
|
- Never report an unconfirmed single-source local answer as fact.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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)
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: picoclaw
|
||||||
|
description: >-
|
||||||
|
2dph is the memory/fact gate, not the agent loop. Use when wiring PicoClaw
|
||||||
|
or any MCP client: call brain search/get/audit before a factual reply.
|
||||||
|
throttled is not a negative finding.
|
||||||
|
---
|
||||||
|
|
||||||
|
# PicoClaw — fact-check before assert
|
||||||
|
|
||||||
|
PicoClaw (or any agent) speaks MCP at `POST /mcp` on `bin/brain/serve.go`.
|
||||||
|
2dph does not run the agent loop. Compose: `docker compose --profile picoclaw up brain-mcp`
|
||||||
|
(see [docs/picoclaw.md](../../docs/picoclaw.md)).
|
||||||
|
|
||||||
|
## Tool order (before a factual reply)
|
||||||
|
|
||||||
|
1. **`search`** — facts root first, then info. The `web` block is a second
|
||||||
|
source when there is no facts hit. Status `throttled` / `skipped` /
|
||||||
|
`refused` is **not** evidence of absence.
|
||||||
|
2. **`get`** — full leaf body only when a hit `id` is needed.
|
||||||
|
3. **`audit`** — if recall or confidence looks wrong.
|
||||||
|
|
||||||
|
Then answer. Confirmed only from facts (≥2 independent sources). Anything
|
||||||
|
else is `(not confirmed)`. Missing graph ≠ “does not exist”.
|
||||||
|
|
||||||
|
Generated tool list: [../brain/tools.md](../brain/tools.md).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
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