feat(telephony): env-based VICIdial config + address3 post-call routing

Move VICIdial agent/non-agent API and FreeSWITCH ESL connection settings
out of hardcoded POC values into environment variables so the same image
works against a PBX on another server.

Add VICIdial update_lead forwarding: X-VICI-UPDATE-LEAD_* extracted
variables are mapped to lead columns and pushed via the non-agent API
before a transfer, with reserved API-control params dropped.

Add hardcoded address3 disposition routing (Y/N -> in-group transfer,
else hang up) and a synchronous final variable extraction so the
transfer path sees the freshest conversation state. Skip upstream_pbx
capture on non-PJSIP channels to avoid Asterisk 500s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-07-20 08:22:54 +00:00
parent 688773d3b3
commit c8d7584f9f
4 changed files with 352 additions and 57 deletions

View file

@ -99,6 +99,10 @@ class PipecatEngine:
self._gathered_context: dict = {}
self._user_response_timeout_task: Optional[asyncio.Task] = None
self._pending_extraction_tasks: set[asyncio.Task] = set()
# True once a final (synchronous) extraction has run, so the end-of-call
# and upstream-transfer paths don't redundantly re-extract the same
# terminal state.
self._final_extraction_done: bool = False
# Will be set later in initialize() when we have
# access to _context
@ -504,6 +508,29 @@ class PipecatEngine:
f"Incomplete: {incomplete}"
)
async def perform_final_variable_extraction(self) -> None:
"""Flush in-flight + current-node variable extraction synchronously.
Awaits any background extractions still running from previous nodes,
then runs the current node's extraction inline so callers that need the
freshest extracted variables before acting can rely on them -- e.g.
end_call_with_reason before disposing the call, or an upstream-PBX
transfer that maps extracted variables into the VICIdial update_lead
call before handing the customer off.
Idempotent: only the first call does work. The upstream-PBX transfer
runs this just before forwarding update_lead, so the subsequent
end_call_with_reason would otherwise re-extract the same terminal state.
"""
if self._final_extraction_done:
logger.debug("Final variable extraction already performed; skipping")
return
self._final_extraction_done = True
await self._await_pending_extractions()
await self._perform_variable_extraction_if_needed(
self._current_node, run_in_background=False
)
async def _setup_llm_context(self, node: Node) -> None:
"""Common method to set up LLM context"""
# Set OTel span name for tracing
@ -739,13 +766,8 @@ class PipecatEngine:
EndTaskReason.PIPELINE_ERROR.value,
EndTaskReason.VOICEMAIL_DETECTED.value,
):
# Await any in-flight background extractions from previous nodes
await self._await_pending_extractions()
# Perform final variable extraction synchronously before ending
await self._perform_variable_extraction_if_needed(
self._current_node, run_in_background=False
)
# Flush in-flight + current-node extractions synchronously before ending
await self.perform_final_variable_extraction()
frame_to_push = (
CancelFrame(reason=reason) if abort_immediately else EndFrame(reason=reason)

View file

@ -561,38 +561,81 @@ class CustomToolManager:
upstream = (workflow_run.initial_context or {}).get("upstream_pbx")
if upstream:
from api.services.telephony.upstream_pbx import (
transfer_upstream_call,
collect_update_lead_fields,
route_upstream_after_call,
update_upstream_lead,
)
# Play the transfer message first so its TTS overlaps the
# extraction latency below.
await self._play_config_message(config)
ok = await transfer_upstream_call(upstream, destination)
# Mark the run so the end-of-call hangup does NOT also hang up
# the now-transferred customer (see ARIHangupStrategy).
await db_client.update_workflow_run(
run_id=self._engine._workflow_run_id,
gathered_context={"upstream_transferred": True},
# Extract variables right before the transfer so any
# X-VICI-UPDATE-LEAD_* values reflect the final conversation
# state, then forward them to the upstream PBX's update_lead
# BEFORE handing the customer off. This lets a workflow plumb
# arbitrary, conversation-derived fields into the VICIdial
# lead. Best-effort: never blocks the transfer.
await self._engine.perform_final_variable_extraction()
update_fields = collect_update_lead_fields(
self._engine._gathered_context
)
await function_call_params.result_callback(
{
"status": "success" if ok else "failed",
"action": "upstream_transfer",
"message": (
"Transferring your call now."
if ok
else "I'm sorry, I couldn't complete the transfer."
),
},
properties=properties,
logger.info(
f"[transfer] update_lead fields from extracted "
f"variables: {update_fields}"
)
if ok:
# Give the upstream PBX time to redirect the customer out
# of the conference toward the destination BEFORE we drop
# dograh's own leg -- otherwise the teardown races the
# redirect and cancels the customer's call to the
# destination (it never rings).
await asyncio.sleep(4)
# Tear down dograh's own legs; the customer has moved to the
# upstream PBX side.
if update_fields:
await update_upstream_lead(upstream, update_fields)
# Hardcoded address3 routing (see route_upstream_after_call):
# "Y"/"N" transfer the customer into in-group
# dograhtest1/dograhtest2; anything else hangs the customer up
# instead of transferring. The destination configured on the
# transfer node is intentionally ignored for upstream calls.
action, ok = await route_upstream_after_call(
upstream, update_fields
)
if action == "transfer":
# Mark the run so the end-of-call hangup does NOT also hang
# up the now-transferred customer (see ARIHangupStrategy).
await db_client.update_workflow_run(
run_id=self._engine._workflow_run_id,
gathered_context={"upstream_transferred": True},
)
await function_call_params.result_callback(
{
"status": "success" if ok else "failed",
"action": "upstream_transfer",
"message": (
"Transferring your call now."
if ok
else "I'm sorry, I couldn't complete the transfer."
),
},
properties=properties,
)
if ok:
# Give the upstream PBX time to redirect the customer
# out of the conference toward the in-group BEFORE we
# drop dograh's own leg -- otherwise the teardown races
# the redirect and cancels the customer's call (it
# never rings).
await asyncio.sleep(4)
else:
# address3 wasn't Y/N: route_upstream_after_call already
# hung up the customer leg. Leave upstream_transferred
# unset so the end-of-call teardown re-confirms the hangup
# (a no-op if it already succeeded, a retry if it didn't).
await function_call_params.result_callback(
{
"status": "success",
"action": "upstream_hangup",
"message": "Ending the call now.",
},
properties=properties,
)
# Tear down dograh's own legs; the customer leg has already
# been handled on the upstream PBX side.
await self._engine.end_call_with_reason(
EndTaskReason.END_CALL_TOOL_REASON.value,
abort_immediately=True,