dv-pipecat-flows 0.0.22.dev1489__tar.gz → 0.0.22.dev1491__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {dv_pipecat_flows-0.0.22.dev1489/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev1491}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  3. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/manager.py +204 -56
  4. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.agents/skills/loki-logs/SKILL.md +0 -0
  5. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.agents/skills/loki-logs/query-reference.md +0 -0
  6. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.claude/skills/loki-logs/SKILL.md +0 -0
  7. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.claude/skills/loki-logs/query-reference.md +0 -0
  8. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.gitattributes +0 -0
  9. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.gitignore +0 -0
  10. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.pre-commit-config.yaml +0 -0
  11. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.python-version +0 -0
  12. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/.readthedocs.yaml +0 -0
  13. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/AGENTS.md +0 -0
  14. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/CHANGELOG.md +0 -0
  15. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/CLAUDE.md +0 -0
  16. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/CONTRIBUTING.md +0 -0
  17. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/LICENSE +0 -0
  18. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/MANIFEST.in +0 -0
  19. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/README.md +0 -0
  20. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/dev-requirements.txt +0 -0
  21. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/docker-compose.dev.yml +0 -0
  22. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/.eslintrc.json +0 -0
  23. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/.prettierrc +0 -0
  24. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/css/tailwind.css +0 -0
  25. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/food_ordering.json +0 -0
  26. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/movie_explorer.json +0 -0
  27. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/patient_intake.json +0 -0
  28. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/restaurant_reservation.json +0 -0
  29. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/travel_planner.json +0 -0
  30. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/favicon.png +0 -0
  31. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/favicon.svg +0 -0
  32. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/index.html +0 -0
  33. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/canvas.js +0 -0
  34. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/editorState.js +0 -0
  35. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/sidePanel.js +0 -0
  36. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/toolbar.js +0 -0
  37. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/main.js +0 -0
  38. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/baseNode.js +0 -0
  39. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/endNode.js +0 -0
  40. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/flowNode.js +0 -0
  41. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/functionNode.js +0 -0
  42. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/index.js +0 -0
  43. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/mergeNode.js +0 -0
  44. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/startNode.js +0 -0
  45. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/types.js +0 -0
  46. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/export.js +0 -0
  47. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/helpers.js +0 -0
  48. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/import.js +0 -0
  49. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/validation.js +0 -0
  50. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/jsdoc.json +0 -0
  51. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/package-lock.json +0 -0
  52. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/package.json +0 -0
  53. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/postcss.config.cjs +0 -0
  54. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/public/favicon.png +0 -0
  55. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/public/favicon.svg +0 -0
  56. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/tailwind.config.cjs +0 -0
  57. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/vercel.json +0 -0
  58. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/editor/vite.config.js +0 -0
  59. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/engine_primitives_plan.md +0 -0
  60. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/env.example +0 -0
  61. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/images/food-ordering-flow.png +0 -0
  62. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/pipecat-flows.png +0 -0
  63. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/pipecat_upgrade.md +0 -0
  64. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/pyproject.toml +0 -0
  65. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/remote-asterisk-code/README.md +0 -0
  66. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/remote-asterisk-code/extensions.conf +0 -0
  67. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/remote-asterisk-code/rtp.conf +0 -0
  68. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/requirements.txt +0 -0
  69. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/scripts/check-pypi-package.py +0 -0
  70. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/scripts/fix-ruff.sh +0 -0
  71. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/scripts/pre-commit.sh +0 -0
  72. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/setup.cfg +0 -0
  73. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  74. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  75. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  76. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  77. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/__init__.py +0 -0
  78. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/actions.py +0 -0
  79. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/adapters.py +0 -0
  80. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/condition_evaluator.py +0 -0
  81. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/exceptions.py +0 -0
  82. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/flow_validator.py +0 -0
  83. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/__init__.py +0 -0
  84. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  85. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  86. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  87. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/router_mode.py +0 -0
  88. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/types.py +0 -0
  89. {dv_pipecat_flows-0.0.22.dev1489 → dv_pipecat_flows-0.0.22.dev1491}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.22.dev1489
3
+ Version: 0.0.22.dev1491
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dv-pipecat-flows
3
- Version: 0.0.22.dev1489
3
+ Version: 0.0.22.dev1491
4
4
  Summary: Conversation Flow management for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Source, https://github.com/pipecat-ai/pipecat-flows
@@ -48,7 +48,7 @@ from typing import (
48
48
 
49
49
  from loguru import logger
50
50
  from utils.tracking_helpers import start_tracking, complete_tracking
51
- from utils.collect_normalizers import normalize_collect_value
51
+ from utils.collect_normalizers import is_repeat_request, normalize_collect_value
52
52
  from utils.collect_prompts import collect_prompt
53
53
  from pipecat.frames.frames import (
54
54
  DTMFUpdateSettingsFrame,
@@ -2149,6 +2149,8 @@ In all of these cases, you can provide a `name` in your new node's config for de
2149
2149
  system_prompt: Optional[str] = None,
2150
2150
  context_prefix: Optional[List[Dict[str, Any]]] = None,
2151
2151
  enum_values: Optional[List[str]] = None,
2152
+ since_ts: Optional[float] = None,
2153
+ with_reply: bool = False,
2152
2154
  ) -> Optional[str]:
2153
2155
  """Wait for the caller's spoken reply and extract a single value from it
2154
2156
  via a constrained LLM call — the same primitive the LLM router uses
@@ -2158,9 +2160,12 @@ In all of these cases, you can provide a `name` in your new node's config for de
2158
2160
  ``context_prefix`` lets a caller prepend grounding messages (e.g. the
2159
2161
  read-back question the confirm is about) ahead of the caller's latest
2160
2162
  turn, and ``system_prompt`` overrides the default extraction instruction.
2163
+ ``since_ts`` reads a reply that ALREADY committed (e.g. the caller's
2164
+ rejection of a read-back) instead of waiting for a new turn — pass it
2165
+ with ``timeout_s=0`` to re-extract a correction from the denial.
2161
2166
  """
2162
2167
  observer = self._user_turn_observer
2163
- entry_ts = time.monotonic()
2168
+ entry_ts = since_ts if since_ts is not None else time.monotonic()
2164
2169
  if observer is not None and timeout_s > 0:
2165
2170
  # Wait for the caller's reply to COMMIT, and stay PATIENT while they're
2166
2171
  # still mid-utterance, so we read their finished answer (e.g. a
@@ -2186,7 +2191,24 @@ In all of these cases, you can provide a `name` in your new node's config for de
2186
2191
  value_spec: Dict[str, Any] = {"type": "string", "description": description}
2187
2192
  if enum_values:
2188
2193
  value_spec = {"type": "enum", "values": list(enum_values), "description": description}
2189
- schema = build_router_response_schema([], {"value": value_spec})
2194
+ extract_spec: Dict[str, Any] = {"value": value_spec}
2195
+ if with_reply:
2196
+ # Same hybrid contract as the digit extractor: when the caller says
2197
+ # something conversational instead of the value, the model answers it
2198
+ # on-topic in `reply` (we speak that) and leaves `value` empty. The field
2199
+ # description below is the instruction — the system prompt stays untouched.
2200
+ self._collect_reply = ""
2201
+ extract_spec["reply"] = {
2202
+ "type": "string",
2203
+ "description": (
2204
+ "Empty whenever the caller gave the value. ONLY when they gave no "
2205
+ "value and instead said something conversational (a question, a "
2206
+ "hesitation, an objection), a SHORT reply in their language that "
2207
+ "answers in ONE sentence and then asks again for it. Stay on topic; "
2208
+ "for anything unrelated give a one-line redirect; make no promises."
2209
+ ),
2210
+ }
2211
+ schema = build_router_response_schema([], extract_spec)
2190
2212
 
2191
2213
  deadline = entry_ts + timeout_s if timeout_s > 0 else entry_ts
2192
2214
  last_value: Optional[str] = None
@@ -2244,6 +2266,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
2244
2266
  value = (extracted or {}).get("value")
2245
2267
  if isinstance(value, str) and value.strip():
2246
2268
  last_value = value
2269
+ if with_reply:
2270
+ reply_val = (extracted or {}).get("reply")
2271
+ self._collect_reply = reply_val.strip() if isinstance(reply_val, str) else ""
2247
2272
  # No observer (unit tests) → single shot. Otherwise re-extract only if
2248
2273
  # the caller said MORE during the call or is still speaking, and time
2249
2274
  # remains.
@@ -2362,6 +2387,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2362
2387
  # can never trap the call in the node (the activity-aware wait below would
2363
2388
  # otherwise reset forever).
2364
2389
  _MAX_CAPTURE_SECONDS = 90.0
2390
+ _MAX_COLLECT_REPLIES = 5 # conversational (non-digit) LLM replies before falling back
2365
2391
 
2366
2392
  def _caller_is_speaking(self) -> bool:
2367
2393
  """True while the caller has an IN-PROGRESS utterance — they started more
@@ -2410,6 +2436,8 @@ In all of these cases, you can provide a `name` in your new node's config for de
2410
2436
  Returns digits 0-9 only, or "" if no digits / on error (degrades to a
2411
2437
  re-ask).
2412
2438
  """
2439
+ self._collect_readback = "" # grouped, language-matched read-back for the confirm
2440
+ self._collect_reply = "" # LLM free reply for a conversational (non-digit) turn
2413
2441
  context_messages = [
2414
2442
  {"role": "user", "content": turn}
2415
2443
  for turn in turns
@@ -2422,39 +2450,67 @@ In all of these cases, you can provide a `name` in your new node's config for de
2422
2450
  llm_service = llm_service.active_llm
2423
2451
  schema = build_router_response_schema(
2424
2452
  [],
2425
- {"value": {
2426
- "type": "string",
2427
- "description": f"Every digit of the {what} the caller has said, concatenated in the order spoken, digits 0-9 only (e.g. 12345). No spaces or punctuation.",
2428
- }},
2453
+ {
2454
+ "value": {
2455
+ "type": "string",
2456
+ "description": (
2457
+ f"The {what} as digits 0-9 only, in the order spoken (no spaces or "
2458
+ "punctuation). Join each spoken number-word left to right — e.g. "
2459
+ "'double seven' → '77', 'nine two triple two' → 9,2,222 → '92222', "
2460
+ "'eight one three zero' → '8130'. Return an empty string if the "
2461
+ "caller has not given any digits yet (e.g. only said 'hello') — "
2462
+ "never invent or guess digits."
2463
+ ),
2464
+ },
2465
+ "readback": {
2466
+ "type": "string",
2467
+ "description": (
2468
+ "A read-back of `value` for confirmation, in the caller's language, "
2469
+ "with repeated digits grouped — e.g. 8555 → 'eight triple five', "
2470
+ "77 → 'double seven'. Empty when `value` is empty."
2471
+ ),
2472
+ },
2473
+ "reply": {
2474
+ "type": "string",
2475
+ "description": (
2476
+ "Empty whenever the caller gave digits. ONLY when they gave no "
2477
+ "digits and instead said something conversational (a question, a "
2478
+ "hesitation, an objection), a SHORT reply in their language that "
2479
+ "answers in ONE sentence and then asks again for the number. Stay "
2480
+ "on topic; for anything unrelated give a one-line redirect back to "
2481
+ "the number; make no promises or commitments."
2482
+ ),
2483
+ },
2484
+ },
2429
2485
  )
2430
2486
  try:
2431
2487
  raw = await self._timed_router_completion(
2432
2488
  node_id,
2433
2489
  llm_service=llm_service,
2434
2490
  system_prompt=(
2435
- f"The caller is dictating a {what} to you, possibly across "
2436
- "several messages and in ANY language (English, Hindi, etc.), or "
2437
- "as keypad input (a message like 'User inputted: 5'). Read "
2438
- f"EVERYTHING they have said and put the full {what} every digit, "
2439
- "in the order spoken — into `value` as digits 0-9 only, with no "
2440
- "spaces or punctuation. Expand spoken repeat-words into digits: "
2441
- "'double X' / 'डबल X' / 'do baar X' means XX, and 'triple X' / "
2442
- "'ट्रिपल X' means XXX (e.g. 'double seven' -> 77, 'triple 9' -> 999, "
2443
- "'double oh' -> 00). If they correct or restate any part e.g. "
2444
- "'no, the first/last part is X', 'the start/beginning is X', "
2445
- "'the end is X', 'where I said Y it's actually Z', or they repeat "
2446
- "the whole thingAPPLY the correction to the earlier value and "
2447
- "output the single final intended number, keeping the un-corrected "
2448
- "digits unchanged. When the position is vague, anchor on the VALUE: "
2449
- "find the run of digits they are replacing and swap only that run. "
2450
- "A correction may be terse and the position named in ANY language "
2451
- "(start/beginning/शुरू/स्टार्टिंग, end/last/एंड/लास्ट), and is often "
2452
- "prefixed with 'no'/'नहीं' still apply it. Examples: '70823 41303' "
2453
- "then 'no, the first part is 807' -> '8072341303'; '70823 41303' "
2454
- "then 'नहीं, स्टार्टिंग में 80 है' (the start is 80) -> '8082341303'; "
2455
- "'70823 41303' then 'एंड में 5 है' (the end is 5) -> '7082341305'; "
2456
- "'70823 41303' then 'where I said 41 it's 14' -> '7082314303'. "
2457
- "Output an empty string if they have given no digits yet."
2491
+ f"The caller is giving you a {what} by voice in ANY language, or by "
2492
+ "keypad (a message like 'User inputted: 5') possibly across several "
2493
+ "messages. Read everything they have said and return the final number "
2494
+ "in `value` and `readback`, or leave both empty if no digits have been "
2495
+ "given yet.\n"
2496
+ "If the caller instead said something conversational (a question, a "
2497
+ "hesitation, an objection) and gave NO digits, leave value/readback "
2498
+ "empty and put a SHORT on-topic reply in `reply`: answer in one "
2499
+ "sentence, then ask again for the number stay on topic, make no "
2500
+ "promises.\n"
2501
+ "Digits: read each spoken number-word in the order spoken and join its "
2502
+ "digits left to right a digit-word adds one digit, 'double X' adds "
2503
+ "XX, 'triple X' adds XXX (in any language, incl. डबल/ट्रिपल), and a lone "
2504
+ "digit next to a double/triple word stays separate.\n"
2505
+ "Corrections: if the caller fixes or restates any part 'no, the "
2506
+ "first/last part is X', 'the start/beginning is X' (शुरू/स्टार्टिंग), "
2507
+ "'the end is X' (एंड/लास्ट), 'where I said Y it's Z', or they repeat the "
2508
+ "whole number — apply it and output the single final number, keeping "
2509
+ "the un-corrected digits unchanged. When the position is vague, find "
2510
+ "the run of digits being replaced and swap only that run. Examples: "
2511
+ "'70823 41303' then 'no, the first part is 807' '8072341303'; then "
2512
+ "'एंड में 5 है' → '7082341305'; then 'where I said 41 it's 14' "
2513
+ "'7082314303'."
2458
2514
  ),
2459
2515
  response_schema=schema,
2460
2516
  context_messages=context_messages,
@@ -2467,6 +2523,10 @@ In all of these cases, you can provide a `name` in your new node's config for de
2467
2523
  return ""
2468
2524
  _branch, extracted = parse_router_response(raw, [UNCLEAR_BRANCH_ID])
2469
2525
  value = (extracted or {}).get("value")
2526
+ readback = (extracted or {}).get("readback")
2527
+ reply = (extracted or {}).get("reply")
2528
+ self._collect_readback = readback.strip() if isinstance(readback, str) else ""
2529
+ self._collect_reply = reply.strip() if isinstance(reply, str) else ""
2470
2530
  return re.sub(r"\D", "", value) if isinstance(value, str) else ""
2471
2531
 
2472
2532
  async def _reextract_if_corrected(
@@ -2582,6 +2642,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2582
2642
  buffer = ""
2583
2643
  echoed = "" # bare digits already spoken back, so we echo only the NEW ones
2584
2644
  misses = 0
2645
+ replies = 0 # conversational (non-digit) replies, bounded so chatter can't loop
2585
2646
  corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2586
2647
  deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2587
2648
 
@@ -2631,7 +2692,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
2631
2692
  # the ORIGINAL number. Only a plain rejection with no new digits
2632
2693
  # falls through to the re-ask. Bounded by ``corrections``.
2633
2694
  while True:
2634
- confirmed = await self._confirm_collected(node_id, " ".join(buffer))
2695
+ confirmed = await self._confirm_collected(
2696
+ node_id, getattr(self, "_collect_readback", "") or " ".join(buffer)
2697
+ )
2635
2698
  if confirmed is True:
2636
2699
  return normalized
2637
2700
  corrections += 1
@@ -2666,12 +2729,33 @@ In all of these cases, you can provide a `name` in your new node's config for de
2666
2729
  continue
2667
2730
 
2668
2731
  if buffer == previous:
2669
- # A turn arrived but the number is UNCHANGED (filler / mis-hear)
2670
- # re-ask and count a miss so garbage can't loop forever. Compare by
2671
- # VALUE, not length: a same-length correction during the echo phase
2672
- # ("no, the 3rd is 5": 7765 7755) changes the value but not the
2673
- # length, so a length check would mis-read the fix as "no progress"
2674
- # and burn a miss instead of echoing the correction below.
2732
+ # A turn arrived but the number is UNCHANGED (filler / mis-hear / a
2733
+ # request to repeat). If the caller asked to hear it back, RE-READ the
2734
+ # digits-so-far (no miss); otherwise re-ask and count a miss so garbage
2735
+ # can't loop forever. Compare by VALUE, not length: a same-length
2736
+ # correction during the echo phase ("no, the 3rd is 5": 7765 → 7755)
2737
+ # changes the value but not the length, so a length check would mis-read
2738
+ # the fix as "no progress" and burn a miss instead of echoing it below.
2739
+ turns = self._collect_turns_since(start_ts)
2740
+ if buffer and turns and is_repeat_request(turns[-1]):
2741
+ # Re-reads bypass the miss counter, so the absolute deadline is the
2742
+ # only bound — without it a caller repeating "say it again" loops
2743
+ # forever (a turn lands every cycle, never silence, never a miss).
2744
+ if time.monotonic() >= deadline:
2745
+ break
2746
+ await self._task.queue_frame(TTSSpeakFrame(
2747
+ text=collect_prompt(language, "echo", digits=" ".join(buffer))
2748
+ ))
2749
+ continue
2750
+ reply = getattr(self, "_collect_reply", "")
2751
+ if reply and replies < self._MAX_COLLECT_REPLIES and time.monotonic() < deadline:
2752
+ # The caller said something other than digits (a question / objection).
2753
+ # Speak the LLM's on-topic reply (it answers AND steers back to the
2754
+ # number) instead of the robotic nudge. Bounded + deadline-capped so it
2755
+ # can't crowd out give-up; a repeat request was already handled above.
2756
+ replies += 1
2757
+ await self._task.queue_frame(TTSSpeakFrame(text=reply))
2758
+ continue
2675
2759
  misses += 1
2676
2760
  if misses < max_attempts:
2677
2761
  await _nudge(language)
@@ -2717,6 +2801,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2717
2801
  buffer = ""
2718
2802
  echoed = "" # bare digits already spoken back, so we echo only the NEW ones
2719
2803
  misses = 0
2804
+ replies = 0 # conversational (non-digit) replies, bounded so chatter can't loop
2720
2805
  corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2721
2806
  deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2722
2807
  # Keypad auto-flushes once the caller has keyed `target` digits.
@@ -2758,7 +2843,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
2758
2843
  # we already have and re-reads the CORRECTED code at once; only a
2759
2844
  # plain rejection with no new digits asks "what should it be?".
2760
2845
  while True:
2761
- confirmed = await self._confirm_collected(node_id, " ".join(normalized))
2846
+ confirmed = await self._confirm_collected(
2847
+ node_id, getattr(self, "_collect_readback", "") or " ".join(normalized)
2848
+ )
2762
2849
  if confirmed is True:
2763
2850
  return normalized
2764
2851
  corrections += 1
@@ -2793,9 +2880,30 @@ In all of these cases, you can provide a `name` in your new node's config for de
2793
2880
  continue
2794
2881
 
2795
2882
  if buffer == previous:
2796
- # Unchanged (filler / mis-hear) re-ask, count a miss. Compare by
2797
- # VALUE not length so a same-length correction during the echo phase
2798
- # counts as progress (echoed below) instead of burning a miss.
2883
+ # Unchanged (filler / mis-hear / a request to repeat). If the caller
2884
+ # asked to hear it back, RE-READ the digits-so-far (no miss); else re-ask
2885
+ # and count a miss. Compare by VALUE not length so a same-length
2886
+ # correction during the echo phase counts as progress (echoed below).
2887
+ turns = self._collect_turns_since(start_ts)
2888
+ if buffer and turns and is_repeat_request(turns[-1]):
2889
+ # Re-reads bypass the miss counter, so the absolute deadline is the
2890
+ # only bound — without it a caller repeating "say it again" loops
2891
+ # forever (a turn lands every cycle, never silence, never a miss).
2892
+ if time.monotonic() >= deadline:
2893
+ break
2894
+ await self._task.queue_frame(TTSSpeakFrame(
2895
+ text=collect_prompt(language, "echo", digits=" ".join(buffer))
2896
+ ))
2897
+ continue
2898
+ reply = getattr(self, "_collect_reply", "")
2899
+ if reply and replies < self._MAX_COLLECT_REPLIES and time.monotonic() < deadline:
2900
+ # The caller said something other than digits (a question / objection).
2901
+ # Speak the LLM's on-topic reply (it answers AND steers back to the
2902
+ # number) instead of the robotic nudge. Bounded + deadline-capped so it
2903
+ # can't crowd out give-up; a repeat request was already handled above.
2904
+ replies += 1
2905
+ await self._task.queue_frame(TTSSpeakFrame(text=reply))
2906
+ continue
2799
2907
  misses += 1
2800
2908
  if misses < max_attempts:
2801
2909
  await _nudge(language)
@@ -2825,7 +2933,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
2825
2933
  reply ("जी" … "हाँ, सही है") can read as unclear, and we'd rather re-ask
2826
2934
  "is that correct?" than treat a confirming caller as a rejection."""
2827
2935
  observer = self._user_turn_observer
2828
- for _ in range(max(1, attempts)):
2936
+ repeats = 0
2937
+ asked = 0
2938
+ while asked < max(1, attempts):
2829
2939
  listen_ts = time.monotonic()
2830
2940
  await self._task.queue_frame(
2831
2941
  TTSSpeakFrame(text=collect_prompt(self._active_language(), "confirm", readback=readback))
@@ -2862,7 +2972,16 @@ In all of these cases, you can provide a `name` in your new node's config for de
2862
2972
  if observer is not None and hasattr(observer, "transcripts_since")
2863
2973
  else ""
2864
2974
  )
2865
- if any(ch.isdigit() for ch in reply):
2975
+ has_digit = any(ch.isdigit() for ch in reply)
2976
+ # A request to hear it again ("can you repeat?") is NOT a yes or a no —
2977
+ # re-read the value and DON'T burn a confirm attempt (bounded so a caller
2978
+ # stuck on "repeat" can't loop forever). A digit-carrying reply is a
2979
+ # correction, handled just below, so only a digit-free reply is a repeat.
2980
+ if not has_digit and is_repeat_request(reply):
2981
+ repeats += 1
2982
+ if repeats <= 3:
2983
+ continue
2984
+ if has_digit:
2866
2985
  return False
2867
2986
  text = (answer or "").strip().lower()
2868
2987
  if text.startswith("yes"):
@@ -2870,6 +2989,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2870
2989
  if text.startswith("no"):
2871
2990
  return False
2872
2991
  # null / unclear → re-ask "is that correct?" (next loop iteration)
2992
+ asked += 1
2873
2993
  return None
2874
2994
 
2875
2995
  async def _collect_single_value(
@@ -2889,9 +3009,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
2889
3009
  description = {
2890
3010
  "email": "the email address the caller said, as a standard address (e.g. john.smith@gmail.com)",
2891
3011
  }.get(collect_type, "the value the caller said")
2892
- for attempt in range(max_attempts):
3012
+ attempt = 0
3013
+ replies = 0
3014
+ while attempt < max_attempts:
2893
3015
  raw_value = await self._extract_spoken_value(
2894
- node_id, description, timeout_s=15.0
3016
+ node_id, description, timeout_s=15.0, with_reply=True
2895
3017
  )
2896
3018
  if not raw_value and allow_keypad:
2897
3019
  capture_timeout = float(keypad_dtmf.get("timeout", 8.0)) + 10.0
@@ -2901,27 +3023,53 @@ In all of these cases, you can provide a `name` in your new node's config for de
2901
3023
  if raw_value and self._caller_is_speaking():
2902
3024
  if await self._await_user_turn(timeout_s=5.0):
2903
3025
  raw_value = await self._extract_spoken_value(
2904
- node_id, description, timeout_s=15.0
3026
+ node_id, description, timeout_s=15.0, with_reply=True
2905
3027
  )
2906
3028
  normalized = normalize_collect_value(
2907
3029
  collect_type, raw_value, country_default=country_default
2908
3030
  )
2909
3031
  if normalized:
2910
- # Read the value back and confirm before storing (phone-parity).
2911
- confirmed = await self._confirm_collected(node_id, normalized)
2912
- if confirmed is True:
2913
- return normalized
2914
- # Denied / unclear re-ask on the next attempt.
2915
- if confirmed is False and attempt < max_attempts - 1:
2916
- await self._task.queue_frame(
2917
- TTSSpeakFrame(text=collect_prompt(self._active_language(), "restart_denied"))
3032
+ # Read the value back and confirm before storing (phone-parity). On a
3033
+ # denial, re-extract from the rejection reply itself ("no, it's
3034
+ # jane@gmail.com") and re-confirm the fix directly, instead of making
3035
+ # the caller repeat the whole address. A plain "no" with no new value
3036
+ # falls through to "what should it be?" and the next attempt.
3037
+ while True:
3038
+ confirm_ts = time.monotonic()
3039
+ confirmed = await self._confirm_collected(node_id, normalized)
3040
+ if confirmed is True:
3041
+ return normalized
3042
+ corrected = normalize_collect_value(
3043
+ collect_type,
3044
+ await self._extract_spoken_value(
3045
+ node_id, description, timeout_s=0.0, since_ts=confirm_ts
3046
+ ),
3047
+ country_default=country_default,
2918
3048
  )
3049
+ if corrected and corrected != normalized:
3050
+ normalized = corrected
3051
+ continue # re-read the corrected value for confirmation
3052
+ if attempt < max_attempts - 1:
3053
+ await self._task.queue_frame(
3054
+ TTSSpeakFrame(text=collect_prompt(self._active_language(), "restart_denied"))
3055
+ )
3056
+ break
3057
+ attempt += 1
3058
+ continue
3059
+ # No valid value. If the caller said something conversational instead, speak
3060
+ # the LLM's on-topic reply (it answers AND steers back) WITHOUT burning an
3061
+ # attempt (bounded). Otherwise count the attempt and re-ask.
3062
+ reply = getattr(self, "_collect_reply", "")
3063
+ if reply and replies < self._MAX_COLLECT_REPLIES:
3064
+ replies += 1
3065
+ await self._task.queue_frame(TTSSpeakFrame(text=reply))
2919
3066
  continue
3067
+ attempt += 1
2920
3068
  logger.info(
2921
3069
  f"Collect '{node_id}': no valid {collect_type} "
2922
- f"(attempt {attempt + 1}/{max_attempts})"
3070
+ f"(attempt {attempt}/{max_attempts})"
2923
3071
  )
2924
- if reask and attempt < max_attempts - 1:
3072
+ if reask and attempt < max_attempts:
2925
3073
  await self._task.queue_frame(TTSSpeakFrame(text=reask))
2926
3074
  return None
2927
3075