diff --git a/internal/brain/client.go b/internal/brain/client.go new file mode 100644 index 0000000..3a5e6e8 --- /dev/null +++ b/internal/brain/client.go @@ -0,0 +1,76 @@ +// internal/brain/client.go +// Package brain provides a lightweight client for querying the ingestion server. +// Skill handlers call Query before spawning workers to inject relevant knowledge +// from the brain into the task prompt. Errors are suppressed — the brain is +// optional context; its absence must never block a skill invocation. +package brain + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +type queryResult struct { + Path string `json:"path"` + Title string `json:"title"` + Excerpt string `json:"excerpt"` + Score int `json:"score"` +} + +// Query calls the ingestion server and returns relevant knowledge as a +// formatted string ready to prepend to a worker task prompt. +// Returns empty string (no error) when baseURL or query is empty, +// when the brain is unreachable, or when no results are found. +func Query(ctx context.Context, baseURL, query string, limit int) (string, error) { + if baseURL == "" || strings.TrimSpace(query) == "" { + return "", nil + } + if limit <= 0 { + limit = 3 + } + + body, _ := json.Marshal(map[string]any{"query": query, "limit": limit}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/query", bytes.NewReader(body)) + if err != nil { + slog.Warn("brain: build request failed", "err", err) + return "", nil + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Warn("brain: ingestion server unreachable", "err", err) + return "", nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + slog.Warn("brain: ingestion server returned non-OK", "status", resp.StatusCode) + return "", nil + } + + out, _ := io.ReadAll(resp.Body) + var result struct { + Results []queryResult `json:"results"` + } + if err := json.Unmarshal(out, &result); err != nil || len(result.Results) == 0 { + return "", nil + } + + var b strings.Builder + b.WriteString("## Relevant knowledge\n\n") + for _, r := range result.Results { + title := r.Title + if title == "" { + title = r.Path + } + fmt.Fprintf(&b, "### %s\n%s\n\n", title, r.Excerpt) + } + return b.String(), nil +} diff --git a/internal/brain/client_test.go b/internal/brain/client_test.go new file mode 100644 index 0000000..3634c63 --- /dev/null +++ b/internal/brain/client_test.go @@ -0,0 +1,67 @@ +package brain_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mathiasbq/supervisor/internal/brain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQueryEmptyBaseURL(t *testing.T) { + result, err := brain.Query(context.Background(), "", "tdd patterns", 3) + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestQueryEmptyQuery(t *testing.T) { + result, err := brain.Query(context.Background(), "http://localhost:9999", "", 3) + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestQueryFormatsResults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/query", r.URL.Path) + var req map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Equal(t, "tdd patterns", req["query"]) + + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "results": []map[string]any{ + {"path": "knowledge/tdd.md", "title": "TDD Guide", "excerpt": "Always write tests first.", "score": 5}, + {"path": "knowledge/go.md", "title": "Go Conventions", "excerpt": "Use table-driven tests.", "score": 3}, + }, + }) + })) + defer srv.Close() + + result, err := brain.Query(context.Background(), srv.URL, "tdd patterns", 3) + require.NoError(t, err) + assert.Contains(t, result, "## Relevant knowledge") + assert.Contains(t, result, "TDD Guide") + assert.Contains(t, result, "Always write tests first.") + assert.Contains(t, result, "Go Conventions") +} + +func TestQueryEmptyResults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"results": []any{}}) //nolint:errcheck + })) + defer srv.Close() + + result, err := brain.Query(context.Background(), srv.URL, "obscure query", 3) + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestQueryUnavailableServerReturnsEmpty(t *testing.T) { + // Brain unavailable — should degrade gracefully, no error + result, err := brain.Query(context.Background(), "http://127.0.0.1:19999", "query", 3) + require.NoError(t, err) + assert.Empty(t, result) +}