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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
server/internal/web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Behavision</title>
<script type="module" crossorigin src="/assets/index-C8M-zRAi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-pUqVBCLm.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,90 @@
// Package web serves the head-office platform at platform.loyaly.ai.
//
// Embedded into the server binary rather than deployed as static files beside
// it. One artefact, for the same reason `provision` is a subcommand and not a
// second image: a second thing to deploy is a second thing to forget to deploy,
// and a UI that is one version behind its API fails in ways nobody can
// reproduce.
package web
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
// dist is written by `npm run build` in ../../../web.
//
// The directory must exist for the package to compile at all, which is a real
// constraint on a fresh checkout: `go build` fails with "pattern all:dist: no
// matching files" until the frontend has been built once. That is why a
// placeholder index.html is kept in the tree - the alternative is a Go build
// that cannot run without npm.
//
//go:embed all:dist
var dist embed.FS
// cacheFor decides how long a response may be reused.
//
// Vite fingerprints everything under assets/, so its name changes whenever its
// content does and a year is safe. Everything else - index.html above all -
// must never be cached: a browser holding last week's entry document runs last
// week's bundle against this week's API, and the resulting failure depends on
// one machine's cache, so nobody else can reproduce it.
func cacheFor(path string) string {
if strings.HasPrefix(path, "assets/") {
return "public, max-age=31536000, immutable"
}
return "no-store"
}
// Handler serves the single-page app, with the routing a SPA needs.
//
// Two behaviours that are not the default and both matter:
//
// - Any path that is not a real file returns index.html, so a deep link or a
// browser reload lands on the app rather than a 404. It does NOT do this
// for /api, which is mounted separately - swallowing an unmatched API path
// into an HTML page turns a typo'd endpoint into a JSON parse error three
// layers away from the cause.
// - Hashed build assets are cached hard, index.html never. Caching the entry
// document is how a browser keeps running last week's bundle against this
// week's API.
func Handler() (http.Handler, error) {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil, err
}
files := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clean := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if clean == "" {
clean = "index.html"
}
if f, err := sub.Open(clean); err == nil {
f.Close() //nolint:errcheck
// Set on BOTH branches, because "/" resolves to a real file and
// would otherwise take the file-server path with no cache header at
// all - the entry document cached by default, which is precisely
// the skew this is here to prevent.
w.Header().Set("Cache-Control", cacheFor(clean))
files.ServeHTTP(w, r)
return
}
// Not a file: hand back the app and let the router decide.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
http.Error(w, "the web application was not built into this server",
http.StatusInternalServerError)
return
}
w.Write(index) //nolint:errcheck
}), nil
}

View File

@@ -0,0 +1,101 @@
package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func get(t *testing.T, path string) *httptest.ResponseRecorder {
t.Helper()
h, err := Handler()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
return rec
}
func TestTheRootServesTheApp(t *testing.T) {
rec := get(t, "/")
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "<") {
t.Fatal("no markup came back")
}
}
// A deep link or a browser reload must land on the app. Returning 404 for a
// path the client router owns is the classic single-page-app deployment bug,
// and it only shows up when somebody refreshes a page that is not the root -
// which is to say, in front of a customer.
func TestAnyClientRouteReturnsTheApp(t *testing.T) {
for _, path := range []string{"/customers", "/reports", "/sites/abc/live"} {
rec := get(t, path)
if rec.Code != http.StatusOK {
t.Errorf("%s: got %d, want the app", path, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("%s: content-type %q", path, ct)
}
}
}
// Caching the entry document is how a browser ends up running last week's
// bundle against this week's API - a version skew nobody can reproduce because
// it depends on one machine's cache.
func TestTheEntryDocumentIsNeverCached(t *testing.T) {
if got := get(t, "/").Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control %q, want no-store", got)
}
}
// A path that climbs out of the bundle must not reach the filesystem. Cleaned
// before it is opened, so this resolves inside dist or not at all.
func TestPathTraversalCannotEscapeTheBundle(t *testing.T) {
for _, path := range []string{
"/../../../../etc/passwd",
"/assets/../../../etc/passwd",
"/..%2f..%2fetc%2fpasswd",
} {
rec := get(t, path)
if body := rec.Body.String(); strings.Contains(body, "root:") {
t.Fatalf("%s leaked a system file", path)
}
// Falling through to the app is the correct answer: it is not a file.
if rec.Code != http.StatusOK {
t.Logf("%s -> %d (fine, as long as nothing leaked)", path, rec.Code)
}
}
}
// Fingerprinted assets are safe to cache hard - their names change with their
// contents - and caching them is most of what makes the app load instantly on
// a shop's connection.
func TestFingerprintedAssetsAreCachedHard(t *testing.T) {
h, err := Handler()
if err != nil {
t.Fatal(err)
}
// Find whatever the current build named them.
rec := get(t, "/")
body := rec.Body.String()
i := strings.Index(body, "/assets/")
if i < 0 {
t.Skip("this build has no fingerprinted assets (placeholder index.html)")
}
rest := body[i:]
name := rest[:strings.IndexAny(rest, "\"'")]
got := httptest.NewRecorder()
h.ServeHTTP(got, httptest.NewRequest("GET", name, nil))
if got.Code != http.StatusOK {
t.Fatalf("%s: got %d", name, got.Code)
}
if cc := got.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Errorf("%s: Cache-Control %q, want immutable", name, cc)
}
}