mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
feat: add vici dial controls from UI (#563)
* ViciDial Working * feat(telephony): add FreeSWITCH provider to upstream-PBX seam Generalize the upstream-PBX capture and control paths to dispatch by provider. FreeSWITCH bridges in with X-PBX-* headers and is driven over the Event Socket Library (uuid_kill / uuid_transfer) by the channel UUID, alongside the existing VICIdial ra_call_control path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * add vici specific configs * 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> * chore: run formatter * feat: make vici configurable from UI * chore: clean up PR * fix: incorporate review comments * chore: incorporate review comments * chore: generate client * chore: incporporate review comments * chore: incorporate review comments --------- Co-authored-by: Dograh POC <payment@dograh.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f7b47a1b4
commit
d8cd34e8d2
51 changed files with 2886 additions and 102 deletions
70
api/services/workflow/configuration_policy.py
Normal file
70
api/services/workflow/configuration_policy.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Workflow-configuration policies shared by workflow update surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.organization_preferences import external_pbx_integrations_enabled
|
||||
|
||||
|
||||
class WorkflowConfigurationNotFoundError(LookupError):
|
||||
"""Raised when configuration policy cannot resolve the target workflow."""
|
||||
|
||||
|
||||
class ExternalPBXConfigurationDisabledError(PermissionError):
|
||||
"""Raised when a disabled organization changes external-PBX mappings."""
|
||||
|
||||
|
||||
async def apply_external_pbx_mapping_policy(
|
||||
workflow_configurations: dict[str, Any] | None,
|
||||
*,
|
||||
workflow_id: int,
|
||||
organization_id: int,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Preserve hidden mappings and reject edits while external PBX is disabled.
|
||||
|
||||
Workflow configuration updates replace the stored configuration document. When
|
||||
the External PBX UI is hidden, its field mappings are absent from the request,
|
||||
so they must be copied from the active draft or published definition before the
|
||||
update is persisted.
|
||||
"""
|
||||
if workflow_configurations is None or await external_pbx_integrations_enabled(
|
||||
organization_id
|
||||
):
|
||||
return workflow_configurations
|
||||
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=organization_id
|
||||
)
|
||||
if workflow is None:
|
||||
raise WorkflowConfigurationNotFoundError(
|
||||
f"Workflow with id {workflow_id} not found"
|
||||
)
|
||||
|
||||
draft = await db_client.get_draft_version(workflow_id)
|
||||
stored_configurations = (
|
||||
draft.workflow_configurations
|
||||
if draft
|
||||
else workflow.released_definition.workflow_configurations
|
||||
)
|
||||
stored_mappings = (stored_configurations or {}).get(
|
||||
"external_pbx_field_mappings", []
|
||||
)
|
||||
incoming_mappings = workflow_configurations.get(
|
||||
"external_pbx_field_mappings", stored_mappings
|
||||
)
|
||||
|
||||
if incoming_mappings != stored_mappings:
|
||||
raise ExternalPBXConfigurationDisabledError(
|
||||
"External PBX integrations are disabled for this organization. "
|
||||
"Enable them in Platform Settings before changing field mappings."
|
||||
)
|
||||
|
||||
if not stored_mappings:
|
||||
return workflow_configurations
|
||||
|
||||
prepared_configurations = dict(workflow_configurations)
|
||||
prepared_configurations["external_pbx_field_mappings"] = deepcopy(stored_mappings)
|
||||
return prepared_configurations
|
||||
|
|
@ -98,6 +98,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
|
||||
|
|
@ -507,6 +511,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 external-PBX
|
||||
transfer that maps extracted variables into a provider lead update
|
||||
call before handing the customer off.
|
||||
|
||||
Idempotent: only the first call does work. The external-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
|
||||
|
|
@ -742,13 +769,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)
|
||||
|
|
@ -766,6 +788,20 @@ class PipecatEngine:
|
|||
call_tags.append(call_disposition)
|
||||
self._gathered_context["call_tags"] = call_tags
|
||||
|
||||
# Hangup strategies run while serializing the terminal frame. Persist
|
||||
# the final extracted values first so external-PBX adapters can apply
|
||||
# workflow lead-field mappings before terminating the customer leg.
|
||||
try:
|
||||
await db_client.update_workflow_run(
|
||||
run_id=self._workflow_run_id,
|
||||
gathered_context=self._gathered_context,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Call teardown must never be held hostage by an enrichment write.
|
||||
logger.warning(
|
||||
f"Could not persist final gathered context before hangup: {exc}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Finishing run with reason: {reason}, disposition: {call_disposition} "
|
||||
f"queueing frame {frame_to_push}"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from api.db import db_client
|
|||
from api.enums import ToolCategory, WorkflowRunMode
|
||||
from api.services.pipecat.audio_playback import play_audio, play_audio_loop
|
||||
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
|
||||
from api.services.telephony.external_pbx import resolve_external_pbx_field_mappings
|
||||
from api.services.telephony.factory import get_telephony_provider_for_run
|
||||
from api.services.telephony.transfer_event_protocol import TransferContext
|
||||
from api.services.workflow.tools.calculator import get_calculator_tools, safe_calculator
|
||||
|
|
@ -609,22 +610,22 @@ class CustomToolManager:
|
|||
)
|
||||
return
|
||||
|
||||
external_pbx_call = (
|
||||
getattr(workflow_run, "initial_context", None) or {}
|
||||
).get("external_pbx_call")
|
||||
# Compatibility for calls that started before the migration.
|
||||
external_pbx_call = external_pbx_call or (
|
||||
getattr(workflow_run, "initial_context", None) or {}
|
||||
).get("upstream_pbx")
|
||||
if external_pbx_call:
|
||||
# Context-to-in-group and lead-field mappings must see the
|
||||
# final conversation-derived values.
|
||||
await self._engine.perform_final_variable_extraction()
|
||||
|
||||
resolver = config.get("resolver") if isinstance(config, dict) else None
|
||||
is_dynamic_transfer = config.get(
|
||||
"destination_source", "static"
|
||||
) == "dynamic" and isinstance(resolver, dict)
|
||||
resolver_phase_muted = False
|
||||
|
||||
def clear_transfer_setup_mute_state() -> None:
|
||||
nonlocal resolver_phase_muted
|
||||
if resolver_phase_muted:
|
||||
self._engine.set_mute_pipeline(False)
|
||||
resolver_phase_muted = False
|
||||
self._engine._queued_speech_mute_state = "idle"
|
||||
|
||||
if is_dynamic_transfer:
|
||||
self._engine.set_mute_pipeline(True)
|
||||
resolver_phase_muted = True
|
||||
|
||||
if is_dynamic_transfer and resolver.get("wait_message"):
|
||||
await self._engine.task.queue_frame(
|
||||
|
|
@ -634,7 +635,6 @@ class CustomToolManager:
|
|||
persist_to_logs=True,
|
||||
)
|
||||
)
|
||||
self._engine._queued_speech_mute_state = "waiting"
|
||||
|
||||
try:
|
||||
resolved_transfer = await resolve_transfer_config(
|
||||
|
|
@ -649,7 +649,6 @@ class CustomToolManager:
|
|||
destination = resolved_transfer.destination
|
||||
timeout_seconds = resolved_transfer.timeout_seconds
|
||||
except TransferResolutionError as e:
|
||||
clear_transfer_setup_mute_state()
|
||||
validation_error_result = {
|
||||
"status": "failed",
|
||||
"message": "I'm sorry, but I couldn't find a valid destination for this transfer.",
|
||||
|
|
@ -661,6 +660,25 @@ class CustomToolManager:
|
|||
)
|
||||
return
|
||||
|
||||
if (
|
||||
resolved_transfer.source == "context_mapping"
|
||||
and not external_pbx_call
|
||||
):
|
||||
await self._handle_transfer_result(
|
||||
{
|
||||
"status": "failed",
|
||||
"message": (
|
||||
"This call did not arrive through the configured "
|
||||
"external PBX."
|
||||
),
|
||||
"action": "transfer_failed",
|
||||
"reason": "external_pbx_call_required",
|
||||
},
|
||||
function_call_params,
|
||||
properties,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate destination phone number
|
||||
if not destination or not destination.strip():
|
||||
validation_error_result = {
|
||||
|
|
@ -669,12 +687,15 @@ class CustomToolManager:
|
|||
"action": "transfer_failed",
|
||||
"reason": "no_destination",
|
||||
}
|
||||
clear_transfer_setup_mute_state()
|
||||
await self._handle_transfer_result(
|
||||
validation_error_result, function_call_params, properties
|
||||
)
|
||||
return
|
||||
|
||||
provider = await get_telephony_provider_for_run(
|
||||
workflow_run, organization_id
|
||||
)
|
||||
|
||||
if resolved_transfer.message:
|
||||
await self._engine.task.queue_frame(
|
||||
TTSSpeakFrame(
|
||||
|
|
@ -683,15 +704,54 @@ class CustomToolManager:
|
|||
persist_to_logs=True,
|
||||
)
|
||||
)
|
||||
self._engine._queued_speech_mute_state = "waiting"
|
||||
else:
|
||||
played = await self._play_config_message(config)
|
||||
if played:
|
||||
self._engine._queued_speech_mute_state = "waiting"
|
||||
await self._play_config_message(config)
|
||||
|
||||
if external_pbx_call:
|
||||
workflow_configurations = (
|
||||
await db_client.get_workflow_run_configurations(
|
||||
self._engine._workflow_run_id, organization_id
|
||||
)
|
||||
)
|
||||
field_updates = resolve_external_pbx_field_mappings(
|
||||
self._engine._gathered_context,
|
||||
workflow_configurations.get("external_pbx_field_mappings", []),
|
||||
)
|
||||
external_result = await provider.transfer_external_pbx_call(
|
||||
identity=external_pbx_call,
|
||||
destination=destination,
|
||||
field_updates=field_updates,
|
||||
)
|
||||
if external_result is not None:
|
||||
if external_result.get("status") == "success":
|
||||
self._engine._gathered_context[
|
||||
"external_pbx_transferred"
|
||||
] = True
|
||||
await db_client.update_workflow_run(
|
||||
run_id=self._engine._workflow_run_id,
|
||||
gathered_context={"external_pbx_transferred": True},
|
||||
)
|
||||
await function_call_params.result_callback(
|
||||
external_result, properties=properties
|
||||
)
|
||||
# Let VICIdial redirect the customer out of its
|
||||
# conference before Dograh tears down the local leg.
|
||||
await asyncio.sleep(4)
|
||||
await self._engine.end_call_with_reason(
|
||||
EndTaskReason.END_CALL_TOOL_REASON.value,
|
||||
abort_immediately=True,
|
||||
)
|
||||
else:
|
||||
await self._handle_transfer_result(
|
||||
{
|
||||
**external_result,
|
||||
"action": "transfer_failed",
|
||||
},
|
||||
function_call_params,
|
||||
properties,
|
||||
)
|
||||
return
|
||||
|
||||
provider = await get_telephony_provider_for_run(
|
||||
workflow_run, organization_id
|
||||
)
|
||||
if not provider.supports_transfers() or not provider.validate_config():
|
||||
validation_error_result = {
|
||||
"status": "failed",
|
||||
|
|
@ -699,7 +759,6 @@ class CustomToolManager:
|
|||
"action": "transfer_failed",
|
||||
"reason": "provider_does_not_support_transfer",
|
||||
}
|
||||
clear_transfer_setup_mute_state()
|
||||
await self._handle_transfer_result(
|
||||
validation_error_result, function_call_params, properties
|
||||
)
|
||||
|
|
@ -750,7 +809,6 @@ class CustomToolManager:
|
|||
except Exception as e:
|
||||
logger.error(f"Transfer provider failed: {e}")
|
||||
self._engine.set_mute_pipeline(False)
|
||||
self._engine._queued_speech_mute_state = "idle"
|
||||
await call_transfer_manager.remove_transfer_context(transfer_id)
|
||||
provider_error_result = {
|
||||
"status": "failed",
|
||||
|
|
@ -850,7 +908,6 @@ class CustomToolManager:
|
|||
f"Transfer call tool '{function_name}' execution failed: {e}"
|
||||
)
|
||||
self._engine.set_mute_pipeline(False)
|
||||
self._engine._queued_speech_mute_state = "idle"
|
||||
|
||||
# Handle generic exception with user-friendly message
|
||||
exception_result = {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import httpx
|
|||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.organization_preferences import external_pbx_integrations_enabled
|
||||
from api.services.workflow.tools.custom_tool import _resolve_preset_parameters
|
||||
from api.utils.credential_auth import build_auth_header
|
||||
from api.utils.template_renderer import render_template
|
||||
|
|
@ -123,6 +124,70 @@ def _resolve_static_transfer(
|
|||
)
|
||||
|
||||
|
||||
def _context_value(
|
||||
path: str,
|
||||
call_context_vars: Optional[Dict[str, Any]],
|
||||
gathered_context_vars: Optional[Dict[str, Any]],
|
||||
) -> Any:
|
||||
initial = call_context_vars or {}
|
||||
gathered = gathered_context_vars or {}
|
||||
normalized = path.strip()
|
||||
if normalized.startswith("initial_context."):
|
||||
current: Any = initial
|
||||
parts = normalized.removeprefix("initial_context.").split(".")
|
||||
elif normalized.startswith("gathered_context."):
|
||||
current = gathered
|
||||
parts = normalized.removeprefix("gathered_context.").split(".")
|
||||
else:
|
||||
current = gathered
|
||||
parts = normalized.split(".")
|
||||
|
||||
for part in parts:
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
current = current.get(part)
|
||||
if current is None and "." not in normalized:
|
||||
extracted = gathered.get("extracted_variables")
|
||||
if isinstance(extracted, dict):
|
||||
current = extracted.get(normalized)
|
||||
return current
|
||||
|
||||
|
||||
def _resolve_context_mapping_transfer(
|
||||
config: dict[str, Any],
|
||||
call_context_vars: Optional[Dict[str, Any]],
|
||||
gathered_context_vars: Optional[Dict[str, Any]],
|
||||
) -> ResolvedTransferConfig:
|
||||
mapping = config.get("context_mapping")
|
||||
if not isinstance(mapping, dict):
|
||||
raise TransferResolutionError(
|
||||
"invalid_context_mapping", "Transfer context mapping is missing"
|
||||
)
|
||||
path = str(mapping.get("context_path", "")).strip()
|
||||
raw_value = _context_value(path, call_context_vars, gathered_context_vars)
|
||||
match_value = "" if raw_value is None else str(raw_value).strip().casefold()
|
||||
destination = ""
|
||||
for route in mapping.get("routes") or []:
|
||||
if not isinstance(route, dict):
|
||||
continue
|
||||
if str(route.get("context_value", "")).strip().casefold() == match_value:
|
||||
destination = str(route.get("destination", "")).strip()
|
||||
break
|
||||
if not destination:
|
||||
destination = str(mapping.get("fallback_destination") or "").strip()
|
||||
if not destination:
|
||||
raise TransferResolutionError(
|
||||
"no_context_mapping_match",
|
||||
f"No destination mapping matched gathered context path '{path}'",
|
||||
)
|
||||
return ResolvedTransferConfig(
|
||||
destination=destination,
|
||||
timeout_seconds=_base_timeout(config),
|
||||
source="context_mapping",
|
||||
metadata={"context_path": path, "matched": bool(match_value)},
|
||||
)
|
||||
|
||||
|
||||
def _resolver_arguments(
|
||||
*,
|
||||
resolver: dict[str, Any],
|
||||
|
|
@ -276,6 +341,25 @@ async def resolve_transfer_config(
|
|||
) -> ResolvedTransferConfig:
|
||||
"""Resolve transfer destination and options for a transfer tool call."""
|
||||
|
||||
destination_source = config.get("destination_source", "static")
|
||||
if destination_source == "context_mapping":
|
||||
if not organization_id or not await external_pbx_integrations_enabled(
|
||||
organization_id
|
||||
):
|
||||
raise TransferResolutionError(
|
||||
"external_pbx_feature_disabled",
|
||||
"External PBX integrations are disabled for this organization",
|
||||
)
|
||||
resolved = _resolve_context_mapping_transfer(
|
||||
config, call_context_vars, gathered_context_vars
|
||||
)
|
||||
logger.info(
|
||||
"Transfer destination resolved from context mapping "
|
||||
f"context_path={resolved.metadata.get('context_path')} "
|
||||
f"source={resolved.source} destination={resolved.destination}"
|
||||
)
|
||||
return resolved
|
||||
|
||||
resolver = config.get("resolver")
|
||||
if config.get("destination_source", "static") != "dynamic" or not isinstance(
|
||||
resolver, dict
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue