Spaces:
Running
Running
File size: 29,653 Bytes
06a1901 4727289 06a1901 4727289 06a1901 1ba52f6 06a1901 1ba52f6 06a1901 1ba52f6 06a1901 1ba52f6 06a1901 1ba52f6 06a1901 1ba52f6 06a1901 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 | """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
|