feat(brain): add brain_answer and brain_classify MCP tools
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 3s

Adds two new LLM-backed MCP tools to the ingestion service:

- brain_answer(query): BM25 retrieval + LLM synthesis → answer + sources
- brain_classify(text): classifies doc into type/title/tags via LLM

Adds llm.Router for primary→fallback routing (berget.ai → iguana).
Wired via BRAIN_LLM_PRIMARY_URL/BRAIN_LLM_FALLBACK_URL env vars;
no-op when unset so existing deployments are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mathias Bergqvist
2026-05-12 11:06:17 +02:00
parent c7e0192486
commit 189ff89c34
10 changed files with 379 additions and 23 deletions

View File

@@ -40,7 +40,7 @@ func TestBrainQueryReturnsResults(t *testing.T) {
0o644,
))
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_query", map[string]any{"query": "tdd"})
require.Nil(t, resp["error"])
@@ -53,7 +53,7 @@ func TestBrainQueryReturnsResults(t *testing.T) {
func TestBrainWriteCreatesFile(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_write", map[string]any{
"content": "# Test\n\nbody",
@@ -72,7 +72,7 @@ func TestBrainWriteCreatesFile(t *testing.T) {
func TestBrainWriteRejectsTraversal(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_write", map[string]any{
"content": "x",
@@ -83,7 +83,7 @@ func TestBrainWriteRejectsTraversal(t *testing.T) {
func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_write", map[string]any{
"content": "x",
@@ -98,7 +98,7 @@ func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) {
func TestBrainIngestRawDryRun(t *testing.T) {
brainDir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "concepts"), 0o755))
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_ingest_raw", map[string]any{
"source": "test-source",
@@ -130,7 +130,7 @@ func TestBrainIngestRawDryRun(t *testing.T) {
func TestBrainIngestRejectsBoth(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_ingest", map[string]any{
"content": "x",
@@ -142,7 +142,7 @@ func TestBrainIngestRejectsBoth(t *testing.T) {
func TestBrainIngestRequiresOne(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_ingest", map[string]any{})
require.NotNil(t, resp["error"])
@@ -150,7 +150,7 @@ func TestBrainIngestRequiresOne(t *testing.T) {
func TestBrainIngestRejectsContentWithoutSource(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "brain_ingest", map[string]any{
"content": "x",
@@ -160,7 +160,7 @@ func TestBrainIngestRejectsContentWithoutSource(t *testing.T) {
func TestBrainIngestRequiresLLMConfigured(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil) // nil pipelineCfg → no LLM
srv := mcp.NewServer(brainDir, nil, nil, nil) // nil pipelineCfg → no LLM
resp := toolCall(t, srv, "brain_ingest", map[string]any{
"content": "some content",
@@ -173,7 +173,7 @@ func TestBrainIngestRequiresLLMConfigured(t *testing.T) {
func TestSessionLogAppends(t *testing.T) {
brainDir := t.TempDir()
srv := mcp.NewServer(brainDir, nil, nil)
srv := mcp.NewServer(brainDir, nil, nil, nil)
resp := toolCall(t, srv, "session_log", map[string]any{
"session_id": "session-x",
@@ -190,7 +190,7 @@ func TestSessionLogAppends(t *testing.T) {
}
func TestSessionLogRequiresSessionID(t *testing.T) {
srv := mcp.NewServer(t.TempDir(), nil, nil)
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
require.NotNil(t, resp["error"])
}