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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

229
web/src/api.js Normal file
View File

@@ -0,0 +1,229 @@
// The cloud API, as this browser sees it.
//
// Everything the platform does goes through `send` below, so there is exactly
// one place that knows how a session is carried, refreshed and lost.
const ACCESS = 'bv.access'
const REFRESH = 'bv.refresh'
// localStorage, not a cookie. The API authenticates with a bearer token and
// sets no cookie of its own, so there is no CSRF surface to defend - and a
// head-office user who closes the tab expects to still be signed in tomorrow.
function read(k) { try { return localStorage.getItem(k) || '' } catch { return '' } }
function write(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k) } catch { /* private mode */ } }
export function tokens() { return { access: read(ACCESS), refresh: read(REFRESH) } }
export function setTokens(access, refresh) { write(ACCESS, access); write(REFRESH, refresh) }
export function clearTokens() { setTokens('', '') }
export function signedIn() { return !!read(REFRESH) }
export class ApiError extends Error {
constructor(status, code, message) {
super(message)
this.status = status
// The server's own code, kept alongside its prose. A caller that has to
// match on English to tell a normal absence from a fault will get it wrong
// the first time the wording is improved.
this.code = code
}
}
// One refresh at a time, ever.
//
// The refresh token is single-use and rotates. Four screens polling at once
// would each spend it and three would lose, logging the user out at random -
// so every caller that hits an expired token waits on the same promise.
let refreshing = null
async function refresh() {
if (refreshing) return refreshing
refreshing = (async () => {
const { refresh: rt } = tokens()
if (!rt) throw new ApiError(401, 'unauthorized', 'Signed out.')
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: rt }),
})
if (!res.ok) {
clearTokens()
throw new ApiError(res.status, 'unauthorized', 'Your session has ended. Sign in again.')
}
const s = await res.json()
// Persisted BEFORE anything else runs. A tab that refreshes and is then
// closed must not come back holding a token the server already retired.
setTokens(s.access_token, s.refresh_token)
return s
})().finally(() => { refreshing = null })
return refreshing
}
async function send(method, path, body, retry = true) {
const { access } = tokens()
const headers = {}
if (access) headers.Authorization = 'Bearer ' + access
if (body !== undefined) headers['Content-Type'] = 'application/json'
const res = await fetch(path, {
method,
headers,
// Serialised up front: a retry has to send the same body again, and a
// stream would be spent after the first attempt.
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 204) return null
const text = await res.text()
let parsed = null
try { parsed = text ? JSON.parse(text) : null } catch { /* not json */ }
if (res.ok) return parsed
const code = parsed?.error || ''
if (code === 'token_expired' && retry) {
// Silent. A shop assistant should not be thrown back to a login form twice
// a day because an access token reached twelve hours old.
await refresh()
return send(method, path, body, false)
}
if (res.status === 401) clearTokens()
throw new ApiError(res.status, code,
parsed?.message || `Something went wrong (${res.status}).`)
}
const qs = (params) => {
const p = new URLSearchParams()
for (const [k, v] of Object.entries(params || {})) {
if (v !== undefined && v !== null && v !== '') p.set(k, v)
}
const s = p.toString()
return s ? '?' + s : ''
}
export const api = {
async login(email, password) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const body = await res.json().catch(() => null)
if (!res.ok) {
throw new ApiError(res.status, body?.error || '',
body?.message || 'Could not sign in.')
}
setTokens(body.access_token, body.refresh_token)
return body.user
},
async logout() {
try { await send('POST', '/api/auth/logout') } catch { /* already gone */ }
clearTokens()
},
me: () => send('GET', '/api/auth/me'),
sites: () => send('GET', '/api/sites'),
// The live arrivals feed. `cursor` is opaque and must be echoed back.
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
visitors: (q, limit = 50) => send('GET', '/api/visitors' + qs({ q, limit })),
visitorHistory: (id, limit = 50) =>
send('GET', `/api/visitors/${encodeURIComponent(id)}/history` + qs({ limit })),
saveProfile: (id, profile) =>
send('PUT', `/api/visitors/${encodeURIComponent(id)}/profile`, profile),
footfall: (params) => send('GET', '/api/reports/footfall' + qs(params)),
conversion: (params) => send('GET', '/api/reports/conversion' + qs(params)),
cameras: () => send('GET', '/api/cameras'),
createCamera: (siteID, cam) =>
send('POST', `/api/sites/${encodeURIComponent(siteID)}/cameras`, cam),
updateCamera: (id, cam) => send('PATCH', `/api/cameras/${encodeURIComponent(id)}`, cam),
deleteCamera: (id) => send('DELETE', `/api/cameras/${encodeURIComponent(id)}`),
// Prove a camera works. `kind` is "connection" (can the shop PC open the
// stream) or "placement" (does someone walking past produce a usable view).
checkCamera: (id, kind, seconds) =>
send('POST', `/api/cameras/${encodeURIComponent(id)}/check`,
{ kind, ...(seconds ? { seconds } : {}) }),
siteCheck: (siteID) =>
send('GET', `/api/sites/${encodeURIComponent(siteID)}/check`),
// The one-shot code a shop PC is claimed with. Returned in full exactly once
// - only a hash is stored - so whatever calls this has to show it there and
// then, and must not expect to read it back later.
enrolmentCode: (siteID, input) =>
send('POST', `/api/sites/${encodeURIComponent(siteID)}/enrolment-code`,
input || {}),
ask: (history) => send('POST', '/api/assistant', { history }),
clients: () => send('GET', '/api/admin/clients'),
createClient: (input) => send('POST', '/api/admin/clients', input),
}
// The live stream, read with fetch rather than EventSource.
//
// EventSource cannot set headers, so using it would mean putting the session
// token in the query string - where it lands in server logs, browser history
// and any screenshot of the URL bar. Reading the stream by hand costs the
// frame parsing below and keeps the token in an Authorization header.
//
// The cursor is held HERE, not derived from what is on screen. That is what
// makes a reconnect lossless: whatever arrived while the connection was down
// is delivered on the next one.
export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
let stopped = false
let position = cursor || ''
const run = async () => {
while (!stopped) {
try {
const { access } = tokens()
const res = await fetch('/api/visits/stream' + qs({ cursor: position, site_id: siteId }), {
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
signal,
})
if (res.status === 401) {
await refresh()
continue
}
if (!res.ok || !res.body) throw new Error('stream unavailable')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (!stopped) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
// Frames are separated by a blank line. Anything after the last one
// is a partial frame and stays in the buffer.
let split
while ((split = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, split)
buf = buf.slice(split + 2)
for (const line of frame.split('\n')) {
if (line.startsWith('id: ')) position = line.slice(4).trim()
else if (line.startsWith('data: ')) {
try { onPage(JSON.parse(line.slice(6))) } catch { /* partial */ }
}
}
}
}
} catch (err) {
if (stopped || signal?.aborted) return
onError?.(err)
}
if (stopped) return
// Reconnect. `position` survives, so nothing that arrived while we were
// away is missed - that is the whole reason the cursor is held here and
// not derived from what is on screen.
await new Promise(r => setTimeout(r, 3000))
}
}
run()
return () => { stopped = true }
}