dv-pipecat-flows 0.0.22.dev1487__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.dev1487/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev1491}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  3. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/manager.py +238 -75
  4. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.agents/skills/loki-logs/SKILL.md +0 -0
  5. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.agents/skills/loki-logs/query-reference.md +0 -0
  6. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.claude/skills/loki-logs/SKILL.md +0 -0
  7. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.claude/skills/loki-logs/query-reference.md +0 -0
  8. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.gitattributes +0 -0
  9. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.gitignore +0 -0
  10. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.pre-commit-config.yaml +0 -0
  11. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.python-version +0 -0
  12. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/.readthedocs.yaml +0 -0
  13. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/AGENTS.md +0 -0
  14. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/CHANGELOG.md +0 -0
  15. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/CLAUDE.md +0 -0
  16. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/CONTRIBUTING.md +0 -0
  17. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/LICENSE +0 -0
  18. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/MANIFEST.in +0 -0
  19. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/README.md +0 -0
  20. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/dev-requirements.txt +0 -0
  21. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/docker-compose.dev.yml +0 -0
  22. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/.eslintrc.json +0 -0
  23. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/.prettierrc +0 -0
  24. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/css/tailwind.css +0 -0
  25. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/food_ordering.json +0 -0
  26. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/movie_explorer.json +0 -0
  27. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/patient_intake.json +0 -0
  28. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/restaurant_reservation.json +0 -0
  29. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/examples/travel_planner.json +0 -0
  30. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/favicon.png +0 -0
  31. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/favicon.svg +0 -0
  32. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/index.html +0 -0
  33. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/canvas.js +0 -0
  34. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/editorState.js +0 -0
  35. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/sidePanel.js +0 -0
  36. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/editor/toolbar.js +0 -0
  37. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/main.js +0 -0
  38. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/baseNode.js +0 -0
  39. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/endNode.js +0 -0
  40. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/flowNode.js +0 -0
  41. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/functionNode.js +0 -0
  42. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/index.js +0 -0
  43. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/mergeNode.js +0 -0
  44. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/nodes/startNode.js +0 -0
  45. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/types.js +0 -0
  46. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/export.js +0 -0
  47. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/helpers.js +0 -0
  48. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/import.js +0 -0
  49. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/js/utils/validation.js +0 -0
  50. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/jsdoc.json +0 -0
  51. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/package-lock.json +0 -0
  52. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/package.json +0 -0
  53. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/postcss.config.cjs +0 -0
  54. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/public/favicon.png +0 -0
  55. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/public/favicon.svg +0 -0
  56. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/tailwind.config.cjs +0 -0
  57. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/vercel.json +0 -0
  58. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/editor/vite.config.js +0 -0
  59. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/engine_primitives_plan.md +0 -0
  60. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/env.example +0 -0
  61. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/images/food-ordering-flow.png +0 -0
  62. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/pipecat-flows.png +0 -0
  63. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/pipecat_upgrade.md +0 -0
  64. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/pyproject.toml +0 -0
  65. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/remote-asterisk-code/README.md +0 -0
  66. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/remote-asterisk-code/extensions.conf +0 -0
  67. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/remote-asterisk-code/rtp.conf +0 -0
  68. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/requirements.txt +0 -0
  69. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/scripts/check-pypi-package.py +0 -0
  70. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/scripts/fix-ruff.sh +0 -0
  71. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/scripts/pre-commit.sh +0 -0
  72. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/setup.cfg +0 -0
  73. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  74. {dv_pipecat_flows-0.0.22.dev1487 → 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.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  76. {dv_pipecat_flows-0.0.22.dev1487 → 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.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/__init__.py +0 -0
  78. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/actions.py +0 -0
  79. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/adapters.py +0 -0
  80. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/condition_evaluator.py +0 -0
  81. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/exceptions.py +0 -0
  82. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/flow_validator.py +0 -0
  83. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/__init__.py +0 -0
  84. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  85. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  86. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/processors/user_turn_observer.py +0 -0
  87. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/router_mode.py +0 -0
  88. {dv_pipecat_flows-0.0.22.dev1487 → dv_pipecat_flows-0.0.22.dev1491}/src/pipecat_flows/types.py +0 -0
  89. {dv_pipecat_flows-0.0.22.dev1487 → 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.dev1487
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.dev1487
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(
@@ -2580,20 +2640,17 @@ In all of these cases, you can provide a `name` in your new node's config for de
2580
2640
  # ignore the rejected attempt's turns.
2581
2641
  start_ts = time.monotonic()
2582
2642
  buffer = ""
2643
+ echoed = "" # bare digits already spoken back, so we echo only the NEW ones
2583
2644
  misses = 0
2645
+ replies = 0 # conversational (non-digit) replies, bounded so chatter can't loop
2584
2646
  corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2585
2647
  deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2586
2648
 
2587
2649
  async def _nudge(language: str) -> None:
2588
- """Re-ask: echo a partial and ask for the rest, else the author reask."""
2589
- if buffer:
2590
- remaining = max(0, target - len(buffer))
2591
- unit = collect_prompt(language, "unit_one" if remaining == 1 else "unit_many")
2592
- await self._task.queue_frame(TTSSpeakFrame(text=collect_prompt(
2593
- language, "continue_partial", readback=" ".join(buffer),
2594
- remaining=remaining, unit=unit,
2595
- )))
2596
- elif reask:
2650
+ """Re-prompt ONLY when we have nothing yet (the author's reask). Holding a
2651
+ partial → stay silent: the progress echo already spoke the digits, so
2652
+ re-speaking them just sounds like a repeat (and talks over the caller)."""
2653
+ if not buffer and reask:
2597
2654
  await self._task.queue_frame(TTSSpeakFrame(text=reask))
2598
2655
 
2599
2656
  while misses < max_attempts:
@@ -2601,7 +2658,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2601
2658
  # Longer window to START dictating; 7s once we hold a partial. Patient
2602
2659
  # while the caller is mid-utterance, so a timeout can't clip them.
2603
2660
  got_turn = await self._wait_for_caller_input(
2604
- 5.0 if buffer else 15.0, deadline, allow_keypad, keypad_dtmf
2661
+ 7.0 if buffer else 15.0, deadline, allow_keypad, keypad_dtmf
2605
2662
  )
2606
2663
  if not got_turn:
2607
2664
  if time.monotonic() >= deadline:
@@ -2635,7 +2692,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
2635
2692
  # the ORIGINAL number. Only a plain rejection with no new digits
2636
2693
  # falls through to the re-ask. Bounded by ``corrections``.
2637
2694
  while True:
2638
- 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
+ )
2639
2698
  if confirmed is True:
2640
2699
  return normalized
2641
2700
  corrections += 1
@@ -2661,6 +2720,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2661
2720
  # Enough digits but not a valid number → reset and re-ask.
2662
2721
  start_ts = time.monotonic()
2663
2722
  buffer = ""
2723
+ echoed = ""
2664
2724
  misses += 1
2665
2725
  if misses < max_attempts:
2666
2726
  await self._task.queue_frame(
@@ -2668,17 +2728,49 @@ In all of these cases, you can provide a `name` in your new node's config for de
2668
2728
  )
2669
2729
  continue
2670
2730
 
2671
- if len(buffer) == len(previous):
2672
- # A turn arrived but added no new digits (filler / mis-hear)
2673
- # re-ask and count a miss so garbage can't loop forever.
2731
+ if buffer == previous:
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
2674
2759
  misses += 1
2675
2760
  if misses < max_attempts:
2676
2761
  await _nudge(language)
2677
2762
  continue
2678
2763
 
2679
- # Buffer GREW (progress) reset the give-up counter so a caller who
2680
- # keeps adding digits (even slowly, pausing to think) is never cut
2681
- # off; only a genuine stall trips the bound. Keep listening silently.
2764
+ # Buffer CHANGED (progress new digits OR a correction) echo it back
2765
+ # (bare, in the caller's language) so they hear we caught the change, and
2766
+ # reset the give-up counter. A clean append echoes only the NEW digits; a
2767
+ # correction (not a prefix of what we echoed) re-echoes the full number.
2768
+ delta = buffer[len(echoed):] if buffer.startswith(echoed) else buffer
2769
+ if delta:
2770
+ await self._task.queue_frame(TTSSpeakFrame(
2771
+ text=collect_prompt(language, "echo", digits=" ".join(delta))
2772
+ ))
2773
+ echoed = buffer
2682
2774
  misses = 0
2683
2775
 
2684
2776
  logger.info(f"Collect '{node_id}': gave up after {misses} misses (buffer='{buffer}')")
@@ -2707,27 +2799,24 @@ In all of these cases, you can provide a `name` in your new node's config for de
2707
2799
  target = int(num_digits) if num_digits else 6
2708
2800
  start_ts = time.monotonic()
2709
2801
  buffer = ""
2802
+ echoed = "" # bare digits already spoken back, so we echo only the NEW ones
2710
2803
  misses = 0
2804
+ replies = 0 # conversational (non-digit) replies, bounded so chatter can't loop
2711
2805
  corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2712
2806
  deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2713
2807
  # Keypad auto-flushes once the caller has keyed `target` digits.
2714
2808
  code_dtmf = {**(keypad_dtmf or {}), "digits": target}
2715
2809
 
2716
2810
  async def _nudge(language: str) -> None:
2717
- if buffer:
2718
- remaining = max(0, target - len(buffer))
2719
- unit = collect_prompt(language, "unit_one" if remaining == 1 else "unit_many")
2720
- await self._task.queue_frame(TTSSpeakFrame(text=collect_prompt(
2721
- language, "continue_partial", readback=" ".join(buffer),
2722
- remaining=remaining, unit=unit,
2723
- )))
2724
- elif reask:
2811
+ """Re-prompt ONLY when we have nothing yet (the author's reask); holding a
2812
+ partial stay silent (the progress echo already spoke the digits)."""
2813
+ if not buffer and reask:
2725
2814
  await self._task.queue_frame(TTSSpeakFrame(text=reask))
2726
2815
 
2727
2816
  while misses < max_attempts:
2728
2817
  language = self._active_language()
2729
2818
  got_turn = await self._wait_for_caller_input(
2730
- 5.0 if buffer else 15.0, deadline, allow_keypad, code_dtmf
2819
+ 7.0 if buffer else 15.0, deadline, allow_keypad, code_dtmf
2731
2820
  )
2732
2821
  if not got_turn:
2733
2822
  if time.monotonic() >= deadline:
@@ -2754,7 +2843,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
2754
2843
  # we already have and re-reads the CORRECTED code at once; only a
2755
2844
  # plain rejection with no new digits asks "what should it be?".
2756
2845
  while True:
2757
- 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
+ )
2758
2849
  if confirmed is True:
2759
2850
  return normalized
2760
2851
  corrections += 1
@@ -2780,6 +2871,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2780
2871
  # Too many digits for this code → reset and re-ask.
2781
2872
  start_ts = time.monotonic()
2782
2873
  buffer = ""
2874
+ echoed = ""
2783
2875
  misses += 1
2784
2876
  if misses < max_attempts:
2785
2877
  await self._task.queue_frame(
@@ -2787,14 +2879,45 @@ In all of these cases, you can provide a `name` in your new node's config for de
2787
2879
  )
2788
2880
  continue
2789
2881
 
2790
- if len(buffer) == len(previous):
2791
- # A turn arrived but added no new digits re-ask, count a miss.
2882
+ if buffer == previous:
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
2792
2907
  misses += 1
2793
2908
  if misses < max_attempts:
2794
2909
  await _nudge(language)
2795
2910
  continue
2796
2911
 
2797
- # Buffer GREW (progress) → reset the give-up counter; keep listening.
2912
+ # Buffer CHANGED (new digits OR a correction) → echo it back, bare, and
2913
+ # reset the give-up counter. Append → only the new digits; a correction →
2914
+ # the full code re-echoed.
2915
+ delta = buffer[len(echoed):] if buffer.startswith(echoed) else buffer
2916
+ if delta:
2917
+ await self._task.queue_frame(TTSSpeakFrame(
2918
+ text=collect_prompt(language, "echo", digits=" ".join(delta))
2919
+ ))
2920
+ echoed = buffer
2798
2921
  misses = 0
2799
2922
 
2800
2923
  logger.info(f"Collect '{node_id}': code give-up after {misses} misses (buffer='{buffer}')")
@@ -2810,7 +2933,9 @@ In all of these cases, you can provide a `name` in your new node's config for de
2810
2933
  reply ("जी" … "हाँ, सही है") can read as unclear, and we'd rather re-ask
2811
2934
  "is that correct?" than treat a confirming caller as a rejection."""
2812
2935
  observer = self._user_turn_observer
2813
- for _ in range(max(1, attempts)):
2936
+ repeats = 0
2937
+ asked = 0
2938
+ while asked < max(1, attempts):
2814
2939
  listen_ts = time.monotonic()
2815
2940
  await self._task.queue_frame(
2816
2941
  TTSSpeakFrame(text=collect_prompt(self._active_language(), "confirm", readback=readback))
@@ -2847,7 +2972,16 @@ In all of these cases, you can provide a `name` in your new node's config for de
2847
2972
  if observer is not None and hasattr(observer, "transcripts_since")
2848
2973
  else ""
2849
2974
  )
2850
- 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:
2851
2985
  return False
2852
2986
  text = (answer or "").strip().lower()
2853
2987
  if text.startswith("yes"):
@@ -2855,6 +2989,7 @@ In all of these cases, you can provide a `name` in your new node's config for de
2855
2989
  if text.startswith("no"):
2856
2990
  return False
2857
2991
  # null / unclear → re-ask "is that correct?" (next loop iteration)
2992
+ asked += 1
2858
2993
  return None
2859
2994
 
2860
2995
  async def _collect_single_value(
@@ -2874,9 +3009,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
2874
3009
  description = {
2875
3010
  "email": "the email address the caller said, as a standard address (e.g. john.smith@gmail.com)",
2876
3011
  }.get(collect_type, "the value the caller said")
2877
- for attempt in range(max_attempts):
3012
+ attempt = 0
3013
+ replies = 0
3014
+ while attempt < max_attempts:
2878
3015
  raw_value = await self._extract_spoken_value(
2879
- node_id, description, timeout_s=15.0
3016
+ node_id, description, timeout_s=15.0, with_reply=True
2880
3017
  )
2881
3018
  if not raw_value and allow_keypad:
2882
3019
  capture_timeout = float(keypad_dtmf.get("timeout", 8.0)) + 10.0
@@ -2886,27 +3023,53 @@ In all of these cases, you can provide a `name` in your new node's config for de
2886
3023
  if raw_value and self._caller_is_speaking():
2887
3024
  if await self._await_user_turn(timeout_s=5.0):
2888
3025
  raw_value = await self._extract_spoken_value(
2889
- node_id, description, timeout_s=15.0
3026
+ node_id, description, timeout_s=15.0, with_reply=True
2890
3027
  )
2891
3028
  normalized = normalize_collect_value(
2892
3029
  collect_type, raw_value, country_default=country_default
2893
3030
  )
2894
3031
  if normalized:
2895
- # Read the value back and confirm before storing (phone-parity).
2896
- confirmed = await self._confirm_collected(node_id, normalized)
2897
- if confirmed is True:
2898
- return normalized
2899
- # Denied / unclear re-ask on the next attempt.
2900
- if confirmed is False and attempt < max_attempts - 1:
2901
- await self._task.queue_frame(
2902
- 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,
2903
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))
2904
3066
  continue
3067
+ attempt += 1
2905
3068
  logger.info(
2906
3069
  f"Collect '{node_id}': no valid {collect_type} "
2907
- f"(attempt {attempt + 1}/{max_attempts})"
3070
+ f"(attempt {attempt}/{max_attempts})"
2908
3071
  )
2909
- if reask and attempt < max_attempts - 1:
3072
+ if reask and attempt < max_attempts:
2910
3073
  await self._task.queue_frame(TTSSpeakFrame(text=reask))
2911
3074
  return None
2912
3075