diff --git a/.dockerignore b/.dockerignore index 9abaaea033..69579efdb9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -39,8 +39,12 @@ logs/ *.jpg /zenohdb + # Config config/memory/ # Saved locations locations/ + +# Packages +*.tar.gz diff --git a/config/greeting_conversation-selfietest.json5 b/config/greeting_conversation-selfietest.json5 new file mode 100644 index 0000000000..6dbf5ea0ca --- /dev/null +++ b/config/greeting_conversation-selfietest.json5 @@ -0,0 +1,515 @@ +{ + // Configuration version + version: "v1.0.8", + + // Mode system configuration for Unitree Go2 + default_mode: "greeting", + allow_manual_switching: true, + mode_memory_enabled: false, + + // Global settings + api_key: "${OM_API_KEY:-openmind_free}", + unitree_ethernet: "${UNITREE_ETHERNET:-enP2p1s0}", + system_governance: "", + cortex_llm: { + type: "GeminiLLM", + config: { + agent_name: "${ROBOT_NAME:-Bits}", + history_length: 10, + }, + }, + + knowledge_base: { + knowledge_base_name: "om", + base_url: "${KB_BASE_URL:-http://localhost:8100}", + min_score: 0.6, + }, + + modes: { + approaching: { + display_name: "Approaching Person Mode", + description: "Robot approaches detected humans autonomously.", + system_prompt_base: "You are ${ROBOT_NAME:-Bits}, a friendly and helpful robotic companion built on a ${ROBOT_PLATFORM:-Unitree Go2} platform.", + hertz: 0.001, + agent_inputs: [ + { + type: "${ASR_INPUT_PLUGIN:-GoogleASRInput}", + config: { + api_version: "v2", + enable_tts_interrupt: false, + rate: 16000, + chunk: 1600, + }, + }, + ], + action_execution_mode: "concurrent", + agent_actions: [], + backgrounds: [ + { + type: "ApproachingPerson", + }, + ], + lifecycle_hooks: [], + }, + greeting: { + display_name: "Greeting Conversation Mode", + description: "Robot engages in greeting conversations with users upon approach. Supports enrollment so it can remember visitors by name.", + system_prompt_base: 'You are Unitree G1, a polite humanoid robot designed for friendly human interaction and conversation.\ +\n\ +Your responsibilities:\n\ +- Speak clearly, politely, and naturally.\n\ +- Answer questions helpfully and respectfully.\n\ +- Keep responses concise and easy to understand.\n\ +- Remain calm, friendly, and professional at all times.\n\ +- If you do not know the answer, politely say so instead of guessing.\n\ +\n\ +Identity:\n\ +- You are Unitree G1, a humanoid robot.\n\ +- Your name is ${ROBOT_NAME:-Pam}.\n\ +- You are presenting OpenMind\'s OM1 robotics software stack.\n\ +- You are currently attending a KraneShares event as part of a live robotics and AI demonstration.\n\ +- OpenMind develops robotics and AI systems for human interaction, automation, and embodied intelligence.\n\ +- KraneShares is hosting the event and focuses on thematic and innovation-related investment strategies.\n\ +\n\ +Face Memory:\n\ +Three actions: selfie (enroll), correct_identity (rename), forget_last (undo).\n\ +\n\ +WHEN TO CALL selfie — simple rule:\n\ + IF visitor speaks their OWN name (self-introduction)\n\ + THEN call selfie(action="") immediately.\n\ + The endpoint handles all cases internally:\n\ + - New person/name → result=success (enrolls)\n\ + - Returning person, same name → result=merged (adds a sample, improves recognition)\n\ + - Same face, different name claimed → result=face_belongs_to (asks to clarify)\n\ + Always call selfie on self-introduction. Don\'t try to skip based on FacePresence —\n\ + the endpoint is the source of truth.\n\ +\n\ +Self-introduction (CALL selfie):\n\ +- "I\'m David" / "My name is Wendy" / "I\'m called John" / "Call me Alex"\n\ +- "Hey, David here" / "David, nice to meet you"\n\ +- "我是大卫" / "我叫小红" / "记住我,我是 David"\n\ +\n\ +NOT self-introduction (DO NOT call selfie):\n\ +- Greeting without a name: "Hello" / "Hi there" / "你好"\n\ +- Returning visitor greeting without name: "Hey it\'s me!" / "I\'m back!" (no name mentioned)\n\ + → just greet by the name shown in FacePresence, no selfie needed.\n\ +- Third-party reference: "My friend David" / "I\'m here with David" / "David said..."\n\ +\n\ +When in doubt whether it\'s self-introduction, CALL selfie. Better to enroll\n\ +than to miss someone who wanted to be remembered.\n\ +\n\ +NAME → ID TRANSLITERATION (before calling selfie):\n\ +- Lowercase ASCII alphanumeric, dash, underscore only.\n\ +- "Wendy" → "wendy"\n\ +- "Jerin Peter" → "jerin-peter" (dash for space)\n\ +- Non-Latin name: ask for English/Pinyin spelling first, then transliterate.\n\ + E.g. "李小红" → ask "How would you like me to spell your name?" → "li-xiaohong".\n\ +- NEVER use trailing _N suffix ("wendy_1"). Reserved for the system.\n\ +\n\ +AFTER selfie returns, read SelfieStatus and respond by result code:\n\ +- success → "Nice to meet you, ! I\'ll remember you."\n\ +- merged → "Welcome back, !" (returning visitor, already in gallery)\n\ +- ambiguous → "I see a few people. Could you step closer?"\n\ +- face_belongs_to → "You look a lot like . Are you , or different?"\n\ +- low_quality → "I can\'t see your face clearly. Could you look at me directly?"\n\ +- partial → "Almost — hold still for one more moment."\n\ +- busy → wait briefly, connector retries automatically.\n\ +- bad_id → "Sorry, I didn\'t catch your name. What is it?"\n\ +- network_error / unknown → "Sorry, something went wrong. Could you try again?"\n\ +\n\ +RETRY AFTER FAILED ATTEMPT (KEY RULE):\n\ +After result=low_quality / partial / ambiguous, when the visitor next speaks\n\ +ANYTHING (including "how about now?", "ok", "yes", "again") AND a face is in view,\n\ +CALL selfie() AGAIN with the SAME name from the previous attempt.\n\ +Do NOT require them to re-say "remember me" or re-introduce themselves.\n\ +The conversation is mid-enrollment — finish it.\n\ +\n\ +CORRECTION DECISION TREE (within 60s of a successful enrollment):\n\ +- WRONG PERSON captured ("That wasn\'t me", "Someone walked in front"):\n\ + → forget_last(), then ask visitor to face you and retry.\n\ +- LABEL ERROR, no third party ("Actually it\'s John", "Sorry J-O-H-N not Joan"):\n\ + → correct_identity(from_id=, to_id=)\n\ +- DIFFERENT PERSON, similar face ("I\'m not Wendy, I\'m John, we\'re twins"):\n\ + → selfie(action=, force=true)\n\ +- Else: ask a clarifying question, don\'t guess.\n\ +\n\ +EXAMPLES:\n\ +\n\ +A) New visitor enrolls.\n\ + Visitor: "Hi, I\'m Wendy." [FacePresence: 1 unknown face]\n\ + → call selfie(action="wendy")\n\ + [result=success id=wendy samples=3]\n\ + → "Nice to meet you, Wendy! What brings you here today?"\n\ +\n\ +B) Known visitor re-introduces themselves — still call selfie, endpoint merges.\n\ + Visitor: "Hi, I\'m Wendy." [FacePresence: 1 known (wendy)]\n\ + → call selfie(action="wendy") ← always call; let endpoint decide\n\ + [result=merged id=wendy samples=2]\n\ + → "Welcome back, Wendy! Good to see you again."\n\ +\n\ +B2) Known visitor greets without giving a name — no selfie needed.\n\ + Visitor: "Hey, it\'s me!" [FacePresence: 1 known (wendy)]\n\ + → no self-introduction, no selfie call\n\ + → "Welcome back, Wendy! Good to see you again."\n\ +\n\ +C) Visitor mentions own name plus a third-party name.\n\ + Visitor: "Hi, I\'m David. My friend Wendy is here too."\n\ + "I\'m David" = self-introduction → call selfie(action="david")\n\ + "my friend Wendy" = third party → do NOT enroll Wendy from this utterance.\n\ + [result=success]\n\ + → "Nice to meet you, David! Hi Wendy."\n\ +\n\ +D) Previous attempt failed — retry without re-introduction.\n\ + [previous attempt got result=low_quality]\n\ + Visitor moves closer, says: "How about now?"\n\ + → call selfie(action="david") AGAIN — same name as before.\n\ + [result=success]\n\ + → "Got it now, David! I\'ll remember you."\n\ +\n\ +E) Look-alike — endpoint detects face mismatch, ask to clarify, then force.\n\ + Visitor: "Hi, I\'m John." [FacePresence: 1 known (wendy)]\n\ + → call selfie(action="john")\n\ + [result=face_belongs_to matched=wendy sim=0.72]\n\ + → "You look a lot like Wendy. Are you Wendy, or someone different?"\n\ + Visitor: "I\'m John, we\'re twins."\n\ + → call selfie(action="john", force=true)\n\ + [result=success]\n\ + → "Got it, John! I\'ll remember you separately."\n\ +\n\ +F) Wrong person captured — undo immediately.\n\ + [right after successful enrollment]\n\ + Visitor: "Wait, that wasn\'t me, someone walked in front."\n\ + → call forget_last()\n\ + [result=success]\n\ + → "Sorry about that! Please face me directly and we\'ll try again."\n\ +\n\ +You should prioritize safe, comfortable, and positive human interaction.', + hertz: 0.001, + agent_inputs: [ + { + type: "${ASR_INPUT_PLUGIN:-GoogleASRInput}", + config: { + api_version: "v2", + enable_tts_interrupt: false, + rate: 16000, + chunk: 1600, + }, + }, + { + type: "ConversationHistoryInput", + config: { + max_rounds: 3, + }, + }, + { + type: "FacePresence", + }, + { + type: "SelfieStatus", + }, + ], + cortex_llm: { + type: "GeminiLLM", + config: { + agent_name: "${ROBOT_NAME:-Bits}", + history_length: 3, + }, + }, + action_execution_mode: "concurrent", + agent_actions: [ + { + name: "greeting_conversation", + llm_label: "greeting_conversation", + connector: "greeting_conversation_elevenlabs", + config: { + voice_id: "${VOICE_ID:-PoHUWWWMHFrA8z7Q88pu}", + }, + }, + { + name: "face", + llm_label: "emotion", + connector: "avatar", + }, + { + name: "selfie", + llm_label: "selfie", + connector: "selfie", + config: { + face_http_base_url: "${FACE_HTTP_BASE_URL:-http://127.0.0.1:6793}", + timeout_sec: 8, + }, + }, + { + name: "correct_identity", + llm_label: "correct_identity", + connector: "correct_identity", + config: { + face_http_base_url: "${FACE_HTTP_BASE_URL:-http://127.0.0.1:6793}", + }, + }, + { + name: "forget_last", + llm_label: "forget_last", + connector: "forget_last", + config: { + face_http_base_url: "${FACE_HTTP_BASE_URL:-http://127.0.0.1:6793}", + }, + }, + ], + backgrounds: [], + lifecycle_hooks: [ + { + hook_type: "on_startup", + handler_type: "function", + handler_config: { + module_name: "greeting_hook", + function: "greeting_start_hook", + tts_provider: "elevenlabs", + robot_name: "${ROBOT_NAME:-Bits}", + voice_id: "${VOICE_ID:-PoHUWWWMHFrA8z7Q88pu}", + }, + }, + { + hook_type: "on_entry", + handler_type: "function", + handler_config: { + module_name: "greeting_hook", + function: "greeting_start_hook", + tts_provider: "elevenlabs", + robot_name: "${ROBOT_NAME:-Bits}", + voice_id: "${VOICE_ID:-PoHUWWWMHFrA8z7Q88pu}", + }, + }, + { + hook_type: "on_exit", + handler_type: "function", + handler_config: { + module_name: "greeting_hook", + function: "greeting_end_hook", + tts_provider: "elevenlabs", + voice_id: "${VOICE_ID:-PoHUWWWMHFrA8z7Q88pu}", + }, + }, + ], + }, + conversation: { + display_name: "Conversation Mode", + description: "Robot general conversation mode.", + system_prompt_base: "You are Unitree G1, a polite humanoid robot designed for friendly human interaction and conversation.\n\ +\n\ +Your responsibilities:\n\ +- Speak clearly, politely, and naturally.\n\ +- Answer questions helpfully and respectfully.\n\ +- Keep responses concise and easy to understand.\n\ +- Remain calm, friendly, and professional at all times.\n\ +- If you do not know the answer, politely say so instead of guessing.\n\ +- Respond in the same language the visitor is speaking whenever possible.\n\ +\n\ +Identity:\n\ +- You are Unitree G1, a humanoid robot.\n\ +- Your name is ${ROBOT_NAME:-Pam}.\n\ +- You are presenting OpenMind's OM1 robotics software stack.\n\ +- You are currently attending a KraneShares event as part of a live robotics and AI demonstration.\n\ +- OpenMind develops robotics and AI systems for human interaction, automation, and embodied intelligence.\n\ +- KraneShares is hosting the event and focuses on thematic and innovation-related investment strategies.\n\ +\n\ +You are allowed to perform the following physical actions when appropriate during interaction: shake_hand, face_wave, hands_up, IDLE, stand_still, show_hand, wave.\n\ +\n\ +Behavior Guidelines:\n\ +- Use gestures naturally to improve interaction.\n\ +- For greetings, you may use WAVE or FACE_WAVE.\n\ +- When meeting someone new, you may offer SHAKE_HAND.\n\ +- When idle or explaining things, prefer STAND_STILL.\n\ +- Avoid excessive or repetitive motions.\n\ +- Never perform unsafe, aggressive, or inappropriate actions.\n\ +\n\ +Conversation Guidelines:\n\ +- Primarily answer questions about OpenMind, OM1, robotics, AI, humanoid interaction, and technology demonstrations.\n\ +- If visitors ask about KraneShares, answer politely and professionally at a high level.\n\ +- You may explain that KraneShares focuses on thematic and innovation-related investment strategies.\n\ +- Do not provide personalized financial advice or investment recommendations.\n\ +- If asked detailed financial or investment questions, politely suggest speaking with a KraneShares representative.\n\ +- Keep conversations engaging, friendly, and suitable for a public event environment.\n\ +\n\ +You should prioritize safe, comfortable, and positive human interaction.", + hertz: 0.001, + agent_inputs: [ + { + type: "${ASR_INPUT_PLUGIN:-GoogleASRInput}", + config: { + api_version: "v2", + enable_tts_interrupt: false, + rate: 16000, + chunk: 1600, + }, + }, + { + type: "FacePresence", + }, + ], + cortex_llm: { + type: "GeminiLLM", + config: { + agent_name: "${ROBOT_NAME:-Bits}", + history_length: 5, + }, + }, + action_execution_mode: "concurrent", + agent_actions: [ + { + name: "speak", + llm_label: "speak", + connector: "elevenlabs_people_tts", + config: { + voice_id: "${VOICE_ID:-PoHUWWWMHFrA8z7Q88pu}", + voice_ids: { + boyuan: "mr9qrgzgp98Y3CKLQHa5", + jerin: "XOhISZ8S2JCoAAhPLE4t", + lifan: "RK1vBUdUjL4AGgQCaxpi", + wendy: "usX0EPrPwf8ZC4BJpllM", + shicai: "m2txcO7RSY7jbTbO0PS4", + jan: "rPwOfJSzVUxWlm2ZLSv9", + rubail: "hi96TjugRiLITatqxwFC", + samantha: "HY0AVCmB90fxGEKkU5rz", + david: "BoTfQVPAlh2bemD7p75F", + }, + }, + }, + { + name: "face", + llm_label: "show_emotion", + connector: "avatar", + }, + { + name: "arm_g1", + llm_label: "robot_action", + connector: "zenoh", + }, + ], + backgrounds: [], + lifecycle_hooks: [ + { + hook_type: "on_startup", + handler_type: "function", + handler_config: { + module_name: "greeting_hook", + function: "greeting_start_hook", + tts_provider: "elevenlabs", + robot_name: "${ROBOT_NAME:-Bits}", + custom_message: "How can I help you today?", + voice_id: "${VOICE_ID:-PoHUWWWMHFrA8z7Q88pu}", + }, + priority: 1, + }, + { + hook_type: "on_entry", + handler_type: "function", + handler_config: { + module_name: "greeting_hook", + function: "greeting_start_hook", + tts_provider: "elevenlabs", + robot_name: "${ROBOT_NAME:-Bits}", + custom_message: "How can I help you today?", + voice_id: "${VOICE_ID:-PoHUWWWMHFrA8z7Q88pu}", + }, + priority: 1, + }, + { + hook_type: "on_entry", + handler_type: "function", + handler_config: { + module_name: "person_follow_hook", + function: "stop_person_follow_hook", + }, + priority: 2, + }, + { + hook_type: "on_entry", + handler_type: "function", + handler_config: { + module_name: "person_follow_hook", + function: "set_mode_hook", + mode: "following", + }, + priority: 3, + }, + { + hook_type: "on_exit", + handler_type: "function", + handler_config: { + module_name: "person_follow_hook", + function: "switch_person_follow_hook", + }, + timeout_seconds: 5, + priority: 1, + }, + { + hook_type: "on_exit", + handler_type: "function", + handler_config: { + module_name: "person_follow_hook", + function: "set_mode_hook", + mode: "greeting", + }, + priority: 2, + }, + ], + }, + }, + + transition_rules: [ + { + from_mode: "approaching", + to_mode: "greeting", + transition_type: "context_aware", + context_conditions: { approaching_detected: true }, + priority: 0, + cooldown_seconds: 5.0, + }, + { + from_mode: "greeting", + to_mode: "conversation", + transition_type: "input_triggered", + trigger_keywords: [ + "switch to conversation", + "switch to the conversation", + "switch to a conversation", + "to conversation", + "to the conversation", + "to a conversation", + ], + priority: 1, + cooldown_seconds: 5.0, + }, + { + from_mode: "approaching", + to_mode: "conversation", + transition_type: "input_triggered", + trigger_keywords: [ + "switch to conversation", + "switch to the conversation", + "switch to a conversation", + "to conversation", + "to the conversation", + "to a conversation", + ], + priority: 1, + cooldown_seconds: 5.0, + }, + { + from_mode: "conversation", + to_mode: "approaching", + transition_type: "input_triggered", + trigger_keywords: [ + "switch to greeting", + "switch to the greeting", + "switch to a greeting", + "to greeting", + "to the greeting", + "to a greeting", + ], + priority: 1, + cooldown_seconds: 5.0, + }, + ], +} diff --git a/src/actions/correct_identity/connector/correct_identity.py b/src/actions/correct_identity/connector/correct_identity.py new file mode 100644 index 0000000000..581021963f --- /dev/null +++ b/src/actions/correct_identity/connector/correct_identity.py @@ -0,0 +1,195 @@ +""" +Correct Identity connector for OM1. + +Renames a recently-enrolled identity by calling /gallery/move_samples on +the face API. Used when the user disputes the LABEL (not the person) of +a just-enrolled identity. + +The connector is stateless — the face API enforces the 60s TTL on +last_enrollment, so this connector can't accidentally rename an old +identity even if the LLM asks it to. +""" + +import asyncio +import logging +import re +import time +from typing import Dict, Optional + +import requests +from pydantic import Field + +from actions.base import ActionConfig, ActionConnector +from actions.correct_identity.interface import CorrectIdentityInput +from providers.elevenlabs_tts_provider import ElevenLabsTTSProvider +from providers.io_provider import IOProvider + +_DEDUP_SUFFIX_RE = re.compile(r"_\d+$") + + +class CorrectIdentityConfig(ActionConfig): + """ + Configuration for CorrectIdentity connector. + + Parameters + ---------- + face_http_base_url : str + Base URL for the face HTTP service. + http_timeout_sec : float + HTTP request timeout in seconds. + """ + + face_http_base_url: str = Field( + default="http://127.0.0.1:6793", + description="Base URL for the face HTTP service.", + ) + http_timeout_sec: float = Field( + default=5.0, + description="HTTP request timeout in seconds.", + ) + + +class CorrectIdentityConnector(ActionConnector[CorrectIdentityConfig, CorrectIdentityInput]): + """Rename a recently-enrolled identity via /gallery/move_samples.""" + + def __init__(self, config: CorrectIdentityConfig): + super().__init__(config) + self.base_url = self.config.face_http_base_url + self.http_timeout = self.config.http_timeout_sec + self.elevenlabs_tts_provider = ElevenLabsTTSProvider() + self.io_provider = IOProvider() + + # -------- HTTP helpers -------- + + def _post_json(self, path: str, body: Dict) -> Optional[Dict]: + """POST JSON to the face service. Returns parsed dict or None on error.""" + url = f"{self.base_url}{path}" + try: + r = requests.post(url, json=body, timeout=self.http_timeout) + return r.json() + except Exception as e: + logging.warning("HTTP POST %s failed (%s) body=%s", url, e, body) + return None + + # -------- Output helpers -------- + + def _write_status(self, line: str) -> None: + """ + Surface the result to the LLM via the shared SelfieStatus channel. + + All face-memory actions (selfie, correct_identity, forget_last) write + to the single 'SelfieStatus' io_provider key so the existing + SelfieStatus input plugin picks them all up. The LLM disambiguates + by reading the `result=...` prefix in the line. + """ + try: + self.io_provider.add_input("SelfieStatus", line, time.time()) + except Exception as e: + logging.warning("SelfieStatus write failed: %s", e) + + def _speak(self, message: Optional[str]) -> None: + """Queue a TTS message. No-op if message is None or empty.""" + if not message: + return + try: + self.elevenlabs_tts_provider.add_pending_message(message) + except Exception as e: + logging.warning("TTS queue failed: %s", e) + + @staticmethod + def _display_name(id_str: str) -> str: + """ + Convert internal id to natural display form for TTS. + 'wendy_1' → 'Wendy', 'jerin-peter' → 'Jerin Peter'. + """ + cleaned = _DEDUP_SUFFIX_RE.sub("", id_str) + cleaned = cleaned.replace("-", " ").replace("_", " ") + return cleaned.title() + + # -------- Main entry point -------- + + async def connect(self, output_interface: CorrectIdentityInput) -> None: + """Execute a single identity rename/merge.""" + from_id = (output_interface.from_id or "").strip().lower() + to_id = (output_interface.to_id or "").strip().lower() + + # Local validation — catch obvious LLM mistakes before HTTP + if not from_id or not to_id: + self._write_status(f"result=bad_id from={from_id!r} to={to_id!r}") + logging.error("[CorrectIdentity] missing ids: from=%r to=%r", from_id, to_id) + # Silent — LLM-side issue, shouldn't reach user + return + + if from_id == to_id: + self._write_status(f"result=same_id id={from_id}") + logging.info("[CorrectIdentity] no-op: from_id == to_id") + # Silent — no change needed + return + + loop = asyncio.get_running_loop() + body = {"from_id": from_id, "to_id": to_id} + resp = await loop.run_in_executor(None, self._post_json, "/gallery/move_samples", body) + + self._dispatch_response(resp, from_id, to_id) + + def _dispatch_response(self, resp: Optional[Dict], from_id: str, to_id: str) -> None: + """Map the API response to status + TTS.""" + if resp is None: + self._write_status("result=network_error") + self._speak("I had trouble updating that.") + logging.error("[CorrectIdentity] network error") + return + + if resp.get("ok"): + moved = int(resp.get("moved", 0)) + from_removed = bool(resp.get("from_removed")) + display = self._display_name(to_id) + self._write_status( + f"result=success from={from_id} to={to_id} " + f"moved={moved} from_removed={'true' if from_removed else 'false'}" + ) + self._speak(f"Got it, I've updated your name to {display}.") + logging.info( + "[CorrectIdentity] ok from=%s to=%s moved=%d", + from_id, + to_id, + moved, + ) + return + + err = str(resp.get("error", "unknown")) + + if err in ("no_recent_enrollment", "stale_enrollment"): + self._write_status(f"result={err}") + self._speak("I can't change that — too much time has passed since I remembered you.") + logging.info("[CorrectIdentity] %s", err) + return + + if err == "bad_id": + detail = str(resp.get("detail", "")) + self._write_status(f"result=bad_id detail={detail}") + # Silent — LLM produced invalid id; should re-prompt user + logging.error("[CorrectIdentity] bad_id: %s", detail) + return + + if err == "same_id": + self._write_status(f"result=same_id id={from_id}") + logging.info("[CorrectIdentity] same_id (caught by API)") + return + + if err == "no_safe_files": + self._write_status("result=no_safe_files") + self._speak("I couldn't find the right files to update.") + logging.error("[CorrectIdentity] no_safe_files") + return + + if err == "recognition_disabled": + self._write_status("result=recognition_disabled") + self._speak("I can't update names right now.") + logging.error("[CorrectIdentity] recognition_disabled") + return + + # Unknown + self._write_status(f"result=unknown error={err}") + self._speak("Something went wrong updating that.") + logging.error("[CorrectIdentity] unknown error: %s", resp) diff --git a/src/actions/correct_identity/interface.py b/src/actions/correct_identity/interface.py new file mode 100644 index 0000000000..2a69742a01 --- /dev/null +++ b/src/actions/correct_identity/interface.py @@ -0,0 +1,58 @@ +# src/actions/correct_identity/interface.py +from dataclasses import dataclass + +from actions.base import Interface + + +@dataclass +class CorrectIdentityInput: + """ + Input to rename or merge a recently-enrolled identity. + + Used when the LLM enrolled the WRONG LABEL on a real person (typo, + speech misrecognition, etc.) — same physical person, wrong name. + + Distinct from: + - selfie(force=True) → different physical person who looks similar + - forget_last() → wrong physical person was captured entirely + + Parameters + ---------- + from_id : str + The current (incorrect) identity name in the gallery. + to_id : str + The desired identity name. If to_id already exists, samples are + merged into it. If not, from_id's folder is renamed to to_id. + + Both must follow API naming rules: lowercase ASCII alphanumeric, + dash, underscore. The trailing `_` suffix is reserved. + + Notes + ----- + Only works within ~60s of the source enrollment (the face API + enforces a TTL on `last_enrollment`). Outside that window, this + action returns `result=stale_enrollment` and the LLM should + apologize rather than retry. + """ + + from_id: str + to_id: str + + +@dataclass +class CorrectIdentity(Interface[CorrectIdentityInput, CorrectIdentityInput]): + """ + Rename a recently-enrolled identity to fix a label error. + + Trigger conditions (all must hold): + - SelfieStatus showed `result=success id=` within ~60s + - User's wording indicates label correction, NOT a different person + ("Actually it's John, you misheard me" / "Sorry, J-O-H-N") + - User does NOT mention a third party + + If those conditions don't hold, prefer selfie(force=True) for + look likes or forget_last() for wrong-person captures. + """ + + input: CorrectIdentityInput + output: CorrectIdentityInput diff --git a/src/actions/forget_last/connector/forget_last.py b/src/actions/forget_last/connector/forget_last.py new file mode 100644 index 0000000000..bebb329339 --- /dev/null +++ b/src/actions/forget_last/connector/forget_last.py @@ -0,0 +1,168 @@ +""" +Forget Last connector for OM1. + +Undoes the most recent /selfie enrollment by calling /gallery/forget_last +on the face API. Used when the WRONG PERSON was captured (samples are +wrong, not just the label). + +The connector is stateless — the face API enforces the 60s TTL. +""" + +import asyncio +import logging +import time +from typing import Dict, Optional + +import requests +from pydantic import Field + +from actions.base import ActionConfig, ActionConnector +from actions.forget_last.interface import ForgetLastInput +from providers.elevenlabs_tts_provider import ElevenLabsTTSProvider +from providers.io_provider import IOProvider + + +class ForgetLastConfig(ActionConfig): + """ + Configuration for ForgetLast connector. + + Parameters + ---------- + face_http_base_url : str + Base URL for the face HTTP service. + http_timeout_sec : float + HTTP request timeout in seconds. + """ + + face_http_base_url: str = Field( + default="http://127.0.0.1:6793", + description="Base URL for the face HTTP service.", + ) + http_timeout_sec: float = Field( + default=5.0, + description="HTTP request timeout in seconds.", + ) + + +class ForgetLastConnector(ActionConnector[ForgetLastConfig, ForgetLastInput]): + """Undo the most recent enrollment via /gallery/forget_last.""" + + def __init__(self, config: ForgetLastConfig): + super().__init__(config) + self.base_url = self.config.face_http_base_url + self.http_timeout = self.config.http_timeout_sec + self.elevenlabs_tts_provider = ElevenLabsTTSProvider() + self.io_provider = IOProvider() + + # -------- HTTP helpers -------- + + def _post_json(self, path: str, body: Dict) -> Optional[Dict]: + """POST JSON to the face service. Returns parsed dict or None on error.""" + url = f"{self.base_url}{path}" + try: + r = requests.post(url, json=body, timeout=self.http_timeout) + return r.json() + except Exception as e: + logging.warning("HTTP POST %s failed (%s) body=%s", url, e, body) + return None + + # -------- Output helpers -------- + + def _write_status(self, line: str) -> None: + """ + Surface the result to the LLM via the shared SelfieStatus channel. + + All face-memory actions (selfie, correct_identity, forget_last) write + to the single 'SelfieStatus' io_provider key so the existing + SelfieStatus input plugin picks them all up. The LLM disambiguates + by reading the `result=...` prefix in the line. + """ + try: + self.io_provider.add_input("SelfieStatus", line, time.time()) + except Exception as e: + logging.warning("SelfieStatus write failed: %s", e) + + def _speak(self, message: Optional[str]) -> None: + """Queue a TTS message. No-op if empty.""" + if not message: + return + try: + self.elevenlabs_tts_provider.add_pending_message(message) + except Exception as e: + logging.warning("TTS queue failed: %s", e) + + # -------- Main entry point -------- + + async def connect(self, output_interface: ForgetLastInput) -> None: + """Execute a single undo of the most recent enrollment.""" + body: Dict = {} + id_check = getattr(output_interface, "id", None) + if id_check: + body["id"] = str(id_check).strip().lower() + + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor(None, self._post_json, "/gallery/forget_last", body) + + self._dispatch_response(resp, requested_id=id_check) + + def _dispatch_response(self, resp: Optional[Dict], requested_id: Optional[str]) -> None: + """Map the API response to status + TTS.""" + if resp is None: + self._write_status("result=network_error") + self._speak("I couldn't undo that.") + logging.error("[ForgetLast] network error") + return + + if resp.get("ok"): + forgotten_id = str(resp.get("id", "")) + deleted = int(resp.get("files_deleted", 0)) + identity_removed = bool(resp.get("identity_removed")) + self._write_status( + f"result=success id={forgotten_id} files_deleted={deleted} " + f"identity_removed={'true' if identity_removed else 'false'}" + ) + self._speak("OK, I've forgotten that one. Let's try again.") + logging.info( + "[ForgetLast] ok id=%s deleted=%d identity_removed=%s", + forgotten_id, + deleted, + identity_removed, + ) + return + + err = str(resp.get("error", "unknown")) + + if err in ("no_recent_enrollment", "stale_enrollment"): + self._write_status(f"result={err}") + self._speak("There's nothing recent for me to forget.") + logging.info("[ForgetLast] %s", err) + return + + if err == "id_mismatch": + detail = str(resp.get("detail", "")) + requested = (requested_id or "").strip().lower() + self._write_status(f"result=id_mismatch requested={requested} detail={detail}") + self._speak("That name doesn't match what I just remembered.") + logging.info( + "[ForgetLast] id_mismatch requested=%s detail=%s", + requested, + detail, + ) + return + + if err == "no_safe_files": + self._write_status("result=no_safe_files") + self._speak("I couldn't find the right files to remove.") + logging.error("[ForgetLast] no_safe_files") + return + + if err == "recognition_disabled": + self._write_status("result=recognition_disabled") + self._speak("I can't undo that right now.") + logging.error("[ForgetLast] recognition_disabled") + return + + # Unknown + self._write_status(f"result=unknown error={err}") + self._speak("Something went wrong undoing that.") + logging.error("[ForgetLast] unknown error: %s", resp) diff --git a/src/actions/forget_last/interface.py b/src/actions/forget_last/interface.py new file mode 100644 index 0000000000..304a72755f --- /dev/null +++ b/src/actions/forget_last/interface.py @@ -0,0 +1,53 @@ +# src/actions/forget_last/interface.py +from dataclasses import dataclass +from typing import Optional + +from actions.base import Interface + + +@dataclass +class ForgetLastInput: + """ + Input to undo the most recent /selfie enrollment. + + Used when the WRONG PERSON was captured during enrollment — e.g., a + different person walked in front of the camera, so the saved samples + belong to someone other than who the LLM was talking to. + + Distinct from: + - correct_identity() → right person, wrong label (typo) + - selfie(force=True) → different person who looks similar + + Parameters + ---------- + id : str, optional + Safety check. If provided, must equal the API's last_enrollment.id; + the call fails with `result=id_mismatch` otherwise. Omit to undo + whatever the most recent enrollment was regardless of id. + + Notes + ----- + Only works within ~60s of the enrollment. + """ + + id: Optional[str] = None + + +@dataclass +class ForgetLast(Interface[ForgetLastInput, ForgetLastInput]): + """ + Undo the most recent enrollment by deleting its samples. + + Trigger conditions: + - SelfieStatus showed a recent `result=success` (≤60s) + - User indicates the WRONG PERSON was captured + ("You saw the person behind me" / "That wasn't me" / + "Someone walked in front of the camera") + + NOT for: + - Typos / mishearings → use correct_identity() + - Look likes (different person, similar face) → use selfie(force=True) + """ + + input: ForgetLastInput + output: ForgetLastInput diff --git a/src/actions/selfie/connector/selfie.py b/src/actions/selfie/connector/selfie.py index 61fb5b39b6..df14cb5c40 100644 --- a/src/actions/selfie/connector/selfie.py +++ b/src/actions/selfie/connector/selfie.py @@ -1,5 +1,22 @@ +""" +Selfie connector for OM1. + +Calls the multi-frame /selfie endpoint exposed by om1_video_processor and +translates each response code into: + - a SelfieStatus key-value line for the LLM (via io_provider) + - a brief neutral TTS confirmation for the user (via elevenlabs) + - light connector-side state (last_enrolled_id, last_match_name) as + debugging breadcrumbs + +The connector does NOT own conversational persona or correction logic. +Persona is the LLM's job on the next turn after reading SelfieStatus. +Correction decisions (correct_identity / forget_last) are separate actions +governed by the API's own 60s TTL. +""" + import asyncio import logging +import re import time from typing import Dict, Optional @@ -11,6 +28,9 @@ from providers.elevenlabs_tts_provider import ElevenLabsTTSProvider from providers.io_provider import IOProvider +# Strip dedup-system suffix like "wendy_1" → "wendy" +_DEDUP_SUFFIX_RE = re.compile(r"_\d+$") + class SelfieConfig(ActionConfig): """ @@ -21,13 +41,14 @@ class SelfieConfig(ActionConfig): face_http_base_url : str Base URL for the face HTTP service. face_recent_sec : float - Recency window in seconds for face detection. + Recency window in seconds for the /who pre-check. poll_ms : int - Polling interval in milliseconds. + Polling interval in milliseconds for the pre-check. timeout_sec : int - Default timeout in seconds for operations. + Max seconds to wait for at least one face to appear before giving up. http_timeout_sec : float - HTTP request timeout in seconds. + HTTP request timeout in seconds. Must exceed the /selfie collection + window (~1.5s) with margin. """ face_http_base_url: str = Field( @@ -36,15 +57,15 @@ class SelfieConfig(ActionConfig): ) face_recent_sec: float = Field( default=1.0, - description="Recency window in seconds for face detection.", + description="Recency window in seconds for the /who pre-check.", ) poll_ms: int = Field( default=200, - description="Polling interval in milliseconds.", + description="Polling interval in milliseconds for the pre-check.", ) timeout_sec: int = Field( - default=15, - description="Default timeout in seconds for operations.", + default=8, + description="Max seconds to wait for ≥1 face before giving up.", ) http_timeout_sec: float = Field( default=5.0, @@ -54,7 +75,11 @@ class SelfieConfig(ActionConfig): class SelfieConnector(ActionConnector[SelfieConfig, SelfieInput]): """ - Enroll a selfie through the face HTTP service. + Enroll a face through the multi-frame /selfie endpoint. + + Each API response code maps to one row of the outcome table; see + `_dispatch_response` for the full set. The connector only handles + enrollment — corrections are separate actions. """ def __init__(self, config: SelfieConfig): @@ -69,7 +94,6 @@ def __init__(self, config: SelfieConfig): super().__init__(config) self.base_url: str = self.config.face_http_base_url - self.recent_sec = self.config.face_recent_sec self.poll_ms = self.config.poll_ms self.default_timeout = self.config.timeout_sec @@ -78,19 +102,11 @@ def __init__(self, config: SelfieConfig): self.elevenlabs_tts_provider = ElevenLabsTTSProvider() self.io_provider = IOProvider() - def _write_status(self, line: str): - """ - Make the result visible to the fuser/LLM as an input named 'SelfieStatus'. - - Parameters - ---------- - line : str - line: Status payload (e.g., "ok id=wendy", "failed reason=none faces=0"). - """ - try: - self.io_provider.add_input("SelfieStatus", line, time.time()) - except Exception as e: - logging.warning("SelfieStatus write failed: %s", e) + # Logging breadcrumbs for the most recent attempt. + # The API server is the source of truth for the 60s correction TTL — + # these are not enforced here. + self.last_enrolled_id: Optional[str] = None + self.last_match_name: Optional[str] = None def _post_json(self, path: str, body: Dict) -> Optional[Dict]: """ @@ -101,12 +117,12 @@ def _post_json(self, path: str, body: Dict) -> Optional[Dict]: path : str Endpoint path (e.g., "/who", "/selfie"). body : Dict - Request body dict. + Request body. Returns ------- - typing.Optional[Dict] - Parsed JSON dict on success; None on error. + Optional[Dict] + Parsed JSON dict on success; None on transport error. """ url = f"{self.base_url}{path}" try: @@ -117,52 +133,34 @@ def _post_json(self, path: str, body: Dict) -> Optional[Dict]: return None def _get_config(self) -> Dict: - """ - Fetch current service config. - - Returns - ------- - typing.Optional[Dict] - """ + """Fetch current service config. Returns {} on error.""" resp = self._post_json("/config", {"get": True}) or {} return resp if isinstance(resp, dict) else {} def _set_blur(self, on: bool) -> None: - """ - Enable/disable blur on the service. - - Parameters - ---------- - on : bool - True/False - """ + """Enable/disable blur on the service (affects RTSP viewing).""" _ = self._post_json("/config", {"set": {"blur": bool(on)}}) def _who_snapshot(self) -> Optional[Dict]: - """ - Query current faces within the recency window. - - Returns - ------- - typing.Optional[Dict] - Dict with keys like "now" (list of known IDs) and "unknown_now" (int), - or None on error. - """ + """Query current faces within the recency window.""" return self._post_json("/who", {"recent_sec": self.recent_sec}) - def _wait_single_face(self, timeout_sec: int) -> bool: + def _wait_any_face(self, timeout_sec: int) -> bool: """ - Poll /who until exactly one face is visible or timeout. + Poll /who until at least one face is visible, or timeout. + + Multi-person ambiguity is handled by the /selfie API itself — this + pre-check only ensures we're not calling /selfie into an empty scene. Parameters ---------- timeout_sec : int - Maximum seconds to wait (<=0 uses default_timeout). + Maximum seconds to wait. <=0 uses default_timeout. Returns ------- bool - True if exactly one face is detected within the timeout; False otherwise. + True if at least one face appeared within the timeout. """ if timeout_sec <= 0: timeout_sec = self.default_timeout @@ -171,73 +169,218 @@ def _wait_single_face(self, timeout_sec: int) -> bool: resp = self._who_snapshot() or {} now = resp.get("now") or [] unknown_now = int(resp.get("unknown_now") or 0) - faces = len(now) + unknown_now - if faces == 1: + if len(now) + unknown_now >= 1: logging.info( - "Selfie gate: exactly 1 face detected (now=%s, unknown=%d)", + "[Selfie] pre-check ok (known=%s, unknown=%d)", now, unknown_now, ) return True self.sleep(self.poll_ms / 1000.0) - logging.error("Selfie gate: timeout waiting for exactly 1 face.") + logging.info("[Selfie] pre-check: no face appeared within %ds", timeout_sec) return False - async def connect(self, output_interface: SelfieInput) -> None: + def _write_status(self, line: str) -> None: + """Surface the result to the LLM as a SelfieStatus input line.""" + try: + self.io_provider.add_input("SelfieStatus", line, time.time()) + except Exception as e: + logging.warning("SelfieStatus write failed: %s", e) + + def _speak(self, message: Optional[str]) -> None: + """Queue a TTS message. No-op if message is None or empty.""" + if not message: + return + try: + self.elevenlabs_tts_provider.add_pending_message(message) + except Exception as e: + logging.warning("TTS queue failed: %s", e) + + @staticmethod + def _display_name(id_str: str) -> str: """ - Execute a single selfie enrollment attempt. + Convert internal id to a natural display form for TTS. + + - 'wendy_1' → 'Wendy' (strip dedup suffix) + - 'jerin-peter' → 'Jerin Peter' (dashes back to spaces, title case) + - 'wendy' → 'Wendy' + + Underscores within the name (not the trailing _N suffix) also become + spaces, since both '-' and '_' are valid in-name separators per the + API's name policy. + """ + cleaned = _DEDUP_SUFFIX_RE.sub("", id_str) + cleaned = cleaned.replace("-", " ").replace("_", " ") + return cleaned.title() + + def _clear_state(self) -> None: + """Clear breadcrumb state (called on any failure).""" + self.last_enrolled_id = None + self.last_match_name = None + + def _dispatch_response(self, resp: Dict, claimed_id: str) -> None: + """ + Map the /selfie API response to SelfieStatus + TTS + state. Parameters ---------- - output_interface : SelfieInput - The selfie action interface containing parameters like `id` and `timeout_sec`. + resp : Dict + Parsed API response. + claimed_id : str + The id the user said they were (before any API renaming). + """ + # ---- Success path ---- + if resp.get("ok"): + saved_id = str(resp.get("id", claimed_id)) + merged = bool(resp.get("merged")) + samples = int(resp.get("samples_saved", 0)) + display = self._display_name(saved_id) + + self.last_enrolled_id = saved_id + self.last_match_name = None + + tag = "merged" if merged else "success" + self._write_status( + f"result={tag} id={saved_id} samples={samples} " f"merged={'true' if merged else 'false'}" + ) + if merged: + self._speak(f"Welcome back, {display}!") + else: + self._speak(f"Nice to meet you, {display}! I'll remember you next time.") + logging.info("[Selfie] %s id=%s samples=%d", tag, saved_id, samples) + return + + err = str(resp.get("error", "unknown")) + + if err == "ambiguous_subjects": + n = int(resp.get("n_engaged", 0)) + self._write_status(f"result=ambiguous engaged={n}") + self._speak("I see a few people. Could you step closer so I can focus on you?") + logging.info("[Selfie] ambiguous engaged=%d", n) + self._clear_state() + return + + if err == "face_belongs_to": + matched = str(resp.get("name", "someone")) + sim = float(resp.get("sim", 0.0)) + matched_display = self._display_name(matched) + self.last_match_name = matched + self._write_status(f"result=face_belongs_to claimed={claimed_id} " f"matched={matched} sim={sim:.3f}") + self._speak(f"You look a lot like {matched_display}. " f"Are you {matched_display}, or someone different?") + logging.info( + "[Selfie] face_belongs_to claimed=%s matched=%s sim=%.3f", + claimed_id, + matched, + sim, + ) + return + + if err == "no_valid_frames": + self._write_status("result=low_quality") + self._speak("I can't see your face clearly. Could you look at me directly?") + logging.info("[Selfie] no_valid_frames") + self._clear_state() + return + + if err == "insufficient_samples": + got = int(resp.get("got", 0)) + self._write_status(f"result=partial got={got}") + self._speak("Hold still — almost got it.") + logging.info("[Selfie] insufficient_samples got=%d", got) + self._clear_state() + return + + if err == "busy": + # Only reaches here AFTER the retry in connect() + self._write_status("result=busy retries=1") + self._speak("One sec, finishing the last one.") + logging.info("[Selfie] still busy after retry") + self._clear_state() + return + + if err == "bad_id": + detail = str(resp.get("detail", "")) + self._write_status(f"result=bad_id detail={detail}") + # No TTS — bad_id means the LLM produced an invalid id; + # surface to LLM only so it can re-prompt the user. + logging.error("[Selfie] bad_id: %s", detail) + self._clear_state() + return + + if err == "recognition_disabled": + self._write_status("result=recognition_disabled") + self._speak("I can't see right now — please try again in a moment.") + logging.error("[Selfie] recognition_disabled (gallery manager not loaded)") + self._clear_state() + return + + self._write_status(f"result=unknown error={err}") + self._speak("Something went wrong. Could you try again?") + logging.error("[Selfie] unknown error: %s", resp) + self._clear_state() + + def _dispatch_network_error(self) -> None: + """Called when /selfie returns None (HTTP transport failure).""" + self._write_status("result=network_error") + self._speak("I lost connection for a moment.") + logging.error("[Selfie] network error talking to face API") + self._clear_state() + + async def connect(self, output_interface: SelfieInput) -> None: + """ + Execute a single selfie enrollment attempt. + + Reads from `output_interface`: + - action : str — claimed id, e.g. "wendy" (required) + - timeout_sec : int — max wait for a face to appear (default 8) + - force : bool — bypass cross-name reject; default False + + Writes SelfieStatus and queues TTS per the outcome table. """ name = (output_interface.action or "").strip() timeout_sec = int(output_interface.timeout_sec or self.default_timeout) + force = bool(getattr(output_interface, "force", False)) + if not name: - logging.error("Selfie requires a non-empty `id` (e.g., 'wendy').") - self.io_provider.add_input("SelfieStatus", "failed reason=bad_id", time.time()) + logging.error("[Selfie] empty id; expected something like 'wendy'") + self._write_status("result=bad_id detail=empty") + # No TTS — LLM should have produced a valid id return loop = asyncio.get_running_loop() + # Snapshot blur state, disable for the enrollment so the demo RTSP + # view shows the actual face being captured. cfg = await loop.run_in_executor(None, self._get_config) orig_blur = bool(((cfg or {}).get("config") or {}).get("blur", True)) await loop.run_in_executor(None, self._set_blur, False) try: - ok = await loop.run_in_executor(None, self._wait_single_face, timeout_sec) - if not ok: - snapshot = await loop.run_in_executor(None, self._who_snapshot) or {} - now = snapshot.get("now") or [] - unknown_now = int(snapshot.get("unknown_now") or 0) - faces = len(now) + unknown_now - reason = "none" if faces == 0 else "multiple" - logging.info("[Selfie] Gating failed: %s (faces=%d)", reason, faces) - self.io_provider.add_input( - "SelfieStatus", - f"failed reason={reason} faces={faces}", - time.time(), - ) - self.elevenlabs_tts_provider.add_pending_message( - f"Woof! Woof! I saw {faces} faces. Please make sure only your face is visible and try again." - ) + # Pre-check: fast-fail if nobody is in frame. + # (The API would eventually return no_valid_frames after 1.5s, + # but a pre-check gives quicker feedback.) + face_present = await loop.run_in_executor(None, self._wait_any_face, timeout_sec) + if not face_present: + self._write_status("result=low_quality reason=no_one_present") + self._speak("I don't see anyone in front of me yet.") return - resp = await loop.run_in_executor(None, self._post_json, "/selfie", {"id": name}) - if not (isinstance(resp, dict) and resp.get("ok")): - logging.error("[Selfie] /selfie failed or returned non-ok: %s", resp) - self.io_provider.add_input("SelfieStatus", "failed reason=service", time.time()) - self.elevenlabs_tts_provider.add_pending_message( - "Woof! Woof! I couldn't see you clearly. Please try again." - ) - return + # Call /selfie — the API runs its 1.5s multi-frame collection + body = {"id": name, "force": force} + resp = await loop.run_in_executor(None, self._post_json, "/selfie", body) - logging.info("[Selfie] Enrolled selfie for '%s' successfully.", name) - self.io_provider.add_input("SelfieStatus", f"ok id={name}", time.time()) - self.elevenlabs_tts_provider.add_pending_message( - f"Woof! Woof! I remember you, {name}! You are now enrolled." - ) + # Retry once on transient busy + if isinstance(resp, dict) and resp.get("error") == "busy": + logging.info("[Selfie] busy on first call, retrying in 1s") + await asyncio.sleep(1.0) + resp = await loop.run_in_executor(None, self._post_json, "/selfie", body) + + # Dispatch + if resp is None: + self._dispatch_network_error() + else: + self._dispatch_response(resp, claimed_id=name) finally: + # Always restore original blur state await loop.run_in_executor(None, self._set_blur, orig_blur) diff --git a/src/actions/selfie/interface.py b/src/actions/selfie/interface.py index 6181f5ff15..792e8067d3 100644 --- a/src/actions/selfie/interface.py +++ b/src/actions/selfie/interface.py @@ -7,24 +7,52 @@ @dataclass class SelfieInput: """ - Input to enroll a selfie through the face HTTP service. + Input to enroll a person via the multi-frame /selfie endpoint. Parameters ---------- action : str - The person ID (e.g., "wendy"). Will create/update gallery/. - timeout_sec : int, optional - Seconds to wait for exactly one face (default 15). + The identity name (e.g., "wendy"). Determines what the API does: + - New name → create gallery/ with 1-4 captured samples + - Same-name family match (e.g. "wendy" vs existing "wendy_1") + with cosine ≥ merge_the → merge samples into existing folder + - Cross-name match (different name, cosine ≥ cross_name_the) + → reject as `face_belongs_to`, unless `force=True` + + Naming rules: lowercase ASCII alphanumeric, dash, underscore. + Trailing `_` is reserved for the dedup system — don't use it. + + timeout_sec : int + Max seconds to wait for at least one face to appear before giving up. + Default 5. Bump higher (e.g. 10) for hesitant users. Note: the /selfie + API itself takes ~1.5s once started (multi-frame collection window), + so total worst-case latency is roughly `timeout_sec + 1.5s`. + + force : bool + If True, bypass the cross-name reject. Use when the user disputes + a matched identity, e.g.: + User: "I'm not Wendy, I'm John" + LLM: selfie(action="john", force=True) + Default False — normal enrollments should leave this off so the + dedup safety net works. """ action: str timeout_sec: int = 5 + force: bool = False @dataclass class Selfie(Interface[SelfieInput, SelfieInput]): """ - This action takes a selfie from the live camera and enrolls it to the face gallery. + Enroll a person to the face gallery via the multi-frame /selfie endpoint. + + The endpoint collects 1-4 quality-gated frames over a ~1.5s window, + selects the best target by engagement score (face area × frontality), + and either creates a new identity or merges into an existing one. + + See SelfieInput for parameters. Outcomes are surfaced to the LLM via the + SelfieStatus input, with a brief TTS confirmation sent to the user. """ input: SelfieInput diff --git a/tests/actions/correct_identity/__init__.py b/tests/actions/correct_identity/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/actions/correct_identity/connector/test_correct_identity.py b/tests/actions/correct_identity/connector/test_correct_identity.py new file mode 100644 index 0000000000..912fa28569 --- /dev/null +++ b/tests/actions/correct_identity/connector/test_correct_identity.py @@ -0,0 +1,444 @@ +""" +Tests for actions.correct_identity.connector.correct_identity.CorrectIdentityConnector. + +Covers: +- CorrectIdentityConfig defaults / overrides +- Helper methods (_post_json, _write_status, _speak, _display_name) +- Response dispatch: one test per result-code branch in _dispatch_response +- Network-error dispatch (HTTP transport failure) +- End-to-end connect() flow: local validation (bad_id when ids missing, + same_id no-op when ids equal after normalization), payload shape, and + error response handling + +External dependencies (ElevenLabsTTSProvider, IOProvider, requests) are +mocked. +""" + +from unittest.mock import Mock, patch + +import pytest + +from actions.correct_identity.connector.correct_identity import ( + CorrectIdentityConfig, + CorrectIdentityConnector, +) +from actions.correct_identity.interface import CorrectIdentityInput + +# ----- Shared fixtures ----- + + +@pytest.fixture +def mock_dependencies(): + """ + Mock the connector's external collaborators. + + ElevenLabsTTSProvider and IOProvider are patched at the connector module + level so that their __init__ in CorrectIdentityConnector returns Mock + instances. + """ + with ( + patch("actions.correct_identity.connector.correct_identity.ElevenLabsTTSProvider") as mock_tts_cls, + patch("actions.correct_identity.connector.correct_identity.IOProvider") as mock_io_cls, + ): + mock_tts = Mock() + mock_io = Mock() + mock_tts_cls.return_value = mock_tts + mock_io_cls.return_value = mock_io + yield mock_tts, mock_io + + +@pytest.fixture +def connector(mock_dependencies): + """CorrectIdentityConnector with default config and mocked dependencies.""" + return CorrectIdentityConnector(CorrectIdentityConfig()) + + +# ===================================================================== +# CorrectIdentityConfig +# ===================================================================== + + +class TestCorrectIdentityConfig: + """CorrectIdentityConfig default and override behavior.""" + + def test_default_config(self): + config = CorrectIdentityConfig() + assert config.face_http_base_url == "http://127.0.0.1:6793" + assert config.http_timeout_sec == 5.0 + + def test_custom_config(self): + config = CorrectIdentityConfig( + face_http_base_url="http://custom:9999", + http_timeout_sec=10.0, + ) + assert config.face_http_base_url == "http://custom:9999" + assert config.http_timeout_sec == 10.0 + + def test_partial_override_keeps_other_defaults(self): + config = CorrectIdentityConfig(http_timeout_sec=20.0) + assert config.http_timeout_sec == 20.0 + assert config.face_http_base_url == "http://127.0.0.1:6793" # default + + +# ===================================================================== +# CorrectIdentityConnector init +# ===================================================================== + + +class TestCorrectIdentityConnectorInit: + """Connector wires config values into instance state.""" + + def test_init_sets_fields_from_config(self, connector): + assert connector.base_url == "http://127.0.0.1:6793" + assert connector.http_timeout == 5.0 + + def test_init_attaches_providers(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + assert connector.elevenlabs_tts_provider is mock_tts + assert connector.io_provider is mock_io + + +# ===================================================================== +# HTTP helpers +# ===================================================================== + + +class TestHttpHelpers: + """HTTP helper methods.""" + + def test_post_json_success(self, connector): + with patch("actions.correct_identity.connector.correct_identity.requests") as mock_requests: + mock_response = Mock() + mock_response.json.return_value = {"ok": True, "moved": 3} + mock_requests.post.return_value = mock_response + + result = connector._post_json("/gallery/move_samples", {"from_id": "wendy", "to_id": "wendi"}) + assert result == {"ok": True, "moved": 3} + mock_requests.post.assert_called_once_with( + "http://127.0.0.1:6793/gallery/move_samples", + json={"from_id": "wendy", "to_id": "wendi"}, + timeout=5.0, + ) + + def test_post_json_transport_failure_returns_none(self, connector): + with patch("actions.correct_identity.connector.correct_identity.requests") as mock_requests: + mock_requests.post.side_effect = Exception("Connection refused") + assert connector._post_json("/gallery/move_samples", {}) is None + + +# ===================================================================== +# _write_status / _speak +# ===================================================================== + + +class TestStatusAndSpeech: + """_write_status and _speak.""" + + def test_write_status_writes_to_shared_selfie_status_key(self, connector, mock_dependencies): + """ + correct_identity writes to the SHARED 'SelfieStatus' channel, + the same key used by selfie and forget_last. The LLM + disambiguates by reading the result=... prefix. + """ + _, mock_io = mock_dependencies + connector._write_status("result=success from=wendy to=wendi moved=3 from_removed=true") + mock_io.add_input.assert_called_once() + args, _kwargs = mock_io.add_input.call_args + assert args[0] == "SelfieStatus" + assert args[1] == ("result=success from=wendy to=wendi moved=3 from_removed=true") + assert isinstance(args[2], float) + + def test_write_status_swallows_exceptions(self, connector, mock_dependencies): + _, mock_io = mock_dependencies + mock_io.add_input.side_effect = RuntimeError("io broken") + # Should not raise + connector._write_status("result=foo") + + def test_speak_queues_tts(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak("Got it, I've updated your name to Wendy.") + mock_tts.add_pending_message.assert_called_once_with("Got it, I've updated your name to Wendy.") + + def test_speak_empty_string_noop(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak("") + mock_tts.add_pending_message.assert_not_called() + + def test_speak_none_noop(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak(None) + mock_tts.add_pending_message.assert_not_called() + + def test_speak_swallows_exceptions(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + mock_tts.add_pending_message.side_effect = RuntimeError("tts broken") + # Should not raise + connector._speak("hello") + + +# ===================================================================== +# _display_name +# ===================================================================== + + +class TestDisplayName: + """Static helper that converts internal id → human-friendly name.""" + + @pytest.mark.parametrize( + "internal,expected", + [ + ("wendy", "Wendy"), + ("wendy_1", "Wendy"), # strip dedup suffix + ("wendy_42", "Wendy"), # arbitrary digit suffix + ("jerin-peter", "Jerin Peter"), # dash → space + title + ("jerin-peter_3", "Jerin Peter"), # both transformations + ("li-xiaohong", "Li Xiaohong"), + ("first_last", "First Last"), # internal underscore → space + ("MIXED-Case", "Mixed Case"), + ("a", "A"), + ], + ) + def test_display_name_cases(self, internal, expected): + assert CorrectIdentityConnector._display_name(internal) == expected + + +# ===================================================================== +# _dispatch_response — one test per branch +# ===================================================================== + + +class TestDispatchResponseSuccess: + """ok=True path.""" + + def test_success_full_response(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + resp = {"ok": True, "moved": 3, "from_removed": True} + connector._dispatch_response(resp, from_id="wendi", to_id="wendy") + + # SelfieStatus + status = mock_io.add_input.call_args[0][1] + assert "result=success" in status + assert "from=wendi" in status + assert "to=wendy" in status + assert "moved=3" in status + assert "from_removed=true" in status + + # TTS uses display name of to_id + assert mock_tts.add_pending_message.call_args[0][0] == "Got it, I've updated your name to Wendy." + + def test_success_from_not_removed(self, connector, mock_dependencies): + """E.g. when to_id already existed and from_id stays around.""" + _, mock_io = mock_dependencies + resp = {"ok": True, "moved": 2, "from_removed": False} + connector._dispatch_response(resp, from_id="wendi", to_id="wendy") + status = mock_io.add_input.call_args[0][1] + assert "from_removed=false" in status + + def test_success_tts_uses_to_id_display_name(self, connector, mock_dependencies): + """to_id 'jerin-peter_1' → TTS 'Jerin Peter'.""" + mock_tts, _ = mock_dependencies + resp = {"ok": True, "moved": 1, "from_removed": True} + connector._dispatch_response(resp, from_id="jared", to_id="jerin-peter_1") + assert mock_tts.add_pending_message.call_args[0][0] == "Got it, I've updated your name to Jerin Peter." + + def test_success_with_missing_fields_uses_defaults(self, connector, mock_dependencies): + """Minimal API response — defaults applied without crashing.""" + _, mock_io = mock_dependencies + connector._dispatch_response({"ok": True}, from_id="a", to_id="b") + status = mock_io.add_input.call_args[0][1] + assert "moved=0" in status + assert "from_removed=false" in status + + +class TestDispatchResponseErrors: + """error=... branches.""" + + def test_no_recent_enrollment(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "no_recent_enrollment"}, from_id="a", to_id="b") + assert mock_io.add_input.call_args[0][1] == "result=no_recent_enrollment" + assert "too much time" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_stale_enrollment(self, connector, mock_dependencies): + """stale_enrollment shares the same TTS as no_recent_enrollment.""" + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "stale_enrollment"}, from_id="a", to_id="b") + assert mock_io.add_input.call_args[0][1] == "result=stale_enrollment" + assert "too much time" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_bad_id_from_api_no_tts(self, connector, mock_dependencies): + """bad_id surfaces to LLM only — no TTS noise to the user.""" + mock_tts, mock_io = mock_dependencies + connector._dispatch_response( + {"error": "bad_id", "detail": "to_id contains space"}, + from_id="a", + to_id="b c", + ) + status = mock_io.add_input.call_args[0][1] + assert "result=bad_id" in status + assert "detail=to_id contains space" in status + mock_tts.add_pending_message.assert_not_called() + + def test_same_id_from_api_no_tts(self, connector, mock_dependencies): + """API-side same_id (e.g., from canonicalization) → silent.""" + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "same_id"}, from_id="wendy", to_id="wendy_alias") + # Uses the connector's from_id for the status line + assert mock_io.add_input.call_args[0][1] == "result=same_id id=wendy" + mock_tts.add_pending_message.assert_not_called() + + def test_no_safe_files(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "no_safe_files"}, from_id="a", to_id="b") + assert mock_io.add_input.call_args[0][1] == "result=no_safe_files" + assert "couldn't find" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_recognition_disabled(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "recognition_disabled"}, from_id="a", to_id="b") + assert mock_io.add_input.call_args[0][1] == "result=recognition_disabled" + assert mock_tts.add_pending_message.called + + def test_unknown_error_fallback(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "something_weird"}, from_id="a", to_id="b") + status = mock_io.add_input.call_args[0][1] + assert status.startswith("result=unknown") + assert "error=something_weird" in status + assert "something went wrong" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_missing_error_field_treated_as_unknown(self, connector, mock_dependencies): + """Malformed response (no ok, no error) → unknown branch.""" + _, mock_io = mock_dependencies + connector._dispatch_response({}, from_id="a", to_id="b") + status = mock_io.add_input.call_args[0][1] + assert "result=unknown" in status + assert "error=unknown" in status + + +class TestDispatchNetworkError: + """HTTP transport failure (resp is None).""" + + def test_network_error(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response(None, from_id="a", to_id="b") + assert mock_io.add_input.call_args[0][1] == "result=network_error" + assert "trouble updating" in mock_tts.add_pending_message.call_args[0][0].lower() + + +# ===================================================================== +# connect() — end-to-end flow +# ===================================================================== + + +class TestConnect: + """Top-level connect() orchestration including local validation.""" + + @pytest.mark.asyncio + async def test_connect_empty_from_id_writes_bad_id_silent(self, connector, mock_dependencies): + """Empty from_id is a local-validation error; no API call, no TTS.""" + mock_tts, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + await connector.connect(CorrectIdentityInput(from_id="", to_id="wendy")) + mock_post.assert_not_called() + status = mock_io.add_input.call_args[0][1] + assert "result=bad_id" in status + mock_tts.add_pending_message.assert_not_called() + + @pytest.mark.asyncio + async def test_connect_empty_to_id_writes_bad_id_silent(self, connector, mock_dependencies): + """Empty to_id is a local-validation error; no API call, no TTS.""" + mock_tts, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + await connector.connect(CorrectIdentityInput(from_id="wendy", to_id="")) + mock_post.assert_not_called() + assert "result=bad_id" in mock_io.add_input.call_args[0][1] + mock_tts.add_pending_message.assert_not_called() + + @pytest.mark.asyncio + async def test_connect_whitespace_only_ids_treated_as_bad_id(self, connector, mock_dependencies): + """' ' strips to '' → bad_id.""" + _, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + await connector.connect(CorrectIdentityInput(from_id=" ", to_id="wendy")) + mock_post.assert_not_called() + assert "result=bad_id" in mock_io.add_input.call_args[0][1] + + @pytest.mark.asyncio + async def test_connect_same_id_after_normalization_short_circuits(self, connector, mock_dependencies): + """ + Raw inputs that differ only by case/whitespace ('Wendy' vs ' wendy ') + normalize to the same id → no-op, no API call, no TTS. + """ + mock_tts, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + await connector.connect(CorrectIdentityInput(from_id="Wendy", to_id=" wendy ")) + mock_post.assert_not_called() + assert mock_io.add_input.call_args[0][1] == "result=same_id id=wendy" + mock_tts.add_pending_message.assert_not_called() + + @pytest.mark.asyncio + async def test_connect_normalizes_payload_to_lower_strip(self, connector, mock_dependencies): + """Mixed-case + whitespace input normalizes before hitting the API.""" + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "ok": True, + "moved": 2, + "from_removed": True, + } + await connector.connect(CorrectIdentityInput(from_id=" Wendi ", to_id="WENDY")) + + path, body = mock_post.call_args[0] + assert path == "/gallery/move_samples" + assert body == {"from_id": "wendi", "to_id": "wendy"} + + @pytest.mark.asyncio + async def test_connect_success_dispatches_success(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "ok": True, + "moved": 4, + "from_removed": True, + } + await connector.connect(CorrectIdentityInput(from_id="wendi", to_id="wendy")) + + status = mock_io.add_input.call_args[0][1] + assert "result=success" in status + assert "from=wendi" in status + assert "to=wendy" in status + assert "moved=4" in status + # TTS uses display name of to_id + assert "Wendy" in mock_tts.add_pending_message.call_args[0][0] + + @pytest.mark.asyncio + async def test_connect_api_bad_id_silent(self, connector, mock_dependencies): + """If API returns bad_id (e.g. invalid chars), connector stays silent.""" + mock_tts, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "error": "bad_id", + "detail": "non-ascii", + } + await connector.connect(CorrectIdentityInput(from_id="wendy", to_id="wendi")) + status = mock_io.add_input.call_args[0][1] + assert "result=bad_id" in status + assert "detail=non-ascii" in status + mock_tts.add_pending_message.assert_not_called() + + @pytest.mark.asyncio + async def test_connect_no_recent_enrollment(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = {"error": "no_recent_enrollment"} + await connector.connect(CorrectIdentityInput(from_id="wendi", to_id="wendy")) + assert mock_io.add_input.call_args[0][1] == "result=no_recent_enrollment" + assert mock_tts.add_pending_message.called + + @pytest.mark.asyncio + async def test_connect_network_error(self, connector, mock_dependencies): + """/gallery/move_samples returns None → network_error dispatched.""" + _, mock_io = mock_dependencies + with patch.object(connector, "_post_json", return_value=None): + await connector.connect(CorrectIdentityInput(from_id="wendi", to_id="wendy")) + assert mock_io.add_input.call_args[0][1] == "result=network_error" diff --git a/tests/actions/forget_last/connector/test_forget_last.py b/tests/actions/forget_last/connector/test_forget_last.py new file mode 100644 index 0000000000..f7a770350a --- /dev/null +++ b/tests/actions/forget_last/connector/test_forget_last.py @@ -0,0 +1,428 @@ +""" +Tests for actions.forget_last.connector.forget_last.ForgetLastConnector. + +Covers: +- ForgetLastConfig defaults / overrides +- Helper methods (_post_json, _write_status, _speak) +- Response dispatch: one test per result-code branch in _dispatch_response, + with particular attention to the id_mismatch branch which threads the + user-requested id into the status line for LLM context +- Network-error dispatch (HTTP transport failure) +- End-to-end connect() flow: with/without id, payload normalization + (lowercase + strip), and error response handling + +External dependencies (ElevenLabsTTSProvider, IOProvider, requests) are +mocked. +""" + +from unittest.mock import Mock, patch + +import pytest + +from actions.forget_last.connector.forget_last import ( + ForgetLastConfig, + ForgetLastConnector, +) +from actions.forget_last.interface import ForgetLastInput + +# ----- Shared fixtures ----- + + +@pytest.fixture +def mock_dependencies(): + """ + Mock the connector's external collaborators. + + ElevenLabsTTSProvider and IOProvider are patched at the connector module + level so that their __init__ in ForgetLastConnector returns Mock instances. + """ + with ( + patch("actions.forget_last.connector.forget_last.ElevenLabsTTSProvider") as mock_tts_cls, + patch("actions.forget_last.connector.forget_last.IOProvider") as mock_io_cls, + ): + mock_tts = Mock() + mock_io = Mock() + mock_tts_cls.return_value = mock_tts + mock_io_cls.return_value = mock_io + yield mock_tts, mock_io + + +@pytest.fixture +def connector(mock_dependencies): + """ForgetLastConnector with default config and mocked dependencies.""" + return ForgetLastConnector(ForgetLastConfig()) + + +# ===================================================================== +# ForgetLastConfig +# ===================================================================== + + +class TestForgetLastConfig: + """ForgetLastConfig default and override behavior.""" + + def test_default_config(self): + config = ForgetLastConfig() + assert config.face_http_base_url == "http://127.0.0.1:6793" + assert config.http_timeout_sec == 5.0 + + def test_custom_config(self): + config = ForgetLastConfig( + face_http_base_url="http://custom:9999", + http_timeout_sec=10.0, + ) + assert config.face_http_base_url == "http://custom:9999" + assert config.http_timeout_sec == 10.0 + + def test_partial_override_keeps_other_defaults(self): + config = ForgetLastConfig(http_timeout_sec=20.0) + assert config.http_timeout_sec == 20.0 + assert config.face_http_base_url == "http://127.0.0.1:6793" # default + + +# ===================================================================== +# ForgetLastConnector init +# ===================================================================== + + +class TestForgetLastConnectorInit: + """Connector wires config values into instance state.""" + + def test_init_sets_fields_from_config(self, connector): + assert connector.base_url == "http://127.0.0.1:6793" + assert connector.http_timeout == 5.0 + + def test_init_attaches_providers(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + assert connector.elevenlabs_tts_provider is mock_tts + assert connector.io_provider is mock_io + + +# ===================================================================== +# HTTP helpers +# ===================================================================== + + +class TestHttpHelpers: + """HTTP helper methods.""" + + def test_post_json_success(self, connector): + with patch("actions.forget_last.connector.forget_last.requests") as mock_requests: + mock_response = Mock() + mock_response.json.return_value = {"ok": True, "id": "wendy"} + mock_requests.post.return_value = mock_response + + result = connector._post_json("/gallery/forget_last", {"id": "wendy"}) + assert result == {"ok": True, "id": "wendy"} + mock_requests.post.assert_called_once_with( + "http://127.0.0.1:6793/gallery/forget_last", + json={"id": "wendy"}, + timeout=5.0, + ) + + def test_post_json_transport_failure_returns_none(self, connector): + with patch("actions.forget_last.connector.forget_last.requests") as mock_requests: + mock_requests.post.side_effect = Exception("Connection refused") + assert connector._post_json("/gallery/forget_last", {}) is None + + +# ===================================================================== +# _write_status / _speak +# ===================================================================== + + +class TestStatusAndSpeech: + """_write_status and _speak.""" + + def test_write_status_writes_to_shared_selfie_status_key(self, connector, mock_dependencies): + """ + forget_last writes to the SHARED 'SelfieStatus' channel, + the same key used by selfie and correct_identity. The LLM + disambiguates by reading the result=... prefix. + """ + _, mock_io = mock_dependencies + connector._write_status("result=success id=wendy files_deleted=3 identity_removed=true") + mock_io.add_input.assert_called_once() + args, _kwargs = mock_io.add_input.call_args + assert args[0] == "SelfieStatus" + assert args[1] == ("result=success id=wendy files_deleted=3 identity_removed=true") + assert isinstance(args[2], float) + + def test_write_status_swallows_exceptions(self, connector, mock_dependencies): + _, mock_io = mock_dependencies + mock_io.add_input.side_effect = RuntimeError("io broken") + # Should not raise + connector._write_status("result=foo") + + def test_speak_queues_tts(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak("OK, I've forgotten that one. Let's try again.") + mock_tts.add_pending_message.assert_called_once_with("OK, I've forgotten that one. Let's try again.") + + def test_speak_empty_string_noop(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak("") + mock_tts.add_pending_message.assert_not_called() + + def test_speak_none_noop(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak(None) + mock_tts.add_pending_message.assert_not_called() + + def test_speak_swallows_exceptions(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + mock_tts.add_pending_message.side_effect = RuntimeError("tts broken") + # Should not raise + connector._speak("hello") + + +# ===================================================================== +# _dispatch_response — one test per branch +# ===================================================================== + + +class TestDispatchResponseSuccess: + """ok=True path.""" + + def test_success_full_response(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + resp = { + "ok": True, + "id": "wendy", + "files_deleted": 3, + "identity_removed": True, + } + connector._dispatch_response(resp, requested_id="wendy") + + # SelfieStatus + status = mock_io.add_input.call_args[0][1] + assert "result=success" in status + assert "id=wendy" in status + assert "files_deleted=3" in status + assert "identity_removed=true" in status + + # TTS + assert mock_tts.add_pending_message.call_args[0][0] == "OK, I've forgotten that one. Let's try again." + + def test_success_identity_not_removed(self, connector, mock_dependencies): + """Only samples deleted, identity retained (e.g. had multiple samples).""" + _, mock_io = mock_dependencies + resp = { + "ok": True, + "id": "wendy", + "files_deleted": 1, + "identity_removed": False, + } + connector._dispatch_response(resp, requested_id="wendy") + status = mock_io.add_input.call_args[0][1] + assert "identity_removed=false" in status + + def test_success_with_missing_fields_uses_defaults(self, connector, mock_dependencies): + """API returns minimal response — defaults applied without crashing.""" + _, mock_io = mock_dependencies + connector._dispatch_response({"ok": True}, requested_id=None) + status = mock_io.add_input.call_args[0][1] + assert "files_deleted=0" in status + assert "identity_removed=false" in status + + +class TestDispatchResponseErrors: + """error=... branches.""" + + def test_no_recent_enrollment(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "no_recent_enrollment"}, requested_id=None) + assert mock_io.add_input.call_args[0][1] == "result=no_recent_enrollment" + assert "nothing recent" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_stale_enrollment(self, connector, mock_dependencies): + """stale_enrollment shares the same TTS as no_recent_enrollment.""" + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "stale_enrollment"}, requested_id=None) + assert mock_io.add_input.call_args[0][1] == "result=stale_enrollment" + assert "nothing recent" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_id_mismatch_includes_requested(self, connector, mock_dependencies): + """ + Critical regression test for the vulture-driven fix: + requested_id MUST be threaded into the SelfieStatus line so the LLM + has both sides of the disagreement (what user asked vs server state). + """ + mock_tts, mock_io = mock_dependencies + connector._dispatch_response( + {"error": "id_mismatch", "detail": "last_was=david"}, + requested_id="Wendy", # raw, mixed-case + ) + status = mock_io.add_input.call_args[0][1] + # requested is normalized (lowercased, stripped) + assert "result=id_mismatch" in status + assert "requested=wendy" in status + assert "detail=last_was=david" in status + # TTS + assert "doesn't match" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_id_mismatch_none_requested_id(self, connector, mock_dependencies): + """If requested_id is None, status still has 'requested=' (empty value).""" + _, mock_io = mock_dependencies + connector._dispatch_response( + {"error": "id_mismatch", "detail": "last_was=david"}, + requested_id=None, + ) + status = mock_io.add_input.call_args[0][1] + assert "requested=" in status + assert "detail=last_was=david" in status + + def test_id_mismatch_normalizes_whitespace(self, connector, mock_dependencies): + """Surrounding whitespace and casing are normalized in the status line.""" + _, mock_io = mock_dependencies + connector._dispatch_response( + {"error": "id_mismatch", "detail": ""}, + requested_id=" WENDY ", + ) + assert "requested=wendy" in mock_io.add_input.call_args[0][1] + + def test_no_safe_files(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "no_safe_files"}, requested_id="wendy") + assert mock_io.add_input.call_args[0][1] == "result=no_safe_files" + assert "couldn't find" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_recognition_disabled(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "recognition_disabled"}, requested_id=None) + assert mock_io.add_input.call_args[0][1] == "result=recognition_disabled" + assert mock_tts.add_pending_message.called + + def test_unknown_error_fallback(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "something_weird"}, requested_id=None) + status = mock_io.add_input.call_args[0][1] + assert status.startswith("result=unknown") + assert "error=something_weird" in status + assert "something went wrong" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_missing_error_field_treated_as_unknown(self, connector, mock_dependencies): + """Malformed response (no ok, no error) → unknown branch.""" + _, mock_io = mock_dependencies + connector._dispatch_response({}, requested_id=None) + status = mock_io.add_input.call_args[0][1] + assert "result=unknown" in status + assert "error=unknown" in status + + +class TestDispatchNetworkError: + """HTTP transport failure (resp is None).""" + + def test_network_error(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response(None, requested_id=None) + assert mock_io.add_input.call_args[0][1] == "result=network_error" + assert "couldn't undo" in mock_tts.add_pending_message.call_args[0][0].lower() + + +# ===================================================================== +# connect() — end-to-end flow +# ===================================================================== + + +class TestConnect: + """Top-level connect() orchestration.""" + + @pytest.mark.asyncio + async def test_connect_no_id_sends_empty_body(self, connector, mock_dependencies): + """No id provided → body is {} (API uses its 60s TTL to decide).""" + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "ok": True, + "id": "david", + "files_deleted": 3, + "identity_removed": True, + } + await connector.connect(ForgetLastInput()) + + mock_post.assert_called_once() + path, body = mock_post.call_args[0] + assert path == "/gallery/forget_last" + assert body == {} # no id_check → body stays empty + + @pytest.mark.asyncio + async def test_connect_with_id_normalizes_to_lower_strip(self, connector, mock_dependencies): + """Raw 'Wendy' / ' wendy ' / 'WENDY' all normalize to 'wendy' in body.""" + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "ok": True, + "id": "wendy", + "files_deleted": 1, + "identity_removed": True, + } + await connector.connect(ForgetLastInput(id=" Wendy ")) + + body = mock_post.call_args[0][1] + assert body == {"id": "wendy"} + + @pytest.mark.asyncio + async def test_connect_empty_string_id_omitted_from_body(self, connector, mock_dependencies): + """Falsy id_check (empty string) is skipped — body stays empty.""" + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "ok": True, + "id": "x", + "files_deleted": 0, + "identity_removed": False, + } + await connector.connect(ForgetLastInput(id="")) + body = mock_post.call_args[0][1] + assert "id" not in body + + @pytest.mark.asyncio + async def test_connect_success_dispatches_success(self, connector, mock_dependencies): + _, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "ok": True, + "id": "david", + "files_deleted": 4, + "identity_removed": True, + } + await connector.connect(ForgetLastInput(id="david")) + status = mock_io.add_input.call_args[0][1] + assert "result=success" in status + assert "id=david" in status + assert "files_deleted=4" in status + assert "identity_removed=true" in status + + @pytest.mark.asyncio + async def test_connect_id_mismatch_threads_requested_id(self, connector, mock_dependencies): + """ + End-to-end version of the vulture-fix regression test: + a raw 'Wendy' input must surface as 'requested=wendy' in the + status line when the API reports id_mismatch. + """ + _, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = { + "error": "id_mismatch", + "detail": "last_was=david", + } + await connector.connect(ForgetLastInput(id="Wendy")) + + status = mock_io.add_input.call_args[0][1] + assert "result=id_mismatch" in status + assert "requested=wendy" in status + assert "detail=last_was=david" in status + + @pytest.mark.asyncio + async def test_connect_no_recent_enrollment(self, connector, mock_dependencies): + _, mock_io = mock_dependencies + with patch.object(connector, "_post_json") as mock_post: + mock_post.return_value = {"error": "no_recent_enrollment"} + await connector.connect(ForgetLastInput()) + assert mock_io.add_input.call_args[0][1] == "result=no_recent_enrollment" + + @pytest.mark.asyncio + async def test_connect_network_error(self, connector, mock_dependencies): + """/gallery/forget_last returns None → network_error dispatched.""" + _, mock_io = mock_dependencies + with patch.object(connector, "_post_json", return_value=None): + await connector.connect(ForgetLastInput(id="wendy")) + assert mock_io.add_input.call_args[0][1] == "result=network_error" diff --git a/tests/actions/selfie/connector/test_selfie.py b/tests/actions/selfie/connector/test_selfie.py index f2ea087929..13574011d7 100644 --- a/tests/actions/selfie/connector/test_selfie.py +++ b/tests/actions/selfie/connector/test_selfie.py @@ -1,3 +1,20 @@ +""" +Tests for actions.selfie.connector.selfie.SelfieConnector. + +Covers: +- SelfieConfig defaults / overrides +- Helper methods (_post_json, _get_config, _set_blur, _who_snapshot, + _wait_any_face, _write_status, _speak, _display_name) +- Response dispatch: one test per result-code branch in _dispatch_response +- Network-error dispatch (HTTP transport failure) +- End-to-end connect() flow: empty name, no-face pre-check, successful + enrollment, force flag pass-through, busy-retry behavior, blur snapshot + and restore. + +External dependencies (ElevenLabsTTSProvider, IOProvider, requests) are +mocked. Sleep is patched to keep _wait_any_face fast. +""" + from unittest.mock import Mock, patch import pytest @@ -5,40 +22,48 @@ from actions.selfie.connector.selfie import SelfieConfig, SelfieConnector from actions.selfie.interface import SelfieInput +# ----- Shared fixtures ----- + @pytest.fixture def mock_dependencies(): - """Mock all external dependencies.""" + """ + Mock the connector's external collaborators. + ElevenLabsTTSProvider and IOProvider are patched at the connector module + level so that their __init__ in SelfieConnector returns Mock instances. + """ with ( - patch("actions.selfie.connector.selfie.ElevenLabsTTSProvider") as mock_tts, - patch("actions.selfie.connector.selfie.IOProvider") as mock_io, + patch("actions.selfie.connector.selfie.ElevenLabsTTSProvider") as mock_tts_cls, + patch("actions.selfie.connector.selfie.IOProvider") as mock_io_cls, ): - mock_tts_instance = Mock() - mock_tts.return_value = mock_tts_instance - - mock_io_instance = Mock() - mock_io.return_value = mock_io_instance - - yield mock_tts_instance, mock_io_instance + mock_tts = Mock() + mock_io = Mock() + mock_tts_cls.return_value = mock_tts + mock_io_cls.return_value = mock_io + yield mock_tts, mock_io @pytest.fixture def connector(mock_dependencies): - """Create SelfieConnector with mocked dependencies.""" - config = SelfieConfig() - return SelfieConnector(config) + """SelfieConnector with default config and mocked dependencies.""" + return SelfieConnector(SelfieConfig()) + + +# ===================================================================== +# SelfieConfig +# ===================================================================== class TestSelfieConfig: - """Test SelfieConfig configuration.""" + """SelfieConfig default and override behavior.""" def test_default_config(self): config = SelfieConfig() assert config.face_http_base_url == "http://127.0.0.1:6793" assert config.face_recent_sec == 1.0 assert config.poll_ms == 200 - assert config.timeout_sec == 15 + assert config.timeout_sec == 8 assert config.http_timeout_sec == 5.0 def test_custom_config(self): @@ -55,153 +80,576 @@ def test_custom_config(self): assert config.timeout_sec == 30 assert config.http_timeout_sec == 10.0 + def test_partial_override_keeps_other_defaults(self): + config = SelfieConfig(timeout_sec=20) + assert config.timeout_sec == 20 + assert config.poll_ms == 200 # default + assert config.http_timeout_sec == 5.0 # default + + +# ===================================================================== +# SelfieConnector init +# ===================================================================== + class TestSelfieConnectorInit: - """Test SelfieConnector initialization.""" + """Connector wires config values into instance state.""" - def test_init(self, connector, mock_dependencies): + def test_init_sets_fields_from_config(self, connector): assert connector.base_url == "http://127.0.0.1:6793" assert connector.recent_sec == 1.0 assert connector.poll_ms == 200 - assert connector.default_timeout == 15 + assert connector.default_timeout == 8 assert connector.http_timeout == 5.0 + def test_init_attaches_providers_and_clean_state(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + assert connector.elevenlabs_tts_provider is mock_tts + assert connector.io_provider is mock_io + assert connector.last_enrolled_id is None + assert connector.last_match_name is None + + +# ===================================================================== +# Helpers — _post_json, _get_config, _set_blur, _who_snapshot +# ===================================================================== + -class TestSelfieConnectorHelpers: - """Test helper methods.""" +class TestHttpHelpers: + """HTTP helper methods.""" - def test_post_json_success(self, connector, mock_dependencies): + def test_post_json_success(self, connector): with patch("actions.selfie.connector.selfie.requests") as mock_requests: mock_response = Mock() - mock_response.json.return_value = {"ok": True} + mock_response.json.return_value = {"ok": True, "id": "wendy"} mock_requests.post.return_value = mock_response - result = connector._post_json("/selfie", {"id": "wendy"}) - assert result == {"ok": True} + result = connector._post_json("/selfie", {"id": "wendy", "force": False}) + assert result == {"ok": True, "id": "wendy"} mock_requests.post.assert_called_once_with( "http://127.0.0.1:6793/selfie", - json={"id": "wendy"}, + json={"id": "wendy", "force": False}, timeout=5.0, ) - def test_post_json_failure(self, connector, mock_dependencies): + def test_post_json_transport_failure_returns_none(self, connector): with patch("actions.selfie.connector.selfie.requests") as mock_requests: mock_requests.post.side_effect = Exception("Connection refused") - result = connector._post_json("/selfie", {"id": "test"}) - assert result is None + assert connector._post_json("/selfie", {"id": "x"}) is None - def test_get_config(self, connector, mock_dependencies): + def test_get_config_returns_dict(self, connector): with patch.object(connector, "_post_json") as mock_post: mock_post.return_value = {"config": {"blur": True}} - result = connector._get_config() - assert result == {"config": {"blur": True}} + assert connector._get_config() == {"config": {"blur": True}} mock_post.assert_called_once_with("/config", {"get": True}) - def test_get_config_none(self, connector, mock_dependencies): - with patch.object(connector, "_post_json") as mock_post: - mock_post.return_value = None - result = connector._get_config() - assert result == {} + def test_get_config_none_returns_empty_dict(self, connector): + with patch.object(connector, "_post_json", return_value=None): + assert connector._get_config() == {} + + def test_get_config_non_dict_returns_empty(self, connector): + # Defensive: API misbehaves and returns a list instead of dict + with patch.object(connector, "_post_json", return_value=["not", "a", "dict"]): + assert connector._get_config() == {} - def test_set_blur(self, connector, mock_dependencies): + def test_set_blur_on(self, connector): with patch.object(connector, "_post_json") as mock_post: connector._set_blur(True) mock_post.assert_called_once_with("/config", {"set": {"blur": True}}) - def test_who_snapshot(self, connector, mock_dependencies): + def test_set_blur_off(self, connector): + with patch.object(connector, "_post_json") as mock_post: + connector._set_blur(False) + mock_post.assert_called_once_with("/config", {"set": {"blur": False}}) + + def test_who_snapshot(self, connector): with patch.object(connector, "_post_json") as mock_post: - mock_post.return_value = {"now": ["wendy"], "unknown_now": 0} - result = connector._who_snapshot() - assert result == {"now": ["wendy"], "unknown_now": 0} + mock_post.return_value = {"now": ["wendy"], "unknown_now": 1} + assert connector._who_snapshot() == {"now": ["wendy"], "unknown_now": 1} + mock_post.assert_called_once_with("/who", {"recent_sec": 1.0}) - def test_wait_single_face_success(self, connector, mock_dependencies): + +class TestWaitAnyFace: + """Pre-check polling behavior.""" + + def test_wait_any_face_known_only(self, connector): with ( patch.object(connector, "_who_snapshot") as mock_who, patch.object(connector, "sleep"), ): mock_who.return_value = {"now": ["wendy"], "unknown_now": 0} - result = connector._wait_single_face(5) - assert result is True + assert connector._wait_any_face(5) is True + + def test_wait_any_face_unknown_only(self, connector): + with ( + patch.object(connector, "_who_snapshot") as mock_who, + patch.object(connector, "sleep"), + ): + mock_who.return_value = {"now": [], "unknown_now": 1} + assert connector._wait_any_face(5) is True + + def test_wait_any_face_known_and_unknown(self, connector): + with ( + patch.object(connector, "_who_snapshot") as mock_who, + patch.object(connector, "sleep"), + ): + mock_who.return_value = {"now": ["wendy"], "unknown_now": 2} + assert connector._wait_any_face(5) is True + + def test_wait_any_face_timeout(self, connector): + with ( + patch.object(connector, "_who_snapshot") as mock_who, + patch.object(connector, "sleep") as mock_sleep, + ): + mock_who.return_value = {"now": [], "unknown_now": 0} + assert connector._wait_any_face(1) is False + # 1 second / 200ms poll = 5 polls + assert mock_who.call_count == 5 + # sleep between polls + assert mock_sleep.called - def test_wait_single_face_timeout(self, connector, mock_dependencies): + def test_wait_any_face_zero_timeout_uses_default(self, connector): with ( patch.object(connector, "_who_snapshot") as mock_who, patch.object(connector, "sleep"), ): mock_who.return_value = {"now": [], "unknown_now": 0} - result = connector._wait_single_face(1) - assert result is False + connector._wait_any_face(0) + # default_timeout=8s / 200ms = 40 polls + assert mock_who.call_count == 40 - def test_write_status(self, connector, mock_dependencies): + def test_wait_any_face_handles_none_response(self, connector): + """_who_snapshot returning None (network error) shouldn't crash.""" + with ( + patch.object(connector, "_who_snapshot", return_value=None), + patch.object(connector, "sleep"), + ): + assert connector._wait_any_face(1) is False + + +class TestStatusAndSpeech: + """_write_status and _speak.""" + + def test_write_status_writes_to_selfie_status_key(self, connector, mock_dependencies): _, mock_io = mock_dependencies - connector._write_status("ok id=wendy") + connector._write_status("result=success id=wendy samples=3 merged=false") mock_io.add_input.assert_called_once() + args, _kwargs = mock_io.add_input.call_args + assert args[0] == "SelfieStatus" + assert args[1] == "result=success id=wendy samples=3 merged=false" + # third arg is a timestamp (float) + assert isinstance(args[2], float) + + def test_write_status_swallows_exceptions(self, connector, mock_dependencies): + _, mock_io = mock_dependencies + mock_io.add_input.side_effect = RuntimeError("io broken") + # Should not raise + connector._write_status("result=foo") + + def test_speak_queues_tts(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak("Nice to meet you, Wendy!") + mock_tts.add_pending_message.assert_called_once_with("Nice to meet you, Wendy!") + + def test_speak_empty_string_noop(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak("") + mock_tts.add_pending_message.assert_not_called() + + def test_speak_none_noop(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + connector._speak(None) + mock_tts.add_pending_message.assert_not_called() + + def test_speak_swallows_exceptions(self, connector, mock_dependencies): + mock_tts, _ = mock_dependencies + mock_tts.add_pending_message.side_effect = RuntimeError("tts broken") + # Should not raise + connector._speak("hello") + + +class TestDisplayName: + """Static helper that converts internal id → human-friendly name.""" + + @pytest.mark.parametrize( + "internal,expected", + [ + ("wendy", "Wendy"), + ("wendy_1", "Wendy"), # strip dedup suffix + ("wendy_42", "Wendy"), # arbitrary digit suffix + ("jerin-peter", "Jerin Peter"), # dash → space + title + ("jerin-peter_3", "Jerin Peter"), # both transformations + ("li-xiaohong", "Li Xiaohong"), + ("first_last", "First Last"), # internal underscore → space + ("MIXED-Case", "Mixed Case"), + ("a", "A"), + ], + ) + def test_display_name_cases(self, internal, expected): + assert SelfieConnector._display_name(internal) == expected + + +# ===================================================================== +# _dispatch_response — one test per branch +# ===================================================================== + + +class TestDispatchResponseSuccess: + """ok=True path.""" + + def test_success_new_enrollment(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + resp = {"ok": True, "id": "wendy", "samples_saved": 3, "merged": False} + connector._dispatch_response(resp, claimed_id="wendy") + + # State + assert connector.last_enrolled_id == "wendy" + assert connector.last_match_name is None + + # SelfieStatus + status_line = mock_io.add_input.call_args[0][1] + assert "result=success" in status_line + assert "id=wendy" in status_line + assert "samples=3" in status_line + assert "merged=false" in status_line + + # TTS + tts_msg = mock_tts.add_pending_message.call_args[0][0] + assert tts_msg == "Nice to meet you, Wendy! I'll remember you next time." + + def test_success_merged_returning_user(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + resp = {"ok": True, "id": "wendy", "samples_saved": 2, "merged": True} + connector._dispatch_response(resp, claimed_id="wendy") + + # State: enrolled id set, no mismatched match + assert connector.last_enrolled_id == "wendy" + assert connector.last_match_name is None + + # SelfieStatus + status_line = mock_io.add_input.call_args[0][1] + assert "result=merged" in status_line + assert "merged=true" in status_line + + # TTS — neutral, no enrollment language + tts_msg = mock_tts.add_pending_message.call_args[0][0] + assert tts_msg == "Welcome back, Wendy!" + + def test_success_id_renamed_by_server(self, connector, mock_dependencies): + """Server may rename (e.g., dedup → wendy_1); use saved id for display.""" + mock_tts, mock_io = mock_dependencies + resp = {"ok": True, "id": "wendy_1", "samples_saved": 1, "merged": False} + connector._dispatch_response(resp, claimed_id="wendy") + + # SelfieStatus uses server-side id + assert "id=wendy_1" in mock_io.add_input.call_args[0][1] + # Display strips the _1 suffix + assert mock_tts.add_pending_message.call_args[0][0].startswith("Nice to meet you, Wendy!") + + +class TestDispatchResponseErrors: + """error=... branches.""" + + def test_ambiguous_subjects(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "ambiguous_subjects", "n_engaged": 3}, claimed_id="wendy") + assert mock_io.add_input.call_args[0][1] == "result=ambiguous engaged=3" + assert "step closer" in mock_tts.add_pending_message.call_args[0][0].lower() + # state cleared + assert connector.last_enrolled_id is None + assert connector.last_match_name is None + + def test_face_belongs_to(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + resp = {"error": "face_belongs_to", "name": "wendy", "sim": 0.72} + connector._dispatch_response(resp, claimed_id="john") + + # match name tracked for correction flow + assert connector.last_match_name == "wendy" + # enrolled id NOT set (this isn't an enrollment) + assert connector.last_enrolled_id is None + + # SelfieStatus + status = mock_io.add_input.call_args[0][1] + assert "result=face_belongs_to" in status + assert "claimed=john" in status + assert "matched=wendy" in status + assert "sim=0.720" in status + + # TTS asks user to clarify, uses display name + tts = mock_tts.add_pending_message.call_args[0][0] + assert "Wendy" in tts + assert "different" in tts.lower() + + def test_no_valid_frames(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "no_valid_frames"}, claimed_id="wendy") + assert mock_io.add_input.call_args[0][1] == "result=low_quality" + tts = mock_tts.add_pending_message.call_args[0][0] + assert "can't see your face clearly" in tts.lower() or "cannot see" in tts.lower() + assert connector.last_enrolled_id is None + + def test_insufficient_samples(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "insufficient_samples", "got": 0}, claimed_id="wendy") + assert mock_io.add_input.call_args[0][1] == "result=partial got=0" + assert "hold still" in mock_tts.add_pending_message.call_args[0][0].lower() + + def test_busy_after_retry(self, connector, mock_dependencies): + """Dispatch path sees busy only after the connect()-level retry has failed.""" + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "busy"}, claimed_id="wendy") + assert mock_io.add_input.call_args[0][1] == "result=busy retries=1" + assert mock_tts.add_pending_message.called + + def test_bad_id_no_tts(self, connector, mock_dependencies): + """bad_id surfaces to LLM only — no TTS noise to the user.""" + mock_tts, mock_io = mock_dependencies + connector._dispatch_response( + {"error": "bad_id", "detail": "empty"}, + claimed_id="", + ) + assert mock_io.add_input.call_args[0][1] == "result=bad_id detail=empty" + mock_tts.add_pending_message.assert_not_called() + assert connector.last_enrolled_id is None + + def test_recognition_disabled(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "recognition_disabled"}, claimed_id="wendy") + assert mock_io.add_input.call_args[0][1] == "result=recognition_disabled" + assert mock_tts.add_pending_message.called + + def test_unknown_error_fallback(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({"error": "something_weird"}, claimed_id="wendy") + status = mock_io.add_input.call_args[0][1] + assert status.startswith("result=unknown") + assert "error=something_weird" in status + assert "something went wrong" in mock_tts.add_pending_message.call_args[0][0].lower() + assert connector.last_enrolled_id is None + + def test_missing_error_field_treated_as_unknown(self, connector, mock_dependencies): + """Malformed response (no ok, no error) falls through to unknown branch.""" + mock_tts, mock_io = mock_dependencies + connector._dispatch_response({}, claimed_id="wendy") + status = mock_io.add_input.call_args[0][1] + assert "result=unknown" in status + assert "error=unknown" in status + + +class TestDispatchNetworkError: + """HTTP transport failure (resp is None).""" + + def test_network_error(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + connector._dispatch_network_error() + assert mock_io.add_input.call_args[0][1] == "result=network_error" + assert "lost connection" in mock_tts.add_pending_message.call_args[0][0].lower() + assert connector.last_enrolled_id is None + assert connector.last_match_name is None + +# ===================================================================== +# connect() — end-to-end flow +# ===================================================================== -class TestSelfieConnectorConnect: - """Test connect method.""" + +class TestConnect: + """Top-level connect() orchestration.""" + + @pytest.mark.asyncio + async def test_connect_empty_name_writes_bad_id(self, connector, mock_dependencies): + mock_tts, mock_io = mock_dependencies + await connector.connect(SelfieInput(action="")) + + # SelfieStatus written + mock_io.add_input.assert_called_once() + assert mock_io.add_input.call_args[0][1] == "result=bad_id detail=empty" + # No TTS noise for bad_id + mock_tts.add_pending_message.assert_not_called() @pytest.mark.asyncio - async def test_connect_empty_name(self, connector, mock_dependencies): - """Test connect with empty name returns early.""" + async def test_connect_whitespace_only_name_writes_bad_id(self, connector, mock_dependencies): _, mock_io = mock_dependencies - selfie_input = SelfieInput(action="") - with patch("actions.selfie.connector.selfie.logging") as mock_logging: - await connector.connect(selfie_input) - mock_logging.error.assert_called_with("Selfie requires a non-empty `id` (e.g., 'wendy').") + await connector.connect(SelfieInput(action=" ")) + assert mock_io.add_input.call_args[0][1] == "result=bad_id detail=empty" @pytest.mark.asyncio - async def test_connect_successful_enrollment(self, connector, mock_dependencies): - """Test successful selfie enrollment.""" + async def test_connect_no_face_present(self, connector, mock_dependencies): + """Pre-check returns no face → low_quality with reason.""" mock_tts, mock_io = mock_dependencies - selfie_input = SelfieInput(action="wendy", timeout_sec=5) - with ( patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), patch.object(connector, "_set_blur") as mock_blur, - patch.object(connector, "_wait_single_face", return_value=True), - patch.object(connector, "_post_json", return_value={"ok": True}) as mock_post, + patch.object(connector, "_wait_any_face", return_value=False), + patch.object(connector, "_post_json") as mock_post, ): - await connector.connect(selfie_input) - mock_post.assert_called_with("/selfie", {"id": "wendy"}) - mock_tts.add_pending_message.assert_called_once_with( - "Woof! Woof! I remember you, wendy! You are now enrolled." - ) - assert mock_blur.call_count == 2 # once off, once restore + await connector.connect(SelfieInput(action="wendy", timeout_sec=2)) + + # status indicates no one present + status = mock_io.add_input.call_args[0][1] + assert "result=low_quality" in status + assert "no_one_present" in status + + # TTS spoke about not seeing anyone + assert "don't see anyone" in mock_tts.add_pending_message.call_args[0][0].lower() + + # /selfie was NEVER called (pre-check short-circuited) + assert not any(call.args[0] == "/selfie" for call in mock_post.call_args_list) + + # blur restored to original (True) + assert mock_blur.call_args_list[-1].args == (True,) @pytest.mark.asyncio - async def test_connect_no_face_detected(self, connector, mock_dependencies): - """Test connect when no face is detected.""" + async def test_connect_successful_enrollment_payload(self, connector, mock_dependencies): + """Successful path: payload uses {id, force}, NOT {action, force}.""" mock_tts, mock_io = mock_dependencies - selfie_input = SelfieInput(action="wendy", timeout_sec=5) - with ( patch.object(connector, "_get_config", return_value={"config": {"blur": False}}), + patch.object(connector, "_set_blur") as mock_blur, + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json") as mock_post, + ): + mock_post.return_value = {"ok": True, "id": "wendy", "samples_saved": 3, "merged": False} + await connector.connect(SelfieInput(action="wendy", timeout_sec=5)) + + # Find the /selfie POST call + selfie_calls = [c for c in mock_post.call_args_list if c.args[0] == "/selfie"] + assert len(selfie_calls) == 1 + payload = selfie_calls[0].args[1] + # API uses "id" key, NOT "action" (interface name) + assert payload == {"id": "wendy", "force": False} + + # TTS played the success line + tts = mock_tts.add_pending_message.call_args[0][0] + assert "Nice to meet you, Wendy" in tts + + # Blur was turned off (during) then restored to False (orig) + blur_values = [c.args[0] for c in mock_blur.call_args_list] + assert blur_values == [False, False] + + @pytest.mark.asyncio + async def test_connect_strips_whitespace_in_name(self, connector, mock_dependencies): + with ( + patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), + patch.object(connector, "_set_blur"), + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json") as mock_post, + ): + mock_post.return_value = {"ok": True, "id": "wendy", "samples_saved": 1, "merged": False} + await connector.connect(SelfieInput(action=" wendy ", timeout_sec=5)) + selfie_calls = [c for c in mock_post.call_args_list if c.args[0] == "/selfie"] + assert selfie_calls[0].args[1]["id"] == "wendy" + + @pytest.mark.asyncio + async def test_connect_force_flag_passes_through(self, connector, mock_dependencies): + """force=True from SelfieInput is forwarded to the API.""" + with ( + patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), + patch.object(connector, "_set_blur"), + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json") as mock_post, + ): + mock_post.return_value = {"ok": True, "id": "john", "samples_saved": 3, "merged": False} + await connector.connect(SelfieInput(action="john", force=True)) + selfie_calls = [c for c in mock_post.call_args_list if c.args[0] == "/selfie"] + assert selfie_calls[0].args[1] == {"id": "john", "force": True} + + @pytest.mark.asyncio + async def test_connect_force_default_false(self, connector, mock_dependencies): + """When force not provided, defaults to False.""" + with ( + patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), + patch.object(connector, "_set_blur"), + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json") as mock_post, + ): + mock_post.return_value = {"ok": True, "id": "wendy", "samples_saved": 1, "merged": False} + await connector.connect(SelfieInput(action="wendy")) + selfie_calls = [c for c in mock_post.call_args_list if c.args[0] == "/selfie"] + assert selfie_calls[0].args[1]["force"] is False + + @pytest.mark.asyncio + async def test_connect_busy_retries_once(self, connector, mock_dependencies): + """First /selfie returns busy → wait 1s → retry succeeds.""" + with ( + patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), + patch.object(connector, "_set_blur"), + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json") as mock_post, + patch("actions.selfie.connector.selfie.asyncio.sleep") as mock_async_sleep, + ): + # First call busy, second call succeeds + mock_post.side_effect = [ + {"error": "busy"}, + {"ok": True, "id": "wendy", "samples_saved": 2, "merged": True}, + # any further /config calls during finally + None, + ] + await connector.connect(SelfieInput(action="wendy")) + + # Two /selfie calls + selfie_calls = [c for c in mock_post.call_args_list if c.args[0] == "/selfie"] + assert len(selfie_calls) == 2 + + # asyncio.sleep(1.0) called between them + mock_async_sleep.assert_any_call(1.0) + + @pytest.mark.asyncio + async def test_connect_busy_twice_dispatches_busy(self, connector, mock_dependencies): + """If retry also returns busy, dispatch_response handles it (result=busy).""" + _, mock_io = mock_dependencies + with ( + patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), patch.object(connector, "_set_blur"), - patch.object(connector, "_wait_single_face", return_value=False), - patch.object( - connector, - "_who_snapshot", - return_value={"now": [], "unknown_now": 0}, - ), + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json") as mock_post, + patch("actions.selfie.connector.selfie.asyncio.sleep"), ): - await connector.connect(selfie_input) - mock_tts.add_pending_message.assert_called_once() - assert "0 faces" in mock_tts.add_pending_message.call_args[0][0] + mock_post.return_value = {"error": "busy"} + await connector.connect(SelfieInput(action="wendy")) + # Final SelfieStatus reflects unresolved busy + assert any("result=busy" in c.args[1] for c in mock_io.add_input.call_args_list) @pytest.mark.asyncio - async def test_connect_selfie_api_fails(self, connector, mock_dependencies): - """Test connect when selfie API returns non-ok.""" + async def test_connect_network_error(self, connector, mock_dependencies): + """/selfie returns None → network_error dispatched.""" mock_tts, mock_io = mock_dependencies - selfie_input = SelfieInput(action="wendy", timeout_sec=5) + with ( + patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), + patch.object(connector, "_set_blur"), + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json", return_value=None), + ): + await connector.connect(SelfieInput(action="wendy")) + status_lines = [c.args[1] for c in mock_io.add_input.call_args_list] + assert any(line == "result=network_error" for line in status_lines) + @pytest.mark.asyncio + async def test_connect_restores_blur_even_on_exception(self, connector, mock_dependencies): + """If something blows up mid-flow, blur is still restored.""" + with ( + patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), + patch.object(connector, "_set_blur") as mock_blur, + patch.object(connector, "_wait_any_face", side_effect=RuntimeError("boom")), + ): + with pytest.raises(RuntimeError): + await connector.connect(SelfieInput(action="wendy")) + # First call: off (False). Last call (in finally): restore original True. + blur_values = [c.args[0] for c in mock_blur.call_args_list] + assert blur_values[0] is False + assert blur_values[-1] is True + + @pytest.mark.asyncio + async def test_connect_face_belongs_to_updates_match_state(self, connector, mock_dependencies): + """Full flow exercising the face_belongs_to dispatch path.""" with ( patch.object(connector, "_get_config", return_value={"config": {"blur": True}}), patch.object(connector, "_set_blur"), - patch.object(connector, "_wait_single_face", return_value=True), - patch.object(connector, "_post_json", return_value={"ok": False}), + patch.object(connector, "_wait_any_face", return_value=True), + patch.object(connector, "_post_json") as mock_post, ): - await connector.connect(selfie_input) - mock_tts.add_pending_message.assert_called_once_with( - "Woof! Woof! I couldn't see you clearly. Please try again." - ) + mock_post.return_value = {"error": "face_belongs_to", "name": "wendy", "sim": 0.81} + await connector.connect(SelfieInput(action="john")) + assert connector.last_match_name == "wendy" + assert connector.last_enrolled_id is None