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:
139
server/internal/api/handlers_reports.go
Normal file
139
server/internal/api/handlers_reports.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Buckets a report may be cut into. A whitelist rather than passing the string
|
||||
// through: date_trunc takes a text argument, so an unchecked value is either a
|
||||
// database error surfaced to a shop floor or, in a query built by
|
||||
// concatenation, something much worse.
|
||||
var buckets = map[string]bool{
|
||||
"hour": true, "day": true, "week": true, "month": true,
|
||||
}
|
||||
|
||||
// maxWindow bounds a report at two years. Not for safety - for honesty: an
|
||||
// open-ended range on a 2 vCPU box times out at the proxy and the user sees a
|
||||
// blank screen with no explanation.
|
||||
const maxWindow = 2 * 366 * 24 * time.Hour
|
||||
|
||||
func (s *Server) reportQuery(r *http.Request) (ReportQuery, error) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
q := r.URL.Query()
|
||||
|
||||
// The tenant comes from the session. A client_id parameter would be a
|
||||
// cross-tenant read waiting for somebody to try it.
|
||||
out := ReportQuery{ClientID: p.ClientID, SiteID: trim(q.Get("site"))}
|
||||
|
||||
now := s.now()
|
||||
from, err := parseDay(q.Get("from"), now.AddDate(0, 0, -29))
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("`from` is not a date: %w", err)
|
||||
}
|
||||
to, err := parseDay(q.Get("to"), now)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("`to` is not a date: %w", err)
|
||||
}
|
||||
// `to` is inclusive to the user ("1st to the 7th" includes the 7th) and
|
||||
// exclusive in SQL. Doing that conversion in one place is the difference
|
||||
// between a report that quietly misses its own last day and one that does
|
||||
// not.
|
||||
if len(trim(q.Get("to"))) == 10 {
|
||||
to = to.AddDate(0, 0, 1)
|
||||
}
|
||||
if !to.After(from) {
|
||||
return out, fmt.Errorf("`to` must be after `from`")
|
||||
}
|
||||
if to.Sub(from) > maxWindow {
|
||||
return out, fmt.Errorf("that range is longer than two years - " +
|
||||
"please narrow it")
|
||||
}
|
||||
out.From, out.To = from, to
|
||||
|
||||
out.Bucket = trim(q.Get("bucket"))
|
||||
if out.Bucket == "" {
|
||||
out.Bucket = "day"
|
||||
}
|
||||
if !buckets[out.Bucket] {
|
||||
return out, fmt.Errorf("bucket must be hour, day, week or month")
|
||||
}
|
||||
|
||||
out.Timezone = trim(q.Get("tz"))
|
||||
if out.Timezone == "" {
|
||||
out.Timezone = "UTC"
|
||||
}
|
||||
// Validated here, where a bad name is a 400 the user can fix, rather than
|
||||
// in Postgres where it is a 500.
|
||||
if _, err := time.LoadLocation(out.Timezone); err != nil {
|
||||
return out, fmt.Errorf("unknown timezone %q", out.Timezone)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseDay accepts a plain date or a full RFC3339 timestamp. Shop staff type
|
||||
// dates; the desktop app sends timestamps.
|
||||
func parseDay(s string, def time.Time) (time.Time, error) {
|
||||
s = trim(s)
|
||||
if s == "" {
|
||||
return def.UTC(), nil
|
||||
}
|
||||
if len(s) == 10 {
|
||||
return time.Parse("2006-01-02", s)
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
return t.UTC(), err
|
||||
}
|
||||
|
||||
func (s *Server) handleFootfall(w http.ResponseWriter, r *http.Request) {
|
||||
q, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
points, totals, err := s.Store.Footfall(r.Context(), q)
|
||||
if err != nil {
|
||||
s.serverError(w, "footfall", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, FootfallReport{
|
||||
From: q.From.Format(time.RFC3339),
|
||||
To: q.To.Format(time.RFC3339),
|
||||
Bucket: q.Bucket,
|
||||
TZ: q.Timezone,
|
||||
Points: points,
|
||||
Total: totals.UniqueVisitors,
|
||||
Visits: totals.Visits,
|
||||
FractionBelowGate: totals.FractionBelowGate,
|
||||
WorstSite: totals.WorstSite,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleConversion(w http.ResponseWriter, r *http.Request) {
|
||||
q, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
rep, err := s.Store.Conversion(r.Context(), q)
|
||||
if err != nil {
|
||||
s.serverError(w, "conversion", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
func (s *Server) handleSites(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "site health", err)
|
||||
return
|
||||
}
|
||||
if sites == nil {
|
||||
// A JSON null would make every caller handle two empty cases.
|
||||
sites = []SiteHealth{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sites)
|
||||
}
|
||||
Reference in New Issue
Block a user