"""Conversation controller — translates events into session state transitions. This is the workflow engine. It listens for: - Face state events from FaceRecognitionWorker. - Tool call completions from the realtime handler. And decides which ReceptionState transition should fire on the SessionManager. Also exposes ``next_action_hint(state)`` — short directives the realtime handler appends to its session context push so the LLM gets per-state workflow guidance dynamically, instead of having the whole flow baked into the system prompt. """ from __future__ import annotations import asyncio import logging from typing import Any, Optional from reachy_mini_receptionist.receptionist_state import ReceptionState from reachy_mini_receptionist.session_manager import SessionManager logger = logging.getLogger(__name__) # States in which an "unknown" face transitioning to "known" should still # short-circuit identification (we haven't yet confirmed the visitor's name # through other channels). _EARLY_IDENTIFICATION_STATES: frozenset[ReceptionState] = frozenset({ ReceptionState.IDLE, ReceptionState.VISITOR_DETECTED, ReceptionState.GREETING, ReceptionState.ASK_NAME, # Include MULTIPLE_PEOPLE so the controller transitions back out as # soon as the crowd thins to one face. ReceptionState.MULTIPLE_PEOPLE, }) # States in which losing the face means the visitor walked away and we should # reset the session for the next person. Inside flow states (e.g. ASK_NAME), # losing the face briefly just means they turned their head — don't reset. _RESET_ON_FACE_LOST_STATES: frozenset[ReceptionState] = frozenset({ ReceptionState.NOTIFIED, ReceptionState.NO_APPOINTMENT, ReceptionState.EMAIL_FAILED, ReceptionState.COMPLETE, ReceptionState.ERROR, }) class ConversationController: """Wire face events + tool completions into session state transitions. Stateless aside from the SessionManager it operates on. Safe to call handlers from any thread because SessionManager handles locking. Optionally records every completed visit to a ``VisitorLog`` when the session resets out of a meaningful state (visitor name, recognized face, or employee was set). """ def __init__(self, session_manager: SessionManager) -> None: self._session = session_manager # ------------------------------------------------------------------ # Face events # ------------------------------------------------------------------ def on_face_event(self, event: dict[str, Any]) -> None: """Translate a ``face_state_changed`` event into a session transition. Event shape (from ``FaceRecognitionWorker._update_stable_state``): state: "no_face" | "unknown" | "known" name: str | None (populated when state == "known") previous_state, previous_name, lbph_confidence, detection_confidence """ state = event.get("state") name = event.get("name") current = self._session.current_state snapshot = self._session.session if state == "known" and name: # Already-completed-flow guard: if we already emailed the host # for this visitor, don't re-enter RECOGNIZED — that re-triggers # the whole check-in (get_today_calendar + send_email) and # produces duplicate emails when the face momentarily flickers # to MULTIPLE_PEOPLE and back. already_done = ( bool(snapshot.email_sent_to) and (snapshot.visitor_name or "").strip().lower() == name.strip().lower() ) # Cross-session dedupe: if the visitor was notified within the # last 10 minutes (per visitor_log) — even though the in-memory # session has since reset to IDLE — DO NOT re-run the flow. # Observed 2026-05-22: Krishna lingered in front of camera past # the 60s idle timeout; session reset; face recognizer fired # again; send_email fired again ("Krishna has arrived # unexpectedly"); host received two emails for one visit. recent_notification: Optional[dict[str, Any]] = None if not already_done: visitor_log = getattr(self._session, "_visitor_log", None) if visitor_log is not None: try: recent_notification = visitor_log.recent_notification_for_visitor( name, max_age_seconds=600.0, ) except Exception as e: logger.debug( "recent_notification lookup failed (non-critical): %s", e, ) if already_done or recent_notification is not None: if current != ReceptionState.NOTIFIED: if recent_notification is not None: logger.info( "Face %r returned within 10min of prior notification (visit id=%s) — restoring NOTIFIED, NOT re-emailing", name, recent_notification.get("id"), ) # Restore the host email so the dashboard shows the # right state and any subsequent SPEAK_NOW cues know # who was notified. self._session.transition( ReceptionState.NOTIFIED, visitor_name=name, recognized_face_name=name, employee_name=recent_notification.get("employee_name"), email_sent_to=recent_notification.get("email_sent_to"), ) else: logger.info( "Face %r returned after notified — restoring NOTIFIED instead of re-running flow", name, ) self._session.transition( ReceptionState.NOTIFIED, recognized_face_name=name, ) else: self._session.update(recognized_face_name=name) return if current in _EARLY_IDENTIFICATION_STATES: # If the visitor has ALREADY told us a different name in # this session (visitor_name set by register_guest), trust # speech over face. The face recognizer can mis-match # (LBPH on a single crop is noisy under different lighting) # and the visitor explicitly correcting "no, I'm X" should # override the camera. Only auto-promote face -> visitor # when no speech-confirmed name exists yet. speech_confirmed = (snapshot.visitor_name or "").strip() if speech_confirmed and speech_confirmed.lower() != name.strip().lower(): logger.info( "Face matched %r but visitor already confirmed %r — keeping speech", name, speech_confirmed, ) self._session.update(recognized_face_name=name) return self._session.transition( ReceptionState.RECOGNIZED, visitor_name=name, recognized_face_name=name, ) # Same auto-resolve as the register_guest path so the # face-recognition shortcut also reaches APPOINTMENT_MATCHED # without depending on the LLM to call get_today_calendar. try: self._auto_resolve_appointment(name) except Exception as e: logger.warning( "Auto-resolve appointment after face match failed: %s", e, ) else: # Past identification — just record the face match. self._session.update(recognized_face_name=name) return if state == "unknown": if current in (ReceptionState.IDLE, ReceptionState.MULTIPLE_PEOPLE): self._session.transition(ReceptionState.VISITOR_DETECTED) return if state == "multiple": if current != ReceptionState.MULTIPLE_PEOPLE: self._session.transition(ReceptionState.MULTIPLE_PEOPLE) return if state == "no_face": if current in _RESET_ON_FACE_LOST_STATES: logger.info("Face lost in terminal state %s — resetting session", current.value) # SessionManager.reset() handles persisting the pre-reset # snapshot to the visitor log on its own. self._session.reset() return # ------------------------------------------------------------------ # Tool completions # ------------------------------------------------------------------ async def on_tool_completed_async( self, tool_name: str, args: dict[str, Any], result: dict[str, Any], ) -> None: """Async-safe wrapper for ``on_tool_completed``. The realtime event loop reaches the controller via this method. Tools whose handlers need an iCal fetch (``register_guest`` triggers ``_auto_resolve_appointment``) pre-fetch the calendar via ``asyncio.to_thread`` so the audio loop never blocks on the synchronous httpx call inside ``ical_calendar.fetch_appointments``. """ if not isinstance(result, dict): return explicit_failure = "error" in result and result.get("success") is False if explicit_failure: self.on_tool_completed(tool_name, args, result) return appointments: Optional[list[dict[str, Any]]] = None if tool_name in ("register_guest", "lookup_employee"): try: from reachy_mini_receptionist import calendar_data appointments = await calendar_data.get_appointments_async() except Exception as e: logger.debug("Pre-fetch appointments failed: %s", e) appointments = None self._dispatch_tool_completion(tool_name, args, result, appointments) def on_tool_completed( self, tool_name: str, args: dict[str, Any], result: dict[str, Any], ) -> None: """Translate a successful tool call into a session transition. Failures are logged but never transition the session into ERROR automatically — that's the caller's policy choice. Synchronous variant — calls into ``_auto_resolve_appointment`` will block on the iCal HTTP fetch. Safe from background threads (face worker). Async callers on the realtime event loop should use ``on_tool_completed_async`` instead so the iCal call gets off-thread. """ if not isinstance(result, dict): return explicit_failure = "error" in result and result.get("success") is False if explicit_failure: logger.debug("Tool %s reported failure: %s", tool_name, result.get("error")) return self._dispatch_tool_completion(tool_name, args, result, None) def _dispatch_tool_completion( self, tool_name: str, args: dict[str, Any], result: dict[str, Any], appointments: Optional[list[dict[str, Any]]], ) -> None: """Core transition logic shared by sync + async entry points. ``appointments`` is the optional pre-fetched calendar (set by the async entry point so the iCal HTTP call doesn't run on the realtime audio loop). When ``None``, ``_auto_resolve_appointment`` falls back to its own sync fetch. """ if tool_name == "register_guest": # Only transition on actual SUCCESS. If register_guest was # blocked (no_confirmation, name_is_filler, hallucinated # chatter, no_face, etc.) it returns success=False — we # MUST NOT advance the session in that case, or the visitor # ends up locked into a bogus name like "Community" with # no path to fix it. if not result.get("success"): logger.debug( "register_guest returned success=False (reason=%r) — not transitioning", result.get("blocked_reason") or result.get("error"), ) return name = (args.get("name") or "").strip() if name: self._session.transition( ReceptionState.RECOGNIZED, visitor_name=name, recognized_face_name=name, ) # The LLM was supposed to follow the RECOGNIZED hint with a # get_today_calendar tool call, but it kept asking the visitor # "who are you here to see?" instead — emails never went out. # Pull the calendar synchronously from the backend and dispatch # APPOINTMENT_MATCHED / NO_APPOINTMENT ourselves so the bot is # never blocked on the LLM remembering to look something up. try: self._auto_resolve_appointment(name, appointments) except Exception as e: logger.warning( "Auto-resolve appointment after register_guest failed: %s", e, ) elif tool_name == "get_today_calendar": calendar = result.get("calendar") or [] snap = self._session.session # Prefer explicit visitor_name (operator typed/spoke it), fall # back to a recognized face match (returning guest whose name # we already trust because LBPH matched their saved crop). visitor_name = snap.visitor_name or snap.recognized_face_name if not visitor_name: # LLM fetched the calendar as a generic lookup (often during # idle exploration) before identifying the visitor. There is # no name to match against yet, so don't change state. logger.debug( "get_today_calendar fired without a visitor_name — skipping transition", ) return matched = self._match_appointment(calendar, visitor_name) updates: dict[str, Any] = {} # If we matched purely off the face name, promote it into # visitor_name so downstream (send_email guard, dashboard, # visitor log) treats it as a confirmed identity. if not snap.visitor_name: updates["visitor_name"] = visitor_name if matched: updates["matched_appointment"] = matched updates["employee_name"] = matched.get("visiting") self._session.transition(ReceptionState.APPOINTMENT_MATCHED, **updates) else: updates["error_message"] = f"No appointment found for {visitor_name!r}" self._session.transition(ReceptionState.NO_APPOINTMENT, **updates) elif tool_name == "send_email": # Only flip to NOTIFIED if the tool actually succeeded. The # send_email tool can refuse (placeholder address, no visitor # identity yet, duplicate-blocked, Resend HTTP error) — those # all return success=False, and the dashboard must not lie # "NOTIFIED" when no email left the system. to = (args.get("to") or "").strip() send_ok = bool(result.get("success")) if to and send_ok: self._session.transition( ReceptionState.NOTIFIED, email_sent_to=to, ) elif to and not send_ok: logger.info( "send_email returned success=False (blocked_reason=%s) — " "NOT transitioning to NOTIFIED", result.get("blocked_reason") or result.get("error"), ) elif tool_name == "lookup_employee": # Walk-in path: visitor named the host instead of themselves. # On hit, drop the synthetic appointment into the session so the # existing APPOINTMENT_MATCHED -> send_email -> NOTIFIED path # works unchanged. On miss, surface UNKNOWN_EMPLOYEE so the # bot tells the visitor that name isn't on the list. found = bool(result.get("found")) if found: emp = result.get("employee") or {} emp_email = (emp.get("email") or "").strip() emp_name = (emp.get("name") or args.get("name") or "").strip() if emp_email: snap = self._session.session # Trust the face DB: if LBPH already recognised this # visitor (recognized_face_name), promote that into # visitor_name so send_email's identity guard passes # without forcing the bot to ask the name again. This # is the "returning known guest came back to see X" # path — they shouldn't be re-prompted for their name. visitor = snap.visitor_name or snap.recognized_face_name # If the visitor is known AND today's calendar has a real # appointment for them with this host, prefer that real # appointment over a synthetic walk-in. Otherwise the # host's notification email loses the scheduled time/note # and reads "Walk-in visitor has arrived" for a meeting # that was actually on the calendar. real_appt: Optional[dict[str, Any]] = None if visitor and appointments: candidate = self._match_appointment(appointments, visitor) if ( candidate and (candidate.get("visiting") or "").strip().lower() == emp_email.lower() ): real_appt = candidate if real_appt is not None: matched_appt = real_appt else: matched_appt = { "time": "now", "name": visitor or "Walk-in visitor", "note": f"Walk-in to see {emp_name}", "visiting": emp_email, } updates: dict[str, Any] = { "matched_appointment": matched_appt, "employee_name": emp_email, } if visitor and not snap.visitor_name: updates["visitor_name"] = visitor self._session.transition(ReceptionState.APPOINTMENT_MATCHED, **updates) else: query = (args.get("name") or "").strip() self._session.transition( ReceptionState.UNKNOWN_EMPLOYEE, error_message=f"No directory match for {query!r}", ) @staticmethod def _match_appointment( calendar: list[dict[str, Any]], visitor_name: Optional[str], ) -> Optional[dict[str, Any]]: """Case-insensitive name match against today's calendar entries. Matching is layered so a visitor who says just their first name ("Rohan") still resolves to a calendar entry like "Rohan Verma": 1. Exact match on the full string. 2. Calendar entry's first whitespace-delimited token equals the visitor string ("Rohan" == first(\"Rohan Verma\")). 3. Substring of the calendar entry (\"rohan\" in \"rohan verma\"). Each layer returns the FIRST hit so we never silently switch which calendar entry a visitor is mapped to. The minimum length guard (>= 2 chars) keeps single-letter transcripts from matching half the calendar. """ if not visitor_name: return None target = visitor_name.strip().lower() if len(target) < 2: return None for appt in calendar: if (appt.get("name") or "").strip().lower() == target: return appt for appt in calendar: name = (appt.get("name") or "").strip().lower() tokens = name.split() if tokens and tokens[0] == target: return appt for appt in calendar: name = (appt.get("name") or "").strip().lower() if target in name: return appt return None def _auto_resolve_appointment( self, visitor_name: str, appointments: Optional[list[dict[str, Any]]] = None, ) -> None: """Look up today's calendar for ``visitor_name`` and dispatch. Called from the RECOGNIZED transition in both the register_guest path and the face-recognition path so the bot doesn't have to depend on the LLM calling get_today_calendar — production showed the LLM acknowledging the visitor and then improvising next-step questions instead of running the tool, so the email never went out. ``appointments`` is an optional pre-fetched list. Async callers on the realtime event loop preload it via ``calendar_data.get_appointments_async`` so the synchronous iCal HTTP call doesn't block the audio loop here. When omitted, falls back to a sync fetch (safe from background threads like the face worker). Dispatches: - APPOINTMENT_MATCHED with the matched appointment + host email, if today's iCal has an entry whose ``name`` matches. - NO_APPOINTMENT otherwise. The bot then offers to take a message or route via lookup_employee. """ if not visitor_name: return if appointments is None: from reachy_mini_receptionist import calendar_data appointments = calendar_data.get_appointments() matched = self._match_appointment(appointments, visitor_name) if matched: self._session.transition( ReceptionState.APPOINTMENT_MATCHED, matched_appointment=matched, employee_name=matched.get("visiting"), ) logger.info( "Auto-resolved appointment for %r -> %s", visitor_name, matched.get("visiting"), ) else: self._session.transition( ReceptionState.NO_APPOINTMENT, error_message=f"No appointment found for {visitor_name!r}", ) logger.info("Auto-resolve: no appointment for %r", visitor_name) # ---------------------------------------------------------------------- # Per-state workflow hints # ---------------------------------------------------------------------- # These get appended to the session context push so the LLM knows what to # do next — without the workflow being hardcoded in the system prompt. # Keep each hint short (one or two sentences) and concrete. The LLM has # already been told to wait for the user to speak before responding; these # hints describe what to do *when* the user speaks. _NEXT_ACTION_HINTS: dict[ReceptionState, str] = { # IDLE is the "no visitor yet" state — normally we stay silent. BUT if a # visitor speaks before the face worker has stabilised (camera obscured, # off-angle, dim light), the bot would otherwise just greet generically # and never advance. So when the user speaks during IDLE, treat the # utterance as the start of the flow and dispatch immediately to the # right tool based on what they said. ReceptionState.IDLE: ( "If the visitor speaks first, just be conversational — greet them " "and figure out who they are or who they want to see. If they named " "themselves, call register_guest with the name you heard right away " "— no 'is that right?' gate first. If they named a host, call " "lookup_employee. ALWAYS respond — never go silent. If you mishear, " "say so and ask again naturally; don't lecture." ), ReceptionState.VISITOR_DETECTED: ( "Greet the visitor warmly. Ask their name or who they're here to see " "if they haven't said yet. As soon as they say what sounds like their " "name, call register_guest with the EXACT name you heard — " "immediately, no 'is that right?' gate. A casual acknowledgement " "afterward ('Got it, thanks!') is fine. Never invent a name. If " "they're here to see someone, call lookup_employee. " "Be conversational — short, friendly replies. ALWAYS respond to " "whatever they say; never go silent." ), ReceptionState.GREETING: ( "Greet the visitor. If they haven't said why they're here yet, " "ask whether they have an appointment or are here to see someone." ), ReceptionState.ASK_NAME: ( "Ask the visitor their name or who they're here to see. As soon as " "they answer with a name, call register_guest with the name you heard " "— right away, no confirmation question first. If you genuinely " "couldn't hear, ask once more naturally. Keep replies short and friendly." ), ReceptionState.MULTIPLE_PEOPLE: ( "More than one face is in view. Say 'I see more than one person — could " "whoever's checking in step forward please?' Do NOT call register_guest, " "lookup_employee, or send_email until the state changes back." ), ReceptionState.RECOGNIZED: ( "Acknowledge the visitor warmly by name in one short line. The backend " "is already looking up their appointment — don't call a tool, just " "wait for the next update." ), ReceptionState.CHECKING_APPOINTMENT: ( "Briefly let the visitor know you're checking the schedule." ), ReceptionState.APPOINTMENT_MATCHED: ( "Use the appointment= and employee= values from the context above. " "Confirm the visitor's appointment and host in one warm line — fill in " "the actual appointment and host name from the context, and tell them " "you'll let the host know they're here. Then immediately call " "send_email to that host. If visitor= is empty, ask their name first." ), ReceptionState.NO_APPOINTMENT: ( "Politely tell the visitor you don't have them on today's schedule. " "Offer to take a message or notify someone." ), ReceptionState.NOTIFYING_EMPLOYEE: ( "Briefly tell the visitor you're notifying their host." ), ReceptionState.NOTIFIED: ( "Use the employee= from context. Tell the visitor you've notified their " "host by name (fill in the actual host name from employee=), then invite " "them to have a seat — the host will be with them shortly. Be warm, not " "robotic." ), ReceptionState.EMAIL_FAILED: ( "Apologize that you couldn't reach the host right now. " "Suggest the visitor wait briefly while you try again." ), ReceptionState.WAITING: "", ReceptionState.COMPLETE: ( "Thank the visitor warmly and wish them a good day." ), ReceptionState.UNKNOWN_EMPLOYEE: ( "Tell the visitor that name isn't in your directory. " "Offer to find someone else who can help." ), ReceptionState.ERROR: ( "Apologize for the issue and ask the visitor to wait a moment." ), } def next_action_hint(state: ReceptionState) -> str: """Return a short workflow directive for the LLM based on the current state.""" return _NEXT_ACTION_HINTS.get(state, "") # States that require the bot to speak IMMEDIATELY when entered, because the # transition was triggered by an in-flight LLM response cycle (tool returned, # state advanced — visitor is waiting for the bot to finish what it started). # All others (face events, idle/reset) wait for the visitor to speak first. _SPEAK_NOW_STATES: frozenset[ReceptionState] = frozenset({ # Greet the visitor as soon as the face is detected. Previously the bot # would silently wait for the visitor to speak first, which gave the # impression of an unresponsive robot — visitors often hesitate when # they don't know if the bot is "on" yet. ReceptionState.VISITOR_DETECTED, ReceptionState.RECOGNIZED, ReceptionState.APPOINTMENT_MATCHED, ReceptionState.NO_APPOINTMENT, ReceptionState.NOTIFIED, ReceptionState.EMAIL_FAILED, ReceptionState.UNKNOWN_EMPLOYEE, }) def should_speak_immediately(state: ReceptionState) -> bool: """True if entering ``state`` should trigger an immediate spoken response. For these states the LLM is mid-flow (just ran a tool, state advanced) and the visitor is waiting. For all other states (face events, timeouts, manual resets) the bot waits for the visitor to speak. """ return state in _SPEAK_NOW_STATES