Files
Behavision/server/internal/api/handlers_checks.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

321 lines
11 KiB
Go

package api
import (
"fmt"
"net/http"
"time"
)
// Proving a camera works, and proving a site works.
//
// The engine already answers both questions and already phrases its answers for
// whoever is standing next to the camera. Nothing here re-words them; this is
// the channel that was missing, plus the one judgement head office can make on
// its own - whether the shop PC is even talking to us.
const (
// A placement check asks somebody to walk through the frame and out of it.
// Under ~15 s and an installer has no time to do that; over ~60 s and they
// have wandered off.
minCheckSeconds = 15
defaultCheckSeconds = 25
maxCheckSeconds = 60
// A shop PC that claimed a check and never reported is assumed to have been
// restarted mid-check. Long enough that a slow placement run is not
// stolen from itself.
checkStaleAfter = 5 * time.Minute
)
func (s *Server) handleRequestCheck(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() {
writeErr(w, http.StatusForbidden, "forbidden",
"Your account cannot run camera checks.")
return
}
id := r.PathValue("id")
if !looksLikeUUID(id) {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
var req CheckRequest
if err := decode(w, r, &req); err != nil {
badRequest(w, err.Error())
return
}
switch req.Kind {
case "connection", "placement":
case "":
req.Kind = "connection"
default:
badRequest(w, `kind must be "connection" or "placement"`)
return
}
if req.Seconds == 0 {
req.Seconds = defaultCheckSeconds
}
if req.Seconds < minCheckSeconds || req.Seconds > maxCheckSeconds {
badRequest(w, fmt.Sprintf(
"a placement check runs for between %d and %d seconds - long enough "+
"to walk through the frame, short enough that nobody wanders off",
minCheckSeconds, maxCheckSeconds))
return
}
if err := s.Store.RequestCheck(r.Context(), p.ClientID, id, req.Kind, req.Seconds); err != nil {
writeErr(w, http.StatusNotFound, "not_found", "That camera no longer exists.")
return
}
cam, err := s.Store.CameraByID(r.Context(), p.ClientID, id)
if err != nil {
s.serverError(w, "camera after check request", err)
return
}
cams := []Camera{cam}
s.attachSnapshots(cams)
// 202: the shop PC has not run it yet, and saying 200 would invite a client
// to read the (empty) result as the answer.
writeJSON(w, http.StatusAccepted, cams[0])
}
// ------------------------------------------------------------------ agent
func (s *Server) handleAgentChecks(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
// Release anything a previous run claimed and abandoned before handing out
// work, so a PC restarted mid-check picks its own job back up rather than
// leaving the camera showing "checking..." for ever.
if err := s.Store.ReleaseStaleChecks(r.Context(), checkStaleAfter); err != nil {
s.logf("ERROR releasing stale checks: %v", err)
}
jobs, err := s.Store.ClaimChecks(r.Context(), ap.SiteID)
if err != nil {
s.serverError(w, "claim checks", err)
return
}
if jobs == nil {
jobs = []AgentCheckJob{}
}
writeJSON(w, http.StatusOK, map[string]any{"checks": jobs})
}
func (s *Server) handleAgentCheckResult(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
var res AgentCheckResult
if err := decode(w, r, &res); err != nil {
badRequest(w, err.Error())
return
}
res.CameraID = cameraSlug(res.CameraID)
if res.CameraID == "" {
badRequest(w, "camera_id is required")
return
}
if err := s.Store.RecordCheckResult(r.Context(), ap.SiteID, res); err != nil {
s.serverError(w, "record check result", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ------------------------------------------------------- the site smoke test
// handleSiteCheck answers "is this shop working", end to end.
//
// Assembled entirely from what head office already knows, so it costs no round
// trip to the shop and works when the PC is off - which is itself one of the
// answers, and the one a footfall report cannot give.
//
// Ordered, and it stops judging once something fails: asking whether cameras
// see faces on a PC that is switched off produces an answer that means nothing,
// and printing it next to a real failure buries the real failure.
func (s *Server) handleSiteCheck(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
siteID := r.PathValue("site")
if !looksLikeUUID(siteID) {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
sites, err := s.Store.SiteHealth(r.Context(), p.ClientID)
if err != nil {
s.serverError(w, "site check", err)
return
}
var site *SiteHealth
for i := range sites {
if sites[i].SiteID == siteID {
site = &sites[i]
break
}
}
if site == nil {
writeErr(w, http.StatusNotFound, "not_found", "That shop no longer exists.")
return
}
cams, err := s.Store.Cameras(r.Context(), p.ClientID, siteID)
if err != nil {
s.serverError(w, "site check cameras", err)
return
}
out := SiteCheck{SiteID: site.SiteID, Site: site.Name,
Steps: BuildSiteSteps(site, cams, s.now())}
out.OK = true
for _, st := range out.Steps {
if st.Status != "pass" {
out.OK = false
break
}
}
writeJSON(w, http.StatusOK, out)
}
// buildSiteSteps is the whole judgement, in one place and with no I/O, so it
// can be tested against every combination without a database.
func BuildSiteSteps(site *SiteHealth, cams []Camera, now time.Time) []CheckStep {
steps := make([]CheckStep, 0, 5)
// 1. Is the shop PC talking to us at all? Everything below is unknowable
// until this passes, so a failure here stops the rest being judged.
pc := CheckStep{Name: "The shop's PC is online"}
switch {
case site.LastHeartbeatAt == "":
pc.Status, pc.Detail = "fail", "This PC has never reported in."
pc.Advice = "Install Behavision on the shop's PC and claim it with the enrolment code for this shop."
case !site.Online:
pc.Status = "fail"
pc.Detail = "Last heard from " + humanAgo(site.LastHeartbeatAt, now) + "."
pc.Advice = "Check the PC is switched on, and that it has internet."
default:
pc.Status, pc.Detail = "pass", "Reported in "+humanAgo(site.LastHeartbeatAt, now)+"."
}
steps = append(steps, pc)
if pc.Status == "fail" {
return append(steps, unknownStep("Cameras are connected"),
unknownStep("Cameras can recognise faces"),
unknownStep("Visits are reaching head office"))
}
// 2. Recognition running, and WHICH model - on a memory-starved box the
// large model loses the fallback chain and the process stays up anyway.
rec := CheckStep{Name: "Recognition is running"}
if site.RecognitionModel == "" {
rec.Status = "warn"
rec.Detail = "The PC is online but has not said which recognition model it loaded."
rec.Advice = "Open Behavision on the shop's PC and check it is started."
} else {
rec.Status, rec.Detail = "pass", "Using "+site.RecognitionModel+"."
}
steps = append(steps, rec)
// 3. Cameras connected.
cam := CheckStep{Name: "Cameras are connected"}
switch {
case len(cams) == 0:
cam.Status, cam.Detail = "fail", "No cameras have been set up for this shop."
cam.Advice = "Add a camera, then run this check again."
default:
var up, down, untested int
for _, c := range cams {
switch {
case c.Connected == nil:
untested++
case *c.Connected:
up++
default:
down++
}
}
switch {
case down > 0:
cam.Status = "fail"
cam.Detail = fmt.Sprintf("%d of %d cameras are not connecting.", down, len(cams))
cam.Advice = "Open the camera and use Check connection to see why."
case untested > 0 && up == 0:
cam.Status = "warn"
cam.Detail = fmt.Sprintf("%d camera(s) set up, none tried yet by the shop's PC.", untested)
cam.Advice = "Wait a couple of minutes, or use Check connection on a camera."
default:
cam.Status = "pass"
cam.Detail = fmt.Sprintf("%d of %d connected.", up, len(cams))
}
}
steps = append(steps, cam)
// 4. The question the whole product turns on, and the one Office1 failed
// silently for weeks: not "is a camera plugged in" but "does a person
// walking past produce a view good enough to recognise".
faces := CheckStep{Name: "Cameras can recognise faces"}
switch {
case site.FractionBelowGate > 0.5:
faces.Status = "fail"
faces.Detail = fmt.Sprintf(
"%.0f%% of the faces seen were too poor to recognise.",
site.FractionBelowGate*100)
faces.Advice = "The camera needs moving: face the way people walk in, at about head height."
case site.FractionBelowGate > 0.2:
faces.Status = "warn"
faces.Detail = fmt.Sprintf("%.0f%% of faces seen were too poor to recognise.",
site.FractionBelowGate*100)
faces.Advice = "Some visitors are being missed. Run a walk-past check on each camera."
case site.LastEventAt == "":
// Not a failure. A shop that has just been set up has seen nobody yet,
// and calling that broken sends an installer looking for a fault that
// does not exist.
faces.Status = "unknown"
faces.Detail = "Nobody has walked past yet."
faces.Advice = "Run a walk-past check on a camera to prove it before the shop opens."
default:
faces.Status = "pass"
faces.Detail = "Faces seen are good enough to recognise."
}
steps = append(steps, faces)
// 5. Do visits actually arrive? A shop can be recognising people perfectly
// and reporting none of it.
send := CheckStep{Name: "Visits are reaching head office"}
switch {
case site.Dropped > 0:
send.Status = "fail"
send.Detail = fmt.Sprintf("%d visits were lost - this PC was offline too long.", site.Dropped)
send.Advice = "Check this shop's internet. Those visits cannot be recovered."
case site.Queued > 20:
send.Status = "warn"
send.Detail = fmt.Sprintf("%d visits are waiting to be sent.", site.Queued)
send.Advice = "The PC is recording but not sending. Check its internet connection."
case site.LastEventAt == "":
send.Status = "unknown"
send.Detail = "No visits recorded yet."
default:
send.Status = "pass"
send.Detail = "Last visit received " + humanAgo(site.LastEventAt, now) + "."
}
steps = append(steps, send)
return steps
}
func unknownStep(name string) CheckStep {
return CheckStep{Name: name, Status: "unknown",
Detail: "Cannot be checked until the shop's PC is online."}
}
// humanAgo phrases a timestamp the way somebody reading a status page would say
// it. Deliberately vague at the top end: "3 days ago" is as actionable as
// "3 days and 4 hours ago" and far easier to scan.
func humanAgo(iso string, now time.Time) string {
t, err := time.Parse(time.RFC3339, iso)
if err != nil {
return "at an unknown time"
}
d := now.Sub(t)
switch {
case d < 90*time.Second:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
default:
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
}
}