Files
call-center/services/asterisk_bridge_service/config.py
T
Hermes Agent 2243f305b8 feat: L1->L2 agent pool and routing engine for voice escalation
Replaces the hardcoded single-extension redirect for AI->human call
escalation with a real Agent Pool + Routing Engine:

- agents/escalations/routing_rules tables (migration 0031), asterisk_call_links
  gains tenant_id/current_level/required_skills_json/priority.
- services/routing_service/engine.py: level/tenant/skill filtered agent
  selection with atomic (CAS) reservation, no double-booking.
- routing-service: /agents CRUD + /internal/routing/reserve-agent and
  /internal/routing/release-agent.
- asterisk-bridge-service: voice_ai.request_handoff now uses the Routing
  Engine automatically for any queue_code configured in
  ASTERISK_QUEUE_LEVEL_MAP_JSON (all other queue_codes keep the existing
  static ASTERISK_TRANSFER_TARGET_MAP_JSON behavior unchanged); new
  POST /asterisk/live-calls/{call_id}/escalations entrypoint; agent is
  released back to AVAILABLE and the escalation closed when the call ends.

Targets the Tele2 Kazgaz DID +77476456048 (from-tele2-kazgaz context) as the
first queue wired to real L2 routing instead of AI-only.

Known gap (documented in docs/architecture/l1-l2-routing-engine.md):
automatic no-answer retry-to-next-agent needs a small, separately reviewed
dialplan change and is left for a follow-up MR rather than guessed at blind.

Tests: services/routing_service/engine.py covered by
tests/test_routing_engine.py (selection filtering, atomic reservation,
release); existing test_asterisk_bridge_service.py and
test_routing_service_pg_counter.py suites still pass unmodified.
2026-08-28 16:22:32 +05:00

529 lines
16 KiB
Python

from __future__ import annotations
import json
import os
from typing import Any
from urllib.parse import urlparse
from fastapi import HTTPException
from services.shared.models import BrowserSoftphoneConfigOut
def bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def bridge_enabled() -> bool:
return bool_env("ASTERISK_BRIDGE_ENABLED", False)
def ami_host() -> str:
return os.getenv("ASTERISK_AMI_HOST", "").strip()
def ami_port() -> int:
return int(os.getenv("ASTERISK_AMI_PORT", "5038"))
def ami_username() -> str:
return os.getenv("ASTERISK_AMI_USERNAME", "").strip()
def ami_secret() -> str:
return os.getenv("ASTERISK_AMI_SECRET", "").strip()
def ami_prefix() -> str:
return os.getenv("ASTERISK_AMI_EVENT_PREFIX", "MVPCC").strip() or "MVPCC"
def poll_interval() -> float:
try:
value = float(os.getenv("ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS", "1").strip())
except ValueError:
value = 1.0
return max(value, 0.25)
def recent_calls_window_seconds() -> int:
raw = os.getenv("ASTERISK_RECENT_CALLS_WINDOW_SECONDS", "120").strip()
try:
value = int(raw)
except ValueError:
value = 120
return max(value, 30)
def failed_retry_enabled() -> bool:
return bool_env("ASTERISK_BRIDGE_FAILED_RETRY_ENABLED", True)
def failed_retry_interval_seconds() -> float:
raw = os.getenv("ASTERISK_BRIDGE_FAILED_RETRY_INTERVAL_SECONDS", "5").strip()
try:
value = float(raw)
except ValueError:
value = 5.0
return max(value, 1.0)
def failed_retry_batch_size() -> int:
raw = os.getenv("ASTERISK_BRIDGE_FAILED_RETRY_BATCH_SIZE", "20").strip()
try:
value = int(raw)
except ValueError:
value = 20
return max(value, 1)
def reconcile_enabled() -> bool:
return bool_env("ASTERISK_BRIDGE_RECONCILE_ENABLED", True)
def reconcile_stale_seconds() -> int:
raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_STALE_SECONDS", "12").strip()
try:
value = int(raw)
except ValueError:
value = 30
return max(value, 5)
def reconcile_settle_seconds() -> int:
raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_SETTLE_SECONDS", "6").strip()
try:
value = int(raw)
except ValueError:
value = 8
return max(value, 2)
def reconcile_scan_limit() -> int:
raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_SCAN_LIMIT", "20").strip()
try:
value = int(raw)
except ValueError:
value = 20
return max(value, 1)
def forward_timeout_seconds(default: float = 45.0) -> float:
raw = os.getenv("ASTERISK_BRIDGE_FORWARD_TIMEOUT_SECONDS", str(default)).strip()
try:
value = float(raw)
except ValueError:
value = default
return max(value, 5.0)
def forward_max_attempts() -> int:
raw = os.getenv("ASTERISK_BRIDGE_FORWARD_MAX_ATTEMPTS", "3").strip()
try:
value = int(raw)
except ValueError:
value = 3
return max(value, 1)
def forward_retry_backoff_seconds() -> float:
raw = os.getenv("ASTERISK_BRIDGE_FORWARD_RETRY_BACKOFF_SECONDS", "0.75").strip()
try:
value = float(raw)
except ValueError:
value = 0.75
return max(value, 0.05)
def queue_map() -> dict[str, str]:
raw = os.getenv("ASTERISK_QUEUE_MAP_JSON", "{}").strip()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid ASTERISK_QUEUE_MAP_JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError("ASTERISK_QUEUE_MAP_JSON must be a JSON object")
return {str(k): str(v) for k, v in payload.items() if str(k).strip() and str(v).strip()}
def sftp_enabled() -> bool:
return bool(
os.getenv("ASTERISK_SFTP_HOST", "").strip()
and os.getenv("ASTERISK_SFTP_USERNAME", "").strip()
)
def sftp_host() -> str:
return os.getenv("ASTERISK_SFTP_HOST", "").strip()
def sftp_port() -> int:
return int(os.getenv("ASTERISK_SFTP_PORT", "22"))
def sftp_username() -> str:
return os.getenv("ASTERISK_SFTP_USERNAME", "").strip()
def sftp_password() -> str:
return os.getenv("ASTERISK_SFTP_PASSWORD", "").strip()
def interaction_service_url() -> str:
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")
def routing_service_url() -> str:
return os.getenv("ROUTING_SERVICE_URL", "http://localhost:8003").rstrip("/")
def queue_level_map() -> dict[str, dict[str, str | None]]:
raw = os.getenv("ASTERISK_QUEUE_LEVEL_MAP_JSON", "{}").strip()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid ASTERISK_QUEUE_LEVEL_MAP_JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError("ASTERISK_QUEUE_LEVEL_MAP_JSON must be a JSON object")
result: dict[str, dict[str, str | None]] = {}
for queue_code, item in payload.items():
key = str(queue_code or "").strip()
if not key or not isinstance(item, dict):
continue
level = str(item.get("level") or "").strip()
if level not in {"L2", "L3"}:
continue
result[key] = {"level": level, "tenant_id": str(item.get("tenant_id") or "").strip() or None}
return result
def routing_level_for_queue_code(queue_code: str | None) -> str | None:
entry = queue_level_map().get(str(queue_code or "").strip())
return entry.get("level") if entry else None
def routing_tenant_for_queue_code(queue_code: str | None) -> str | None:
entry = queue_level_map().get(str(queue_code or "").strip())
return entry.get("tenant_id") if entry else None
def voice_adapter_service_url() -> str:
return os.getenv("VOICE_ADAPTER_SERVICE_URL", "http://localhost:8006").rstrip("/")
def recording_service_url() -> str:
return os.getenv("RECORDING_SERVICE_URL", "http://localhost:8013").rstrip("/")
def ivr_service_url() -> str:
return os.getenv("IVR_SERVICE_URL", "http://localhost:8014").rstrip("/")
def ai_voice_runtime_service_url() -> str:
return os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018").rstrip("/")
def ai_voice_enabled() -> bool:
return bool_env("AI_VOICE_ENABLED", False)
def ai_voice_queue_config() -> dict[str, dict[str, Any]]:
raw = os.getenv("AI_VOICE_QUEUE_CONFIG_JSON", "{}").strip()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid AI_VOICE_QUEUE_CONFIG_JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError("AI_VOICE_QUEUE_CONFIG_JSON must be a JSON object")
result: dict[str, dict[str, Any]] = {}
for queue_code, item in payload.items():
key = str(queue_code or "").strip()
if not key or not isinstance(item, dict):
continue
result[key] = item
return result
def _legacy_voice_start_profile(queue_code: str, *, stage: str | None, agent_profile: str) -> tuple[str | None, str]:
if stage or agent_profile != "voice_support":
return stage, agent_profile
# Backward compatibility for deployed IVR AI queues that predate the
# explicit voice_start stage/profile fields.
if queue_code.startswith("ivr_") and "_ai_" in queue_code:
return "voice_start", "voice_start"
return stage, agent_profile
def ai_voice_config_for_queue(queue_code: str | None) -> dict[str, Any] | None:
key = str(queue_code or "").strip()
if not key or not ai_voice_enabled():
return None
item = ai_voice_queue_config().get(key)
if not isinstance(item, dict):
return None
if str(item.get("mode") or "ai_first").strip().lower() != "ai_first":
return None
stage = str(item.get("stage") or "").strip() or None
agent_profile = str(item.get("agent_profile") or "voice_support").strip() or "voice_support"
stage, agent_profile = _legacy_voice_start_profile(
key,
stage=stage,
agent_profile=agent_profile,
)
next_queue_code = str(item.get("next_queue_code") or "").strip() or None
next_queue_id = str(item.get("next_queue_id") or "").strip() or None
handoff_queue_code = str(item.get("handoff_queue_code") or next_queue_code or key).strip() or key
handoff_queue_id = queue_map().get(handoff_queue_code)
if next_queue_code and not next_queue_id:
next_queue_id = queue_map().get(next_queue_code)
if stage == "voice_start" and not handoff_queue_id:
handoff_queue_id = next_queue_id
return {
"queue_code": key,
"mode": "ai_first",
"agent_profile": agent_profile,
"language": str(item.get("language") or "").strip() or None,
"stage": stage,
"next_queue_code": next_queue_code,
"next_queue_id": next_queue_id,
"handoff_queue_code": handoff_queue_code,
"handoff_queue_id": handoff_queue_id,
}
def ivr_fastagi_enabled() -> bool:
return bool_env("ASTERISK_IVR_FASTAGI_ENABLED", False)
def ivr_fastagi_host() -> str:
return os.getenv("ASTERISK_IVR_FASTAGI_HOST", "127.0.0.1").strip() or "127.0.0.1"
def ivr_fastagi_port() -> int:
raw = os.getenv("ASTERISK_IVR_FASTAGI_PORT", "4573").strip()
try:
value = int(raw)
except ValueError:
value = 4573
return max(value, 1)
def ivr_dtmf_timeout_seconds() -> int:
raw = os.getenv("ASTERISK_IVR_DTMF_TIMEOUT_SECONDS", "5").strip()
try:
value = int(raw)
except ValueError:
value = 5
return max(value, 1)
def ivr_max_no_input_retries() -> int:
raw = os.getenv("ASTERISK_IVR_MAX_NO_INPUT_RETRIES", "2").strip()
try:
value = int(raw)
except ValueError:
value = 2
return max(value, 0)
def ivr_max_invalid_retries() -> int:
raw = os.getenv("ASTERISK_IVR_MAX_INVALID_RETRIES", "2").strip()
try:
value = int(raw)
except ValueError:
value = 2
return max(value, 0)
def ivr_call_link_wait_seconds() -> float:
raw = os.getenv("ASTERISK_IVR_CALL_LINK_WAIT_SECONDS", "5").strip()
try:
value = float(raw)
except ValueError:
value = 5.0
return max(value, 0.5)
def callcontrol_enabled() -> bool:
return bool_env("ASTERISK_CALLCONTROL_ENABLED", False)
def callcontrol_action_timeout_seconds() -> float:
raw = os.getenv("ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS", "10").strip()
try:
value = float(raw)
except ValueError:
value = 10.0
return max(value, 2.0)
def webrtc_enabled() -> bool:
return bool_env("ASTERISK_WEBRTC_ENABLED", False)
def webrtc_ws_url() -> str:
return os.getenv("ASTERISK_WEBRTC_WS_URL", "").strip()
def webrtc_ice_servers() -> list[dict[str, Any]]:
raw = os.getenv("ASTERISK_WEBRTC_ICE_SERVERS_JSON", "[]").strip()
try:
payload = json.loads(raw or "[]")
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid ASTERISK_WEBRTC_ICE_SERVERS_JSON") from exc
if not isinstance(payload, list):
raise RuntimeError("ASTERISK_WEBRTC_ICE_SERVERS_JSON must be a JSON array")
result: list[dict[str, Any]] = []
for item in payload:
if isinstance(item, dict):
result.append(item)
return result
def operator_extension_map() -> dict[str, str]:
raw = os.getenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", "{}").strip()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid ASTERISK_OPERATOR_EXTENSION_MAP_JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError("ASTERISK_OPERATOR_EXTENSION_MAP_JSON must be a JSON object")
return {str(k).strip(): str(v).strip() for k, v in payload.items() if str(k).strip() and str(v).strip()}
def user_for_operator_extension(extension: str | None) -> str | None:
target = str(extension or "").strip()
if not target:
return None
matches = [user for user, mapped in operator_extension_map().items() if mapped == target]
unique_matches = list(dict.fromkeys(matches))
if len(unique_matches) == 1:
return unique_matches[0]
return None
def browser_sip_map() -> dict[str, dict[str, Any]]:
raw = os.getenv("ASTERISK_BROWSER_SIP_MAP_JSON", "{}").strip()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid ASTERISK_BROWSER_SIP_MAP_JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError("ASTERISK_BROWSER_SIP_MAP_JSON must be a JSON object")
result: dict[str, dict[str, Any]] = {}
for user, item in payload.items():
if not str(user).strip() or not isinstance(item, dict):
continue
result[str(user).strip()] = item
return result
def default_browser_sip_domain() -> str:
ws_url = webrtc_ws_url()
if ws_url:
parsed = urlparse(ws_url)
if parsed.hostname:
return parsed.hostname
return ami_host()
def browser_softphone_config_for_actor(actor: dict) -> BrowserSoftphoneConfigOut:
user = str(actor.get("user") or "").strip()
entry = browser_sip_map().get(user)
if not entry:
raise HTTPException(status_code=403, detail="Browser softphone is not configured for this user")
operator_extension = (
str(entry.get("operator_extension") or "").strip()
or operator_extension_map().get(user, "")
)
authorization_username = (
str(entry.get("authorization_username") or "").strip()
or str(entry.get("username") or "").strip()
or operator_extension
)
password = str(entry.get("password") or "").strip()
display_name = str(entry.get("display_name") or actor.get("full_name") or user).strip() or user
sip_uri = str(entry.get("sip_uri") or "").strip()
if not sip_uri and authorization_username:
domain = default_browser_sip_domain()
if domain:
sip_uri = f"sip:{authorization_username}@{domain}"
return BrowserSoftphoneConfigOut(
enabled=webrtc_enabled(),
ws_url=webrtc_ws_url() or None,
sip_uri=sip_uri or None,
authorization_username=authorization_username or None,
password=password or None,
display_name=display_name or None,
ice_servers=webrtc_ice_servers(),
operator_extension=operator_extension or None,
)
def transfer_target_map() -> dict[str, str]:
raw = os.getenv("ASTERISK_TRANSFER_TARGET_MAP_JSON", "{}").strip()
try:
payload = json.loads(raw or "{}")
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid ASTERISK_TRANSFER_TARGET_MAP_JSON") from exc
if not isinstance(payload, dict):
raise RuntimeError("ASTERISK_TRANSFER_TARGET_MAP_JSON must be a JSON object")
return {str(k).strip(): str(v).strip() for k, v in payload.items() if str(k).strip() and str(v).strip()}
def claim_context() -> str:
return os.getenv("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim").strip() or "mvpcc-claim"
def transfer_context() -> str:
return os.getenv("ASTERISK_CALLCONTROL_TRANSFER_CONTEXT", "mvpcc-transfer").strip() or "mvpcc-transfer"
def bridge_auth_mode() -> str:
mode = os.getenv("ASTERISK_BRIDGE_AUTH_MODE", "legacy_headers").strip().lower()
if mode in {"legacy_headers", "bearer", "bearer_first"}:
return mode
return "legacy_headers"
def bridge_auth_fallback_legacy() -> bool:
return bool_env("ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY", True)
def bridge_auth_user() -> str:
return os.getenv("ASTERISK_BRIDGE_AUTH_USER", "asterisk-bridge").strip() or "asterisk-bridge"
def bridge_auth_role() -> str:
return os.getenv("ASTERISK_BRIDGE_AUTH_ROLE", "admin").strip().lower() or "admin"
def bridge_auth_subject() -> str:
return os.getenv("ASTERISK_BRIDGE_AUTH_SUBJECT", "svc:asterisk-bridge").strip() or "svc:asterisk-bridge"
def bridge_auth_token_ttl_seconds() -> int:
raw = os.getenv("ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS", "300").strip()
try:
value = int(raw)
except ValueError:
value = 300
return max(value, 60)
def ai_voice_runtime_trusted_subjects() -> set[str]:
raw = os.getenv(
"AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS",
"svc:ai-voice-runtime",
)
return {
item.strip()
for item in raw.split(",")
if item.strip()
}