Spaces:
Running
Running
| """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}", | |
| ) | |
| 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 | |