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
177 lines
6.1 KiB
Python
177 lines
6.1 KiB
Python
"""Merge endpoints over HTTP against a real gallery on a temp database.
|
|
|
|
The gallery here is real (SQLite + numpy index, no models) because the point
|
|
of these tests is the refusal contract the UI depends on: a refused merge must
|
|
come back as a 409 carrying the measured similarity, so the operator can be
|
|
shown what they are being asked to override.
|
|
"""
|
|
import threading
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
pytest.importorskip("httpx")
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from behavision.api import create_app # noqa: E402
|
|
from behavision.cameras import CameraStore # noqa: E402
|
|
from behavision.config import Config # noqa: E402
|
|
from behavision.engine import Engine # noqa: E402
|
|
from behavision.events import EventBus # noqa: E402
|
|
from behavision.gallery import Gallery, IdentityStore, VectorIndex # noqa: E402
|
|
|
|
DIM = 16
|
|
|
|
|
|
def _unit(seed):
|
|
rng = np.random.default_rng(seed)
|
|
v = rng.normal(size=DIM).astype(np.float32)
|
|
return v / np.linalg.norm(v)
|
|
|
|
|
|
def _at_similarity(base, target, seed=99):
|
|
other = _unit(seed)
|
|
other -= (other @ base) * base
|
|
other /= np.linalg.norm(other)
|
|
v = target * base + np.sqrt(1 - target ** 2) * other
|
|
return (v / np.linalg.norm(v)).astype(np.float32)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path):
|
|
eng = object.__new__(Engine)
|
|
eng.cfg = Config()
|
|
eng.cfg.app.data_dir = tmp_path
|
|
eng.cfg.api.username = eng.cfg.api.password = ""
|
|
eng.cfg.recognition.sighting_cooldown_seconds = 0.0
|
|
eng.store = IdentityStore(tmp_path / "api.db")
|
|
eng.gallery = Gallery(eng.store, VectorIndex(DIM), eng.cfg.recognition)
|
|
eng.bus = EventBus()
|
|
eng.encoder = eng.attributes = None
|
|
eng._lock = threading.RLock()
|
|
eng.workers, eng.detectors = {}, {}
|
|
eng.started_at, eng._running = 1.0, True
|
|
eng.camera_store = CameraStore(tmp_path / "cameras.json")
|
|
yield TestClient(create_app(eng)), eng
|
|
eng.store.close()
|
|
|
|
|
|
def test_merge_succeeds_and_emits_an_audit_event(client):
|
|
c, eng = client
|
|
base = _unit(1)
|
|
a = eng.gallery.enroll("Alice", [base])
|
|
b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)])
|
|
|
|
r = c.post(f"/api/identities/{b}/merge", json={"into": a})
|
|
|
|
assert r.status_code == 200
|
|
assert r.json()["ok"] is True
|
|
assert r.json()["similarity"] == pytest.approx(0.50, abs=0.01)
|
|
assert len(c.get("/api/identities").json()) == 1
|
|
|
|
# Merging is destructive and irreversible; it has to leave a trace.
|
|
types = [e["type"] for e in eng.bus.recent]
|
|
assert "identity.merged" in types
|
|
|
|
|
|
def test_refusal_is_409_and_reports_the_similarity(client):
|
|
"""The UI needs the number, not just a failure — it is what justifies
|
|
offering the force override to a human."""
|
|
c, eng = client
|
|
a = eng.gallery.enroll("Alice", [_unit(1)])
|
|
b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)])
|
|
|
|
r = c.post(f"/api/identities/{b}/merge", json={"into": a})
|
|
|
|
assert r.status_code == 409
|
|
detail = r.json()["detail"]
|
|
assert detail["ok"] is False
|
|
assert detail["similarity"] == pytest.approx(0.05, abs=0.01)
|
|
assert detail["threshold"] == eng.cfg.recognition.enroll_threshold
|
|
assert len(c.get("/api/identities").json()) == 2
|
|
|
|
|
|
def test_force_is_not_the_default(client):
|
|
c, eng = client
|
|
a = eng.gallery.enroll("Alice", [_unit(1)])
|
|
b = eng.gallery.enroll("Bob", [_at_similarity(_unit(1), 0.05)])
|
|
|
|
assert c.post(f"/api/identities/{b}/merge",
|
|
json={"into": a}).status_code == 409
|
|
r = c.post(f"/api/identities/{b}/merge", json={"into": a, "force": True})
|
|
assert r.status_code == 200
|
|
assert r.json()["forced"] is True
|
|
|
|
|
|
def test_missing_identity_is_404(client):
|
|
c, eng = client
|
|
a = eng.gallery.enroll("Alice", [_unit(1)])
|
|
r = c.post(f"/api/identities/9999/merge", json={"into": a})
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_self_merge_is_409(client):
|
|
c, eng = client
|
|
a = eng.gallery.enroll("Alice", [_unit(1)])
|
|
assert c.post(f"/api/identities/{a}/merge",
|
|
json={"into": a}).status_code == 409
|
|
|
|
|
|
def test_duplicates_endpoint_lists_candidates(client):
|
|
c, eng = client
|
|
base = _unit(1)
|
|
a = eng.gallery.enroll("Alice", [base])
|
|
b = eng.gallery.enroll("Visitor 2", [_at_similarity(base, 0.50)])
|
|
|
|
pairs = c.get("/api/identities/duplicates").json()
|
|
|
|
assert len(pairs) == 1
|
|
assert {pairs[0]["a"]["id"], pairs[0]["b"]["id"]} == {a, b}
|
|
assert pairs[0]["confident"] is True # 0.50 clears match_threshold
|
|
|
|
|
|
def test_duplicates_route_is_not_shadowed_by_the_id_routes(client):
|
|
"""`/api/identities/duplicates` sits under a path whose siblings take an
|
|
int id; if it ever gets matched by one of those it returns 422, not a
|
|
list."""
|
|
c, _ = client
|
|
r = c.get("/api/identities/duplicates")
|
|
assert r.status_code == 200
|
|
assert isinstance(r.json(), list)
|
|
|
|
|
|
def test_embedding_endpoint_returns_the_best_view(client):
|
|
"""The server needs a vector to match across sites, and the event bus
|
|
deliberately does not carry one."""
|
|
c, eng = client
|
|
base = _unit(1)
|
|
poor = _at_similarity(base, 0.60, seed=5)
|
|
# enroll() defaults to quality 1.0, which would win regardless and make
|
|
# the assertion below vacuous.
|
|
ident = eng.gallery.enroll("Alice", [base], quality=0.50)
|
|
eng.store.add_embedding(ident, poor, 0.20, eng.gallery.model_name)
|
|
eng.store.add_embedding(ident, base, 0.91, eng.gallery.model_name)
|
|
|
|
r = c.get(f"/api/identities/{ident}/embedding")
|
|
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["quality"] == 0.91, "did not pick the best view"
|
|
assert len(body["embedding"]) == DIM
|
|
assert body["model"] == eng.gallery.model_name
|
|
|
|
|
|
def test_embedding_endpoint_404s_for_an_unknown_identity(client):
|
|
c, _ = client
|
|
assert c.get("/api/identities/9999/embedding").status_code == 404
|
|
|
|
|
|
def test_embedding_endpoint_404s_when_the_model_differs(client):
|
|
"""Vectors from another encoder are not comparable, so returning one would
|
|
hand the server a template it must not use."""
|
|
c, eng = client
|
|
ident = eng.store.create_identity("Bob")
|
|
eng.store.add_embedding(ident, _unit(3), 0.9, "some_other_model")
|
|
assert c.get(f"/api/identities/{ident}/embedding").status_code == 404
|