Skip to content

feat(breeze_buddy): add IVR mode — DTMF-driven menu flow#833

Open
manas-narra wants to merge 1 commit into
juspay:releasefrom
manas-narra:add-buddy-ivr-support
Open

feat(breeze_buddy): add IVR mode — DTMF-driven menu flow#833
manas-narra wants to merge 1 commit into
juspay:releasefrom
manas-narra:add-buddy-ivr-support

Conversation

@manas-narra

@manas-narra manas-narra commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

New flow.mode="ivr" runs a pure DTMF state machine (no STT/LLM/pipeline):
walks a per-template menu tree, plays TTS prompts, routes on keypresses.
Supports nested sub-menus, back-navigation, greeting-driven opening node,
per-node timeout/retry, invalid-key replay (no retry consumed), digit
logging (dtmf_inputs), synthetic transcription, and BUSY-on-incomplete
so unanswered calls retry.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added IVR (Interactive Voice Response) mode supporting DTMF keypad navigation through voice menus
    • IVR flows bypass the traditional voice AI pipeline for pure menu-driven call handling with retry logic, timeouts, and no-input handling
    • Included example IVR template demonstrating order confirmation workflow with multiple menu options and outcomes

Copilot AI review requested due to automatic review settings June 16, 2026 06:35
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 02159042-6cc4-425e-bbdf-c5e79fcfc03c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

Adds a DTMF IVR mode to the Breeze Buddy voice agent. Introduces new Pydantic schemas (IvrAction, IvrOption, IvrNode, IvrModeFlow) and a FlowMode.IVR enum value, a template variable renderer for IVR flows, record_ivr_input on TemplateContext, and a new IvrWalker state machine that walks a node tree via keypad input, bypassing the LLM/STT/TTS pipeline entirely. Agent.run() dispatches to IvrWalker for IVR-mode flows, and end_conversation conditionally skips EndFrame when no pipeline task exists.

Changes

IVR DTMF Mode

Layer / File(s) Summary
IVR data contracts
app/ai/voice/agents/breeze_buddy/template/types.py
FlowMode gains IVR = "ivr"; new IvrAction, IvrOption, IvrNode, and IvrModeFlow Pydantic models define the full DTMF node-tree schema.
IVR template rendering
app/ai/voice/agents/breeze_buddy/template/loader.py
_render_ivr_flow substitutes {placeholder} variables in IVR node/option text fields; load_template gains an IVR early-return branch that skips the task/role rendering loop.
IVR keypress tracking
app/ai/voice/agents/breeze_buddy/template/context.py
TemplateContext.record_ivr_input appends DTMF digit records (digit, timestamp, validity, metadata) to the active node traversal entry's dtmf_inputs list.
IvrWalker state machine
app/ai/voice/agents/breeze_buddy/agent/ivr_walker.py
Full IvrWalker implementation: run() entrypoint, _walk/_run_node traversal with retry/invalid-press logic, _apply_option/_play for outcomes and audio, _wait_for_digit with Plivo barge-in support, _persist_outcome/_flush_outcome/_run_hooks for persistence and side effects, _finalize_and_close reusing end_conversation.
Agent wiring and EndFrame guard
app/ai/voice/agents/breeze_buddy/agent/__init__.py, app/ai/voice/agents/breeze_buddy/handlers/internal/end_conversation.py
Agent.run() dispatches to IvrWalker before pipeline construction for IVR-mode flows; end_conversation conditionally skips EndFrame queuing when context.task is None.
Example IVR template
examples/templates/order-confirmation-ivr.json
New complete order-confirmation IVR template with two nodes: a main confirmation branch and a cancel-reasons node recording cancellation metadata.

Sequence Diagram

sequenceDiagram
  participant Caller as Caller (WebSocket)
  participant Agent as Agent.run()
  participant IvrWalker as IvrWalker
  participant TTS as TTS Provider (ElevenLabs)
  participant DB as Lead Persistence

  Agent->>IvrWalker: detect FlowMode.IVR → IvrWalker(self).run()
  IvrWalker->>IvrWalker: parse IvrModeFlow, resolve TTS config
  loop Node traversal
    IvrWalker->>TTS: prepare_ivr_menu_audio(node.prompt)
    TTS-->>IvrWalker: audio bytes
    IvrWalker->>Caller: _send_audio(bytes)
    IvrWalker->>Caller: _wait_for_digit(timeout_window)
    alt DTMF digit received
      Caller-->>IvrWalker: digit
      IvrWalker->>IvrWalker: _apply_option() → record IVR input
      IvrWalker->>DB: _persist_outcome() background task
      IvrWalker->>IvrWalker: transition to next node or end
    else invalid digit
      Caller-->>IvrWalker: unrecognized digit
      IvrWalker->>TTS: prepare_ivr_menu_audio(invalid_prompt)
      IvrWalker->>Caller: replay invalid prompt
    else hangup / timeout exhausted
      Caller-->>IvrWalker: stop event or retries=0
      IvrWalker->>IvrWalker: _finalize_and_close()
    end
  end
  IvrWalker->>DB: end_conversation (transcript + outcome)
  IvrWalker->>Caller: close WebSocket
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 Hop, hop, press a key today,
No LLM needed along the way!
DTMF digits dance in a tree,
Each node a burrow — press 1, 2, or 3.
The walker hops through prompts galore,
Persisting outcomes, then closing the door. 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: adding IVR mode with DTMF-driven menu flow. It clearly summarizes the primary feature introduced across multiple files.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
app/ai/voice/agents/breeze_buddy/template/types.py (2)

2117-2122: 💤 Low value

Consider using List[HookConfig] for type consistency.

FlowFunction.hooks uses List[HookConfig] (line 1685), but IvrOption.hooks uses List[Dict[str, Any]]. This loses Pydantic validation that HookConfig provides (e.g., http_request structure, expected_fields typing). If IVR hooks truly follow the same shape, using List[HookConfig] would catch authoring errors at template validation time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/template/types.py` around lines 2117 - 2122,
The `IvrOption.hooks` field is typed as `List[Dict[str, Any]]` but should be
typed as `List[HookConfig]` to match `FlowFunction.hooks` (line 1685) and
maintain consistency. Change the type annotation of the hooks field in the
IvrOption class from `List[Dict[str, Any]]` to `List[HookConfig]` so that
Pydantic validation is applied consistently across all hook configurations,
enabling validation of structures like http_request and expected_fields at
template validation time.

2193-2195: ⚡ Quick win

Consider validating initial_node exists in nodes.

If initial_node references a non-existent node, the error is only discovered at call time (the walker logs an error and ends with IVR_NODE_MISSING outcome). Adding a model_validator would catch this authoring mistake at template creation/update time, improving template author experience.

✨ Proposed validation
+    `@model_validator`(mode="after")
+    def _validate_initial_node_exists(self) -> "IvrModeFlow":
+        if self.initial_node not in self.nodes:
+            raise ValueError(
+                f"initial_node '{self.initial_node}' not found in nodes. "
+                f"Available nodes: {list(self.nodes.keys())}"
+            )
+        return self
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/ai/voice/agents/breeze_buddy/template/types.py` around lines 2193 - 2195,
Add a Pydantic model_validator to the class containing the initial_node and
nodes fields to validate that initial_node references an existing key in the
nodes dictionary. The validator should check if the value of initial_node exists
as a key in nodes, and if not, raise a validation error with a clear message
indicating the referenced node does not exist. This catches authoring mistakes
at template creation/update time instead of deferring the error to runtime call
time.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/ai/voice/agents/breeze_buddy/agent/ivr_walker.py`:
- Around line 515-517: The finalize method in ivr_walker.py is using
close_websocket_safely(ws) for socket closure, but this bypasses the Breeze
Buddy graceful-disconnect handler. Replace the direct close_websocket_safely(ws)
call with a call to end_call_with_errors(ws, stream_sid, errors) to ensure
consistent disconnect behavior and proper error semantics as per Breeze Buddy
guidelines. You will need to ensure the stream_sid and errors parameters are
available in the finalize context or passed appropriately to maintain the
graceful disconnect flow.

In `@app/ai/voice/agents/breeze_buddy/template/types.py`:
- Around line 2092-2101: The `target_node` field in the IvrOption class (or
relevant parent class) is marked as Optional with a default of None, but the
description states it is required when action is a transition. Add a
`model_validator` to enforce this constraint: when the `action` field equals the
TRANSITION variant of IvrAction, the `target_node` field must not be None, and
raise a validation error if this condition is violated. This ensures template
authors receive immediate feedback instead of silent failures during walker
execution.

In `@examples/templates/order-confirmation-ivr.json`:
- Line 29: The on_timeout_outcome field set to "NO_RESPONSE" on the main entry
node disables the default BUSY behavior for incomplete calls, preventing the
retry logic from triggering as intended. Either remove the on_timeout_outcome
field entirely from the main node to preserve the default BUSY fallback
behavior, or replace its value with "BUSY" if your outcome model explicitly
supports it.

---

Nitpick comments:
In `@app/ai/voice/agents/breeze_buddy/template/types.py`:
- Around line 2117-2122: The `IvrOption.hooks` field is typed as `List[Dict[str,
Any]]` but should be typed as `List[HookConfig]` to match `FlowFunction.hooks`
(line 1685) and maintain consistency. Change the type annotation of the hooks
field in the IvrOption class from `List[Dict[str, Any]]` to `List[HookConfig]`
so that Pydantic validation is applied consistently across all hook
configurations, enabling validation of structures like http_request and
expected_fields at template validation time.
- Around line 2193-2195: Add a Pydantic model_validator to the class containing
the initial_node and nodes fields to validate that initial_node references an
existing key in the nodes dictionary. The validator should check if the value of
initial_node exists as a key in nodes, and if not, raise a validation error with
a clear message indicating the referenced node does not exist. This catches
authoring mistakes at template creation/update time instead of deferring the
error to runtime call time.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d611e202-5dbf-4def-a1cd-4f220a5b7249

📥 Commits

Reviewing files that changed from the base of the PR and between 786a4b7 and 04fdfc8.

📒 Files selected for processing (7)
  • app/ai/voice/agents/breeze_buddy/agent/__init__.py
  • app/ai/voice/agents/breeze_buddy/agent/ivr_walker.py
  • app/ai/voice/agents/breeze_buddy/handlers/internal/end_conversation.py
  • app/ai/voice/agents/breeze_buddy/template/context.py
  • app/ai/voice/agents/breeze_buddy/template/loader.py
  • app/ai/voice/agents/breeze_buddy/template/types.py
  • examples/templates/order-confirmation-ivr.json

Comment thread app/ai/voice/agents/breeze_buddy/ivr/walker.py
Comment thread app/ai/voice/agents/breeze_buddy/template/types.py
Comment thread examples/templates/order-confirmation-ivr.json
@manas-narra manas-narra force-pushed the add-buddy-ivr-support branch from 04fdfc8 to 44a19c8 Compare June 16, 2026 10:41
if self.lead.id:
# Non-blocking: never stall the menu on a DB write (same principle
# as hook persistence in transition.py).
asyncio.create_task(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 [MAJOR — functional] Fire-and-forget full-overwrite meta_data flush can lose the final transcript

_persist_outcome schedules asyncio.create_task(_flush_outcome(..., dict(self.lead.metaData))) on every option press (and on errors). _flush_outcomeupdate_lead_call_completion_details, whose query sets "meta_data" = $N — a full column overwrite, not a jsonb merge (database/queries/breeze_buddy/lead_call_tracker.py:355; the merge path at line 582 is a different function). The dict(self.lead.metaData) snapshot is taken here, before _finalize_and_close adds transcription / call_ended_by / node_traversal / errors.

Because these flush tasks are neither awaited nor ordered relative to _finalize_and_close's final write, a stale intermediate flush can land after the final write and overwrite it with a snapshot missing the transcript → lost transcripts and analytics on IVR calls, nondeterministically. (Separately, neither create_task here nor the one at line 347 retains a reference, so either can be GC'd before completing — Task was destroyed but it is pending!.)

Fix: await the final flush before end_conversation in _finalize_and_close, and/or make intermediate flushes write only their own keys via the jsonb-merge query (meta_data || $N) so they can't clobber the finalizer; retain task refs (self._bg_tasks.add(t); t.add_done_callback(...)).

@narsimhaReddyJuspay

Copy link
Copy Markdown
Contributor

PR #833feat(breeze_buddy): add IVR mode — DTMF-driven menu flow

Verdict: request changes — 1 major. Gates green: black/isort/autoflake/pyrefly ✅, pytest 441 passed. The walker/state-machine structure is solid and the integration is clean; the one blocker is a persistence race that can silently drop IVR transcripts.

🟥 Major (inline comment posted)

  • Fire-and-forget full-overwrite meta_data flush can lose the final transcript. _persist_outcome schedules an un-awaited create_task(_flush_outcome(...)) per option press; _flush_outcome does a full "meta_data" = $N overwrite (not a jsonb merge), and the snapshot predates _finalize_and_close adding transcription/call_ended_by. A stale flush landing after the final write clobbers it. (ivr/walker.py:450)

🟨 Minor (not blocking)

  • Invalid-press exhaustion falls through to the timeout path (walker.py:425-433): once invalid_presses >= MAX_INVALID_PRESSES it breaks into the shared "retries exhausted" block → plays on_timeout_message and records on_timeout_outcome. A caller mashing wrong keys is misreported as a timeout (and could be marked non-retryable) rather than an invalid-input outcome. Consider a distinct terminal path/outcome.
  • IVR template validation is deferred to call-time, not load-time. load_template renders the IVR flow and returns; IvrModeFlow.model_validate only runs inside walker.run(), so a malformed IVR template (typo'd initial_node, dangling target_node) fails mid-call (bounded — it fails gracefully to IVR_ERROR_OUTCOME) rather than at upload. The validator docstring promises load-time validation that isn't honored.

✅ What's good (verified)

  • Mode gating is clean (agent/__init__.py): the IVR branch is gated on not is_daily_mode and flow mode == IVR; a non-IVR template can't enter the walker and an IVR template can't fall through to the speech pipeline. ws/stream_sid/greeting are set up before the branch.
  • end_conversation reuse is safe: the if context.task: guard skips EndFrame for IVR (context.task is None), and the transcription block doesn't overwrite the walker's pre-set metaData["transcription"].
  • Digit→option mapping is correct: exact by_digit.get(digit) lookup (handles */#/0), no off-by-one; unmapped digits replay the invalid prompt, no crash.
  • Loop guards bound cycles: MAX_NODE_TRANSITIONS=25, MAX_INVALID_PRESSES=5.
  • telephony/answer/handlers.py is a pure import rename (agent.ivrivr.selection) — no behavioral change, no regression for non-IVR inbound calls.

  New flow.mode="ivr" runs a pure DTMF state machine (no STT/LLM/pipeline):
  walks a per-template menu tree, plays TTS prompts, routes on keypresses.
  Supports nested sub-menus, back-navigation, greeting-driven opening node,
  per-node timeout/retry, invalid-key replay (no retry consumed), digit
  logging (dtmf_inputs), synthetic transcription, and BUSY-on-incomplete
  so unanswered calls retry.
@manas-narra manas-narra force-pushed the add-buddy-ivr-support branch from 44a19c8 to 770b175 Compare June 17, 2026 05:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants