Files
Behavision/server/internal/api/handlers_reports.go
Suriyakumarvijayanayagam dad04e8cda 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
2026-09-04 11:14:18 +05:30

140 lines
3.9 KiB
Go

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)
}