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:
90
server/internal/web/web.go
Normal file
90
server/internal/web/web.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user