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

327 lines
11 KiB
Go

package api
import (
"encoding/base64"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
const (
// A poller asking for more than this is either paging history through the
// wrong endpoint or has lost its cursor. Capped rather than rejected: a
// mobile app coming back from a tunnel should get a big catch-up page and
// a fresh cursor, not an error it has no way to recover from.
maxArrivals = 200
defaultArrivals = 50
// How often a live stream re-queries even if no doorbell rings. This is the
// safety net for a second server instance whose ingest this process cannot
// hear, so it is slow on purpose - it is the fallback, not the mechanism.
streamFallback = 15 * time.Second
// SSE comment sent on an idle connection. Mobile networks and reverse
// proxies both close a stream that has been silent for a minute or two,
// and a client that reconnects every ninety seconds is a client that
// re-queries constantly.
streamKeepalive = 20 * time.Second
)
// handleArrivals is the live feed: who walked in, with their photos, in one
// request.
//
// This is the endpoint a mobile app or a shop-floor screen actually needs, and
// it is the one thing the API could not previously answer. `GET /api/visitors`
// searches a customer list by name; it cannot tell you that four people just
// came through the door, and until it could, a client had no way to know which
// customer ids to ask about.
func (s *Server) handleArrivals(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
q := ArrivalQuery{
ClientID: p.ClientID,
SiteID: trim(r.URL.Query().Get("site_id")),
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
}
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
badRequest(w, "site_id must be a site identifier")
return
}
// The site is still filtered by client_id in SQL as well. A site_id from
// the query string is caller-controlled, and this is a read of other
// people's customers if it is ever trusted on its own.
if cur := trim(r.URL.Query().Get("cursor")); cur != "" {
seq, err := decodeCursor(cur)
if err != nil {
badRequest(w, "that cursor is not one of ours - drop it and poll again without one")
return
}
q.AfterSeq = &seq
} else if since := trim(r.URL.Query().Get("since")); since != "" {
at, err := time.Parse(time.RFC3339, since)
if err != nil {
badRequest(w, "since must look like 2026-09-02T10:30:00Z")
return
}
at = at.UTC()
q.Since = &at
}
page, err := s.arrivalPage(r, q)
if err != nil {
s.serverError(w, "arrivals", err)
return
}
writeJSON(w, http.StatusOK, page)
}
// arrivalPage runs the query, attaches photos and returns the next cursor.
// Shared by the poll and the stream so the two cannot answer differently.
func (s *Server) arrivalPage(r *http.Request, q ArrivalQuery) (ArrivalPage, error) {
rows, err := s.Store.Arrivals(r.Context(), q)
if err != nil {
return ArrivalPage{}, err
}
s.attachImages(r, rows)
page := ArrivalPage{
Arrivals: rows,
PolledAt: s.now().UTC().Format(time.RFC3339Nano),
}
if page.Arrivals == nil {
// An empty list, never null. A client looping over the response should
// not have to special-case a quiet minute.
page.Arrivals = []Arrival{}
}
if n := len(rows); n > 0 {
// The LAST row, and the rows are ascending by position, so this is the
// highest position the caller has now seen.
page.Cursor = encodeCursor(rows[n-1].Seq)
} else if q.AfterSeq != nil {
// Nothing new. Hand the caller its own position back rather than an
// empty string, so a poll that returns nothing does not reset the feed
// to the beginning on the next request.
page.Cursor = encodeCursor(*q.AfterSeq)
}
return page, nil
}
// attachImages swaps each row's object key for a short-lived signed link.
//
// One audit row for the whole page, not one per photo. Every read of a face is
// worth recording - "who looked at my customers" has to be answerable - but a
// tablet polling this feed every two seconds would write tens of thousands of
// rows a day and bury the single deliberate look that an investigation is
// actually after. The row records how many faces were surfaced and to whom,
// which is the fact worth keeping.
func (s *Server) attachImages(r *http.Request, rows []Arrival) {
p := PrincipalFrom(r.Context())
seen := make([]string, 0, len(rows))
for i := range rows {
key := rows[i].ImageKey
rows[i].ImageKey = ""
switch {
case s.Blob == nil:
rows[i].Image.Reason = "This system is not storing customer photos."
case key == "":
rows[i].Image.Reason = "No photo was captured for this visit."
default:
url, err := s.Blob.PresignGet(key, viewTTL)
if err != nil {
// Log it, but never fail the feed over a picture. The visit is
// the number the customer pays for; the photo is decoration on
// top of it. This is the same rule the agent follows when an
// upload fails.
s.logf("ERROR presign arrival image: %v", err)
rows[i].Image.Reason = "That photo could not be loaded."
continue
}
rows[i].Image = Image{Available: true, URL: url,
ExpiresIn: int(viewTTL.Seconds())}
if rows[i].VisitorID != "" {
seen = append(seen, rows[i].VisitorID)
}
}
}
if len(seen) == 0 {
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "image.view.feed", Entity: "visits",
Detail: map[string]any{"count": len(seen), "visitor_ids": seen},
})
}
// handleArrivalStream is the same feed pushed instead of polled.
//
// Server-sent events rather than websockets: this direction is one-way, SSE is
// stdlib with no dependency, it survives the reverse proxy in front of this
// server unchanged, and browsers and mobile HTTP clients reconnect it on their
// own. A websocket would buy bidirectionality that nothing here wants.
func (s *Server) handleArrivalStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
// Without flushing this is not a stream, it is a response that arrives
// at the end of the day. Say so rather than appearing to work.
writeErr(w, http.StatusInternalServerError, "server_error",
"streaming is not available on this connection")
return
}
p := PrincipalFrom(r.Context())
q := ArrivalQuery{
ClientID: p.ClientID,
SiteID: trim(r.URL.Query().Get("site_id")),
Limit: queryInt(r, "limit", defaultArrivals, maxArrivals),
}
if q.SiteID != "" && !looksLikeUUID(q.SiteID) {
badRequest(w, "site_id must be a site identifier")
return
}
// Last-Event-ID is what the browser's EventSource resends automatically on
// a dropped connection, so honouring it is what makes a reconnect lossless
// without the client writing any recovery code. ?cursor= is the same thing
// for a native client that cannot set the header.
cur := trim(r.Header.Get("Last-Event-ID"))
if cur == "" {
cur = trim(r.URL.Query().Get("cursor"))
}
if cur != "" {
if seq, err := decodeCursor(cur); err == nil {
q.AfterSeq = &seq
}
// A cursor we cannot read is not worth failing a reconnect over: the
// client falls back to the recent window, which is the same thing it
// would get on a fresh connection.
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
// Traefik and nginx both buffer by default, which turns an event stream
// into a file that arrives when the connection closes.
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
bell, release := s.hub().Subscribe()
defer release()
ctx := r.Context()
fallback := time.NewTicker(streamFallback)
defer fallback.Stop()
keepalive := time.NewTicker(streamKeepalive)
defer keepalive.Stop()
// Send whatever is already there before waiting for a doorbell, so a client
// that connects after people have walked in is not blind until the next
// one does.
send := func() bool {
page, err := s.arrivalPage(r, q)
if err != nil {
s.logf("ERROR arrival stream: %v", err)
// Keep the connection: a transient database error should not log
// a shop's screen out and start a reconnect storm across an estate.
return true
}
if len(page.Arrivals) == 0 {
return true
}
if page.Cursor != "" {
// The SSE id becomes the client's Last-Event-ID, so the cursor
// rides the protocol's own reconnect machinery instead of needing
// application-level recovery.
fmt.Fprintf(w, "id: %s\n", page.Cursor)
// Advance our own position from the rows we just sent, so the next
// wake-up asks for what comes after them.
last := page.Arrivals[len(page.Arrivals)-1].Seq
q.AfterSeq = &last
}
fmt.Fprint(w, "event: arrivals\ndata: ")
if err := writeCompactJSON(w, page); err != nil {
return false
}
fmt.Fprint(w, "\n\n")
flusher.Flush()
return true
}
if !send() {
return
}
for {
select {
case <-ctx.Done():
return
case id, open := <-bell:
if !open {
return
}
// One process serves every tenant, so a doorbell for somebody
// else's shop must not cost this connection a query.
if id != p.ClientID {
continue
}
if !send() {
return
}
case <-fallback.C:
if !send() {
return
}
case <-keepalive.C:
// A comment line. Keeps proxies and mobile radios from deciding
// the connection is dead, and is ignored by every SSE client.
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
}
}
}
func (s *Server) hub() *Hub {
if s.Hub == nil {
// A server built without one still streams; it just has no doorbell,
// so it falls back to the slow tick. A nil map panic on an endpoint
// somebody forgot to wire is a worse outcome than a slower feed.
return nil
}
return s.Hub
}
// ---------------------------------------------------------------- cursors
// Cursors are opaque on purpose, and base64 is what makes them look it. A
// caller that reads one starts depending on the ordering column, and changing
// that later then breaks every deployed mobile app rather than just this file -
// which is exactly what happened once already, when the feed was ordered by a
// timestamp and a random uuid.
func encodeCursor(seq int64) string {
return base64.RawURLEncoding.EncodeToString(
[]byte("v1:" + strconv.FormatInt(seq, 10)))
}
func decodeCursor(s string) (int64, error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return 0, fmt.Errorf("cursor is not base64: %w", err)
}
// The version prefix is what lets the ordering change again without
// silently misreading cursors already held by deployed clients: an old
// cursor fails to parse and the client restarts cleanly from the recent
// window, rather than resuming at a position that now means something else.
body, ok := strings.CutPrefix(string(raw), "v1:")
if !ok {
return 0, fmt.Errorf("cursor is not a v1 cursor")
}
seq, err := strconv.ParseInt(body, 10, 64)
if err != nil || seq < 0 {
return 0, fmt.Errorf("cursor position is not a number")
}
return seq, nil
}