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:
182
desktop/frontend/src/views/CustomerForm.jsx
Normal file
182
desktop/frontend/src/views/CustomerForm.jsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
|
||||
import VisitHistory from './VisitHistory.jsx'
|
||||
import EraseCustomer from './EraseCustomer.jsx'
|
||||
|
||||
// The in-store form. Two jobs in one sheet: capture who this person is, and
|
||||
// record what they bought — because staff have the customer in front of them
|
||||
// once, and asking them to open a second screen means the sale never gets
|
||||
// recorded.
|
||||
export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
const [f, setF] = useState({
|
||||
full_name: customer.full_name ?? '',
|
||||
phone: customer.phone ?? '',
|
||||
email: customer.email ?? '',
|
||||
gender: '',
|
||||
date_of_birth: '',
|
||||
notes: '',
|
||||
consent: customer.has_consent ?? false,
|
||||
})
|
||||
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [erasing, setErasing] = useState(false)
|
||||
const [photo, setPhoto] = useState(null)
|
||||
const fetched = useCustomerPhoto(customer.id)
|
||||
const shown = photo ?? fetched
|
||||
|
||||
// Erasure destroys a record permanently, so it is a manager's decision. The
|
||||
// server enforces this too — this only avoids offering a button that would
|
||||
// come back 403.
|
||||
const role = session?.user?.role
|
||||
const canErase = ['admin', 'owner', 'manager'].includes(role)
|
||||
|
||||
const set = k => e => setF({ ...f, [k]: e.target.value })
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.saveProfile({ visitor_id: customer.id, ...f })
|
||||
const amount = parseFloat(purchase.amount)
|
||||
if (!isNaN(amount) && amount > 0) {
|
||||
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
|
||||
await api.recordPurchase(customer.id, amount, items, purchase.notes)
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<div className="who">
|
||||
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
||||
onBroken={() => setPhoto({ available: false,
|
||||
reason: 'The photo could not be loaded.' })} />
|
||||
<div className="grow">
|
||||
<h3>{customer.full_name || customer.label}</h3>
|
||||
<p className="note">
|
||||
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
|
||||
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
{shown && !shown.available && shown.reason &&
|
||||
<p className="note">{shown.reason}</p>}
|
||||
</div>
|
||||
<button type="button" className="btn sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Customer details</h3>
|
||||
<label className="field">
|
||||
<span>Full name</span>
|
||||
<input value={f.full_name} onChange={set('full_name')} autoFocus />
|
||||
</label>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Phone</span>
|
||||
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input value={f.email} onChange={set('email')} type="email" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Gender</span>
|
||||
<select value={f.gender} onChange={set('gender')}>
|
||||
<option value="">Not recorded</option>
|
||||
<option>Female</option><option>Male</option><option>Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Date of birth</span>
|
||||
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Notes</span>
|
||||
<textarea value={f.notes} onChange={set('notes')}
|
||||
placeholder="Preferences, sizes, anything worth remembering" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Purchase (optional)</h3>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Amount</span>
|
||||
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
|
||||
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Items</span>
|
||||
<input value={purchase.items} placeholder="shirt, belt"
|
||||
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="note">Leave the amount blank if they did not buy anything —
|
||||
a visit without a sale is still worth recording.</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h3>Consent</h3>
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
|
||||
onChange={e => setF({ ...f, consent: e.target.checked })} />
|
||||
<span>This customer agreed to us keeping their details and
|
||||
recognising them on future visits.</span>
|
||||
</label>
|
||||
<p className="note" style={{ marginTop: 10 }}>
|
||||
Recorded with the date and who collected it. They can withdraw it
|
||||
at any time, which erases their face data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Visits</h3>
|
||||
<VisitHistory customer={customer} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{canErase && (
|
||||
<div className="card danger-zone">
|
||||
<h3>At the customer's request</h3>
|
||||
{erasing
|
||||
? <EraseCustomer customer={customer}
|
||||
onCancel={() => setErasing(false)}
|
||||
onDone={onSaved} />
|
||||
: <>
|
||||
<p className="note">
|
||||
Erase this person's face data, photo and details. Their
|
||||
past visits stay in your footfall figures, without their
|
||||
name.
|
||||
</p>
|
||||
<button type="button" className="btn danger"
|
||||
onClick={() => setErasing(true)}>
|
||||
Erase this customer…
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user