Files
Behavision/server/internal/assistant/claude_test.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

349 lines
11 KiB
Go

package assistant
import (
"context"
"strconv"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// These run the REAL SDK against a stub Anthropic endpoint.
//
// No API key is needed and no request leaves the machine, but every byte the
// SDK would send is built and every byte it would receive is parsed - which is
// where the likely bugs are: a tool schema the API would reject, tool results
// split across messages, a principal that fails to reach the tool.
type stub struct {
*httptest.Server
requests []map[string]any
replies []string
}
func newStub(replies ...string) *stub {
s := &stub{replies: replies}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var parsed map[string]any
_ = json.Unmarshal(body, &parsed)
s.requests = append(s.requests, parsed)
i := len(s.requests) - 1
if i >= len(s.replies) {
i = len(s.replies) - 1
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, s.replies[i])
}))
return s
}
func textReply(text string) string {
return `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5",
"content":[{"type":"text","text":` + strconv.Quote(text) + `}],
"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}`
}
func toolReply(name, args string) string {
return `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-5",
"content":[{"type":"tool_use","id":"toolu_1","name":"` + name + `","input":` + args + `}],
"stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5}}`
}
func clientFor(t *testing.T, s *stub) (*Client, *fakeStore) {
t.Helper()
reg, fs := registry()
c := &Client{Tools: reg, APIKey: "test-key", Model: "claude-opus-5"}
c.api = newTestAPI(s.URL)
return c, fs
}
func TestAQuestionWithNoToolsReturnsTheAnswer(t *testing.T) {
s := newStub(textReply("Everything is working."))
defer s.Close()
c, _ := clientFor(t, s)
out, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "is everything ok"}})
if err != nil {
t.Fatal(err)
}
if out.Text != "Everything is working." {
t.Fatalf("got %q", out.Text)
}
}
// The whole tool loop, end to end through the real SDK.
func TestAToolCallIsExecutedAndItsResultFedBack(t *testing.T) {
s := newStub(
toolReply("list_shops", `{}`),
textReply("You have one shop, Chennai, and it is online."),
)
defer s.Close()
c, _ := clientFor(t, s)
out, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "what shops do I have"}})
if err != nil {
t.Fatal(err)
}
if len(out.Used) != 1 || out.Used[0] != "list_shops" {
t.Fatalf("tools used: %v", out.Used)
}
if !strings.Contains(out.Text, "Chennai") {
t.Fatalf("got %q", out.Text)
}
if len(s.requests) != 2 {
t.Fatalf("made %d requests, want 2", len(s.requests))
}
// The second request must carry the tool RESULT back, and the real shop
// name must be in it - proving the tool actually ran against the store.
second, _ := json.Marshal(s.requests[1])
if !strings.Contains(string(second), "tool_result") {
t.Fatalf("no tool_result was sent back:\n%s", second)
}
if !strings.Contains(string(second), "Chennai") {
t.Fatalf("the tool result did not contain real data:\n%s", second)
}
}
// Text produced alongside a tool call is thinking-out-loud, not the answer.
// Keeping it would prefix every answer with "Let me check that for you."
func TestChatterBeforeAToolCallIsNotTheAnswer(t *testing.T) {
s := newStub(
`{"id":"m","type":"message","role":"assistant","model":"claude-opus-5",
"content":[{"type":"text","text":"Let me check."},
{"type":"tool_use","id":"t1","name":"list_shops","input":{}}],
"stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":1}}`,
textReply("One shop, and it is fine."),
)
defer s.Close()
c, _ := clientFor(t, s)
out, _ := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "how are things"}})
if strings.Contains(out.Text, "Let me check") {
t.Fatalf("thinking-out-loud leaked into the answer: %q", out.Text)
}
}
// The tenancy line. The model names another company's shop; the tool runs as
// the signed-in user and cannot reach it.
func TestTheModelCannotReachAnotherCompanyByNamingIt(t *testing.T) {
s := newStub(
toolReply("check_shop", `{"shop":"Rival Flagship"}`),
textReply("I could not find a shop by that name."),
)
defer s.Close()
c, _ := clientFor(t, s)
if _, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "check Rival Flagship"}}); err != nil {
t.Fatal(err)
}
second, _ := json.Marshal(s.requests[1])
if strings.Contains(string(second), "site-9") {
t.Fatalf("another tenant's data reached the model:\n%s", second)
}
if !strings.Contains(string(second), "no shop matching") {
t.Fatalf("expected a refusal in the tool result:\n%s", second)
}
}
// A tool that errors must come back as a result the model can recover from,
// not kill the turn and leave the user with a blank panel.
func TestAFailingToolStillProducesAnAnswer(t *testing.T) {
s := newStub(
toolReply("footfall", `{"from":"nonsense","to":"also nonsense"}`),
textReply("I need dates like 2026-09-01."),
)
defer s.Close()
c, _ := clientFor(t, s)
out, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "footfall for last tuesday"}})
if err != nil {
t.Fatalf("a bad argument killed the turn: %v", err)
}
if out.Text == "" {
t.Fatal("no answer was produced")
}
}
// A model that loops forever must stop, and say something rather than nothing.
func TestALoopingModelIsBounded(t *testing.T) {
s := newStub(toolReply("list_shops", `{}`)) // always asks for a tool
defer s.Close()
c, _ := clientFor(t, s)
out, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "loop"}})
if err != nil {
t.Fatal(err)
}
if len(s.requests) > maxIterations {
t.Fatalf("made %d requests, cap is %d", len(s.requests), maxIterations)
}
if out.Text == "" {
t.Fatal("gave up silently - the user would see an empty panel")
}
}
// Every tool must serialise into something the API would accept: a name, a
// description, and an object schema. A malformed one is a 400 at runtime.
func TestEveryToolSerialisesIntoTheRequest(t *testing.T) {
s := newStub(textReply("hello"))
defer s.Close()
c, reg := clientFor(t, s)
_ = reg
if _, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "hi"}}); err != nil {
t.Fatal(err)
}
sent, _ := json.Marshal(s.requests[0])
for _, tool := range c.Tools.Tools() {
if !strings.Contains(string(sent), `"`+tool.Name+`"`) {
t.Errorf("tool %q never reached the request", tool.Name)
}
}
// The tools that need arguments must send `required`, or the model may
// omit one and the failure surfaces as a confusing "that did not work".
if !strings.Contains(string(sent), `"required"`) {
t.Errorf("no tool declared required arguments:\n%s", sent)
}
}
// Who is asking has to reach the model, or it cannot say "a manager can do
// that" when it refuses something.
func TestTheModelIsToldWhoItIsSpeakingTo(t *testing.T) {
s := newStub(textReply("hello"))
defer s.Close()
c, _ := clientFor(t, s)
p := owner()
p.FullName = "Aravind"
if _, err := c.Ask(context.Background(), p, []Turn{{Role: "user", Text: "hi"}}); err != nil {
t.Fatal(err)
}
sent, _ := json.Marshal(s.requests[0])
if !strings.Contains(string(sent), "Aravind") || !strings.Contains(string(sent), "owner") {
t.Fatalf("the model was not told who is asking:\n%s", sent)
}
}
// A deployment with no key is a supported configuration, not a fault.
func TestNoAPIKeyIsASupportedState(t *testing.T) {
c := &Client{Tools: &Registry{}}
c.APIKey = ""
t.Setenv("ANTHROPIC_API_KEY", "")
if c.Configured() {
t.Fatal("reported configured with no key")
}
if _, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "hi"}});
!errors.Is(err, ErrNotConfigured) {
t.Fatalf("got %v, want ErrNotConfigured", err)
}
}
// ---------------------------------------------------------- workspace id
// An identity-linked key is rejected on EVERY endpoint without this header -
// including /v1/models, so the id cannot be discovered from the key. It has to
// be configuration, and it has to be sent when set.
func TestTheWorkspaceHeaderIsSentWhenConfigured(t *testing.T) {
var seen string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("anthropic-workspace-id")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, textReply("ok"))
}))
defer srv.Close()
reg, _ := registry()
c := &Client{Tools: reg, APIKey: "k", Workspace: "wrkspc_test", Model: "claude-sonnet-5"}
c.api = newTestAPIWithWorkspace(srv.URL, c.Workspace)
if _, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "hi"}}); err != nil {
t.Fatal(err)
}
if seen != "wrkspc_test" {
t.Fatalf("workspace header was %q, want wrkspc_test", seen)
}
}
// A classic API key needs no workspace and ignores the header, so omitting it
// must not break anything.
func TestNoWorkspaceHeaderWhenNoneIsConfigured(t *testing.T) {
var present bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, present = r.Header["Anthropic-Workspace-Id"]
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, textReply("ok"))
}))
defer srv.Close()
reg, _ := registry()
c := &Client{Tools: reg, APIKey: "k", Model: "claude-sonnet-5"}
c.api = newTestAPI(srv.URL)
if _, err := c.Ask(context.Background(), owner(),
[]Turn{{Role: "user", Text: "hi"}}); err != nil {
t.Fatal(err)
}
if present {
t.Fatal("sent an empty workspace header")
}
}
// The operator sees "something went wrong at our end", which is true and
// useless when the fix is one environment variable. This is what lets the
// handler say the useful thing instead.
func TestAMissingWorkspaceIsRecognisedAsMisconfiguration(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"type":"error","error":{"type":"invalid_request_error",
"message":"anthropic-workspace-id is required when authenticating with an identity-linked API key; send the id of the workspace this request acts in."}}`)
}))
defer srv.Close()
reg, _ := registry()
c := &Client{Tools: reg, APIKey: "k", Model: "claude-sonnet-5"}
c.api = newTestAPI(srv.URL)
_, err := c.Ask(context.Background(), owner(), []Turn{{Role: "user", Text: "hi"}})
if err == nil {
t.Fatal("no error")
}
if !NeedsWorkspace(err) {
t.Fatalf("not recognised as a workspace problem: %v", err)
}
}
// The model is a deployment decision, not a rebuild.
func TestTheModelCanBeChangedByEnvironment(t *testing.T) {
c := &Client{Tools: &Registry{}}
if got := c.modelID(); got != "claude-sonnet-5" {
t.Errorf("default model is %q", got)
}
t.Setenv("BEHAVISION_ASSISTANT_MODEL", "claude-haiku-4-5")
if got := c.modelID(); got != "claude-haiku-4-5" {
t.Errorf("env override ignored, got %q", got)
}
// An explicit field still wins, so a test or a caller can pin it.
c.Model = "claude-opus-5"
if got := c.modelID(); got != "claude-opus-5" {
t.Errorf("explicit model ignored, got %q", got)
}
}