Files
Behavision/server/internal/assistant/tools_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

291 lines
10 KiB
Go

package assistant
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/loyaly/behavision-server/internal/api"
"github.com/loyaly/behavision-server/internal/auth"
)
type fakeStore struct {
sites map[string][]api.SiteHealth // by client id
cameras map[string][]api.Camera
checked []string
lastQuery api.ReportQuery
visitors []api.Customer
}
func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]api.SiteHealth, error) {
return f.sites[clientID], nil
}
func (f *fakeStore) Cameras(_ context.Context, clientID, siteID string) ([]api.Camera, error) {
var out []api.Camera
for _, c := range f.cameras[clientID] {
if siteID == "" || c.SiteID == siteID {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) CameraByID(_ context.Context, clientID, id string) (api.Camera, error) {
for _, c := range f.cameras[clientID] {
if c.ID == id {
return c, nil
}
}
return api.Camera{}, context.Canceled
}
func (f *fakeStore) RequestCheck(_ context.Context, clientID, id, kind string, _ int) error {
for _, c := range f.cameras[clientID] {
if c.ID == id {
f.checked = append(f.checked, id+":"+kind)
return nil
}
}
return context.Canceled
}
func (f *fakeStore) Footfall(_ context.Context, q api.ReportQuery) (
[]api.FootfallPoint, api.Totals, error) {
f.lastQuery = q
return []api.FootfallPoint{{Bucket: "2026-09-01T00:00:00", Visitors: 40, New: 30, Returning: 8}},
api.Totals{UniqueVisitors: 38, Visits: 40, FractionBelowGate: 0.727, WorstSite: "Bengaluru"}, nil
}
func (f *fakeStore) Conversion(_ context.Context, q api.ReportQuery) (api.SalesReport, error) {
f.lastQuery = q
return api.SalesReport{Visitors: 38, Purchasers: 9, Conversion: 0.24, Currency: "INR"}, nil
}
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, _ string, _ int) (
[]api.Customer, error) {
if clientID != "acme" {
return nil, nil
}
return f.visitors, nil
}
func registry() (*Registry, *fakeStore) {
up := true
fs := &fakeStore{
sites: map[string][]api.SiteHealth{
"acme": {
{SiteID: "site-1", Slug: "chennai", Name: "Chennai · Anna Nagar",
Online: true, RecognitionModel: "w600k_r50.onnx",
LastHeartbeatAt: time.Now().UTC().Format(time.RFC3339)},
},
"rival": {{SiteID: "site-9", Slug: "secret", Name: "Rival Flagship"}},
},
cameras: map[string][]api.Camera{
"acme": {{ID: "cam-1", SiteID: "site-1", Site: "Chennai · Anna Nagar", Label: "Entrance", Connected: &up}},
"rival": {{ID: "cam-9", SiteID: "site-9", Label: "Rival Entrance"}},
},
visitors: []api.Customer{{ID: "v1", FullName: "Asha Menon", VisitCount: 3}},
}
return &Registry{Store: fs, SiteChecker: api.BuildSiteSteps,
Now: func() time.Time { return time.Now().UTC() }}, fs
}
func owner() auth.Principal {
return auth.Principal{UserID: "u1", ClientID: "acme", ClientName: "Acme", Role: "owner"}
}
func call(t *testing.T, r *Registry, p auth.Principal, name, args string) string {
t.Helper()
out, err := r.Call(context.Background(), p, name, json.RawMessage(args))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return out
}
// ---------------------------------------------------------------- tenancy
// The single most important property. No tool takes a client id, so there is
// nothing for the model to set - tenancy is a property of the signatures, not
// of anybody remembering to check.
func TestNoToolAcceptsATenantArgument(t *testing.T) {
r, _ := registry()
for _, tool := range r.Tools() {
props, _ := tool.Schema["properties"].(map[string]any)
for name := range props {
lower := strings.ToLower(name)
if strings.Contains(lower, "client") || strings.Contains(lower, "tenant") {
t.Errorf("tool %q takes %q - the model could point it at another company",
tool.Name, name)
}
}
}
}
// Asking for another company's shop by its real name must fail, and the
// failure must not disclose that the shop exists.
func TestAnotherCompanysShopCannotBeReached(t *testing.T) {
r, _ := registry()
got := call(t, r, owner(), "check_shop", `{"shop":"Rival Flagship"}`)
if strings.Contains(got, "Rival") && !strings.Contains(got, "no shop matching") {
t.Fatalf("leaked another tenant: %s", got)
}
if !strings.Contains(got, "no shop matching") {
t.Fatalf("expected a refusal, got: %s", got)
}
// The refusal lists this account's OWN shops, which is a legitimate help.
if !strings.Contains(got, "Chennai") {
t.Errorf("the refusal should say which shops this account does have: %s", got)
}
}
func TestAnotherCompanysCameraCannotBeChecked(t *testing.T) {
r, fs := registry()
got := call(t, r, owner(), "check_camera", `{"camera_id":"cam-9","kind":"connection"}`)
if !strings.Contains(got, "no camera") {
t.Fatalf("expected a refusal, got: %s", got)
}
if len(fs.checked) != 0 {
t.Fatalf("a check was queued on another tenant's camera: %v", fs.checked)
}
}
// The permission check lives in the tool, not in the prompt. An instruction not
// to do something is not a permission check, and this one writes to a shop's PC.
func TestStaffCannotMakeTheAssistantRunACameraCheck(t *testing.T) {
r, fs := registry()
staff := auth.Principal{UserID: "u2", ClientID: "acme", Role: "staff"}
got := call(t, r, staff, "check_camera", `{"camera_id":"cam-1","kind":"placement"}`)
if !strings.Contains(got, "cannot run camera checks") {
t.Fatalf("staff were allowed through: %s", got)
}
if len(fs.checked) != 0 {
t.Fatalf("a check ran anyway: %v", fs.checked)
}
// And it says who can, so the person is not stuck.
if !strings.Contains(got, "manager or owner") {
t.Errorf("the refusal does not say who can: %s", got)
}
}
func TestAManagerCanRunACameraCheck(t *testing.T) {
r, fs := registry()
mgr := auth.Principal{UserID: "u3", ClientID: "acme", Role: "manager"}
call(t, r, mgr, "check_camera", `{"camera_id":"cam-1","kind":"connection"}`)
if len(fs.checked) != 1 || fs.checked[0] != "cam-1:connection" {
t.Fatalf("check not queued: %v", fs.checked)
}
}
// ---------------------------------------------------------------- arithmetic
// The reason these are business tools rather than raw SQL: the model must not
// be able to re-derive the arithmetic, because this product's arithmetic has
// traps that produce a plausible wrong number rather than an error.
func TestFootfallReturnsBothNumbersAndSaysNotToAddTheBucketsUp(t *testing.T) {
r, _ := registry()
got := call(t, r, owner(), "footfall", `{"from":"2026-09-01","to":"2026-09-02"}`)
for _, want := range []string{"unique_people", "visits", "do not add the buckets up"} {
if !strings.Contains(got, want) {
t.Errorf("footfall result is missing %q: %s", want, got)
}
}
}
// A footfall figure from a badly placed camera is wrong in a way the figure
// itself cannot show. The confidence has to travel with it.
func TestFootfallCarriesTheShareOfFacesTooPoorToRecognise(t *testing.T) {
r, _ := registry()
got := call(t, r, owner(), "footfall", `{"from":"2026-09-01","to":"2026-09-02"}`)
if !strings.Contains(got, "fraction_of_faces_too_poor_to_recognise") {
t.Fatalf("the confidence did not travel with the number: %s", got)
}
if !strings.Contains(got, "0.727") {
t.Errorf("the measured value is missing: %s", got)
}
}
// `to` is inclusive to a person and exclusive in SQL. Getting this wrong
// quietly loses the last day's trade.
func TestTheEndDateIsInclusive(t *testing.T) {
r, fs := registry()
call(t, r, owner(), "footfall", `{"from":"2026-09-01","to":"2026-09-07"}`)
want := time.Date(2026, 9, 8, 0, 0, 0, 0, time.UTC)
if !fs.lastQuery.To.Equal(want) {
t.Fatalf("to = %s, want %s - the 7th's trade would be missing",
fs.lastQuery.To, want)
}
}
func TestABadDateIsRefusedWithAnExample(t *testing.T) {
r, _ := registry()
got := call(t, r, owner(), "footfall", `{"from":"last tuesday","to":"2026-09-02"}`)
if !strings.Contains(got, "2026-09-01") {
t.Fatalf("the refusal does not show the expected shape: %s", got)
}
}
// ---------------------------------------------------------------- behaviour
// Connected and verified are different claims, and the assistant has to be able
// to tell a person which one a camera has.
func TestCamerasReportVerificationSeparatelyFromConnection(t *testing.T) {
r, fs := registry()
up := true
fs.cameras["acme"] = []api.Camera{{
ID: "cam-1", SiteID: "site-1", Label: "Entrance", Connected: &up,
Check: api.CameraCheck{State: "done", Kind: "connection", OK: true},
}}
got := call(t, r, owner(), "list_cameras", `{}`)
if !strings.Contains(got, "nobody has proved it can recognise a face") {
t.Fatalf("a connected-but-unverified camera did not say so: %s", got)
}
}
// With one shop and no name, the question can only be about that shop - asking
// which one would be obtuse.
func TestOneShopNeedsNoNaming(t *testing.T) {
r, _ := registry()
got := call(t, r, owner(), "check_shop", `{"shop":""}`)
if !strings.Contains(got, "Chennai") {
t.Fatalf("did not resolve the only shop: %s", got)
}
}
// A tool failure comes back as a RESULT, so the model can recover and say
// something useful, rather than killing the turn and leaving a blank screen.
func TestAToolFailureIsAnAnswerNotAnError(t *testing.T) {
r, _ := registry()
out, err := r.Call(context.Background(), owner(), "check_shop", json.RawMessage(`{"shop":"Nowhere"}`))
if err != nil {
t.Fatalf("a missing shop killed the turn: %v", err)
}
if !strings.Contains(out, "did not work") {
t.Fatalf("unexpected: %s", out)
}
}
func TestAnUnknownToolIsAnError(t *testing.T) {
r, _ := registry()
if _, err := r.Call(context.Background(), owner(), "drop_database", json.RawMessage(`{}`)); err == nil {
t.Fatal("an invented tool name was accepted")
}
}
// Every tool needs a description the model can route on, and a schema.
func TestEveryToolIsDescribedWellEnoughToChoose(t *testing.T) {
r, _ := registry()
for _, tool := range r.Tools() {
if len(tool.Description) < 60 {
t.Errorf("tool %q has too thin a description to route on", tool.Name)
}
if tool.Schema["type"] != "object" {
t.Errorf("tool %q has no object schema", tool.Name)
}
if tool.Run == nil {
t.Errorf("tool %q does nothing", tool.Name)
}
}
}