Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions smart-kiosk-assistant/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ Sample_data/sample2.mp4

# Metrics tool output dumps (large, machine-generated)
metrics/

# TypeScript incremental build cache (machine/environment-specific)
kiosk-ui/**/*.tsbuildinfo
60 changes: 26 additions & 34 deletions smart-kiosk-assistant/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,30 @@ SAMPLE_DATA_DIR ?= ./Sample_data
SAMPLE_VIDEO_1_URL ?=
SAMPLE_VIDEO_2_URL ?=

# edge-ai-libraries path (audio-analyzer + text-to-speech build contexts live here)
EAL_PATH := ../edge-ai-libraries/microservices

# Services with fully local build contexts (no edge-ai-libraries needed)
# Services with fully local build contexts
LOCAL_ONLY_SERVICES := metrics-collector rag-service kiosk-core kiosk-ui queue-service rtsp-streamer

# Identity feature (profile-gated). Pass IDENTITY=true to build/start the
# identity-service container AND tell kiosk-core to call it. Default off so the
# standard stack is unchanged.
IDENTITY ?= false
# Identity feature (profile-gated). Driven by KIOSK_CORE_IDENTITY_ENABLED in
# .env by default — set it to true there to build/start the identity-service
# container AND tell kiosk-core to call it. `IDENTITY=true|false` on the make
# command line still takes precedence over .env for one-off overrides.
# Default off so the standard stack is unchanged.
# NOTE: capture the .env value with ":=" (immediate expansion) BEFORE the
# "export KIOSK_CORE_IDENTITY_ENABLED = ..." line below rebinds that same
# name — otherwise IDENTITY and KIOSK_CORE_IDENTITY_ENABLED would reference
# each other recursively and make would abort with "Recursive variable
# 'IDENTITY' references itself".
_ENV_IDENTITY_ENABLED := $(KIOSK_CORE_IDENTITY_ENABLED)
IDENTITY ?= $(_ENV_IDENTITY_ENABLED)
_IDENTITY_ON = $(filter true TRUE,$(IDENTITY))
_IDENTITY_PROFILE = $(if $(_IDENTITY_ON),--profile identity,)
_IDENTITY_SVC = $(if $(_IDENTITY_ON),identity-service,)
export KIOSK_CORE_IDENTITY_ENABLED = $(if $(_IDENTITY_ON),true,false)

# Services that require edge-ai-libraries checkout to build from source.
# When EAL_PATH is absent, pre-built images (intel/*:RELEASE_TAG) are used instead.
EAL_SERVICES := audio-analyzer text-to-speech
# audio-analyzer and text-to-speech live in a separate repository
# (edge-ai-libraries) and have no local build context here — they are always
# pulled pre-built from Docker Hub, using the tags pinned in docker-compose.yml.
REGISTRY_ONLY_SERVICES := audio-analyzer text-to-speech

# Colors
RED := \033[0;31m
Expand Down Expand Up @@ -268,7 +274,6 @@ show-config: ## Print resolved configuration
@echo " REGISTRY = $(REGISTRY) (docker-compose prefix: $(_DOCKER_REGISTRY))"
@echo " RELEASE_TAG = $(RELEASE_TAG)"
@echo " COMPOSE_FILE = $(COMPOSE_FILE)"
@echo " EAL_PATH = $(EAL_PATH) ($$([ -d '$(EAL_PATH)' ] && echo 'found' || echo 'not present — EAL services use pre-built images'))"
@echo ""
@echo "$(YELLOW)Inference:$(NC)"
@echo " TARGET_DEVICE = $(TARGET_DEVICE)"
Expand All @@ -278,6 +283,10 @@ show-config: ## Print resolved configuration
@echo "$(YELLOW)Authentication:$(NC)"
@echo " HF_TOKEN = $$([ -n '$(HF_TOKEN)' ] && echo '***set***' || echo 'NOT SET')"
@echo ""
@echo "$(YELLOW)Identity feature:$(NC)"
@echo " IDENTITY = $(IDENTITY) (from .env KIOSK_CORE_IDENTITY_ENABLED, override with IDENTITY=true|false)"
@echo " identity-service = $$([ -n '$(_IDENTITY_SVC)' ] && echo 'enabled — will start with --profile identity' || echo 'disabled')"
@echo ""

# =============================================================================
# Build
Expand All @@ -288,33 +297,16 @@ build: check-env ## Build (REGISTRY=false) or pull (REGISTRY=true) all images
@if [ "$(REGISTRY)" = "false" ] || [ "$(REGISTRY)" = "FALSE" ]; then \
echo "$(YELLOW) REGISTRY=false → Building local services from source$(NC)"; \
REGISTRY=$(_DOCKER_REGISTRY) docker compose -f $(COMPOSE_FILE) $(_IDENTITY_PROFILE) build $(LOCAL_ONLY_SERVICES) $(_IDENTITY_SVC); \
if [ -d "$(EAL_PATH)/audio-analyzer" ] && [ -d "$(EAL_PATH)/text-to-speech" ]; then \
echo "$(YELLOW) Building EAL services (audio-analyzer, text-to-speech) from source...$(NC)"; \
REGISTRY=$(_DOCKER_REGISTRY) docker compose -f $(COMPOSE_FILE) build audio-analyzer text-to-speech; \
else \
echo "$(YELLOW) ⚠ edge-ai-libraries not found at $(EAL_PATH) — pulling pre-built images...$(NC)"; \
_aa_image="$(_ENV_REGISTRY)/audio-analyzer:$(RELEASE_TAG)"; \
_tts_image="$(_ENV_REGISTRY)/text-to-speech:2026.1.0"; \
if docker image inspect "$$_aa_image" >/dev/null 2>&1; then \
echo "$(GREEN) ✓ $$_aa_image already present locally — skipping pull$(NC)"; \
else \
REGISTRY=$(_ENV_REGISTRY) docker compose -f $(COMPOSE_FILE) pull audio-analyzer; \
fi; \
if docker image inspect "$$_tts_image" >/dev/null 2>&1; then \
echo "$(GREEN) ✓ $$_tts_image already present locally — skipping pull$(NC)"; \
else \
REGISTRY=$(_ENV_REGISTRY) docker compose -f $(COMPOSE_FILE) pull text-to-speech; \
fi; \
fi; \
else \
echo "$(YELLOW) REGISTRY=$(REGISTRY) → Pulling images from registry$(NC)"; \
REGISTRY=$(REGISTRY) docker compose -f $(COMPOSE_FILE) pull \
$(LOCAL_ONLY_SERVICES) $(EAL_SERVICES); \
REGISTRY=$(REGISTRY) docker compose -f $(COMPOSE_FILE) pull $(LOCAL_ONLY_SERVICES); \
if [ -n "$(_IDENTITY_SVC)" ]; then \
echo "$(YELLOW) Building identity-service locally (no registry image)...$(NC)"; \
REGISTRY=$(_DOCKER_REGISTRY) docker compose -f $(COMPOSE_FILE) $(_IDENTITY_PROFILE) build identity-service; \
fi; \
fi
fi; \
echo "$(YELLOW) Pulling audio-analyzer, text-to-speech from Docker Hub (edge-ai-libraries — separate repo, no local build)...$(NC)"; \
REGISTRY=$(_DOCKER_REGISTRY) docker compose -f $(COMPOSE_FILE) pull $(REGISTRY_ONLY_SERVICES)
@echo "$(GREEN)✓ Images ready$(NC)"

# =============================================================================
Expand All @@ -337,7 +329,7 @@ up: check-env setup-dirs ## Start all services

down: ## Stop all services
@echo "$(BLUE)Stopping Smart Kiosk Assistant...$(NC)"
docker compose -f $(COMPOSE_FILE) down
docker compose -f $(COMPOSE_FILE) $(_IDENTITY_PROFILE) down
@echo "$(GREEN)✓ All services stopped$(NC)"

restart: down up ## Stop then restart all services
Expand Down
15 changes: 12 additions & 3 deletions smart-kiosk-assistant/configs/identity/identity_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,18 @@ voice_embedding_dim: 192
# ── Verification thresholds & fusion ─────────────────────────────────────────
# Authentication requires BOTH modalities. The fused score must clear the
# combined threshold. Per-modality thresholds are retained for diagnostics.
fusion_face_weight: 0.6
fusion_voice_weight: 0.4
combined_threshold: 0.78
#
# NOTE (2026-07-14): tuned down from the initial defaults (0.6/0.4, 0.78) after
# real-world testing showed face recognition is highly reliable (0.75-0.98)
# while the converted ECAPA voice model shows wide session-to-session variance
# (0.29-0.84 for genuine same-person attempts) due to uncalibrated browser-mic
# audio -- no code defect found in the embedding/normalization/search pipeline.
# Leaning more on the more-reliable face modality and lowering the combined
# bar reduces false rejects while still requiring both biometrics to broadly
# agree.
fusion_face_weight: 0.7
fusion_voice_weight: 0.3
combined_threshold: 0.65
face_threshold: 0.80
voice_threshold: 0.75

Expand Down
12 changes: 4 additions & 8 deletions smart-kiosk-assistant/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
# Mic audio is captured by the browser via Web Audio API and uploaded as a
# file -- no host mic passthrough into the container is required.
#
# audio-analyzer and text-to-speech live in the separate edge-ai-libraries
# repository and have no local build context here — they are always pulled
# pre-built from Docker Hub using the image tags declared below.
#
# Pull (default): docker compose pull && docker compose up -d
# Build from src: docker compose build && docker compose up -d
# (requires edge-ai-libraries cloned as a sibling repo;
# see docs/build-from-source.md)
# Stop: docker compose down
# Logs: docker compose logs -f

Expand Down Expand Up @@ -148,9 +150,6 @@ services:
restart: unless-stopped

audio-analyzer:
build:
context: ../edge-ai-libraries/microservices/audio-analyzer
dockerfile: Dockerfile
image: ${REGISTRY:-intel}/audio-analyzer:${RELEASE_TAG:-latest}
container_name: audio-analyzer
# Hardcoded to match the image's baked-in app user and the
Expand Down Expand Up @@ -208,9 +207,6 @@ services:
restart: unless-stopped

text-to-speech:
build:
context: ../edge-ai-libraries/microservices/text-to-speech
dockerfile: Dockerfile
image: ${REGISTRY:-intel}/text-to-speech:2026.1.0
container_name: text-to-speech
user: "1000:1000"
Expand Down
47 changes: 41 additions & 6 deletions smart-kiosk-assistant/identity-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,43 @@ Base URL inside the cluster: `http://identity-service:8013`
| GET | `/health` | Liveness probe | ✅ |
| GET | `/api/v1/identity/challenge` | Random voice challenge prompt | ✅ |
| GET | `/api/v1/identity/stats` | Profile + index counts, `inference_ready` | ✅ |
| POST | `/api/v1/identity/verify` | Multimodal (face+voice) verification | ⏳ Phase 6 (pipeline) |
| POST | `/api/v1/identity/register` | Admin / manual enrolment | ⏳ Phase 5 (pipeline) |
| POST | `/api/v1/identity/verify` | Multimodal (face+voice) verification | |
| POST | `/api/v1/identity/register` | Admin / manual enrolment | |

`verify` and `register` are reachable today but return a structured
*"pipeline pending"* response until Phases 5–6 wire the decode → embed → search →
fuse path.
kiosk-core (`:8012`) proxies this contract 1:1 when `KIOSK_CORE_IDENTITY_ENABLED=true`
(see [kiosk-core proxy](#kiosk-core-proxy--kiosk-ui-gate) below), plus one always-on
capability endpoint kiosk-ui uses to decide whether to show the auth gate at all:

| Method | Path (kiosk-core) | Purpose |
|---|---|---|
| GET | `/api/v1/identity/enabled` | Runtime flag mirroring `KIOSK_CORE_IDENTITY_ENABLED`; **always reachable**, even when the identity feature is off. |
| GET | `/api/v1/identity/challenge` | Proxies identity-service `/challenge` (gated by the flag). |
| POST | `/api/v1/identity/verify` | Proxies identity-service `/verify`; both `image_base64` and `audio_base64` required. |
| POST | `/api/v1/identity/register` | Proxies identity-service `/register` (self-service enrolment); both `image_base64` and `audio_base64` required. |

---

## kiosk-core proxy + kiosk-ui gate

kiosk-ui wraps the existing chat home page in an `AuthGate` (mounted once in
`main.tsx`, so `App.tsx`/the chat experience itself is untouched):

1. On load, it calls `GET /api/v1/identity/enabled`. If the identity feature is
disabled or the backend is unreachable, the gate **bypasses** and renders the
chat home page exactly as before — zero behavioural change.
2. If enabled, it shows a **Login** screen: live camera preview + an on-screen
challenge phrase (read aloud). On "Authenticate" it captures one JPEG frame
and a ~3s WAV clip and calls `verify()`. A verified user is redirected to the
existing chat home page; an unverified user sees an on-screen
*"User not authenticated"* error and can retry or register.
3. A **Register** screen (linked from Login) collects a display name, generates
a `user_id` slug (`name` + random suffix), captures face + voice the same
way, and calls `register()` — reusing the same identity-service enrolment
pipeline the video-file bootstrap path uses, so newly registered users are
written to the same FAISS indices + `loyalty_profiles` SQLite table.

Relevant kiosk-ui source: `src/components/Auth/{AuthGate,LoginScreen,RegisterScreen}.tsx`,
`src/hooks/{useCamera,useVoiceCapture}.ts`, `src/api/identityApi.ts`.

---

Expand Down Expand Up @@ -195,7 +226,11 @@ identity-service/
`inference_ready` flips to `true`.
- **Phase 6 — verification:** fusion scoring + profile retrieval in `verify()`;
inject favourites/restrictions into the kiosk-core LLM session context.
- **Phase 7 — UI:** challenge display + verify gate in the Gradio / kiosk-ui flow.
- **Phase 7 — UI:** ✅ done — `AuthGate` in kiosk-ui gates the chat home page
behind face+voice login, with a self-service Register screen. Gate presence
is driven entirely by the backend `KIOSK_CORE_IDENTITY_ENABLED` flag (via
`GET /api/v1/identity/enabled`), so it is a pure add-on with no impact on the
chat experience when the feature is off.
- **Phase 8 — tests & docs:** pytest suite under `tests/`; LLD, sequence/class
diagrams, schema and API documentation under `docs/`.

Expand Down
31 changes: 31 additions & 0 deletions smart-kiosk-assistant/identity-service/identity_core/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,30 @@ def _ok(record, f_sim, v_sim, fused) -> VerifyResponse:
)
if fused >= settings.combined_threshold:
return _ok(face_record, face_sim, voice_sim, fused)
logger.info(
"[IDENTITY] Verify REJECTED user_id=%s face=%s voice=%s "
"fused=%s < combined_threshold=%s",
face_record.user_id,
face_sim,
voice_sim,
fused,
settings.combined_threshold,
)
return VerifyResponse(
verified=False,
face_similarity=face_sim,
voice_similarity=voice_sim,
fused_score=fused,
reason="No matching profile above the combined threshold.",
)
logger.info(
"[IDENTITY] Verify REJECTED face_user=%s voice_user=%s "
"face_sim=%s voice_sim=%s (modality mismatch or no hit)",
face_record.user_id if face_record else None,
voice_record.user_id if voice_record else None,
face_sim,
voice_sim,
)
return VerifyResponse(
verified=False,
face_similarity=face_sim,
Expand All @@ -218,6 +235,13 @@ def _ok(record, f_sim, v_sim, fused) -> VerifyResponse:
if request.image_base64:
if face_record is not None and face_sim >= settings.face_threshold:
return _ok(face_record, face_sim, None, None)
logger.info(
"[IDENTITY] Verify REJECTED (face-only) user=%s face_sim=%s "
"< face_threshold=%s",
face_record.user_id if face_record else None,
face_sim,
settings.face_threshold,
)
return VerifyResponse(
verified=False,
face_similarity=face_sim,
Expand All @@ -226,6 +250,13 @@ def _ok(record, f_sim, v_sim, fused) -> VerifyResponse:

if voice_record is not None and voice_sim >= settings.voice_threshold:
return _ok(voice_record, None, voice_sim, None)
logger.info(
"[IDENTITY] Verify REJECTED (voice-only) user=%s voice_sim=%s "
"< voice_threshold=%s",
voice_record.user_id if voice_record else None,
voice_sim,
settings.voice_threshold,
)
return VerifyResponse(
verified=False,
voice_similarity=voice_sim,
Expand Down
Loading