dv-pipecat-flows 0.0.22.dev1484__tar.gz → 0.0.22.dev1487__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.dev1487}/PKG-INFO +1 -1
  2. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
  3. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/condition_evaluator.py +12 -0
  4. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/manager.py +369 -132
  5. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/user_turn_observer.py +46 -1
  6. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.agents/skills/loki-logs/SKILL.md +0 -0
  7. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.agents/skills/loki-logs/query-reference.md +0 -0
  8. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.claude/skills/loki-logs/SKILL.md +0 -0
  9. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.claude/skills/loki-logs/query-reference.md +0 -0
  10. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.gitattributes +0 -0
  11. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.gitignore +0 -0
  12. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.pre-commit-config.yaml +0 -0
  13. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.python-version +0 -0
  14. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.readthedocs.yaml +0 -0
  15. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/AGENTS.md +0 -0
  16. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/CHANGELOG.md +0 -0
  17. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/CLAUDE.md +0 -0
  18. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/CONTRIBUTING.md +0 -0
  19. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/LICENSE +0 -0
  20. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/MANIFEST.in +0 -0
  21. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/README.md +0 -0
  22. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/dev-requirements.txt +0 -0
  23. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/docker-compose.dev.yml +0 -0
  24. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/.eslintrc.json +0 -0
  25. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/.prettierrc +0 -0
  26. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/css/tailwind.css +0 -0
  27. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/food_ordering.json +0 -0
  28. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/movie_explorer.json +0 -0
  29. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/patient_intake.json +0 -0
  30. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/restaurant_reservation.json +0 -0
  31. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/travel_planner.json +0 -0
  32. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/favicon.png +0 -0
  33. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/favicon.svg +0 -0
  34. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/index.html +0 -0
  35. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/canvas.js +0 -0
  36. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/editorState.js +0 -0
  37. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/sidePanel.js +0 -0
  38. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/toolbar.js +0 -0
  39. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/main.js +0 -0
  40. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/baseNode.js +0 -0
  41. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/endNode.js +0 -0
  42. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/flowNode.js +0 -0
  43. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/functionNode.js +0 -0
  44. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/index.js +0 -0
  45. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/mergeNode.js +0 -0
  46. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/startNode.js +0 -0
  47. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/types.js +0 -0
  48. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/export.js +0 -0
  49. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/helpers.js +0 -0
  50. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/import.js +0 -0
  51. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/validation.js +0 -0
  52. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/jsdoc.json +0 -0
  53. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/package-lock.json +0 -0
  54. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/package.json +0 -0
  55. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/postcss.config.cjs +0 -0
  56. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/public/favicon.png +0 -0
  57. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/public/favicon.svg +0 -0
  58. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/tailwind.config.cjs +0 -0
  59. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/vercel.json +0 -0
  60. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/vite.config.js +0 -0
  61. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/engine_primitives_plan.md +0 -0
  62. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/env.example +0 -0
  63. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/images/food-ordering-flow.png +0 -0
  64. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/pipecat-flows.png +0 -0
  65. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/pipecat_upgrade.md +0 -0
  66. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/pyproject.toml +0 -0
  67. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/README.md +0 -0
  68. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/extensions.conf +0 -0
  69. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/rtp.conf +0 -0
  70. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/requirements.txt +0 -0
  71. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/scripts/check-pypi-package.py +0 -0
  72. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/scripts/fix-ruff.sh +0 -0
  73. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/scripts/pre-commit.sh +0 -0
  74. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/setup.cfg +0 -0
  75. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
  76. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/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.dev1487}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
  78. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/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.dev1487}/src/pipecat_flows/__init__.py +0 -0
  80. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/actions.py +0 -0
  81. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/adapters.py +0 -0
  82. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/exceptions.py +0 -0
  83. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/flow_validator.py +0 -0
  84. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/__init__.py +0 -0
  85. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
  86. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
  87. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/router_mode.py +0 -0
  88. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/types.py +0 -0
  89. {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/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.dev1487
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.dev1487
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,11 +2575,14 @@ 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 = ""
2417
2583
  misses = 0
2584
+ corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2585
+ deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2418
2586
 
2419
2587
  async def _nudge(language: str) -> None:
2420
2588
  """Re-ask: echo a partial and ask for the rest, else the author reask."""
@@ -2430,51 +2598,68 @@ In all of these cases, you can provide a `name` in your new node's config for de
2430
2598
 
2431
2599
  while misses < max_attempts:
2432
2600
  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
2601
+ # Longer window to START dictating; 7s once we hold a partial. Patient
2602
+ # while the caller is mid-utterance, so a timeout can't clip them.
2603
+ got_turn = await self._wait_for_caller_input(
2604
+ 5.0 if buffer else 15.0, deadline, allow_keypad, keypad_dtmf
2605
+ )
2442
2606
  if not got_turn:
2443
- # Silence: nothing new heard → re-ask, count a miss.
2607
+ if time.monotonic() >= deadline:
2608
+ break # absolute cap reached → give up rather than loop forever
2609
+ # Genuine silence (caller not mid-utterance): re-ask, count a miss.
2444
2610
  misses += 1
2445
2611
  if misses < max_attempts:
2446
2612
  await _nudge(language)
2447
2613
  continue
2448
2614
 
2449
- # Collapse a rapid multi-chunk dictation into a single extraction.
2450
- await self._drain_user_turns(settle_s=1.2)
2451
-
2452
2615
  previous = buffer
2616
+ turns_before = len(self._collect_turns_since(start_ts))
2453
2617
  buffer = await self._extract_accumulated_digits(
2454
- node_id, self._caller_turns_since(start_index)
2618
+ node_id, self._collect_turns_since(start_ts)
2455
2619
  )
2620
+ # Freshness: a turn committed during extraction, or the caller is
2621
+ # speaking now → the value may be stale (mid-correction). Re-extract
2622
+ # next pass instead of reading back a number they're still changing.
2623
+ if len(self._collect_turns_since(start_ts)) > turns_before or self._caller_is_speaking():
2624
+ continue
2456
2625
  normalized = normalize_collect_value(
2457
2626
  "phone", buffer, country_default=country_default
2458
2627
  )
2459
2628
  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:
2629
+ # Confirm; on a "no, it's X" correction RE-EXTRACT over the turns we
2630
+ # already have (the caller's fix is already among them) and re-read
2631
+ # the CORRECTED number at once — instead of asking "what should it
2632
+ # be?" and making them repeat what they just said. The idempotent
2633
+ # extractor merges "no, the first part is 807" into 70823… →
2634
+ # 8072341303. KEEP start_ts throughout so corrections resolve against
2635
+ # the ORIGINAL number. Only a plain rejection with no new digits
2636
+ # falls through to the re-ask. Bounded by ``corrections``.
2637
+ while True:
2638
+ confirmed = await self._confirm_collected(node_id, " ".join(buffer))
2639
+ if confirmed is True:
2640
+ return normalized
2641
+ corrections += 1
2642
+ if corrections >= max_attempts:
2643
+ break
2644
+ corrected = await self._reextract_if_corrected(
2645
+ node_id, "phone", start_ts, normalized,
2646
+ country_default=country_default,
2647
+ )
2648
+ if corrected is not None:
2649
+ buffer, normalized = corrected
2650
+ continue # re-read the corrected number, no "what should it be?"
2651
+ buffer = ""
2468
2652
  await self._task.queue_frame(
2469
2653
  TTSSpeakFrame(text=collect_prompt(language, "restart_denied"))
2470
2654
  )
2471
- else:
2472
- misses += 1
2655
+ break
2656
+ if corrections >= max_attempts:
2657
+ break
2473
2658
  continue
2474
2659
 
2475
2660
  if len(buffer) >= target:
2476
2661
  # Enough digits but not a valid number → reset and re-ask.
2477
- start_index = len(self._user_messages())
2662
+ start_ts = time.monotonic()
2478
2663
  buffer = ""
2479
2664
  misses += 1
2480
2665
  if misses < max_attempts:
@@ -2491,8 +2676,10 @@ In all of these cases, you can provide a `name` in your new node's config for de
2491
2676
  await _nudge(language)
2492
2677
  continue
2493
2678
 
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.
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.
2682
+ misses = 0
2496
2683
 
2497
2684
  logger.info(f"Collect '{node_id}': gave up after {misses} misses (buffer='{buffer}')")
2498
2685
  return None
@@ -2518,9 +2705,11 @@ In all of these cases, you can provide a `name` in your new node's config for de
2518
2705
  digits). Returns the digit string or ``None`` on give-up.
2519
2706
  """
2520
2707
  target = int(num_digits) if num_digits else 6
2521
- start_index = len(self._user_messages())
2708
+ start_ts = time.monotonic()
2522
2709
  buffer = ""
2523
2710
  misses = 0
2711
+ corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
2712
+ deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
2524
2713
  # Keypad auto-flushes once the caller has keyed `target` digits.
2525
2714
  code_dtmf = {**(keypad_dtmf or {}), "digits": target}
2526
2715
 
@@ -2537,45 +2726,59 @@ In all of these cases, you can provide a `name` in your new node's config for de
2537
2726
 
2538
2727
  while misses < max_attempts:
2539
2728
  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
2729
+ got_turn = await self._wait_for_caller_input(
2730
+ 5.0 if buffer else 15.0, deadline, allow_keypad, code_dtmf
2731
+ )
2545
2732
  if not got_turn:
2733
+ if time.monotonic() >= deadline:
2734
+ break # absolute cap reached → give up
2546
2735
  misses += 1
2547
2736
  if misses < max_attempts:
2548
2737
  await _nudge(language)
2549
2738
  continue
2550
2739
 
2551
- # Collapse a rapid multi-chunk dictation into a single extraction.
2552
- await self._drain_user_turns(settle_s=1.2)
2553
-
2554
2740
  previous = buffer
2741
+ turns_before = len(self._collect_turns_since(start_ts))
2555
2742
  buffer = await self._extract_accumulated_digits(
2556
- node_id, self._caller_turns_since(start_index), what="numeric code"
2743
+ node_id, self._collect_turns_since(start_ts), what="numeric code"
2557
2744
  )
2745
+ # Freshness: stale if a turn committed mid-extraction or they're still
2746
+ # speaking → re-extract next pass rather than read back a half-code.
2747
+ if len(self._collect_turns_since(start_ts)) > turns_before or self._caller_is_speaking():
2748
+ continue
2558
2749
  normalized = normalize_collect_value(
2559
2750
  collect_type, buffer, num_digits=target
2560
2751
  )
2561
2752
  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:
2753
+ # Same as phone: a "no, it's X" correction re-extracts over the turns
2754
+ # we already have and re-reads the CORRECTED code at once; only a
2755
+ # plain rejection with no new digits asks "what should it be?".
2756
+ while True:
2757
+ confirmed = await self._confirm_collected(node_id, " ".join(normalized))
2758
+ if confirmed is True:
2759
+ return normalized
2760
+ corrections += 1
2761
+ if corrections >= max_attempts:
2762
+ break
2763
+ corrected = await self._reextract_if_corrected(
2764
+ node_id, collect_type, start_ts, normalized,
2765
+ what="numeric code", num_digits=target,
2766
+ )
2767
+ if corrected is not None:
2768
+ buffer, normalized = corrected
2769
+ continue # re-read the corrected code, no "what should it be?"
2770
+ buffer = ""
2569
2771
  await self._task.queue_frame(
2570
2772
  TTSSpeakFrame(text=collect_prompt(language, "restart_denied"))
2571
2773
  )
2572
- else:
2573
- misses += 1
2774
+ break
2775
+ if corrections >= max_attempts:
2776
+ break
2574
2777
  continue
2575
2778
 
2576
2779
  if len(buffer) > target:
2577
2780
  # Too many digits for this code → reset and re-ask.
2578
- start_index = len(self._user_messages())
2781
+ start_ts = time.monotonic()
2579
2782
  buffer = ""
2580
2783
  misses += 1
2581
2784
  if misses < max_attempts:
@@ -2591,41 +2794,68 @@ In all of these cases, you can provide a `name` in your new node's config for de
2591
2794
  await _nudge(language)
2592
2795
  continue
2593
2796
 
2594
- # Buffer grew but is still short stay silent and keep listening.
2797
+ # Buffer GREW (progress) reset the give-up counter; keep listening.
2798
+ misses = 0
2595
2799
 
2596
2800
  logger.info(f"Collect '{node_id}': code give-up after {misses} misses (buffer='{buffer}')")
2597
2801
  return None
2598
2802
 
2599
- async def _confirm_collected(self, node_id: str, readback: str) -> Optional[bool]:
2803
+ async def _confirm_collected(
2804
+ self, node_id: str, readback: str, attempts: int = 2
2805
+ ) -> Optional[bool]:
2600
2806
  """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
2807
+ ``False`` (denied), or ``None`` (still unclear after re-asking).
2808
+
2809
+ Re-asks the yes/no up to ``attempts`` times: a barge-in / fragmented first
2810
+ reply ("जी" … "हाँ, सही है") can read as unclear, and we'd rather re-ask
2811
+ "is that correct?" than treat a confirming caller as a rejection."""
2812
+ observer = self._user_turn_observer
2813
+ for _ in range(max(1, attempts)):
2814
+ listen_ts = time.monotonic()
2815
+ await self._task.queue_frame(
2816
+ TTSSpeakFrame(text=collect_prompt(self._active_language(), "confirm", readback=readback))
2817
+ )
2818
+ # Structured yes/no enum: the model must return "yes" or "no" (or null
2819
+ # when genuinely unclear), grounded with the read-back question so it
2820
+ # knows WHAT is being confirmed. Works in any language.
2821
+ answer = await self._extract_spoken_value(
2822
+ node_id,
2823
+ "yes if the caller agrees the value is correct, no if they say it is wrong",
2824
+ timeout_s=10.0,
2825
+ system_prompt=(
2826
+ "The assistant just read a value back to the caller and asked them "
2827
+ "to confirm it (see the assistant message). Based ONLY on the "
2828
+ "caller's latest reply — in any language — answer whether they "
2829
+ "confirmed it is correct. If they point out ANY mistake or give a "
2830
+ "different value — even while sounding agreeable (e.g. 'no, the "
2831
+ "last one is 5 not 3', 'haan but change the first digit') — that "
2832
+ "is NOT confirmed; answer 'no'. Answer 'yes' only if they accept "
2833
+ "it exactly as read."
2834
+ ),
2835
+ context_prefix=[
2836
+ {"role": "assistant", "content": f"I have {readback}. Is that correct?"}
2837
+ ],
2838
+ enum_values=["yes", "no"],
2839
+ )
2840
+ # Hard guard: a genuine confirmation never contains digits, a correction
2841
+ # ("no, the last is 5 not 3") always does. If the reply has any digit,
2842
+ # treat it as a correction (no) regardless of the model — otherwise a
2843
+ # mis-read "yes" stores the UN-corrected number. Read from the non-lossy
2844
+ # observer so a dropped/fragmented reply is still seen.
2845
+ reply = (
2846
+ " ".join(observer.transcripts_since(listen_ts))
2847
+ if observer is not None and hasattr(observer, "transcripts_since")
2848
+ else ""
2849
+ )
2850
+ if any(ch.isdigit() for ch in reply):
2851
+ return False
2852
+ text = (answer or "").strip().lower()
2853
+ if text.startswith("yes"):
2854
+ return True
2855
+ if text.startswith("no"):
2856
+ return False
2857
+ # null / unclear → re-ask "is that correct?" (next loop iteration)
2858
+ return None
2629
2859
 
2630
2860
  async def _collect_single_value(
2631
2861
  self,
@@ -2651,6 +2881,13 @@ In all of these cases, you can provide a `name` in your new node's config for de
2651
2881
  if not raw_value and allow_keypad:
2652
2882
  capture_timeout = float(keypad_dtmf.get("timeout", 8.0)) + 10.0
2653
2883
  raw_value = await self._await_dtmf_capture(keypad_dtmf, capture_timeout)
2884
+ # Freshness: if they're still finishing the address, let them complete
2885
+ # and re-read once before validating — don't act on a half-heard value.
2886
+ if raw_value and self._caller_is_speaking():
2887
+ if await self._await_user_turn(timeout_s=5.0):
2888
+ raw_value = await self._extract_spoken_value(
2889
+ node_id, description, timeout_s=15.0
2890
+ )
2654
2891
  normalized = normalize_collect_value(
2655
2892
  collect_type, raw_value, country_default=country_default
2656
2893
  )
@@ -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]