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
140 lines
3.9 KiB
Go
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)
|
|
}
|