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