feat: add OpenBrain and EaaS scoring API client methods
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 5m39s

Brain operations (remember, recall, forget, ensureCollection) and
EaaS scoring (content, imprint, health) on the existing Client.
Maps to api.lthn.ai/v1/brain/* and api.lthn.ai/v1/score/* endpoints
served by php-agentic's BrainApiController and ScoreApiController.

20 new tests, all passing. Full suite green.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-03 19:32:17 +00:00
parent b77c590f61
commit deb7021b93
4 changed files with 762 additions and 0 deletions

215
brain.go Normal file
View file

@ -0,0 +1,215 @@
package agentic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"forge.lthn.ai/core/go/pkg/log"
)
// MemoryType represents the classification of a brain memory.
type MemoryType string
const (
MemoryFact MemoryType = "fact"
MemoryDecision MemoryType = "decision"
MemoryPattern MemoryType = "pattern"
MemoryContext MemoryType = "context"
MemoryProcedure MemoryType = "procedure"
)
// Memory represents a single memory entry from the OpenBrain API.
type Memory struct {
ID string `json:"id"`
AgentID string `json:"agent_id,omitempty"`
Type string `json:"type"`
Content string `json:"content"`
Tags []string `json:"tags,omitempty"`
Project string `json:"project,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
SupersedesID string `json:"supersedes_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// RememberRequest is the payload for storing a new memory.
type RememberRequest struct {
Content string `json:"content"`
Type string `json:"type"`
Project string `json:"project,omitempty"`
AgentID string `json:"agent_id,omitempty"`
Tags []string `json:"tags,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
SupersedesID string `json:"supersedes_id,omitempty"`
Source string `json:"source,omitempty"`
}
// RememberResponse is returned after storing a memory.
type RememberResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Project string `json:"project"`
CreatedAt string `json:"created_at"`
}
// RecallRequest is the payload for semantic search.
type RecallRequest struct {
Query string `json:"query"`
TopK int `json:"top_k,omitempty"`
Project string `json:"project,omitempty"`
Type string `json:"type,omitempty"`
AgentID string `json:"agent_id,omitempty"`
MinConfidence *float64 `json:"min_confidence,omitempty"`
}
// RecallResponse is returned from a semantic search.
type RecallResponse struct {
Memories []Memory `json:"memories"`
Scores map[string]float64 `json:"scores"`
}
// Remember stores a memory via POST /v1/brain/remember.
func (c *Client) Remember(ctx context.Context, req RememberRequest) (*RememberResponse, error) {
const op = "agentic.Client.Remember"
if req.Content == "" {
return nil, log.E(op, "content is required", nil)
}
if req.Type == "" {
return nil, log.E(op, "type is required", nil)
}
data, err := json.Marshal(req)
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := c.BaseURL + "/v1/brain/remember"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result RememberResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}
// Recall performs semantic search via POST /v1/brain/recall.
func (c *Client) Recall(ctx context.Context, req RecallRequest) (*RecallResponse, error) {
const op = "agentic.Client.Recall"
if req.Query == "" {
return nil, log.E(op, "query is required", nil)
}
data, err := json.Marshal(req)
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := c.BaseURL + "/v1/brain/recall"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result RecallResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}
// Forget removes a memory via DELETE /v1/brain/forget/{id}.
func (c *Client) Forget(ctx context.Context, id string) error {
const op = "agentic.Client.Forget"
if id == "" {
return log.E(op, "memory ID is required", nil)
}
endpoint := fmt.Sprintf("%s/v1/brain/forget/%s", c.BaseURL, url.PathEscape(id))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return log.E(op, "API error", err)
}
return nil
}
// EnsureCollection ensures the Qdrant collection exists via POST /v1/brain/collections.
func (c *Client) EnsureCollection(ctx context.Context) error {
const op = "agentic.Client.EnsureCollection"
endpoint := c.BaseURL + "/v1/brain/collections"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return log.E(op, "API error", err)
}
return nil
}

234
brain_test.go Normal file
View file

@ -0,0 +1,234 @@
package agentic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_Remember_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/brain/remember", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
var req RememberRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
assert.Equal(t, "Go uses structural typing", req.Content)
assert.Equal(t, "fact", req.Type)
assert.Equal(t, "go-agentic", req.Project)
assert.Equal(t, []string{"go", "typing"}, req.Tags)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(RememberResponse{
ID: "mem-abc-123",
Type: "fact",
Project: "go-agentic",
CreatedAt: "2026-03-03T12:00:00+00:00",
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.Remember(context.Background(), RememberRequest{
Content: "Go uses structural typing",
Type: "fact",
Project: "go-agentic",
Tags: []string{"go", "typing"},
})
require.NoError(t, err)
assert.Equal(t, "mem-abc-123", result.ID)
assert.Equal(t, "fact", result.Type)
assert.Equal(t, "go-agentic", result.Project)
}
func TestClient_Remember_Bad_EmptyContent(t *testing.T) {
client := NewClient("https://api.example.com", "test-token")
result, err := client.Remember(context.Background(), RememberRequest{
Type: "fact",
})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "content is required")
}
func TestClient_Remember_Bad_EmptyType(t *testing.T) {
client := NewClient("https://api.example.com", "test-token")
result, err := client.Remember(context.Background(), RememberRequest{
Content: "something",
})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "type is required")
}
func TestClient_Remember_Bad_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(APIError{Message: "validation failed"})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.Remember(context.Background(), RememberRequest{
Content: "test",
Type: "fact",
})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "validation failed")
}
func TestClient_Recall_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/brain/recall", r.URL.Path)
var req RecallRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
assert.Equal(t, "how does typing work in Go", req.Query)
assert.Equal(t, 5, req.TopK)
assert.Equal(t, "go-agentic", req.Project)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(RecallResponse{
Memories: []Memory{
{
ID: "mem-abc-123",
Type: "fact",
Content: "Go uses structural typing",
Project: "go-agentic",
Confidence: 0.95,
},
},
Scores: map[string]float64{
"mem-abc-123": 0.87,
},
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.Recall(context.Background(), RecallRequest{
Query: "how does typing work in Go",
TopK: 5,
Project: "go-agentic",
})
require.NoError(t, err)
assert.Len(t, result.Memories, 1)
assert.Equal(t, "mem-abc-123", result.Memories[0].ID)
assert.Equal(t, "Go uses structural typing", result.Memories[0].Content)
assert.InDelta(t, 0.87, result.Scores["mem-abc-123"], 0.001)
}
func TestClient_Recall_Good_EmptyResults(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(RecallResponse{
Memories: []Memory{},
Scores: map[string]float64{},
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.Recall(context.Background(), RecallRequest{
Query: "something obscure",
})
require.NoError(t, err)
assert.Empty(t, result.Memories)
assert.Empty(t, result.Scores)
}
func TestClient_Recall_Bad_EmptyQuery(t *testing.T) {
client := NewClient("https://api.example.com", "test-token")
result, err := client.Recall(context.Background(), RecallRequest{})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "query is required")
}
func TestClient_Forget_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodDelete, r.Method)
assert.Equal(t, "/v1/brain/forget/mem-abc-123", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]bool{"deleted": true})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.Forget(context.Background(), "mem-abc-123")
assert.NoError(t, err)
}
func TestClient_Forget_Bad_EmptyID(t *testing.T) {
client := NewClient("https://api.example.com", "test-token")
err := client.Forget(context.Background(), "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "memory ID is required")
}
func TestClient_Forget_Bad_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(APIError{Message: "memory not found"})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.Forget(context.Background(), "nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "memory not found")
}
func TestClient_EnsureCollection_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/brain/collections", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.EnsureCollection(context.Background())
assert.NoError(t, err)
}
func TestClient_EnsureCollection_Bad_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(APIError{Message: "collection setup failed"})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
err := client.EnsureCollection(context.Background())
assert.Error(t, err)
assert.Contains(t, err.Error(), "collection setup failed")
}

147
score.go Normal file
View file

@ -0,0 +1,147 @@
package agentic
import (
"bytes"
"context"
"encoding/json"
"net/http"
"forge.lthn.ai/core/go/pkg/log"
)
// ScoreContentRequest is the payload for content scoring.
type ScoreContentRequest struct {
Text string `json:"text"`
Prompt string `json:"prompt,omitempty"`
}
// ScoreImprintRequest is the payload for linguistic imprint analysis.
type ScoreImprintRequest struct {
Text string `json:"text"`
}
// ScoreResult holds the response from the scoring engine.
// The shape is proxied from the EaaS Go binary, so fields are dynamic.
type ScoreResult map[string]any
// ScoreHealthResponse holds the health check result.
type ScoreHealthResponse struct {
Status string `json:"status"`
UpstreamStatus int `json:"upstream_status,omitempty"`
}
// ScoreContent scores text for AI patterns via POST /v1/score/content.
func (c *Client) ScoreContent(ctx context.Context, req ScoreContentRequest) (ScoreResult, error) {
const op = "agentic.Client.ScoreContent"
if req.Text == "" {
return nil, log.E(op, "text is required", nil)
}
data, err := json.Marshal(req)
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := c.BaseURL + "/v1/score/content"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result ScoreResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return result, nil
}
// ScoreImprint performs linguistic imprint analysis via POST /v1/score/imprint.
func (c *Client) ScoreImprint(ctx context.Context, req ScoreImprintRequest) (ScoreResult, error) {
const op = "agentic.Client.ScoreImprint"
if req.Text == "" {
return nil, log.E(op, "text is required", nil)
}
data, err := json.Marshal(req)
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := c.BaseURL + "/v1/score/imprint"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result ScoreResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return result, nil
}
// ScoreHealth checks the scoring engine health via GET /v1/score/health.
// This endpoint does not require authentication.
func (c *Client) ScoreHealth(ctx context.Context) (*ScoreHealthResponse, error) {
const op = "agentic.Client.ScoreHealth"
endpoint := c.BaseURL + "/v1/score/health"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
// Health endpoint is unauthenticated but we still set headers for consistency.
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", "core-agentic-client/1.0")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result ScoreHealthResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}

166
score_test.go Normal file
View file

@ -0,0 +1,166 @@
package agentic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_ScoreContent_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/score/content", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
var req ScoreContentRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
assert.Contains(t, req.Text, "sample text for scoring")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"score": 0.23,
"confidence": 0.91,
"label": "human",
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.ScoreContent(context.Background(), ScoreContentRequest{
Text: "This is some sample text for scoring that is at least twenty characters",
})
require.NoError(t, err)
assert.InDelta(t, 0.23, result["score"], 0.001)
assert.Equal(t, "human", result["label"])
}
func TestClient_ScoreContent_Good_WithPrompt(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req ScoreContentRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
assert.Equal(t, "Check for formality", req.Prompt)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"score": 0.5})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.ScoreContent(context.Background(), ScoreContentRequest{
Text: "This text should be checked for formality and style patterns",
Prompt: "Check for formality",
})
require.NoError(t, err)
assert.InDelta(t, 0.5, result["score"], 0.001)
}
func TestClient_ScoreContent_Bad_EmptyText(t *testing.T) {
client := NewClient("https://api.example.com", "test-token")
result, err := client.ScoreContent(context.Background(), ScoreContentRequest{})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "text is required")
}
func TestClient_ScoreContent_Bad_ServiceDown(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": "scoring_unavailable",
"message": "Could not reach the scoring service.",
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.ScoreContent(context.Background(), ScoreContentRequest{
Text: "This text needs at least twenty characters to validate",
})
assert.Error(t, err)
assert.Nil(t, result)
}
func TestClient_ScoreImprint_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/v1/score/imprint", r.URL.Path)
var req ScoreImprintRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
assert.NotEmpty(t, req.Text)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"imprint": "abc123def456",
"confidence": 0.88,
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.ScoreImprint(context.Background(), ScoreImprintRequest{
Text: "This text has a distinct linguistic imprint pattern to analyse",
})
require.NoError(t, err)
assert.Equal(t, "abc123def456", result["imprint"])
}
func TestClient_ScoreImprint_Bad_EmptyText(t *testing.T) {
client := NewClient("https://api.example.com", "test-token")
result, err := client.ScoreImprint(context.Background(), ScoreImprintRequest{})
assert.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "text is required")
}
func TestClient_ScoreHealth_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, "/v1/score/health", r.URL.Path)
// Health endpoint should not require auth token
assert.Empty(t, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ScoreHealthResponse{
Status: "healthy",
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.ScoreHealth(context.Background())
require.NoError(t, err)
assert.Equal(t, "healthy", result.Status)
}
func TestClient_ScoreHealth_Bad_Unhealthy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(ScoreHealthResponse{
Status: "unhealthy",
UpstreamStatus: 503,
})
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
result, err := client.ScoreHealth(context.Background())
assert.Error(t, err)
assert.Nil(t, result)
}