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.
- {dv_pipecat_flows-0.0.22.dev1484/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev1487}/PKG-INFO +1 -1
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/condition_evaluator.py +12 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/manager.py +369 -132
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/user_turn_observer.py +46 -1
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.agents/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.agents/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.claude/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.claude/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.gitattributes +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.gitignore +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.pre-commit-config.yaml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.python-version +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/.readthedocs.yaml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/AGENTS.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/CHANGELOG.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/CLAUDE.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/CONTRIBUTING.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/LICENSE +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/MANIFEST.in +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/README.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/dev-requirements.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/docker-compose.dev.yml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/.eslintrc.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/.prettierrc +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/css/tailwind.css +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/food_ordering.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/movie_explorer.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/patient_intake.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/restaurant_reservation.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/examples/travel_planner.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/favicon.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/index.html +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/canvas.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/editorState.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/sidePanel.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/toolbar.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/main.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/baseNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/endNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/flowNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/functionNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/index.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/mergeNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/startNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/types.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/export.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/helpers.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/import.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/validation.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/jsdoc.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/package-lock.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/package.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/postcss.config.cjs +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/public/favicon.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/public/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/tailwind.config.cjs +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/vercel.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/vite.config.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/engine_primitives_plan.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/env.example +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/images/food-ordering-flow.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/pipecat-flows.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/pipecat_upgrade.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/pyproject.toml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/README.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/extensions.conf +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/rtp.conf +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/requirements.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/scripts/check-pypi-package.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/scripts/fix-ruff.sh +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/scripts/pre-commit.sh +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/setup.cfg +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
- {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
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
- {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
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/__init__.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/actions.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/adapters.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/exceptions.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/flow_validator.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/__init__.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/router_mode.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/types.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/uv.lock +0 -0
|
@@ -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
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/manager.py
RENAMED
|
@@ -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
|
-
|
|
2139
|
-
|
|
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
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
2317
|
-
"
|
|
2318
|
-
"
|
|
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
|
|
2414
|
-
# node; a confirm "no" / invalid advances it to
|
|
2415
|
-
|
|
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
|
-
#
|
|
2434
|
-
#
|
|
2435
|
-
got_turn = await self.
|
|
2436
|
-
|
|
2437
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
#
|
|
2464
|
-
#
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
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
|
-
|
|
2472
|
-
|
|
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
|
-
|
|
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
|
|
2495
|
-
#
|
|
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
|
-
|
|
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.
|
|
2541
|
-
|
|
2542
|
-
|
|
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.
|
|
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
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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
|
-
|
|
2573
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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`` (
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
)
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
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
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/canvas.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/editorState.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/sidePanel.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/editor/toolbar.js
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/baseNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/endNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/flowNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/functionNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/index.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/mergeNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/nodes/startNode.js
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/export.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/helpers.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/import.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/js/utils/validation.js
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/package-lock.json
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/postcss.config.cjs
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/public/favicon.png
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/public/favicon.svg
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/editor/tailwind.config.cjs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/engine_primitives_plan.md
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/images/food-ordering-flow.png
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/README.md
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/remote-asterisk-code/rtp.conf
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/scripts/check-pypi-package.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/__init__.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/actions.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/adapters.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/router_mode.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1487}/src/pipecat_flows/types.py
RENAMED
|
File without changes
|
|
File without changes
|