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
349 lines
11 KiB
Go
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)
|
|
}
|
|
}
|