dv-pipecat-flows 0.0.22.dev1484__tar.gz → 0.0.22.dev1489__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dv_pipecat_flows-0.0.22.dev1484/src/dv_pipecat_flows.egg-info → dv_pipecat_flows-0.0.22.dev1489}/PKG-INFO +1 -1
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489/src/dv_pipecat_flows.egg-info}/PKG-INFO +1 -1
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/condition_evaluator.py +12 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/manager.py +406 -154
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/user_turn_observer.py +46 -1
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.agents/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.agents/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.claude/skills/loki-logs/SKILL.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.claude/skills/loki-logs/query-reference.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.gitattributes +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.gitignore +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.pre-commit-config.yaml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.python-version +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/.readthedocs.yaml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/AGENTS.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/CHANGELOG.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/CLAUDE.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/CONTRIBUTING.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/LICENSE +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/MANIFEST.in +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/README.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/dev-requirements.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/docker-compose.dev.yml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/.eslintrc.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/.prettierrc +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/css/tailwind.css +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/food_ordering.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/movie_explorer.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/patient_intake.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/restaurant_reservation.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/examples/travel_planner.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/favicon.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/index.html +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/canvas.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/editorState.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/sidePanel.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/toolbar.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/main.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/baseNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/endNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/flowNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/functionNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/index.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/mergeNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/startNode.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/types.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/export.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/helpers.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/import.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/validation.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/jsdoc.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/package-lock.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/package.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/postcss.config.cjs +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/public/favicon.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/public/favicon.svg +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/tailwind.config.cjs +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/vercel.json +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/vite.config.js +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/engine_primitives_plan.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/env.example +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/images/food-ordering-flow.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/pipecat-flows.png +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/pipecat_upgrade.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/pyproject.toml +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/remote-asterisk-code/README.md +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/remote-asterisk-code/extensions.conf +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/remote-asterisk-code/rtp.conf +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/requirements.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/scripts/check-pypi-package.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/scripts/fix-ruff.sh +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/scripts/pre-commit.sh +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/setup.cfg +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/SOURCES.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/dependency_links.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/requires.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/dv_pipecat_flows.egg-info/top_level.txt +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/__init__.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/actions.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/adapters.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/exceptions.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/flow_validator.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/__init__.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/router_mode_guard.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/processors/speak_interruption_guard.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/router_mode.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/types.py +0 -0
- {dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1489}/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,72 +2575,89 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
2410
2575
|
the caller. Returns the E.164 value or ``None``.
|
|
2411
2576
|
"""
|
|
2412
2577
|
target = 10 # min national digits; validity decided by normalize_phone
|
|
2413
|
-
# Snapshot
|
|
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 = ""
|
|
2583
|
+
echoed = "" # bare digits already spoken back, so we echo only the NEW ones
|
|
2417
2584
|
misses = 0
|
|
2585
|
+
corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
|
|
2586
|
+
deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
|
|
2418
2587
|
|
|
2419
2588
|
async def _nudge(language: str) -> None:
|
|
2420
|
-
"""Re-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
await self._task.queue_frame(TTSSpeakFrame(text=collect_prompt(
|
|
2425
|
-
language, "continue_partial", readback=" ".join(buffer),
|
|
2426
|
-
remaining=remaining, unit=unit,
|
|
2427
|
-
)))
|
|
2428
|
-
elif reask:
|
|
2589
|
+
"""Re-prompt ONLY when we have nothing yet (the author's reask). Holding a
|
|
2590
|
+
partial → stay silent: the progress echo already spoke the digits, so
|
|
2591
|
+
re-speaking them just sounds like a repeat (and talks over the caller)."""
|
|
2592
|
+
if not buffer and reask:
|
|
2429
2593
|
await self._task.queue_frame(TTSSpeakFrame(text=reask))
|
|
2430
2594
|
|
|
2431
2595
|
while misses < max_attempts:
|
|
2432
2596
|
language = self._active_language()
|
|
2433
|
-
#
|
|
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
|
|
2597
|
+
# Longer window to START dictating; 7s once we hold a partial. Patient
|
|
2598
|
+
# while the caller is mid-utterance, so a timeout can't clip them.
|
|
2599
|
+
got_turn = await self._wait_for_caller_input(
|
|
2600
|
+
7.0 if buffer else 15.0, deadline, allow_keypad, keypad_dtmf
|
|
2601
|
+
)
|
|
2442
2602
|
if not got_turn:
|
|
2443
|
-
|
|
2603
|
+
if time.monotonic() >= deadline:
|
|
2604
|
+
break # absolute cap reached → give up rather than loop forever
|
|
2605
|
+
# Genuine silence (caller not mid-utterance): re-ask, count a miss.
|
|
2444
2606
|
misses += 1
|
|
2445
2607
|
if misses < max_attempts:
|
|
2446
2608
|
await _nudge(language)
|
|
2447
2609
|
continue
|
|
2448
2610
|
|
|
2449
|
-
# Collapse a rapid multi-chunk dictation into a single extraction.
|
|
2450
|
-
await self._drain_user_turns(settle_s=1.2)
|
|
2451
|
-
|
|
2452
2611
|
previous = buffer
|
|
2612
|
+
turns_before = len(self._collect_turns_since(start_ts))
|
|
2453
2613
|
buffer = await self._extract_accumulated_digits(
|
|
2454
|
-
node_id, self.
|
|
2614
|
+
node_id, self._collect_turns_since(start_ts)
|
|
2455
2615
|
)
|
|
2616
|
+
# Freshness: a turn committed during extraction, or the caller is
|
|
2617
|
+
# speaking now → the value may be stale (mid-correction). Re-extract
|
|
2618
|
+
# next pass instead of reading back a number they're still changing.
|
|
2619
|
+
if len(self._collect_turns_since(start_ts)) > turns_before or self._caller_is_speaking():
|
|
2620
|
+
continue
|
|
2456
2621
|
normalized = normalize_collect_value(
|
|
2457
2622
|
"phone", buffer, country_default=country_default
|
|
2458
2623
|
)
|
|
2459
2624
|
if normalized:
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
#
|
|
2464
|
-
#
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2625
|
+
# Confirm; on a "no, it's X" correction RE-EXTRACT over the turns we
|
|
2626
|
+
# already have (the caller's fix is already among them) and re-read
|
|
2627
|
+
# the CORRECTED number at once — instead of asking "what should it
|
|
2628
|
+
# be?" and making them repeat what they just said. The idempotent
|
|
2629
|
+
# extractor merges "no, the first part is 807" into 70823… →
|
|
2630
|
+
# 8072341303. KEEP start_ts throughout so corrections resolve against
|
|
2631
|
+
# the ORIGINAL number. Only a plain rejection with no new digits
|
|
2632
|
+
# falls through to the re-ask. Bounded by ``corrections``.
|
|
2633
|
+
while True:
|
|
2634
|
+
confirmed = await self._confirm_collected(node_id, " ".join(buffer))
|
|
2635
|
+
if confirmed is True:
|
|
2636
|
+
return normalized
|
|
2637
|
+
corrections += 1
|
|
2638
|
+
if corrections >= max_attempts:
|
|
2639
|
+
break
|
|
2640
|
+
corrected = await self._reextract_if_corrected(
|
|
2641
|
+
node_id, "phone", start_ts, normalized,
|
|
2642
|
+
country_default=country_default,
|
|
2643
|
+
)
|
|
2644
|
+
if corrected is not None:
|
|
2645
|
+
buffer, normalized = corrected
|
|
2646
|
+
continue # re-read the corrected number, no "what should it be?"
|
|
2647
|
+
buffer = ""
|
|
2468
2648
|
await self._task.queue_frame(
|
|
2469
2649
|
TTSSpeakFrame(text=collect_prompt(language, "restart_denied"))
|
|
2470
2650
|
)
|
|
2471
|
-
|
|
2472
|
-
|
|
2651
|
+
break
|
|
2652
|
+
if corrections >= max_attempts:
|
|
2653
|
+
break
|
|
2473
2654
|
continue
|
|
2474
2655
|
|
|
2475
2656
|
if len(buffer) >= target:
|
|
2476
2657
|
# Enough digits but not a valid number → reset and re-ask.
|
|
2477
|
-
|
|
2658
|
+
start_ts = time.monotonic()
|
|
2478
2659
|
buffer = ""
|
|
2660
|
+
echoed = ""
|
|
2479
2661
|
misses += 1
|
|
2480
2662
|
if misses < max_attempts:
|
|
2481
2663
|
await self._task.queue_frame(
|
|
@@ -2483,16 +2665,29 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
2483
2665
|
)
|
|
2484
2666
|
continue
|
|
2485
2667
|
|
|
2486
|
-
if
|
|
2487
|
-
# A turn arrived but
|
|
2488
|
-
# re-ask and count a miss so garbage can't loop forever.
|
|
2668
|
+
if buffer == previous:
|
|
2669
|
+
# A turn arrived but the number is UNCHANGED (filler / mis-hear) →
|
|
2670
|
+
# re-ask and count a miss so garbage can't loop forever. Compare by
|
|
2671
|
+
# VALUE, not length: a same-length correction during the echo phase
|
|
2672
|
+
# ("no, the 3rd is 5": 7765 → 7755) changes the value but not the
|
|
2673
|
+
# length, so a length check would mis-read the fix as "no progress"
|
|
2674
|
+
# and burn a miss instead of echoing the correction below.
|
|
2489
2675
|
misses += 1
|
|
2490
2676
|
if misses < max_attempts:
|
|
2491
2677
|
await _nudge(language)
|
|
2492
2678
|
continue
|
|
2493
2679
|
|
|
2494
|
-
# Buffer
|
|
2495
|
-
#
|
|
2680
|
+
# Buffer CHANGED (progress — new digits OR a correction) → echo it back
|
|
2681
|
+
# (bare, in the caller's language) so they hear we caught the change, and
|
|
2682
|
+
# reset the give-up counter. A clean append echoes only the NEW digits; a
|
|
2683
|
+
# correction (not a prefix of what we echoed) re-echoes the full number.
|
|
2684
|
+
delta = buffer[len(echoed):] if buffer.startswith(echoed) else buffer
|
|
2685
|
+
if delta:
|
|
2686
|
+
await self._task.queue_frame(TTSSpeakFrame(
|
|
2687
|
+
text=collect_prompt(language, "echo", digits=" ".join(delta))
|
|
2688
|
+
))
|
|
2689
|
+
echoed = buffer
|
|
2690
|
+
misses = 0
|
|
2496
2691
|
|
|
2497
2692
|
logger.info(f"Collect '{node_id}': gave up after {misses} misses (buffer='{buffer}')")
|
|
2498
2693
|
return None
|
|
@@ -2518,65 +2713,78 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
2518
2713
|
digits). Returns the digit string or ``None`` on give-up.
|
|
2519
2714
|
"""
|
|
2520
2715
|
target = int(num_digits) if num_digits else 6
|
|
2521
|
-
|
|
2716
|
+
start_ts = time.monotonic()
|
|
2522
2717
|
buffer = ""
|
|
2718
|
+
echoed = "" # bare digits already spoken back, so we echo only the NEW ones
|
|
2523
2719
|
misses = 0
|
|
2720
|
+
corrections = 0 # confirm "no" rounds, bounded so a correction can't loop
|
|
2721
|
+
deadline = time.monotonic() + self._MAX_CAPTURE_SECONDS
|
|
2524
2722
|
# Keypad auto-flushes once the caller has keyed `target` digits.
|
|
2525
2723
|
code_dtmf = {**(keypad_dtmf or {}), "digits": target}
|
|
2526
2724
|
|
|
2527
2725
|
async def _nudge(language: str) -> None:
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
await self._task.queue_frame(TTSSpeakFrame(text=collect_prompt(
|
|
2532
|
-
language, "continue_partial", readback=" ".join(buffer),
|
|
2533
|
-
remaining=remaining, unit=unit,
|
|
2534
|
-
)))
|
|
2535
|
-
elif reask:
|
|
2726
|
+
"""Re-prompt ONLY when we have nothing yet (the author's reask); holding a
|
|
2727
|
+
partial → stay silent (the progress echo already spoke the digits)."""
|
|
2728
|
+
if not buffer and reask:
|
|
2536
2729
|
await self._task.queue_frame(TTSSpeakFrame(text=reask))
|
|
2537
2730
|
|
|
2538
2731
|
while misses < max_attempts:
|
|
2539
2732
|
language = self._active_language()
|
|
2540
|
-
got_turn = await self.
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
if await self._await_dtmf_capture(code_dtmf, capture_timeout):
|
|
2544
|
-
got_turn = True
|
|
2733
|
+
got_turn = await self._wait_for_caller_input(
|
|
2734
|
+
7.0 if buffer else 15.0, deadline, allow_keypad, code_dtmf
|
|
2735
|
+
)
|
|
2545
2736
|
if not got_turn:
|
|
2737
|
+
if time.monotonic() >= deadline:
|
|
2738
|
+
break # absolute cap reached → give up
|
|
2546
2739
|
misses += 1
|
|
2547
2740
|
if misses < max_attempts:
|
|
2548
2741
|
await _nudge(language)
|
|
2549
2742
|
continue
|
|
2550
2743
|
|
|
2551
|
-
# Collapse a rapid multi-chunk dictation into a single extraction.
|
|
2552
|
-
await self._drain_user_turns(settle_s=1.2)
|
|
2553
|
-
|
|
2554
2744
|
previous = buffer
|
|
2745
|
+
turns_before = len(self._collect_turns_since(start_ts))
|
|
2555
2746
|
buffer = await self._extract_accumulated_digits(
|
|
2556
|
-
node_id, self.
|
|
2747
|
+
node_id, self._collect_turns_since(start_ts), what="numeric code"
|
|
2557
2748
|
)
|
|
2749
|
+
# Freshness: stale if a turn committed mid-extraction or they're still
|
|
2750
|
+
# speaking → re-extract next pass rather than read back a half-code.
|
|
2751
|
+
if len(self._collect_turns_since(start_ts)) > turns_before or self._caller_is_speaking():
|
|
2752
|
+
continue
|
|
2558
2753
|
normalized = normalize_collect_value(
|
|
2559
2754
|
collect_type, buffer, num_digits=target
|
|
2560
2755
|
)
|
|
2561
2756
|
if normalized:
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2757
|
+
# Same as phone: a "no, it's X" correction re-extracts over the turns
|
|
2758
|
+
# we already have and re-reads the CORRECTED code at once; only a
|
|
2759
|
+
# plain rejection with no new digits asks "what should it be?".
|
|
2760
|
+
while True:
|
|
2761
|
+
confirmed = await self._confirm_collected(node_id, " ".join(normalized))
|
|
2762
|
+
if confirmed is True:
|
|
2763
|
+
return normalized
|
|
2764
|
+
corrections += 1
|
|
2765
|
+
if corrections >= max_attempts:
|
|
2766
|
+
break
|
|
2767
|
+
corrected = await self._reextract_if_corrected(
|
|
2768
|
+
node_id, collect_type, start_ts, normalized,
|
|
2769
|
+
what="numeric code", num_digits=target,
|
|
2770
|
+
)
|
|
2771
|
+
if corrected is not None:
|
|
2772
|
+
buffer, normalized = corrected
|
|
2773
|
+
continue # re-read the corrected code, no "what should it be?"
|
|
2774
|
+
buffer = ""
|
|
2569
2775
|
await self._task.queue_frame(
|
|
2570
2776
|
TTSSpeakFrame(text=collect_prompt(language, "restart_denied"))
|
|
2571
2777
|
)
|
|
2572
|
-
|
|
2573
|
-
|
|
2778
|
+
break
|
|
2779
|
+
if corrections >= max_attempts:
|
|
2780
|
+
break
|
|
2574
2781
|
continue
|
|
2575
2782
|
|
|
2576
2783
|
if len(buffer) > target:
|
|
2577
2784
|
# Too many digits for this code → reset and re-ask.
|
|
2578
|
-
|
|
2785
|
+
start_ts = time.monotonic()
|
|
2579
2786
|
buffer = ""
|
|
2787
|
+
echoed = ""
|
|
2580
2788
|
misses += 1
|
|
2581
2789
|
if misses < max_attempts:
|
|
2582
2790
|
await self._task.queue_frame(
|
|
@@ -2584,48 +2792,85 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
2584
2792
|
)
|
|
2585
2793
|
continue
|
|
2586
2794
|
|
|
2587
|
-
if
|
|
2588
|
-
#
|
|
2795
|
+
if buffer == previous:
|
|
2796
|
+
# Unchanged (filler / mis-hear) → re-ask, count a miss. Compare by
|
|
2797
|
+
# VALUE not length so a same-length correction during the echo phase
|
|
2798
|
+
# counts as progress (echoed below) instead of burning a miss.
|
|
2589
2799
|
misses += 1
|
|
2590
2800
|
if misses < max_attempts:
|
|
2591
2801
|
await _nudge(language)
|
|
2592
2802
|
continue
|
|
2593
2803
|
|
|
2594
|
-
# Buffer
|
|
2804
|
+
# Buffer CHANGED (new digits OR a correction) → echo it back, bare, and
|
|
2805
|
+
# reset the give-up counter. Append → only the new digits; a correction →
|
|
2806
|
+
# the full code re-echoed.
|
|
2807
|
+
delta = buffer[len(echoed):] if buffer.startswith(echoed) else buffer
|
|
2808
|
+
if delta:
|
|
2809
|
+
await self._task.queue_frame(TTSSpeakFrame(
|
|
2810
|
+
text=collect_prompt(language, "echo", digits=" ".join(delta))
|
|
2811
|
+
))
|
|
2812
|
+
echoed = buffer
|
|
2813
|
+
misses = 0
|
|
2595
2814
|
|
|
2596
2815
|
logger.info(f"Collect '{node_id}': code give-up after {misses} misses (buffer='{buffer}')")
|
|
2597
2816
|
return None
|
|
2598
2817
|
|
|
2599
|
-
async def _confirm_collected(
|
|
2818
|
+
async def _confirm_collected(
|
|
2819
|
+
self, node_id: str, readback: str, attempts: int = 2
|
|
2820
|
+
) -> Optional[bool]:
|
|
2600
2821
|
"""Read the value back and capture a yes/no. Returns ``True`` (confirmed),
|
|
2601
|
-
``False`` (denied), or ``None`` (
|
|
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
|
-
|
|
2822
|
+
``False`` (denied), or ``None`` (still unclear after re-asking).
|
|
2823
|
+
|
|
2824
|
+
Re-asks the yes/no up to ``attempts`` times: a barge-in / fragmented first
|
|
2825
|
+
reply ("जी" … "हाँ, सही है") can read as unclear, and we'd rather re-ask
|
|
2826
|
+
"is that correct?" than treat a confirming caller as a rejection."""
|
|
2827
|
+
observer = self._user_turn_observer
|
|
2828
|
+
for _ in range(max(1, attempts)):
|
|
2829
|
+
listen_ts = time.monotonic()
|
|
2830
|
+
await self._task.queue_frame(
|
|
2831
|
+
TTSSpeakFrame(text=collect_prompt(self._active_language(), "confirm", readback=readback))
|
|
2832
|
+
)
|
|
2833
|
+
# Structured yes/no enum: the model must return "yes" or "no" (or null
|
|
2834
|
+
# when genuinely unclear), grounded with the read-back question so it
|
|
2835
|
+
# knows WHAT is being confirmed. Works in any language.
|
|
2836
|
+
answer = await self._extract_spoken_value(
|
|
2837
|
+
node_id,
|
|
2838
|
+
"yes if the caller agrees the value is correct, no if they say it is wrong",
|
|
2839
|
+
timeout_s=10.0,
|
|
2840
|
+
system_prompt=(
|
|
2841
|
+
"The assistant just read a value back to the caller and asked them "
|
|
2842
|
+
"to confirm it (see the assistant message). Based ONLY on the "
|
|
2843
|
+
"caller's latest reply — in any language — answer whether they "
|
|
2844
|
+
"confirmed it is correct. If they point out ANY mistake or give a "
|
|
2845
|
+
"different value — even while sounding agreeable (e.g. 'no, the "
|
|
2846
|
+
"last one is 5 not 3', 'haan but change the first digit') — that "
|
|
2847
|
+
"is NOT confirmed; answer 'no'. Answer 'yes' only if they accept "
|
|
2848
|
+
"it exactly as read."
|
|
2849
|
+
),
|
|
2850
|
+
context_prefix=[
|
|
2851
|
+
{"role": "assistant", "content": f"I have {readback}. Is that correct?"}
|
|
2852
|
+
],
|
|
2853
|
+
enum_values=["yes", "no"],
|
|
2854
|
+
)
|
|
2855
|
+
# Hard guard: a genuine confirmation never contains digits, a correction
|
|
2856
|
+
# ("no, the last is 5 not 3") always does. If the reply has any digit,
|
|
2857
|
+
# treat it as a correction (no) regardless of the model — otherwise a
|
|
2858
|
+
# mis-read "yes" stores the UN-corrected number. Read from the non-lossy
|
|
2859
|
+
# observer so a dropped/fragmented reply is still seen.
|
|
2860
|
+
reply = (
|
|
2861
|
+
" ".join(observer.transcripts_since(listen_ts))
|
|
2862
|
+
if observer is not None and hasattr(observer, "transcripts_since")
|
|
2863
|
+
else ""
|
|
2864
|
+
)
|
|
2865
|
+
if any(ch.isdigit() for ch in reply):
|
|
2866
|
+
return False
|
|
2867
|
+
text = (answer or "").strip().lower()
|
|
2868
|
+
if text.startswith("yes"):
|
|
2869
|
+
return True
|
|
2870
|
+
if text.startswith("no"):
|
|
2871
|
+
return False
|
|
2872
|
+
# null / unclear → re-ask "is that correct?" (next loop iteration)
|
|
2873
|
+
return None
|
|
2629
2874
|
|
|
2630
2875
|
async def _collect_single_value(
|
|
2631
2876
|
self,
|
|
@@ -2651,6 +2896,13 @@ In all of these cases, you can provide a `name` in your new node's config for de
|
|
|
2651
2896
|
if not raw_value and allow_keypad:
|
|
2652
2897
|
capture_timeout = float(keypad_dtmf.get("timeout", 8.0)) + 10.0
|
|
2653
2898
|
raw_value = await self._await_dtmf_capture(keypad_dtmf, capture_timeout)
|
|
2899
|
+
# Freshness: if they're still finishing the address, let them complete
|
|
2900
|
+
# and re-read once before validating — don't act on a half-heard value.
|
|
2901
|
+
if raw_value and self._caller_is_speaking():
|
|
2902
|
+
if await self._await_user_turn(timeout_s=5.0):
|
|
2903
|
+
raw_value = await self._extract_spoken_value(
|
|
2904
|
+
node_id, description, timeout_s=15.0
|
|
2905
|
+
)
|
|
2654
2906
|
normalized = normalize_collect_value(
|
|
2655
2907
|
collect_type, raw_value, country_default=country_default
|
|
2656
2908
|
)
|
|
@@ -33,11 +33,21 @@ from __future__ import annotations
|
|
|
33
33
|
|
|
34
34
|
import asyncio
|
|
35
35
|
import time
|
|
36
|
+
from collections import deque
|
|
37
|
+
from typing import List, Tuple
|
|
36
38
|
|
|
37
|
-
from pipecat.frames.frames import
|
|
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.dev1489}/editor/js/editor/canvas.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/editorState.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/editor/sidePanel.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1489}/editor/js/nodes/baseNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/endNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/flowNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/functionNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/index.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/nodes/mergeNode.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1489}/editor/js/utils/export.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/helpers.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/js/utils/import.js
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1489}/editor/package-lock.json
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/postcss.config.cjs
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/public/favicon.png
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/editor/public/favicon.svg
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1489}/engine_primitives_plan.md
RENAMED
|
File without changes
|
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1489}/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.dev1489}/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.dev1489}/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.dev1489}/src/pipecat_flows/__init__.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/actions.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/adapters.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/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.dev1489}/src/pipecat_flows/router_mode.py
RENAMED
|
File without changes
|
{dv_pipecat_flows-0.0.22.dev1484 → dv_pipecat_flows-0.0.22.dev1489}/src/pipecat_flows/types.py
RENAMED
|
File without changes
|
|
File without changes
|