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

97
web/src/App.jsx Normal file
View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react'
import { api, signedIn, clearTokens } from './api.js'
import Login from './views/Login.jsx'
import Sites from './views/Sites.jsx'
import Live from './views/Live.jsx'
import CamerasView from './views/Cameras.jsx'
import Assistant from './views/Assistant.jsx'
import Clients from './views/Clients.jsx'
// A platform admin has no client of their own, so the tenant screens have
// nothing to show them. Rather than render empty pages, they get the one screen
// that is theirs.
//
// Customers and Reports were removed from the navigation - not deleted, the
// views and their API routes are untouched - the same trim the desktop app
// already made and for the same reason: this is the screen somebody opens to
// find out whether their shops are WORKING. A customer search and a
// month-on-month chart are a different job, and putting them in the same nav
// implies the estate is healthy enough to be worth reporting on before anyone
// has checked. They come back when there is something to say with them.
const TENANT_VIEWS = [
{ id: 'sites', label: 'Shops', View: Sites },
{ id: 'live', label: 'Live', View: Live },
{ id: 'cameras', label: 'Cameras', View: CamerasView },
]
const ADMIN_VIEWS = [
{ id: 'clients', label: 'Companies', View: Clients },
]
export default function App() {
const [user, setUser] = useState(null)
const [booting, setBooting] = useState(true)
const [view, setView] = useState('sites')
const [asking, setAsking] = useState(false)
useEffect(() => {
(async () => {
if (signedIn()) {
try { setUser(await api.me()) } catch { clearTokens() }
}
setBooting(false)
})()
}, [])
if (booting) {
return <div className="boot"><span className="spinner" aria-hidden="true" />Loading…</div>
}
if (!user) return <Login onSignedIn={setUser} />
const isAdmin = user.role === 'admin' && !user.client_id
const views = isAdmin ? ADMIN_VIEWS : TENANT_VIEWS
const current = views.find(v => v.id === view) || views[0]
const Current = current.View
const signOut = async () => { await api.logout(); setUser(null) }
return (
<div className="app">
<header className="topbar">
<div className="brand">
<span className="mark" aria-hidden="true" />
<div>
<strong>Behavision</strong>
<span className="org">{user.client_name || 'Loyaly platform'}</span>
</div>
</div>
<nav className="tabs">
{views.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
aria-current={current.id === v.id ? 'page' : undefined}
>{v.label}</button>
))}
</nav>
<div className="who">
<button className={'ask-btn' + (asking ? ' on' : '')}
onClick={() => setAsking(a => !a)}>
<span aria-hidden="true">✳</span> Ask
</button>
<span className="name">{user.full_name || user.email}</span>
<span className="role">{isAdmin ? 'platform admin' : user.role}</span>
<button className="ghost" onClick={signOut}>Sign out</button>
</div>
</header>
<div className={'with-assistant' + (asking ? ' open' : '')}>
<main className="page">
<Current user={user} />
</main>
<Assistant open={asking} onClose={() => setAsking(false)} />
</div>
</div>
)
}