dv-pipecat-flows 0.0.22.dev1484__tar.gz → 0.0.22.dev1489__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.dev1484/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev1489}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  3. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/condition_evaluator.py +12 -0
  4. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/manager.py +406 -154
  5. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/user_turn_observer.py +46 -1
  6. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.agents/skills/loki-logs/SKILL.md +0 -0
  7. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.agents/skills/loki-logs/query-reference.md +0 -0
  8. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.claude/skills/loki-logs/SKILL.md +0 -0
  9. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.claude/skills/loki-logs/query-reference.md +0 -0
  10. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.gitattributes +0 -0
  11. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.gitignore +0 -0
  12. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.pre-commit-config.yaml +0 -0
  13. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.python-version +0 -0
  14. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.readthedocs.yaml +0 -0
  15. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/AGENTS.md +0 -0
  16. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/CHANGELOG.md +0 -0
  17. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/CLAUDE.md +0 -0
  18. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/CONTRIBUTING.md +0 -0
  19. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/LICENSE +0 -0
  20. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/MANIFEST.in +0 -0
  21. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/README.md +0 -0
  22. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/dev-requirements.txt +0 -0
  23. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/docker-compose.dev.yml +0 -0
  24. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/.eslintrc.json +0 -0
  25. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/.prettierrc +0 -0
  26. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/css/tailwind.css +0 -0
  27. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/food_ordering.json +0 -0
  28. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/movie_explorer.json +0 -0
  29. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/patient_intake.json +0 -0
  30. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/restaurant_reservation.json +0 -0
  31. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/travel_planner.json +0 -0
  32. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/favicon.png +0 -0
  33. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/favicon.svg +0 -0
  34. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/index.html +0 -0
  35. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/canvas.js +0 -0
  36. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/editorState.js +0 -0
  37. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/sidePanel.js +0 -0
  38. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/toolbar.js +0 -0
  39. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/main.js +0 -0
  40. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/baseNode.js +0 -0
  41. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/endNode.js +0 -0
  42. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/flowNode.js +0 -0
  43. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/functionNode.js +0 -0
  44. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/index.js +0 -0
  45. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/mergeNode.js +0 -0
  46. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/startNode.js +0 -0
  47. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/types.js +0 -0
  48. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/export.js +0 -0
  49. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/helpers.js +0 -0
  50. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/import.js +0 -0
  51. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/validation.js +0 -0
  52. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/jsdoc.json +0 -0
  53. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/package-lock.json +0 -0
  54. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/package.json +0 -0
  55. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/postcss.config.cjs +0 -0
  56. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/public/favicon.png +0 -0
  57. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/public/favicon.svg +0 -0
  58. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/tailwind.config.cjs +0 -0
  59. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/vercel.json +0 -0
  60. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/vite.config.js +0 -0
  61. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/engine_primitives_plan.md +0 -0
  62. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/env.example +0 -0
  63. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/images/food-ordering-flow.png +0 -0
  64. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/pipecat-flows.png +0 -0
  65. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/pipecat_upgrade.md +0 -0
  66. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/pyproject.toml +0 -0
  67. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/remote-asterisk-code/README.md +0 -0
  68. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/remote-asterisk-code/extensions.conf +0 -0
  69. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/remote-asterisk-code/rtp.conf +0 -0
  70. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/requirements.txt +0 -0
  71. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/scripts/check-pypi-package.py +0 -0
  72. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/scripts/fix-ruff.sh +0 -0
  73. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/scripts/pre-commit.sh +0 -0
  74. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/setup.cfg +0 -0
  75. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  76. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
  77. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  78. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
  79. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/__init__.py +0 -0
  80. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/actions.py +0 -0
  81. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/adapters.py +0 -0
  82. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/exceptions.py +0 -0
  83. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/flow_validator.py +0 -0
  84. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/__init__.py +0 -0
  85. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  86. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  87. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/router_mode.py +0 -0
  88. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/types.py +0 -0
  89. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1484
3
+ Version: 0.0.22.dev1489
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.dev1484
3
+ Version: 0.0.22.dev1489
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
@@ -380,6 +380,18 @@ def _evaluate_rule(
380
380
 
381
381
  rhs = _resolve_side(rule, "value", state, tool_outputs)
382
382
 
383
+ # Fail-closed for jq sides: a jq LHS/RHS that errored or yielded null must not
384
+ # drive a match. Without this a None jq LHS satisfies ``not_equals`` against
385
+ # any non-null RHS (None != value) and misroutes the router — contradicting
386
+ # ``evaluate_jq_side``'s "bad jq degrades the rule to False" policy. Plain
387
+ # variable refs keep their historic None handling (a missing var is still
388
+ # legitimately != a value), and ``exists`` / ``not_exists`` above are untouched.
389
+ if isinstance(rule, dict) and (
390
+ (rule.get("variable_kind") == "jq" and lhs is None)
391
+ or (rule.get("value_kind") == "jq" and rhs is None)
392
+ ):
393
+ return False
394
+
383
395
  if op == "equals":
384
396
  if lhs is None:
385
397
  return False
@@ -2114,6 +2114,32 @@ In all of these cases, you can provide a `name` in your new node's config for de
2114
2114
  "router_default_next"
2115
2115
  )
2116
2116
 
2117
+ async def _timed_router_completion(self, node_id: str, **kwargs: Any) -> Optional[str]:
2118
+ """Run a Collect-path constrained completion, logging its latency.
2119
+
2120
+ Every LLM call the Collect node makes — digit extraction, spoken-value
2121
+ extraction, and the yes/no confirm — funnels through here. The calls are
2122
+ non-streaming (``stream=False``) and return a tiny JSON, so the whole
2123
+ round-trip is effectively the time-to-first-token; it's logged per call,
2124
+ labelled by ``schema_name`` and the resolved model, so the node's LLM
2125
+ tail latency is visible in call logs (grep ``ttft``). The ``finally`` logs
2126
+ even on failure, so a slow call that then errors is still measured.
2127
+ """
2128
+ llm_service = kwargs.get("llm_service")
2129
+ model = (
2130
+ getattr(llm_service, "model_name", None)
2131
+ or getattr(llm_service, "_model_name", None)
2132
+ or getattr(llm_service, "_model", None)
2133
+ or "?"
2134
+ )
2135
+ label = kwargs.get("schema_name", "router_decision")
2136
+ start = time.monotonic()
2137
+ try:
2138
+ return await self._adapter.run_router_completion(**kwargs)
2139
+ finally:
2140
+ elapsed_ms = (time.monotonic() - start) * 1000.0
2141
+ logger.info(f"Collect '{node_id}': {label} on {model} ttft {elapsed_ms:.0f}ms")
2142
+
2117
2143
  async def _extract_spoken_value(
2118
2144
  self,
2119
2145
  node_id: str,
@@ -2134,35 +2160,21 @@ In all of these cases, you can provide a `name` in your new node's config for de
2134
2160
  turn, and ``system_prompt`` overrides the default extraction instruction.
2135
2161
  """
2136
2162
  observer = self._user_turn_observer
2163
+ entry_ts = time.monotonic()
2137
2164
  if observer is not None and timeout_s > 0:
2138
- entry_ts = time.monotonic()
2139
- if observer.last_user_stopped_ts < entry_ts:
2165
+ # Wait for the caller's reply to COMMIT, and stay PATIENT while they're
2166
+ # still mid-utterance, so we read their finished answer (e.g. a
2167
+ # barge-in "जी हाँ, सही है") and not an empty/partial first fragment.
2168
+ deadline = entry_ts + timeout_s
2169
+ while observer.last_user_stopped_ts < entry_ts or self._caller_is_speaking():
2140
2170
  observer.event.clear()
2141
- if observer.last_user_stopped_ts < entry_ts:
2142
- try:
2143
- await asyncio.wait_for(observer.event.wait(), timeout=timeout_s)
2144
- except asyncio.TimeoutError:
2145
- return None
2146
-
2147
- # Scope the extraction to the caller's latest turn (plus any explicit
2148
- # grounding prefix). Feeding the whole transcript makes the constrained
2149
- # extraction ambiguous: the read-back ("I have 8130579687…") and prior
2150
- # digit turns sit in history, so a yes/no confirm would extract the phone
2151
- # number instead of "yes". The confirm passes the read-back question as a
2152
- # `context_prefix` so the model knows WHAT is being confirmed without the
2153
- # whole noisy history.
2154
- context_messages: List[Dict[str, Any]] = list(context_prefix or [])
2155
- if self._context_aggregator:
2156
- ctx = self._context_aggregator.user()._context
2157
- all_messages = (
2158
- ctx.get_messages() if hasattr(ctx, "get_messages")
2159
- else getattr(ctx, "messages", [])
2160
- )
2161
- latest_user = next(
2162
- (m for m in reversed(all_messages) if m.get("role") == "user"), None
2163
- )
2164
- if latest_user is not None:
2165
- context_messages.append(latest_user)
2171
+ remaining = deadline - time.monotonic()
2172
+ if remaining <= 0:
2173
+ break
2174
+ try:
2175
+ await asyncio.wait_for(observer.event.wait(), timeout=remaining)
2176
+ except asyncio.TimeoutError:
2177
+ break
2166
2178
 
2167
2179
  llm_service = self._llm
2168
2180
  if isinstance(llm_service, LLMSwitcher):
@@ -2175,26 +2187,82 @@ In all of these cases, you can provide a `name` in your new node's config for de
2175
2187
  if enum_values:
2176
2188
  value_spec = {"type": "enum", "values": list(enum_values), "description": description}
2177
2189
  schema = build_router_response_schema([], {"value": value_spec})
2178
- try:
2179
- raw = await self._adapter.run_router_completion(
2180
- llm_service=llm_service,
2181
- system_prompt=system_prompt or (
2182
- "Extract the requested value from the caller's latest "
2183
- "message. Put ONLY the value in the `value` field, or an "
2184
- "empty string if the caller did not clearly provide it."
2185
- ),
2186
- response_schema=schema,
2187
- context_messages=context_messages,
2188
- schema_name=f"collect_{node_id}",
2189
- )
2190
- except Exception as exc:
2191
- logger.warning(f"Collect '{node_id}': extraction call failed: {exc!r}")
2192
- return None
2193
- if not raw:
2194
- return None
2195
- _branch, extracted = parse_router_response(raw, [UNCLEAR_BRANCH_ID])
2196
- value = (extracted or {}).get("value")
2197
- return value if isinstance(value, str) and value.strip() else None
2190
+
2191
+ deadline = entry_ts + timeout_s if timeout_s > 0 else entry_ts
2192
+ last_value: Optional[str] = None
2193
+ # Extract, then RE-EXTRACT if the caller kept talking while we were calling
2194
+ # the LLM. A paused / fragmented reply ("उह…" <pause> "जी हाँ, यह सही है")
2195
+ # commits its first fragment as its own turn, so a single extraction fires
2196
+ # on the leading filler and misses the real answer which is exactly how a
2197
+ # clear "yes" got read as a rejection. The LLM round-trip doubles as a
2198
+ # settle window: if the reply GREW (or the caller is still mid-utterance) by
2199
+ # the time it returns, our input was partial, so we let them finish and
2200
+ # extract again. Bounded so a chatty caller can never loop us unbounded, and
2201
+ # zero added latency when the first reply is already complete.
2202
+ for _ in range(3):
2203
+ # Read the caller's reply from the NON-LOSSY observer log — the context
2204
+ # aggregator DROPS turns on barge-in, which made the confirm read an
2205
+ # empty/partial reply. Join any chunks spoken since we started listening
2206
+ # so a fragmented answer reaches the model whole. ``context_prefix``
2207
+ # carries the read-back question so the model knows WHAT is being
2208
+ # confirmed. Fall back to the aggregator when there's no observer (tests).
2209
+ reply = ""
2210
+ if observer is not None and hasattr(observer, "transcripts_since"):
2211
+ reply = " ".join(observer.transcripts_since(entry_ts)).strip()
2212
+ context_messages: List[Dict[str, Any]] = list(context_prefix or [])
2213
+ if reply:
2214
+ context_messages.append({"role": "user", "content": reply})
2215
+ elif self._context_aggregator:
2216
+ ctx = self._context_aggregator.user()._context
2217
+ all_messages = (
2218
+ ctx.get_messages() if hasattr(ctx, "get_messages")
2219
+ else getattr(ctx, "messages", [])
2220
+ )
2221
+ latest_user = next(
2222
+ (m for m in reversed(all_messages) if m.get("role") == "user"), None
2223
+ )
2224
+ if latest_user is not None:
2225
+ context_messages.append(latest_user)
2226
+ try:
2227
+ raw = await self._timed_router_completion(
2228
+ node_id,
2229
+ llm_service=llm_service,
2230
+ system_prompt=system_prompt or (
2231
+ "Extract the requested value from the caller's latest "
2232
+ "message. Put ONLY the value in the `value` field, or an "
2233
+ "empty string if the caller did not clearly provide it."
2234
+ ),
2235
+ response_schema=schema,
2236
+ context_messages=context_messages,
2237
+ schema_name=f"collect_{node_id}",
2238
+ )
2239
+ except Exception as exc:
2240
+ logger.warning(f"Collect '{node_id}': extraction call failed: {exc!r}")
2241
+ return last_value
2242
+ if raw:
2243
+ _branch, extracted = parse_router_response(raw, [UNCLEAR_BRANCH_ID])
2244
+ value = (extracted or {}).get("value")
2245
+ if isinstance(value, str) and value.strip():
2246
+ last_value = value
2247
+ # No observer (unit tests) → single shot. Otherwise re-extract only if
2248
+ # the caller said MORE during the call or is still speaking, and time
2249
+ # remains.
2250
+ if observer is None or not hasattr(observer, "transcripts_since"):
2251
+ break
2252
+ grew = " ".join(observer.transcripts_since(entry_ts)).strip() != reply
2253
+ if (not grew and not self._caller_is_speaking()) or time.monotonic() >= deadline:
2254
+ break
2255
+ # Let the caller finish the rest of their reply before re-extracting, so
2256
+ # we don't fire again on yet another partial.
2257
+ while self._caller_is_speaking() and time.monotonic() < deadline:
2258
+ observer.event.clear()
2259
+ try:
2260
+ await asyncio.wait_for(
2261
+ observer.event.wait(), timeout=max(0.05, deadline - time.monotonic())
2262
+ )
2263
+ except asyncio.TimeoutError:
2264
+ break
2265
+ return last_value
2198
2266
 
2199
2267
  def _active_language(self) -> str:
2200
2268
  """The conversation's current language for caller-facing prompts.
@@ -2267,6 +2335,21 @@ In all of these cases, you can provide a `name` in your new node's config for de
2267
2335
  )
2268
2336
  return turns
2269
2337
 
2338
+ def _collect_turns_since(self, since_ts: float) -> List[str]:
2339
+ """Caller turns (voice + keypad) committed since ``since_ts``, read from
2340
+ the NON-LOSSY observer transcript log rather than the context aggregator.
2341
+
2342
+ The context aggregator (and the transcript handler) DROP a turn when the
2343
+ caller re-speaks before it's pushed — so a chunked dictation like
2344
+ "70823" … "41303" loses the first chunk, and Collect reads a partial.
2345
+ The observer captured the raw ``TranscriptionFrame`` upstream of that
2346
+ drop, so reading from it recovers every chunk. Falls back to ``[]`` when
2347
+ there's no observer (unit tests stub the extractor directly)."""
2348
+ observer = self._user_turn_observer
2349
+ if observer is not None and hasattr(observer, "transcripts_since"):
2350
+ return observer.transcripts_since(since_ts)
2351
+ return []
2352
+
2270
2353
  async def _drain_user_turns(self, settle_s: float) -> None:
2271
2354
  """Absorb any rapid follow-up turns within a short settle window, so a
2272
2355
  multi-chunk dictation triggers ONE digit extraction instead of one per
@@ -2274,6 +2357,46 @@ In all of these cases, you can provide a `name` in your new node's config for de
2274
2357
  while await self._await_user_turn(timeout_s=settle_s):
2275
2358
  pass
2276
2359
 
2360
+ # Absolute backstop: most a Capture accumulate-loop will wait before giving
2361
+ # up, so a hot mic / endless background noise that VAD reads as "speaking"
2362
+ # can never trap the call in the node (the activity-aware wait below would
2363
+ # otherwise reset forever).
2364
+ _MAX_CAPTURE_SECONDS = 90.0
2365
+
2366
+ def _caller_is_speaking(self) -> bool:
2367
+ """True while the caller has an IN-PROGRESS utterance — they started more
2368
+ recently than they last stopped. Lets the Capture node avoid nudging over
2369
+ them or acting on a half-spoken value. False when there's no observer
2370
+ yet (e.g. tests / pre-pipeline)."""
2371
+ observer = self._user_turn_observer
2372
+ return bool(observer and observer.last_user_started_ts > observer.last_user_stopped_ts)
2373
+
2374
+ async def _wait_for_caller_input(
2375
+ self,
2376
+ timeout_s: float,
2377
+ deadline_ts: float,
2378
+ allow_keypad: bool,
2379
+ keypad_dtmf: Dict[str, Any],
2380
+ ) -> bool:
2381
+ """Wait for a committed caller turn (voice, or a keypad fallback). PATIENT
2382
+ while the caller is mid-utterance: a voice timeout that lands while they
2383
+ are still speaking is NOT counted as silence — keep waiting for them to
2384
+ finish, bounded by ``deadline_ts``. Returns True if input arrived, False
2385
+ on genuine silence (or once the absolute deadline passes)."""
2386
+ while True:
2387
+ if await self._await_user_turn(timeout_s):
2388
+ return True
2389
+ # Voice window elapsed with nothing committed. If they're still
2390
+ # talking and we have time, keep waiting rather than nudging over them.
2391
+ if self._caller_is_speaking() and time.monotonic() < deadline_ts:
2392
+ continue
2393
+ # Genuine silence → optional keypad fallback, else report silence.
2394
+ if allow_keypad:
2395
+ capture_timeout = float((keypad_dtmf or {}).get("timeout", 8.0)) + 10.0
2396
+ if await self._await_dtmf_capture(keypad_dtmf, capture_timeout):
2397
+ return True
2398
+ return False
2399
+
2277
2400
  async def _extract_accumulated_digits(
2278
2401
  self, node_id: str, turns: List[str], what: str = "phone number"
2279
2402
  ) -> str:
@@ -2305,17 +2428,33 @@ In all of these cases, you can provide a `name` in your new node's config for de
2305
2428
  }},
2306
2429
  )
2307
2430
  try:
2308
- raw = await self._adapter.run_router_completion(
2431
+ raw = await self._timed_router_completion(
2432
+ node_id,
2309
2433
  llm_service=llm_service,
2310
2434
  system_prompt=(
2311
2435
  f"The caller is dictating a {what} to you, possibly across "
2312
2436
  "several messages and in ANY language (English, Hindi, etc.), or "
2313
- f"as keypad input (a message like 'User inputted: 5'). Read "
2437
+ "as keypad input (a message like 'User inputted: 5'). Read "
2314
2438
  f"EVERYTHING they have said and put the full {what} — every digit, "
2315
2439
  "in the order spoken — into `value` as digits 0-9 only, with no "
2316
- "spaces or punctuation. If they correct or restate it, "
2317
- "use their final intended value. Output an empty string if they "
2318
- "have given no digits yet."
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 thing — APPLY 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."
2319
2458
  ),
2320
2459
  response_schema=schema,
2321
2460
  context_messages=context_messages,
@@ -2330,6 +2469,32 @@ In all of these cases, you can provide a `name` in your new node's config for de
2330
2469
  value = (extracted or {}).get("value")
2331
2470
  return re.sub(r"\D", "", value) if isinstance(value, str) else ""
2332
2471
 
2472
+ async def _reextract_if_corrected(
2473
+ self,
2474
+ node_id: str,
2475
+ collect_type: str,
2476
+ start_ts: float,
2477
+ current_normalized: Any,
2478
+ *,
2479
+ what: str = "phone number",
2480
+ **norm_opts: Any,
2481
+ ) -> Optional[Tuple[str, Any]]:
2482
+ """After a REJECTED confirm, re-extract over the turns we already have —
2483
+ the caller's correction ("no, the end is 5") is already among them — and
2484
+ return ``(new_buffer, new_normalized)`` when it resolves to a DIFFERENT
2485
+ valid value. That means the caller actually corrected the number, so we
2486
+ re-read the corrected value at once instead of asking them to repeat it.
2487
+ Returns ``None`` for a plain rejection that carries no new/changed digits
2488
+ (the caller just said "no") → fall through to the "what should it be?" ask.
2489
+ """
2490
+ new_buffer = await self._extract_accumulated_digits(
2491
+ node_id, self._collect_turns_since(start_ts), what=what
2492
+ )
2493
+ new_normalized = normalize_collect_value(collect_type, new_buffer, **norm_opts)
2494
+ if new_normalized and new_normalized != current_normalized:
2495
+ return new_buffer, new_normalized
2496
+ return None
2497
+
2333
2498
  async def _run_collect_node(
2334
2499
  self, node_id: str, node_config: Dict[str, Any]
2335
2500
  ) -> Optional[str]:
@@ -2410,72 +2575,89 @@ In all of these cases, you can provide a `name` in your new node's config for de
2410
2575
  the caller. Returns the E.164 value or ``None``.
2411
2576
  """
2412
2577
  target = 10 # min national digits; validity decided by normalize_phone
2413
- # Snapshot the caller-turn count so we read only turns spoken to THIS
2414
- # node; a confirm "no" / invalid advances it to ignore the rejected attempt.
2415
- start_index = len(self._user_messages())
2578
+ # Snapshot a monotonic time so we read (from the non-lossy observer log)
2579
+ # only turns spoken to THIS node; a confirm "no" / invalid advances it to
2580
+ # ignore the rejected attempt's turns.
2581
+ start_ts = time.monotonic()
2416
2582
  buffer = ""
2583
+ echoed = "" # bare digits already spoken back, so we echo only the NEW ones
2417
2584
  misses = 0
2585
+ corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2586
+ deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2418
2587
 
2419
2588
  async def _nudge(language: str) -> None:
2420
- """Re-ask: echo a partial and ask for the rest, else the author reask."""
2421
- if buffer:
2422
- remaining = max(0, target - len(buffer))
2423
- unit = collect_prompt(language, "unit_one" if remaining == 1 else "unit_many")
2424
- await self._task.queue_frame(TTSSpeakFrame(text=collect_prompt(
2425
- language, "continue_partial", readback=" ".join(buffer),
2426
- remaining=remaining, unit=unit,
2427
- )))
2428
- elif reask:
2589
+ """Re-prompt ONLY when we have nothing yet (the author's reask). Holding a
2590
+ partial → stay silent: the progress echo already spoke the digits, so
2591
+ re-speaking them just sounds like a repeat (and talks over the caller)."""
2592
+ if not buffer and reask:
2429
2593
  await self._task.queue_frame(TTSSpeakFrame(text=reask))
2430
2594
 
2431
2595
  while misses < max_attempts:
2432
2596
  language = self._active_language()
2433
- # Give the caller longer to START dictating; once we hold a partial,
2434
- # nudge sooner if they pause rather than leaving dead air.
2435
- got_turn = await self._await_user_turn(timeout_s=7.0 if buffer else 15.0)
2436
- if not got_turn and allow_keypad:
2437
- # Keypad fallback: a keypress commits a "User inputted: N." turn
2438
- # that the extractor reads alongside spoken turns.
2439
- capture_timeout = float(keypad_dtmf.get("timeout", 8.0)) + 10.0
2440
- if await self._await_dtmf_capture(keypad_dtmf, capture_timeout):
2441
- got_turn = True
2597
+ # Longer window to START dictating; 7s once we hold a partial. Patient
2598
+ # while the caller is mid-utterance, so a timeout can't clip them.
2599
+ got_turn = await self._wait_for_caller_input(
2600
+ 7.0 if buffer else 15.0, deadline, allow_keypad, keypad_dtmf
2601
+ )
2442
2602
  if not got_turn:
2443
- # Silence: nothing new heard → re-ask, count a miss.
2603
+ if time.monotonic() >= deadline:
2604
+ break # absolute cap reached → give up rather than loop forever
2605
+ # Genuine silence (caller not mid-utterance): re-ask, count a miss.
2444
2606
  misses += 1
2445
2607
  if misses < max_attempts:
2446
2608
  await _nudge(language)
2447
2609
  continue
2448
2610
 
2449
- # Collapse a rapid multi-chunk dictation into a single extraction.
2450
- await self._drain_user_turns(settle_s=1.2)
2451
-
2452
2611
  previous = buffer
2612
+ turns_before = len(self._collect_turns_since(start_ts))
2453
2613
  buffer = await self._extract_accumulated_digits(
2454
- node_id, self._caller_turns_since(start_index)
2614
+ node_id, self._collect_turns_since(start_ts)
2455
2615
  )
2616
+ # Freshness: a turn committed during extraction, or the caller is
2617
+ # speaking now → the value may be stale (mid-correction). Re-extract
2618
+ # next pass instead of reading back a number they're still changing.
2619
+ if len(self._collect_turns_since(start_ts)) > turns_before or self._caller_is_speaking():
2620
+ continue
2456
2621
  normalized = normalize_collect_value(
2457
2622
  "phone", buffer, country_default=country_default
2458
2623
  )
2459
2624
  if normalized:
2460
- confirmed = await self._confirm_collected(node_id, " ".join(buffer))
2461
- if confirmed is True:
2462
- return normalized
2463
- # Wrong / unclear start over: ignore everything said so far so the
2464
- # next dictation isn't re-extracted together with the rejected one.
2465
- start_index = len(self._user_messages())
2466
- buffer = ""
2467
- if confirmed is False:
2625
+ # Confirm; on a "no, it's X" correction RE-EXTRACT over the turns we
2626
+ # already have (the caller's fix is already among them) and re-read
2627
+ # the CORRECTED number at once — instead of asking "what should it
2628
+ # be?" and making them repeat what they just said. The idempotent
2629
+ # extractor merges "no, the first part is 807" into 70823… →
2630
+ # 8072341303. KEEP start_ts throughout so corrections resolve against
2631
+ # the ORIGINAL number. Only a plain rejection with no new digits
2632
+ # falls through to the re-ask. Bounded by ``corrections``.
2633
+ while True:
2634
+ confirmed = await self._confirm_collected(node_id, " ".join(buffer))
2635
+ if confirmed is True:
2636
+ return normalized
2637
+ corrections += 1
2638
+ if corrections >= max_attempts:
2639
+ break
2640
+ corrected = await self._reextract_if_corrected(
2641
+ node_id, "phone", start_ts, normalized,
2642
+ country_default=country_default,
2643
+ )
2644
+ if corrected is not None:
2645
+ buffer, normalized = corrected
2646
+ continue # re-read the corrected number, no "what should it be?"
2647
+ buffer = ""
2468
2648
  await self._task.queue_frame(
2469
2649
  TTSSpeakFrame(text=collect_prompt(language, "restart_denied"))
2470
2650
  )
2471
- else:
2472
- misses += 1
2651
+ break
2652
+ if corrections >= max_attempts:
2653
+ break
2473
2654
  continue
2474
2655
 
2475
2656
  if len(buffer) >= target:
2476
2657
  # Enough digits but not a valid number → reset and re-ask.
2477
- start_index = len(self._user_messages())
2658
+ start_ts = time.monotonic()
2478
2659
  buffer = ""
2660
+ echoed = ""
2479
2661
  misses += 1
2480
2662
  if misses < max_attempts:
2481
2663
  await self._task.queue_frame(
@@ -2483,16 +2665,29 @@ In all of these cases, you can provide a `name` in your new node's config for de
2483
2665
  )
2484
2666
  continue
2485
2667
 
2486
- if len(buffer) == len(previous):
2487
- # A turn arrived but added no new digits (filler / mis-hear) →
2488
- # re-ask and count a miss so garbage can't loop forever.
2668
+ 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.
2489
2675
  misses += 1
2490
2676
  if misses < max_attempts:
2491
2677
  await _nudge(language)
2492
2678
  continue
2493
2679
 
2494
- # Buffer grew but is still incomplete stay silent and let the caller
2495
- # keep dictating; don't interrupt mid-number or burn an attempt.
2680
+ # Buffer CHANGED (progress new digits OR a correction) echo it back
2681
+ # (bare, in the caller's language) so they hear we caught the change, and
2682
+ # reset the give-up counter. A clean append echoes only the NEW digits; a
2683
+ # correction (not a prefix of what we echoed) re-echoes the full number.
2684
+ delta = buffer[len(echoed):] if buffer.startswith(echoed) else buffer
2685
+ if delta:
2686
+ await self._task.queue_frame(TTSSpeakFrame(
2687
+ text=collect_prompt(language, "echo", digits=" ".join(delta))
2688
+ ))
2689
+ echoed = buffer
2690
+ misses = 0
2496
2691
 
2497
2692
  logger.info(f"Collect '{node_id}': gave up after {misses} misses (buffer='{buffer}')")
2498
2693
  return None
@@ -2518,65 +2713,78 @@ In all of these cases, you can provide a `name` in your new node's config for de
2518
2713
  digits). Returns the digit string or ``None`` on give-up.
2519
2714
  """
2520
2715
  target = int(num_digits) if num_digits else 6
2521
- start_index = len(self._user_messages())
2716
+ start_ts = time.monotonic()
2522
2717
  buffer = ""
2718
+ echoed = "" # bare digits already spoken back, so we echo only the NEW ones
2523
2719
  misses = 0
2720
+ corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2721
+ deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2524
2722
  # Keypad auto-flushes once the caller has keyed `target` digits.
2525
2723
  code_dtmf = {**(keypad_dtmf or {}), "digits": target}
2526
2724
 
2527
2725
  async def _nudge(language: str) -> None:
2528
- if buffer:
2529
- remaining = max(0, target - len(buffer))
2530
- unit = collect_prompt(language, "unit_one" if remaining == 1 else "unit_many")
2531
- await self._task.queue_frame(TTSSpeakFrame(text=collect_prompt(
2532
- language, "continue_partial", readback=" ".join(buffer),
2533
- remaining=remaining, unit=unit,
2534
- )))
2535
- elif reask:
2726
+ """Re-prompt ONLY when we have nothing yet (the author's reask); holding a
2727
+ partial stay silent (the progress echo already spoke the digits)."""
2728
+ if not buffer and reask:
2536
2729
  await self._task.queue_frame(TTSSpeakFrame(text=reask))
2537
2730
 
2538
2731
  while misses < max_attempts:
2539
2732
  language = self._active_language()
2540
- got_turn = await self._await_user_turn(timeout_s=7.0 if buffer else 15.0)
2541
- if not got_turn and allow_keypad:
2542
- capture_timeout = float(code_dtmf.get("timeout", 8.0)) + 10.0
2543
- if await self._await_dtmf_capture(code_dtmf, capture_timeout):
2544
- got_turn = True
2733
+ got_turn = await self._wait_for_caller_input(
2734
+ 7.0 if buffer else 15.0, deadline, allow_keypad, code_dtmf
2735
+ )
2545
2736
  if not got_turn:
2737
+ if time.monotonic() >= deadline:
2738
+ break # absolute cap reached → give up
2546
2739
  misses += 1
2547
2740
  if misses < max_attempts:
2548
2741
  await _nudge(language)
2549
2742
  continue
2550
2743
 
2551
- # Collapse a rapid multi-chunk dictation into a single extraction.
2552
- await self._drain_user_turns(settle_s=1.2)
2553
-
2554
2744
  previous = buffer
2745
+ turns_before = len(self._collect_turns_since(start_ts))
2555
2746
  buffer = await self._extract_accumulated_digits(
2556
- node_id, self._caller_turns_since(start_index), what="numeric code"
2747
+ node_id, self._collect_turns_since(start_ts), what="numeric code"
2557
2748
  )
2749
+ # Freshness: stale if a turn committed mid-extraction or they're still
2750
+ # speaking → re-extract next pass rather than read back a half-code.
2751
+ if len(self._collect_turns_since(start_ts)) > turns_before or self._caller_is_speaking():
2752
+ continue
2558
2753
  normalized = normalize_collect_value(
2559
2754
  collect_type, buffer, num_digits=target
2560
2755
  )
2561
2756
  if normalized:
2562
- confirmed = await self._confirm_collected(node_id, " ".join(normalized))
2563
- if confirmed is True:
2564
- return normalized
2565
- # Wrong / unclear → start over: ignore everything said so far.
2566
- start_index = len(self._user_messages())
2567
- buffer = ""
2568
- if confirmed is False:
2757
+ # Same as phone: a "no, it's X" correction re-extracts over the turns
2758
+ # we already have and re-reads the CORRECTED code at once; only a
2759
+ # plain rejection with no new digits asks "what should it be?".
2760
+ while True:
2761
+ confirmed = await self._confirm_collected(node_id, " ".join(normalized))
2762
+ if confirmed is True:
2763
+ return normalized
2764
+ corrections += 1
2765
+ if corrections >= max_attempts:
2766
+ break
2767
+ corrected = await self._reextract_if_corrected(
2768
+ node_id, collect_type, start_ts, normalized,
2769
+ what="numeric code", num_digits=target,
2770
+ )
2771
+ if corrected is not None:
2772
+ buffer, normalized = corrected
2773
+ continue # re-read the corrected code, no "what should it be?"
2774
+ buffer = ""
2569
2775
  await self._task.queue_frame(
2570
2776
  TTSSpeakFrame(text=collect_prompt(language, "restart_denied"))
2571
2777
  )
2572
- else:
2573
- misses += 1
2778
+ break
2779
+ if corrections >= max_attempts:
2780
+ break
2574
2781
  continue
2575
2782
 
2576
2783
  if len(buffer) > target:
2577
2784
  # Too many digits for this code → reset and re-ask.
2578
- start_index = len(self._user_messages())
2785
+ start_ts = time.monotonic()
2579
2786
  buffer = ""
2787
+ echoed = ""
2580
2788
  misses += 1
2581
2789
  if misses < max_attempts:
2582
2790
  await self._task.queue_frame(
@@ -2584,48 +2792,85 @@ In all of these cases, you can provide a `name` in your new node's config for de
2584
2792
  )
2585
2793
  continue
2586
2794
 
2587
- if len(buffer) == len(previous):
2588
- # A turn arrived but added no new digits → re-ask, count a miss.
2795
+ 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.
2589
2799
  misses += 1
2590
2800
  if misses < max_attempts:
2591
2801
  await _nudge(language)
2592
2802
  continue
2593
2803
 
2594
- # Buffer grew but is still shortstay silent and keep listening.
2804
+ # Buffer CHANGED (new digits OR a correction) echo it back, bare, and
2805
+ # reset the give-up counter. Append → only the new digits; a correction →
2806
+ # the full code re-echoed.
2807
+ delta = buffer[len(echoed):] if buffer.startswith(echoed) else buffer
2808
+ if delta:
2809
+ await self._task.queue_frame(TTSSpeakFrame(
2810
+ text=collect_prompt(language, "echo", digits=" ".join(delta))
2811
+ ))
2812
+ echoed = buffer
2813
+ misses = 0
2595
2814
 
2596
2815
  logger.info(f"Collect '{node_id}': code give-up after {misses} misses (buffer='{buffer}')")
2597
2816
  return None
2598
2817
 
2599
- async def _confirm_collected(self, node_id: str, readback: str) -> Optional[bool]:
2818
+ async def _confirm_collected(
2819
+ self, node_id: str, readback: str, attempts: int = 2
2820
+ ) -> Optional[bool]:
2600
2821
  """Read the value back and capture a yes/no. Returns ``True`` (confirmed),
2601
- ``False`` (denied), or ``None`` (no / unclear answer)."""
2602
- await self._task.queue_frame(
2603
- TTSSpeakFrame(text=collect_prompt(self._active_language(), "confirm", readback=readback))
2604
- )
2605
- # Structured yes/no enum: the model must return "yes" or "no" (or null
2606
- # when genuinely unclear), grounded with the read-back question so it
2607
- # knows WHAT is being confirmed. Works in any language.
2608
- answer = await self._extract_spoken_value(
2609
- node_id,
2610
- "yes if the caller agrees the value is correct, no if they say it is wrong",
2611
- timeout_s=10.0,
2612
- system_prompt=(
2613
- "The assistant just read a value back to the caller and asked them "
2614
- "to confirm it (see the assistant message). Based ONLY on the "
2615
- "caller's latest reply — in any language — answer whether they "
2616
- "confirmed it is correct."
2617
- ),
2618
- context_prefix=[
2619
- {"role": "assistant", "content": f"I have {readback}. Is that correct?"}
2620
- ],
2621
- enum_values=["yes", "no"],
2622
- )
2623
- text = (answer or "").strip().lower()
2624
- if text.startswith("yes"):
2625
- return True
2626
- if text.startswith("no"):
2627
- return False
2628
- return None # null / unclear → re-ask
2822
+ ``False`` (denied), or ``None`` (still unclear after re-asking).
2823
+
2824
+ Re-asks the yes/no up to ``attempts`` times: a barge-in / fragmented first
2825
+ reply ("जी" … "हाँ, सही है") can read as unclear, and we'd rather re-ask
2826
+ "is that correct?" than treat a confirming caller as a rejection."""
2827
+ observer = self._user_turn_observer
2828
+ for _ in range(max(1, attempts)):
2829
+ listen_ts = time.monotonic()
2830
+ await self._task.queue_frame(
2831
+ TTSSpeakFrame(text=collect_prompt(self._active_language(), "confirm", readback=readback))
2832
+ )
2833
+ # Structured yes/no enum: the model must return "yes" or "no" (or null
2834
+ # when genuinely unclear), grounded with the read-back question so it
2835
+ # knows WHAT is being confirmed. Works in any language.
2836
+ answer = await self._extract_spoken_value(
2837
+ node_id,
2838
+ "yes if the caller agrees the value is correct, no if they say it is wrong",
2839
+ timeout_s=10.0,
2840
+ system_prompt=(
2841
+ "The assistant just read a value back to the caller and asked them "
2842
+ "to confirm it (see the assistant message). Based ONLY on the "
2843
+ "caller's latest reply — in any language — answer whether they "
2844
+ "confirmed it is correct. If they point out ANY mistake or give a "
2845
+ "different value — even while sounding agreeable (e.g. 'no, the "
2846
+ "last one is 5 not 3', 'haan but change the first digit') — that "
2847
+ "is NOT confirmed; answer 'no'. Answer 'yes' only if they accept "
2848
+ "it exactly as read."
2849
+ ),
2850
+ context_prefix=[
2851
+ {"role": "assistant", "content": f"I have {readback}. Is that correct?"}
2852
+ ],
2853
+ enum_values=["yes", "no"],
2854
+ )
2855
+ # Hard guard: a genuine confirmation never contains digits, a correction
2856
+ # ("no, the last is 5 not 3") always does. If the reply has any digit,
2857
+ # treat it as a correction (no) regardless of the model — otherwise a
2858
+ # mis-read "yes" stores the UN-corrected number. Read from the non-lossy
2859
+ # observer so a dropped/fragmented reply is still seen.
2860
+ reply = (
2861
+ " ".join(observer.transcripts_since(listen_ts))
2862
+ if observer is not None and hasattr(observer, "transcripts_since")
2863
+ else ""
2864
+ )
2865
+ if any(ch.isdigit() for ch in reply):
2866
+ return False
2867
+ text = (answer or "").strip().lower()
2868
+ if text.startswith("yes"):
2869
+ return True
2870
+ if text.startswith("no"):
2871
+ return False
2872
+ # null / unclear → re-ask "is that correct?" (next loop iteration)
2873
+ return None
2629
2874
 
2630
2875
  async def _collect_single_value(
2631
2876
  self,
@@ -2651,6 +2896,13 @@ In all of these cases, you can provide a `name` in your new node's config for de
2651
2896
  if not raw_value and allow_keypad:
2652
2897
  capture_timeout = float(keypad_dtmf.get("timeout", 8.0)) + 10.0
2653
2898
  raw_value = await self._await_dtmf_capture(keypad_dtmf, capture_timeout)
2899
+ # Freshness: if they're still finishing the address, let them complete
2900
+ # and re-read once before validating — don't act on a half-heard value.
2901
+ if raw_value and self._caller_is_speaking():
2902
+ if await self._await_user_turn(timeout_s=5.0):
2903
+ raw_value = await self._extract_spoken_value(
2904
+ node_id, description, timeout_s=15.0
2905
+ )
2654
2906
  normalized = normalize_collect_value(
2655
2907
  collect_type, raw_value, country_default=country_default
2656
2908
  )
@@ -33,11 +33,21 @@ from __future__ import annotations
33
33
 
34
34
  import asyncio
35
35
  import time
36
+ from collections import deque
37
+ from typing import List, Tuple
36
38
 
37
- from pipecat.frames.frames import UserStartedSpeakingFrame
39
+ from pipecat.frames.frames import (
40
+ InterimTranscriptionFrame,
41
+ TranscriptionFrame,
42
+ UserStartedSpeakingFrame,
43
+ )
38
44
  from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame
39
45
  from pipecat.observers.base_observer import BaseObserver, FramePushed
40
46
 
47
+ # Bound the per-call transcript log so a very long call can't grow it without
48
+ # limit; far more than any single Collect dictation needs.
49
+ _MAX_TRANSCRIPT_LOG = 256
50
+
41
51
 
42
52
  class UserTurnObserver(BaseObserver):
43
53
  """Tracks the timestamp of the most recent ``OpenAILLMContextFrame``.
@@ -69,6 +79,17 @@ class UserTurnObserver(BaseObserver):
69
79
  # resolver has to decide stash-vs-shortcircuit.
70
80
  self.last_user_started_ts: float = 0.0
71
81
  self._event: asyncio.Event = asyncio.Event()
82
+ # Non-lossy log of FINAL transcripts seen on the wire, as
83
+ # ``(monotonic_ts, text)``. The downstream context aggregator AND the
84
+ # transcript handler both DROP a turn when the caller re-speaks
85
+ # (interruption), which silently loses chunks of a multi-part dictation.
86
+ # The observer sees the raw ``TranscriptionFrame`` BEFORE any drop, so
87
+ # the Collect node reads its turns from here to never lose a chunk.
88
+ self._final_transcripts: List[Tuple[float, str]] = []
89
+ # ``on_push_frame`` fires once per pipeline HOP, so the same frame is seen
90
+ # many times as it flows downstream. Dedupe by frame id over a bounded
91
+ # recent window so a transcript is logged exactly once (not once per hop).
92
+ self._seen_frame_ids: deque = deque(maxlen=128)
72
93
 
73
94
  @property
74
95
  def event(self) -> asyncio.Event:
@@ -91,3 +112,27 @@ class UserTurnObserver(BaseObserver):
91
112
  # this purely as a barge-in detection signal for the interrupt
92
113
  # classifier dispatch (`manager._resolve_pure_code_target`).
93
114
  self.last_user_started_ts = time.monotonic()
115
+ elif isinstance(data.frame, TranscriptionFrame) and not isinstance(
116
+ data.frame, InterimTranscriptionFrame
117
+ ):
118
+ # FINAL transcript on the wire — record it BEFORE any downstream
119
+ # interruption-drop so a multi-chunk dictation never loses a chunk.
120
+ # Dedupe by frame id: on_push_frame fires once per hop, so the same
121
+ # frame arrives many times — log it only the first time.
122
+ frame_id = getattr(data.frame, "id", None)
123
+ if frame_id is not None and frame_id in self._seen_frame_ids:
124
+ return
125
+ if frame_id is not None:
126
+ self._seen_frame_ids.append(frame_id)
127
+ text = (getattr(data.frame, "text", "") or "").strip()
128
+ if text:
129
+ self._final_transcripts.append((time.monotonic(), text))
130
+ if len(self._final_transcripts) > _MAX_TRANSCRIPT_LOG:
131
+ del self._final_transcripts[:-_MAX_TRANSCRIPT_LOG]
132
+
133
+ def transcripts_since(self, since_ts: float) -> List[str]:
134
+ """Texts of the FINAL transcripts seen strictly after ``since_ts``
135
+ (monotonic), in arrival order. Non-lossy: includes chunks the downstream
136
+ aggregator / transcript handler dropped on a re-speech interruption, so
137
+ the Collect node can re-extract the full number across all chunks."""
138
+ return [text for ts, text in self._final_transcripts if ts > since_ts]