feat(brain): add brain_answer and brain_classify MCP tools
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:
@@ -56,7 +56,25 @@ func main() {
|
||||
|
||||
h := api.NewHandler(brainDir, logger, pipelineCfg)
|
||||
|
||||
mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete)
|
||||
var answerComplete pipeline.CompleteFunc
|
||||
if primaryURL := os.Getenv("BRAIN_LLM_PRIMARY_URL"); primaryURL != "" {
|
||||
primaryModel := envOr("BRAIN_LLM_PRIMARY_MODEL", "gemma4:31b")
|
||||
primaryKey := os.Getenv("BERGET_API_KEY")
|
||||
timeoutMS := envInt("BRAIN_LLM_TIMEOUT_MS", 10000)
|
||||
timeout := time.Duration(timeoutMS) * time.Millisecond
|
||||
|
||||
primary := llm.New(primaryURL, primaryKey, primaryModel, timeout)
|
||||
router := &llm.Router{Primary: primary}
|
||||
|
||||
if fallbackURL := os.Getenv("BRAIN_LLM_FALLBACK_URL"); fallbackURL != "" {
|
||||
fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b")
|
||||
router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout)
|
||||
}
|
||||
answerComplete = router.Complete
|
||||
logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel)
|
||||
}
|
||||
|
||||
mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete, answerComplete)
|
||||
|
||||
mcpToken := os.Getenv("BRAIN_MCP_TOKEN")
|
||||
if mcpToken == "" {
|
||||
|
||||
29
ingestion/internal/llm/router.go
Normal file
29
ingestion/internal/llm/router.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Router calls Primary first; on any error falls back to Fallback.
|
||||
// Fallback may be nil, in which case primary errors are returned directly.
|
||||
type Router struct {
|
||||
Primary *Client
|
||||
Fallback *Client
|
||||
}
|
||||
|
||||
// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback.
|
||||
func (r *Router) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
out, err := r.Primary.Complete(ctx, system, user)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
if r.Fallback == nil {
|
||||
return "", fmt.Errorf("primary llm: %w", err)
|
||||
}
|
||||
out, err2 := r.Fallback.Complete(ctx, system, user)
|
||||
if err2 != nil {
|
||||
return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
71
ingestion/internal/llm/router_test.go
Normal file
71
ingestion/internal/llm/router_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRouter_PrimarySucceeds(t *testing.T) {
|
||||
primary := mockServer(t, "from-primary")
|
||||
defer primary.Close()
|
||||
fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("fallback must not be called when primary succeeds")
|
||||
}))
|
||||
defer fallback.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(primary.URL, "", "m", time.Second),
|
||||
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-primary", out)
|
||||
}
|
||||
|
||||
func TestRouter_FallsBackOnPrimaryError(t *testing.T) {
|
||||
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer primary.Close()
|
||||
fallback := mockServer(t, "from-fallback")
|
||||
defer fallback.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(primary.URL, "", "m", time.Second),
|
||||
Fallback: New(fallback.URL, "", "m", time.Second),
|
||||
}
|
||||
out, err := r.Complete(context.Background(), "sys", "user")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "from-fallback", out)
|
||||
}
|
||||
|
||||
func TestRouter_BothFail(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{
|
||||
Primary: New(fail.URL, "", "m", time.Second),
|
||||
Fallback: New(fail.URL, "", "m", time.Second),
|
||||
}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRouter_NilFallback(t *testing.T) {
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "err", http.StatusBadGateway)
|
||||
}))
|
||||
defer fail.Close()
|
||||
|
||||
r := &Router{Primary: New(fail.URL, "", "m", time.Second)}
|
||||
_, err := r.Complete(context.Background(), "sys", "user")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -69,6 +69,20 @@ func (s *Server) tools() []map[string]any {
|
||||
"dry_run": map[string]any{"type": "boolean"},
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_answer",
|
||||
"description": "Retrieve relevant brain content via BM25 and synthesize a coherent answer using an LLM.",
|
||||
"inputSchema": schema([]string{"query"}, map[string]any{
|
||||
"query": str("question to answer"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_classify",
|
||||
"description": "Classify raw text into doc type, title, and tags using an LLM.",
|
||||
"inputSchema": schema([]string{"text"}, map[string]any{
|
||||
"text": str("raw document text to classify (first 3000 chars used)"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "session_log",
|
||||
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func TestMCPMountedHandler(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /mcp", srv)
|
||||
|
||||
|
||||
@@ -32,19 +32,21 @@ type rpcError struct {
|
||||
|
||||
// Server handles MCP JSON-RPC over HTTP for the ingestion service.
|
||||
type Server struct {
|
||||
brainDir string
|
||||
pipeline pipeline.Config
|
||||
llm pipeline.CompleteFunc
|
||||
brainDir string
|
||||
pipeline pipeline.Config
|
||||
llm pipeline.CompleteFunc
|
||||
answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable
|
||||
}
|
||||
|
||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||
// LLM-backed pipeline; llm may be nil for non-LLM tools only.
|
||||
func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc) *Server {
|
||||
// answerLLM drives brain_answer and brain_classify; nil disables those tools.
|
||||
func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc, answerLLM pipeline.CompleteFunc) *Server {
|
||||
cfg := pipeline.Config{}
|
||||
if pipelineCfg != nil {
|
||||
cfg = *pipelineCfg
|
||||
}
|
||||
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm}
|
||||
return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM}
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -140,6 +142,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
||||
return s.brainIngest(ctx, args)
|
||||
case "session_log":
|
||||
return s.sessionLog(ctx, args)
|
||||
case "brain_answer":
|
||||
return s.brainAnswer(ctx, args)
|
||||
case "brain_classify":
|
||||
return s.brainClassify(ctx, args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func body(t *testing.T, v any) *bytes.Buffer {
|
||||
}
|
||||
|
||||
func TestServerInitialize(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
@@ -38,7 +38,7 @@ func TestServerInitialize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerToolsList(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||
@@ -55,12 +55,13 @@ func TestServerToolsList(t *testing.T) {
|
||||
names = append(names, t.(map[string]any)["name"].(string))
|
||||
}
|
||||
assert.ElementsMatch(t, []string{
|
||||
"brain_query", "brain_write", "brain_ingest_raw", "brain_ingest", "session_log",
|
||||
"brain_query", "brain_write", "brain_ingest_raw", "brain_ingest",
|
||||
"brain_answer", "brain_classify", "session_log",
|
||||
}, names)
|
||||
}
|
||||
|
||||
func TestServerNotificationGetsNoBody(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||
"jsonrpc": "2.0", "method": "notifications/initialized",
|
||||
@@ -73,7 +74,7 @@ func TestServerNotificationGetsNoBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerUnknownMethodReturnsError(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil)
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{
|
||||
"jsonrpc": "2.0", "id": 3, "method": "unknown/method",
|
||||
|
||||
114
ingestion/internal/mcp/tools_answer.go
Normal file
114
ingestion/internal/mcp/tools_answer.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
)
|
||||
|
||||
const (
|
||||
answerSystemPrompt = `You are a knowledge assistant. Answer the question using ONLY the provided sources.
|
||||
Cite source file paths inline when referencing specific content.
|
||||
If the context does not contain enough information to answer, say so clearly.`
|
||||
|
||||
classifySystemPrompt = `Classify the document. Respond with JSON only, no markdown fences.
|
||||
{"type":"...","title":"...","tags":["..."]}
|
||||
Valid types: spec, plan, decision, note, wiki, log, code, unknown.`
|
||||
)
|
||||
|
||||
type brainAnswerArgs struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
if s.answerLLM == nil {
|
||||
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
|
||||
}
|
||||
var a brainAnswerArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
if a.Query == "" {
|
||||
return nil, fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
results, err := search.Query(s.brainDir, a.Query, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: %w", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return json.Marshal(map[string]any{
|
||||
"answer": "No relevant content found in brain.",
|
||||
"sources": []string{},
|
||||
})
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sources := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
fmt.Fprintf(&sb, "<source path=%q>\n%s\n</source>\n\n", r.Path, r.Excerpt)
|
||||
sources = append(sources, r.Path)
|
||||
}
|
||||
|
||||
answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: %w", err)
|
||||
}
|
||||
|
||||
return json.Marshal(map[string]any{
|
||||
"answer": answer,
|
||||
"sources": sources,
|
||||
})
|
||||
}
|
||||
|
||||
type brainClassifyArgs struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type classifyResult struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (s *Server) brainClassify(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
if s.answerLLM == nil {
|
||||
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
|
||||
}
|
||||
var a brainClassifyArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
if a.Text == "" {
|
||||
return nil, fmt.Errorf("text is required")
|
||||
}
|
||||
|
||||
text := a.Text
|
||||
if len(text) > 3000 {
|
||||
text = text[:3000]
|
||||
}
|
||||
|
||||
raw, err := s.answerLLM(ctx, classifySystemPrompt, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: %w", err)
|
||||
}
|
||||
|
||||
// Strip markdown fences if model adds them despite the instruction.
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var cr classifyResult
|
||||
if err := json.Unmarshal([]byte(raw), &cr); err != nil {
|
||||
return nil, fmt.Errorf("parse classify response %q: %w", raw, err)
|
||||
}
|
||||
if cr.Tags == nil {
|
||||
cr.Tags = []string{}
|
||||
}
|
||||
return json.Marshal(cr)
|
||||
}
|
||||
103
ingestion/internal/mcp/tools_answer_test.go
Normal file
103
ingestion/internal/mcp/tools_answer_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package mcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mockAnswerLLM(response string) pipeline.CompleteFunc {
|
||||
return func(_ context.Context, _, _ string) (string, error) {
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
|
||||
func brainDirWithContent(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
wikiDir := filepath.Join(dir, "wiki")
|
||||
require.NoError(t, os.MkdirAll(wikiDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(wikiDir, "test.md"), []byte(
|
||||
"---\ntitle: Pass-rate Logging\ntype: spec\n---\n\nPass-rate logging tracks skill invocations.",
|
||||
), 0o644))
|
||||
return dir
|
||||
}
|
||||
|
||||
func callTool(t *testing.T, ts *httptest.Server, name string, arguments map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
req := map[string]any{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": name, "arguments": arguments},
|
||||
}
|
||||
resp, err := http.Post(ts.URL, "application/json", body(t, req))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
var out map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBrainAnswer_NoLLM(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "test"})
|
||||
assert.NotNil(t, rpc["error"], "expected error when answerLLM is nil")
|
||||
}
|
||||
|
||||
func TestBrainAnswer_Synthesizes(t *testing.T) {
|
||||
brainDir := brainDirWithContent(t)
|
||||
srv := mcp.NewServer(brainDir, nil, nil, mockAnswerLLM("Pass-rate logging is described in spec."))
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "pass-rate logging"})
|
||||
require.Nil(t, rpc["error"])
|
||||
|
||||
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||
assert.Equal(t, "Pass-rate logging is described in spec.", result["answer"])
|
||||
assert.NotEmpty(t, result["sources"])
|
||||
}
|
||||
|
||||
func TestBrainClassify_ReturnsJSON(t *testing.T) {
|
||||
llmResp := `{"type":"spec","title":"My Spec","tags":["go","mcp"]}`
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, mockAnswerLLM(llmResp))
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
rpc := callTool(t, ts, "brain_classify", map[string]any{"text": "# My Spec\n\nThis is a Go MCP spec."})
|
||||
require.Nil(t, rpc["error"])
|
||||
|
||||
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||
assert.Equal(t, "spec", result["type"])
|
||||
assert.Equal(t, "My Spec", result["title"])
|
||||
}
|
||||
|
||||
func TestBrainClassify_StripsFences(t *testing.T) {
|
||||
llmResp := "```json\n{\"type\":\"note\",\"title\":\"T\",\"tags\":[]}\n```"
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, mockAnswerLLM(llmResp))
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
rpc := callTool(t, ts, "brain_classify", map[string]any{"text": "some text"})
|
||||
require.Nil(t, rpc["error"])
|
||||
|
||||
content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(content), &result))
|
||||
assert.Equal(t, "note", result["type"])
|
||||
}
|
||||
Reference in New Issue
Block a user