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
This commit is contained in:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
package ingest
import (
"io"
"log"
)
func newTestLogger(w io.Writer) *log.Logger { return log.New(w, "", 0) }

View File

@@ -0,0 +1,173 @@
// Package ingest turns broker messages into database rows.
//
// The whole tenancy decision happens here, in one place, so it can be read and
// tested as a unit rather than being spread across handlers.
package ingest
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"github.com/loyaly/behavision-server/internal/contract"
)
// Store is the persistence the consumer needs. An interface so the routing and
// tenancy rules below can be tested without a database.
type Store interface {
// ResolveSite maps an authenticated MQTT username to a tenant. It must
// NEVER create anything: see the comment in Handle.
ResolveSite(ctx context.Context, mqttUsername string) (Site, error)
RecordVisit(ctx context.Context, site Site, v *contract.Visit) (inserted bool, err error)
RecordHeartbeat(ctx context.Context, site Site, h *contract.Heartbeat) error
}
// Site is a resolved tenant.
type Site struct {
ClientID string
SiteID string
AgentID string
Slug string
}
// ErrUnknownSite means no provisioned site matches the username.
var ErrUnknownSite = errors.New("unknown site")
// Consumer applies one message at a time.
type Consumer struct {
Store Store
Log *log.Logger
// Metrics, read by /healthz. Counting drops matters as much as counting
// successes: a consumer silently discarding a tenth of its traffic looks
// identical to a quiet week.
Accepted uint64
Duplicate uint64
Dropped uint64
// Notify, when set, is rung once per visit that was genuinely new, so live
// listeners re-query instead of waiting out their fallback tick. It carries
// a client id and nothing else - the listeners run the same query a polling
// client would, so there is one definition of what an arrival looks like.
//
// It MUST NOT block. This runs on the consumer's own goroutine, and one
// slow subscriber holding it up would stall ingest for every tenant on the
// server, turning a cosmetic delay on one shop screen into lost throughput
// across the estate.
Notify func(clientID string)
}
// Handle processes one broker message.
//
// Returning nil means "done with this message, do not redeliver". A permanent
// failure returns nil too, on purpose: retrying a malformed payload forever
// would stop every good message behind it, which is exactly the failure the
// agent's spool quarantine exists to avoid. Only a transient error asks for
// redelivery.
func (c *Consumer) Handle(ctx context.Context, topic string, payload []byte) error {
t, err := contract.ParseTopic(topic)
if err != nil {
c.drop("topic %q: %v", topic, err)
return nil
}
// The tenant comes from the topic prefix, which the broker enforces with
// `pattern write bv/%u/...` - a site physically cannot publish under
// another site's prefix. So this lookup is a resolution, not a trust
// decision.
//
// It must never auto-create. A site typo'd into existence would silently
// become a tenant with its own visitors and its own footfall report, and
// nobody would notice until the numbers did not add up. Provisioning is a
// deliberate act.
site, err := c.Store.ResolveSite(ctx, t.Username)
if err != nil {
if errors.Is(err, ErrUnknownSite) {
c.drop("no provisioned site for %q (broker credential exists but "+
"the site was never registered)", t.Username)
return nil
}
return fmt.Errorf("resolve site %q: %w", t.Username, err)
}
switch t.Kind {
case "visit":
return c.handleVisit(ctx, site, payload)
case "heartbeat":
return c.handleHeartbeat(ctx, site, payload)
case "status":
// Same shape as a heartbeat, sent on change rather than on a timer.
return c.handleHeartbeat(ctx, site, payload)
default:
c.drop("unknown message kind %q from %s", t.Kind, t.Username)
return nil
}
}
func (c *Consumer) handleVisit(ctx context.Context, site Site, payload []byte) error {
var v contract.Visit
if err := json.Unmarshal(payload, &v); err != nil {
c.drop("visit from %s is not valid json: %v", site.Slug, err)
return nil
}
if err := v.Validate(); err != nil {
if errors.Is(err, contract.ErrPermanent) {
c.drop("visit %q from %s rejected: %v", v.EventID, site.Slug, err)
return nil
}
return err
}
inserted, err := c.Store.RecordVisit(ctx, site, &v)
if err != nil {
// A database failure IS transient - the broker should redeliver rather
// than the event being lost. This is the one path that returns an error.
return fmt.Errorf("record visit %q: %w", v.EventID, err)
}
if inserted {
c.Accepted++
// Only on a genuine insert. At-least-once delivery makes redelivery
// normal, and ringing for a duplicate would wake every live stream on
// the estate to re-query for a row they already have.
if c.Notify != nil {
c.Notify(site.ClientID)
}
} else {
// Not an error and not a warning: at-least-once delivery makes this
// normal after any reconnect. It is counted so that a sudden rise -
// which would mean the agent is not acking - is visible.
c.Duplicate++
}
return nil
}
func (c *Consumer) handleHeartbeat(ctx context.Context, site Site, payload []byte) error {
var h contract.Heartbeat
if err := json.Unmarshal(payload, &h); err != nil {
c.drop("heartbeat from %s is not valid json: %v", site.Slug, err)
return nil
}
if err := c.Store.RecordHeartbeat(ctx, site, &h); err != nil {
return fmt.Errorf("record heartbeat for %s: %w", site.Slug, err)
}
if h.Dropped > 0 {
// The site's own queue overflowed, so it genuinely lost footfall.
// Surfaced loudly - this is data the customer paid for and will never
// get back, and it must not be discoverable only by reading a graph.
c.logf("WARNING: site %s reports %d events dropped from its local "+
"queue (it was offline long enough to overflow)", site.Slug, h.Dropped)
}
c.Accepted++
return nil
}
func (c *Consumer) drop(format string, args ...any) {
c.Dropped++
c.logf("dropped: "+format, args...)
}
func (c *Consumer) logf(format string, args ...any) {
if c.Log != nil {
c.Log.Printf(format, args...)
}
}

View File

@@ -0,0 +1,226 @@
package ingest
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/loyaly/behavision-server/internal/contract"
)
type fakeStore struct {
sites map[string]Site
visits []contract.Visit
seenEvents map[string]bool
heartbeats []contract.Heartbeat
failVisit error
}
func newFake() *fakeStore {
return &fakeStore{
sites: map[string]Site{"acme.store1": {ClientID: "c1", SiteID: "s1", Slug: "acme.store1"}},
seenEvents: map[string]bool{},
}
}
func (f *fakeStore) ResolveSite(_ context.Context, u string) (Site, error) {
s, ok := f.sites[u]
if !ok {
return Site{}, ErrUnknownSite
}
return s, nil
}
func (f *fakeStore) RecordVisit(_ context.Context, _ Site, v *contract.Visit) (bool, error) {
if f.failVisit != nil {
return false, f.failVisit
}
if f.seenEvents[v.EventID] {
return false, nil
}
f.seenEvents[v.EventID] = true
f.visits = append(f.visits, *v)
return true, nil
}
func (f *fakeStore) RecordHeartbeat(_ context.Context, _ Site, h *contract.Heartbeat) error {
f.heartbeats = append(f.heartbeats, *h)
return nil
}
func visitJSON(id string) []byte {
b, _ := json.Marshal(contract.Visit{
EventID: id, OccurredAt: time.Now(), CameraID: "entrance"})
return b
}
func consumer() (*Consumer, *fakeStore) {
f := newFake()
return &Consumer{Store: f}, f
}
func TestAVisitIsStored(t *testing.T) {
c, f := consumer()
if err := c.Handle(context.Background(), "bv/acme.store1/visit", visitJSON("e1")); err != nil {
t.Fatal(err)
}
if len(f.visits) != 1 || c.Accepted != 1 {
t.Fatalf("visits=%d accepted=%d", len(f.visits), c.Accepted)
}
}
func TestRedeliveryDoesNotDoubleFootfall(t *testing.T) {
// MQTT is at-least-once by design, so this happens after every reconnect.
// Getting it wrong inflates the one number the customer pays for.
c, f := consumer()
ctx := context.Background()
c.Handle(ctx, "bv/acme.store1/visit", visitJSON("same-id"))
c.Handle(ctx, "bv/acme.store1/visit", visitJSON("same-id"))
if len(f.visits) != 1 {
t.Fatalf("stored %d rows for one event", len(f.visits))
}
if c.Accepted != 1 || c.Duplicate != 1 {
t.Fatalf("accepted=%d duplicate=%d - a redelivery is normal, not an error",
c.Accepted, c.Duplicate)
}
}
func TestAnUnprovisionedSiteIsDroppedNotCreated(t *testing.T) {
// A site typo'd into existence would silently become a tenant with its own
// visitors and its own footfall report, and nobody would notice until the
// numbers stopped adding up. Provisioning is a deliberate act.
c, f := consumer()
err := c.Handle(context.Background(), "bv/ghost.store9/visit", visitJSON("e1"))
if err != nil {
t.Fatalf("an unknown site must not be a transient error: %v", err)
}
if len(f.visits) != 0 || len(f.sites) != 1 {
t.Fatal("an unprovisioned site produced rows")
}
if c.Dropped != 1 {
t.Fatal("the drop was not counted")
}
}
func TestMalformedPayloadIsDroppedNotRetriedForever(t *testing.T) {
// A message retried forever stops every good one behind it - the same
// failure the agent's spool quarantine exists to prevent.
c, _ := consumer()
for _, bad := range [][]byte{
[]byte("{ truncated"),
[]byte(`{"event_id":"","occurred_at":"2026-01-01T00:00:00Z"}`),
[]byte(`{"event_id":"e","occurred_at":"2026-01-01T00:00:00Z","embedding":[1,2,3],"model":"m"}`),
} {
if err := c.Handle(context.Background(), "bv/acme.store1/visit", bad); err != nil {
t.Errorf("malformed payload asked for redelivery: %v", err)
}
}
if c.Dropped != 3 {
t.Fatalf("dropped=%d, want 3", c.Dropped)
}
}
func TestADatabaseFailureAsksForRedelivery(t *testing.T) {
// The one path that must NOT swallow the message: the event is valid and
// the store is broken, so losing it would lose real footfall.
c, f := consumer()
f.failVisit = context.DeadlineExceeded
err := c.Handle(context.Background(), "bv/acme.store1/visit", visitJSON("e1"))
if err == nil {
t.Fatal("a database failure was swallowed")
}
if c.Dropped != 0 {
t.Fatal("a database failure was counted as a drop")
}
}
func TestABadTopicIsDropped(t *testing.T) {
c, _ := consumer()
for _, topic := range []string{"bv/acme/visit", "nope/acme.store1/visit",
"bv/acme.store1/nonsense"} {
if err := c.Handle(context.Background(), topic, visitJSON("e1")); err != nil {
t.Errorf("%s: %v", topic, err)
}
}
if c.Dropped != 3 {
t.Fatalf("dropped=%d, want 3", c.Dropped)
}
}
func TestHeartbeatIsRecorded(t *testing.T) {
c, f := consumer()
h, _ := json.Marshal(contract.Heartbeat{
SentAt: time.Now(), RecognitionModel: "w600k_r50.onnx"})
if err := c.Handle(context.Background(), "bv/acme.store1/heartbeat", h); err != nil {
t.Fatal(err)
}
if len(f.heartbeats) != 1 || f.heartbeats[0].RecognitionModel != "w600k_r50.onnx" {
t.Fatalf("%+v", f.heartbeats)
}
}
func TestASiteReportingDroppedEventsIsLoggedLoudly(t *testing.T) {
// That site lost footfall the customer paid for and will never get back.
// It must not be discoverable only by staring at a graph.
var sb strings.Builder
c, _ := consumer()
c.Log = newTestLogger(&sb)
h, _ := json.Marshal(contract.Heartbeat{SentAt: time.Now(), Dropped: 417})
c.Handle(context.Background(), "bv/acme.store1/heartbeat", h)
out := sb.String()
if !strings.Contains(out, "417") || !strings.Contains(out, "WARNING") {
t.Fatalf("dropped events not surfaced: %q", out)
}
}
// ---------------------------------------------------------------- the doorbell
// A live arrivals stream should learn about a visitor within milliseconds, not
// whenever its fallback tick next comes round.
func TestANewVisitRingsTheDoorbell(t *testing.T) {
c, _ := consumer()
var rung []string
c.Notify = func(clientID string) { rung = append(rung, clientID) }
if err := c.Handle(context.Background(), "bv/acme.store1/visit", visitJSON("e1")); err != nil {
t.Fatal(err)
}
if len(rung) != 1 || rung[0] != "c1" {
t.Fatalf("want one ring carrying the tenant, got %v", rung)
}
}
// At-least-once delivery makes redelivery normal after any reconnect. Ringing
// for one would wake every live stream on the estate to re-query for rows they
// already have - and a reconnect redelivers a whole batch at once.
func TestARedeliveredVisitDoesNotRingTheDoorbell(t *testing.T) {
c, _ := consumer()
rings := 0
c.Notify = func(string) { rings++ }
for i := 0; i < 3; i++ {
if err := c.Handle(context.Background(), "bv/acme.store1/visit", visitJSON("e1")); err != nil {
t.Fatal(err)
}
}
if rings != 1 {
t.Fatalf("three deliveries of one event rang %d times, want 1", rings)
}
}
// The consumer must work unchanged with no listener wired up: a server
// assembled without a hub still has to ingest.
func TestIngestWorksWithNoDoorbellWired(t *testing.T) {
c, f := consumer()
c.Notify = nil
if err := c.Handle(context.Background(), "bv/acme.store1/visit", visitJSON("e1")); err != nil {
t.Fatal(err)
}
if len(f.visits) != 1 {
t.Fatalf("visits=%d", len(f.visits))
}
}