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
291 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|