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:
117
server/internal/api/handlers_assistant.go
Normal file
117
server/internal/api/handlers_assistant.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/auth"
|
||||
)
|
||||
|
||||
// The assistant, as an HTTP route.
|
||||
//
|
||||
// Session-authenticated like every other person-facing endpoint, and the
|
||||
// principal it derives is handed to every tool the model calls - so the
|
||||
// assistant can only ever see what the person asking could already see.
|
||||
|
||||
// Assistant is what the API needs from the assistant package.
|
||||
//
|
||||
// Declared HERE with the api package's own types, because `assistant` imports
|
||||
// `api` for the report and camera shapes - so the dependency can only run one
|
||||
// way, and main.go supplies a small adapter. That also makes the handler
|
||||
// testable with no API key and no network call.
|
||||
type Assistant interface {
|
||||
Configured() bool
|
||||
Ask(ctx context.Context, p auth.Principal, history []AssistantTurn) (AssistantAnswer, error)
|
||||
}
|
||||
|
||||
// ErrAssistantOff is returned when no API key is configured. A supported
|
||||
// state, not a fault.
|
||||
var ErrAssistantOff = errors.New("the assistant is not switched on for this server")
|
||||
|
||||
// ErrAssistantMisconfigured means the credentials are present but incomplete -
|
||||
// today, an identity-linked API key with no workspace id.
|
||||
var ErrAssistantMisconfigured = errors.New("the assistant is configured incorrectly")
|
||||
|
||||
type AssistantTurn struct {
|
||||
Role string `json:"role"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type AssistantAnswer struct {
|
||||
Text string `json:"text"`
|
||||
Used []string `json:"used,omitempty"`
|
||||
}
|
||||
|
||||
// AssistantRequest is one question plus the conversation so far. The client
|
||||
// holds the history: this server keeps no chat state, so there is no per-user
|
||||
// transcript sitting in a database that nobody agreed to.
|
||||
type AssistantRequest struct {
|
||||
History []AssistantTurn `json:"history"`
|
||||
}
|
||||
|
||||
const (
|
||||
// A conversation longer than this is not a support question any more, and
|
||||
// every turn is resent on every request.
|
||||
maxAssistantTurns = 24
|
||||
maxQuestionChars = 2000
|
||||
)
|
||||
|
||||
func (s *Server) handleAssistant(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if s.Assistant == nil || !s.Assistant.Configured() {
|
||||
// 501, not 500. "This deployment has no assistant" is a supported
|
||||
// configuration; the UI hides the panel rather than showing an error.
|
||||
writeErr(w, http.StatusNotImplemented, "assistant_off",
|
||||
"The assistant is not switched on for this server.")
|
||||
return
|
||||
}
|
||||
var body AssistantRequest
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if len(body.History) == 0 {
|
||||
badRequest(w, "ask a question")
|
||||
return
|
||||
}
|
||||
if len(body.History) > maxAssistantTurns {
|
||||
// Keep the most recent turns rather than refusing: a long conversation
|
||||
// is a person still trying to solve their problem.
|
||||
body.History = body.History[len(body.History)-maxAssistantTurns:]
|
||||
}
|
||||
for i := range body.History {
|
||||
body.History[i].Text = clip(trim(body.History[i].Text), maxQuestionChars)
|
||||
if body.History[i].Role != "assistant" {
|
||||
body.History[i].Role = "user"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(body.History[len(body.History)-1].Text) == "" {
|
||||
badRequest(w, "ask a question")
|
||||
return
|
||||
}
|
||||
|
||||
answer, err := s.Assistant.Ask(r.Context(), p, body.History)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAssistantOff) {
|
||||
writeErr(w, http.StatusNotImplemented, "assistant_off",
|
||||
"The assistant is not switched on for this server.")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrAssistantMisconfigured) {
|
||||
// Told to the operator, not swallowed. "Something went wrong at our
|
||||
// end" is true and useless when the fix is one environment
|
||||
// variable, and this failure happens on the very first request so
|
||||
// it is exactly when a clear message is worth most.
|
||||
s.logf("ERROR assistant: %v", err)
|
||||
writeErr(w, http.StatusServiceUnavailable, "assistant_misconfigured",
|
||||
"The assistant is switched on but not configured correctly. "+
|
||||
"This API key needs ANTHROPIC_WORKSPACE_ID set on the server.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "assistant", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, answer)
|
||||
}
|
||||
Reference in New Issue
Block a user