Files
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

192 lines
6.5 KiB
Python

from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy import select
from services.shared.core import utc_now_iso
from services.shared.db import get_session
from services.shared.models import (
EscalationOut,
EscalationRequestIn,
HealthResponse,
VoiceAICallStateUpdateIn,
VoiceAIHandoffRequestIn,
VoiceAISummaryOut,
VoiceCallBlindTransferIn,
VoiceCallClaimIn,
VoiceLiveCallOut,
)
from services.shared.sql_models import AsteriskCallLinkRow, AsteriskEventLogRow
from services.asterisk_bridge_service import call_control
def _bridge_app():
import services.asterisk_bridge_service.app as bridge_app
return bridge_app
def list_live_calls(include_ended: bool = False, limit: int = 100) -> list[VoiceLiveCallOut]:
bridge = _bridge_app()
session = get_session()
try:
stmt = select(AsteriskCallLinkRow).order_by(AsteriskCallLinkRow.id.desc())
if not include_ended:
stmt = stmt.where(AsteriskCallLinkRow.status != "ended")
rows = session.execute(stmt).scalars().all()
result: list[VoiceLiveCallOut] = []
for row in rows[: max(limit, 1)]:
if not row.interaction_id:
continue
if not include_ended and bridge._call_is_ended(row):
continue
result.append(bridge._live_call_to_out(session, row))
return result
finally:
session.close()
def list_recent_calls(limit: int = 20) -> list[VoiceLiveCallOut]:
bridge = _bridge_app()
session = get_session()
try:
rows = session.execute(
select(AsteriskCallLinkRow)
.where(AsteriskCallLinkRow.status == "ended")
.order_by(AsteriskCallLinkRow.id.desc())
.limit(max(limit * 4, limit, 20))
).scalars().all()
now = bridge.datetime.now(bridge.timezone.utc)
window_seconds = bridge._recent_calls_window_seconds()
result: list[tuple[bridge.datetime, VoiceLiveCallOut]] = []
for row in rows:
if not row.interaction_id:
continue
payload = bridge._live_call_to_out(session, row)
transition_dt = bridge._parse_iso(payload.last_transition_at or row.ended_at or row.updated_at)
if transition_dt is None:
continue
if (now - transition_dt).total_seconds() > window_seconds:
continue
result.append((transition_dt, payload))
result.sort(key=lambda item: item[0], reverse=True)
return [item for _, item in result[: max(limit, 1)]]
finally:
session.close()
def claim_live_call(call_id: str, body: VoiceCallClaimIn, actor: dict):
return call_control.claim_live_call(call_id, body, actor)
def hangup_live_call(call_id: str, actor: dict):
return call_control.hangup_live_call(call_id, actor)
def blind_transfer_live_call(call_id: str, body: VoiceCallBlindTransferIn, actor: dict):
return call_control.blind_transfer_live_call(call_id, body, actor)
def list_live_call_actions(call_id: str, limit: int, actor: dict):
return call_control.list_live_call_actions(call_id, limit, actor)
def get_live_call_ai_summary(call_id: str) -> VoiceAISummaryOut | None:
bridge = _bridge_app()
return bridge._voice_ai_summary_for_call(call_id)
def escalate_live_call(call_id: str, body: EscalationRequestIn, actor: dict) -> EscalationOut:
bridge = _bridge_app()
return bridge._create_escalation(call_id, body, actor)
def voice_ai_handoff_call(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> VoiceLiveCallOut:
bridge = _bridge_app()
return bridge._request_voice_ai_handoff(call_id, body, actor)
def voice_ai_update_call_state(call_id: str, body: VoiceAICallStateUpdateIn, actor: dict) -> VoiceLiveCallOut:
bridge = _bridge_app()
return bridge._update_voice_ai_call_state(call_id, body, actor)
def health() -> HealthResponse:
return HealthResponse(status="ok", service="asterisk-bridge-service")
def asterisk_status():
bridge = _bridge_app()
snap = bridge._STATE.snapshot()
status = "ok" if bridge._bridge_enabled() else "disabled"
return bridge.AsteriskBridgeStatusOut(
status=status,
ami_connected=bool(snap["ami_connected"]),
ami_host=bridge._ami_host() or None,
last_event_at=snap["last_event_at"],
queue_codes_loaded=sorted(bridge._queue_map().keys()),
sftp_enabled=bridge._sftp_enabled(),
bridge_auth_mode=bridge._bridge_auth_mode(),
callcontrol_enabled=bridge._callcontrol_enabled(),
webrtc_enabled=bridge._webrtc_enabled(),
webrtc_ws_url=bridge._webrtc_ws_url() or None,
)
def browser_softphone_config(actor: dict):
bridge = _bridge_app()
return bridge._browser_softphone_config_for_actor(actor)
def list_asterisk_events(status: str | None = None, limit: int = 100):
bridge = _bridge_app()
session = get_session()
try:
stmt = select(AsteriskEventLogRow).order_by(AsteriskEventLogRow.id.desc())
if status:
stmt = stmt.where(AsteriskEventLogRow.forward_status == status)
rows = session.execute(stmt).scalars().all()
return [bridge._to_out(row) for row in rows[:limit]]
finally:
session.close()
def get_asterisk_event(bridge_event_id: str):
bridge = _bridge_app()
session = get_session()
try:
row = session.execute(
select(AsteriskEventLogRow)
.where(AsteriskEventLogRow.bridge_event_id == bridge_event_id)
).scalar_one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="Asterisk bridge event not found")
return bridge._to_out(row)
finally:
session.close()
def retry_asterisk_event(bridge_event_id: str):
bridge = _bridge_app()
if not bridge._claim_bridge_event_for_processing(bridge_event_id):
session = get_session()
try:
row = bridge._load_bridge_event_row(session, bridge_event_id)
if row is None:
raise HTTPException(status_code=404, detail="Asterisk bridge event not found")
return bridge._to_out(row)
finally:
session.close()
row = bridge._process_claimed_bridge_event(bridge_event_id)
if row is None:
raise HTTPException(status_code=404, detail="Asterisk bridge event not found")
return bridge._to_out(row)
def reconnect_asterisk():
bridge = _bridge_app()
bridge._STATE.request_reconnect()
return asterisk_status()