Merge pull request 'feat: L1->L2 agent pool and routing engine for voice escalation' (#4) from feature/l1-l2-routing-engine into main
deploy / deploy (push) Successful in 32s
deploy / deploy (push) Successful in 32s
This commit was merged in pull request #4.
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# L1 -> L2 Routing Engine (Phase 1)
|
||||||
|
|
||||||
|
Implements a real Agent Pool + Routing Engine for voice escalation from
|
||||||
|
Voice AI (L1) to live L2 operators, replacing the previous static
|
||||||
|
single-extension redirect target.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- `agents` table (`services/routing_service/engine.py`): agents are rows with
|
||||||
|
`level`, `tenant_ids`, `skills`, `status`, `max_concurrent_calls`, `enabled` -
|
||||||
|
not a hardcoded list. Selection filters by level/tenant/skills and picks the
|
||||||
|
longest-idle, least-loaded match. Reservation is an atomic
|
||||||
|
`UPDATE ... WHERE status='AVAILABLE'` (rowcount-checked), so two concurrent
|
||||||
|
calls can never reserve the same agent.
|
||||||
|
- `escalations` table: one row per escalation attempt (`from_level`,
|
||||||
|
`to_level`, `reason_code`, `required_skills`, `priority`, `status`).
|
||||||
|
- `routing_rules` table: seeded data (not code) describing which
|
||||||
|
level-to-level transitions are allowed; `L3` and multi-tenant rules can be
|
||||||
|
added later without code changes.
|
||||||
|
- `asterisk_call_links` gained `tenant_id`, `current_level`,
|
||||||
|
`required_skills_json`, `priority`.
|
||||||
|
- `POST /asterisk/live-calls/{call_id}/escalations` (asterisk-bridge-service):
|
||||||
|
explicit escalation entrypoint. `voice_ai.request_handoff` (used by the AI
|
||||||
|
runtime) now also uses the Agent Pool automatically for any `queue_code`
|
||||||
|
configured in `ASTERISK_QUEUE_LEVEL_MAP_JSON`; every other queue_code keeps
|
||||||
|
the old static `ASTERISK_TRANSFER_TARGET_MAP_JSON` behavior unchanged.
|
||||||
|
- `routing-service` gained `/agents` CRUD + `/internal/routing/reserve-agent`
|
||||||
|
and `/internal/routing/release-agent`, called by asterisk-bridge-service
|
||||||
|
the same way it already calls interaction-service/ai-voice-runtime-service
|
||||||
|
(bridge-issued bearer token, `ROUTING_SERVICE_URL`).
|
||||||
|
- Agent is released back to `AVAILABLE` when the call ends
|
||||||
|
(`process_call_ended` in `bridge_processing.py`), which also closes the
|
||||||
|
matching open `escalations` row.
|
||||||
|
|
||||||
|
## Config for a given DID/queue
|
||||||
|
|
||||||
|
```
|
||||||
|
ASTERISK_QUEUE_LEVEL_MAP_JSON={"<queue_code>":{"level":"L2","tenant_id":"konturai"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Any `queue_code` not listed here keeps behaving exactly as before (static
|
||||||
|
`ASTERISK_TRANSFER_TARGET_MAP_JSON` redirect) - this is additive, not a
|
||||||
|
replacement of the legacy mechanism.
|
||||||
|
|
||||||
|
Seed `agents` rows via `POST /agents` (admin/supervisor). Level `L2`,
|
||||||
|
`extension` must match a real SIP endpoint (currently only `2001`/`2002`
|
||||||
|
exist on the telecom server).
|
||||||
|
|
||||||
|
## Known gap - no-answer retry
|
||||||
|
|
||||||
|
Today the Routing Engine picks and reserves one agent and issues a single AMI
|
||||||
|
`Redirect` to their extension; if that extension does not answer, the caller
|
||||||
|
currently depends on whatever the dialplan does at that extension (unchanged
|
||||||
|
from before this change). Automatic "agent didn't answer -> release and pick
|
||||||
|
next" requires either:
|
||||||
|
|
||||||
|
1. A small dialplan addition at the target extension's context: `Dial` with a
|
||||||
|
fixed timeout and, on failure, a `UserEvent` back into the bridge (mirrors
|
||||||
|
the existing `MVPCCAIFallbackToHuman` pattern) so `asterisk_bridge_service`
|
||||||
|
can call `/internal/routing/reserve-agent` again with the failed agent in
|
||||||
|
`exclude_agent_ids`, or
|
||||||
|
2. Switching from `Redirect` to AMI `Originate` with `Timeout` and reacting to
|
||||||
|
`OriginateResponse`.
|
||||||
|
|
||||||
|
Left out of Phase 1 deliberately: changing dialplan behavior without full
|
||||||
|
visibility into what a given extension's context currently does on no-answer
|
||||||
|
risks silently dropping a live customer call. Needs a follow-up MR with an
|
||||||
|
explicit, reviewed dialplan change.
|
||||||
|
|
||||||
|
## Not in Phase 1
|
||||||
|
|
||||||
|
- L3 (technical specialists) - `agents.level='L3'` and `routing_rules` already
|
||||||
|
support it; enabling it is a data-only follow-up once real L3 agents exist.
|
||||||
|
- SLA config / callback (ТЗ sections 20-21).
|
||||||
|
- Metrics (ТЗ section 38).
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS tenant_id TEXT;
|
||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS current_level TEXT;
|
||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS required_skills_json TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 3;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_tenant_id ON asterisk_call_links(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_current_level ON asterisk_call_links(current_level);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
agent_id TEXT NOT NULL UNIQUE,
|
||||||
|
tenant_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
extension TEXT NOT NULL,
|
||||||
|
endpoint TEXT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
skills_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
status TEXT NOT NULL DEFAULT 'OFFLINE',
|
||||||
|
current_call_id TEXT,
|
||||||
|
max_concurrent_calls INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
calls_handled_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_agent_id ON agents(agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_extension ON agents(extension);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_level ON agents(level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_status ON agents(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_current_call_id ON agents(current_call_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_enabled ON agents(enabled);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agents_level_status_enabled ON agents(level, status, enabled);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS escalations (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
escalation_id TEXT NOT NULL UNIQUE,
|
||||||
|
call_id TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
from_level TEXT NOT NULL,
|
||||||
|
to_level TEXT NOT NULL,
|
||||||
|
reason_code TEXT NOT NULL,
|
||||||
|
required_skills_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 3,
|
||||||
|
topic TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'requested',
|
||||||
|
assigned_agent_id TEXT,
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
connected_at TEXT,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_escalation_id ON escalations(escalation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_call_id ON escalations(call_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_tenant_id ON escalations(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_from_level ON escalations(from_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_to_level ON escalations(to_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_reason_code ON escalations(reason_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_status ON escalations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_assigned_agent_id ON escalations(assigned_agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_requested_at ON escalations(requested_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_escalations_call_id_status ON escalations(call_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS routing_rules (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL UNIQUE,
|
||||||
|
from_level TEXT NOT NULL,
|
||||||
|
to_level TEXT NOT NULL,
|
||||||
|
reason_code TEXT,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 3,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_rule_id ON routing_rules(rule_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_from_level ON routing_rules(from_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_to_level ON routing_rules(to_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_reason_code ON routing_rules(reason_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_enabled ON routing_rules(enabled);
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN tenant_id TEXT;
|
||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN current_level TEXT;
|
||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN required_skills_json TEXT NOT NULL DEFAULT '[]';
|
||||||
|
ALTER TABLE asterisk_call_links ADD COLUMN priority INTEGER NOT NULL DEFAULT 3;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_tenant_id ON asterisk_call_links(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_current_level ON asterisk_call_links(current_level);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id TEXT NOT NULL UNIQUE,
|
||||||
|
tenant_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
extension TEXT NOT NULL,
|
||||||
|
endpoint TEXT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
skills_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
status TEXT NOT NULL DEFAULT 'OFFLINE',
|
||||||
|
current_call_id TEXT,
|
||||||
|
max_concurrent_calls INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
calls_handled_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_agent_id ON agents(agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_extension ON agents(extension);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_level ON agents(level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_status ON agents(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_current_call_id ON agents(current_call_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_agents_enabled ON agents(enabled);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agents_level_status_enabled ON agents(level, status, enabled);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS escalations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
escalation_id TEXT NOT NULL UNIQUE,
|
||||||
|
call_id TEXT NOT NULL,
|
||||||
|
tenant_id TEXT,
|
||||||
|
from_level TEXT NOT NULL,
|
||||||
|
to_level TEXT NOT NULL,
|
||||||
|
reason_code TEXT NOT NULL,
|
||||||
|
required_skills_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 3,
|
||||||
|
topic TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'requested',
|
||||||
|
assigned_agent_id TEXT,
|
||||||
|
requested_at TEXT NOT NULL,
|
||||||
|
connected_at TEXT,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_escalation_id ON escalations(escalation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_call_id ON escalations(call_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_tenant_id ON escalations(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_from_level ON escalations(from_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_to_level ON escalations(to_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_reason_code ON escalations(reason_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_status ON escalations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_assigned_agent_id ON escalations(assigned_agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_escalations_requested_at ON escalations(requested_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_escalations_call_id_status ON escalations(call_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS routing_rules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rule_id TEXT NOT NULL UNIQUE,
|
||||||
|
from_level TEXT NOT NULL,
|
||||||
|
to_level TEXT NOT NULL,
|
||||||
|
reason_code TEXT,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 3,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_rule_id ON routing_rules(rule_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_from_level ON routing_rules(from_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_to_level ON routing_rules(to_level);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_reason_code ON routing_rules(reason_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routing_rules_enabled ON routing_rules(enabled);
|
||||||
@@ -16,6 +16,8 @@ from services.shared.models import (
|
|||||||
AsteriskBridgeStatusOut,
|
AsteriskBridgeStatusOut,
|
||||||
AsteriskEventOut,
|
AsteriskEventOut,
|
||||||
BrowserSoftphoneConfigOut,
|
BrowserSoftphoneConfigOut,
|
||||||
|
EscalationOut,
|
||||||
|
EscalationRequestIn,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
VoiceAICallStateUpdateIn,
|
VoiceAICallStateUpdateIn,
|
||||||
VoiceAIHandoffRequestIn,
|
VoiceAIHandoffRequestIn,
|
||||||
@@ -105,6 +107,9 @@ _bridge_auth_role = bridge_config.bridge_auth_role
|
|||||||
_bridge_auth_subject = bridge_config.bridge_auth_subject
|
_bridge_auth_subject = bridge_config.bridge_auth_subject
|
||||||
_bridge_auth_token_ttl_seconds = bridge_config.bridge_auth_token_ttl_seconds
|
_bridge_auth_token_ttl_seconds = bridge_config.bridge_auth_token_ttl_seconds
|
||||||
_ai_voice_runtime_trusted_subjects = bridge_config.ai_voice_runtime_trusted_subjects
|
_ai_voice_runtime_trusted_subjects = bridge_config.ai_voice_runtime_trusted_subjects
|
||||||
|
_routing_service_url = bridge_config.routing_service_url
|
||||||
|
_routing_level_for_queue_code = bridge_config.routing_level_for_queue_code
|
||||||
|
_routing_tenant_for_queue_code = bridge_config.routing_tenant_for_queue_code
|
||||||
|
|
||||||
|
|
||||||
def _legacy_headers() -> dict[str, str]:
|
def _legacy_headers() -> dict[str, str]:
|
||||||
@@ -298,6 +303,8 @@ _queue_code_for_queue_id = bridge_voice_ai._queue_code_for_queue_id
|
|||||||
_request_voice_ai_handoff = bridge_voice_ai.request_handoff
|
_request_voice_ai_handoff = bridge_voice_ai.request_handoff
|
||||||
_update_voice_ai_call_state = bridge_voice_ai.update_call_ai_state
|
_update_voice_ai_call_state = bridge_voice_ai.update_call_ai_state
|
||||||
_voice_ai_summary_for_call = bridge_voice_ai.voice_ai_summary_for_call
|
_voice_ai_summary_for_call = bridge_voice_ai.voice_ai_summary_for_call
|
||||||
|
_create_escalation = bridge_voice_ai.create_escalation
|
||||||
|
_release_routing_agent = bridge_voice_ai.release_routing_agent
|
||||||
|
|
||||||
_first_non_empty = bridge_ami.first_non_empty
|
_first_non_empty = bridge_ami.first_non_empty
|
||||||
_extract_call_id = bridge_ami.extract_call_id
|
_extract_call_id = bridge_ami.extract_call_id
|
||||||
@@ -422,6 +429,15 @@ def blind_transfer_live_call(
|
|||||||
return bridge_routes.blind_transfer_live_call(call_id=call_id, body=body, actor=actor)
|
return bridge_routes.blind_transfer_live_call(call_id=call_id, body=body, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/asterisk/live-calls/{call_id}/escalations", response_model=EscalationOut)
|
||||||
|
def escalate_live_call(
|
||||||
|
call_id: str,
|
||||||
|
body: EscalationRequestIn,
|
||||||
|
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
|
||||||
|
) -> EscalationOut:
|
||||||
|
return bridge_routes.escalate_live_call(call_id=call_id, body=body, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/asterisk/live-calls/{call_id}/actions", response_model=list[VoiceCallActionOut])
|
@app.get("/asterisk/live-calls/{call_id}/actions", response_model=list[VoiceCallActionOut])
|
||||||
def list_live_call_actions(
|
def list_live_call_actions(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from services.shared.sql_models import (
|
|||||||
AsteriskCallActionLogRow,
|
AsteriskCallActionLogRow,
|
||||||
AsteriskCallLinkRow,
|
AsteriskCallLinkRow,
|
||||||
AsteriskEventLogRow,
|
AsteriskEventLogRow,
|
||||||
|
EscalationRow,
|
||||||
Interaction,
|
Interaction,
|
||||||
VoiceAISessionRow,
|
VoiceAISessionRow,
|
||||||
)
|
)
|
||||||
@@ -880,6 +881,21 @@ def process_call_ended(
|
|||||||
payload.get("DurationSeconds"),
|
payload.get("DurationSeconds"),
|
||||||
link.ai_state,
|
link.ai_state,
|
||||||
)
|
)
|
||||||
|
open_escalation = session.execute(
|
||||||
|
select(EscalationRow).where(
|
||||||
|
EscalationRow.call_id == row.call_id,
|
||||||
|
EscalationRow.status.in_(["requested", "ringing"]),
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if open_escalation is not None:
|
||||||
|
open_escalation.status = "completed" if answered else "failed"
|
||||||
|
open_escalation.completed_at = now
|
||||||
|
if answered and not open_escalation.connected_at:
|
||||||
|
open_escalation.connected_at = now
|
||||||
|
try:
|
||||||
|
bridge._release_routing_agent(row.call_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
bridge._notify_voice_ai_telephony_event(
|
bridge._notify_voice_ai_telephony_event(
|
||||||
voice_session_id=link.voice_session_id,
|
voice_session_id=link.voice_session_id,
|
||||||
|
|||||||
@@ -176,6 +176,40 @@ def interaction_service_url() -> str:
|
|||||||
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")
|
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:
|
def voice_adapter_service_url() -> str:
|
||||||
return os.getenv("VOICE_ADAPTER_SERVICE_URL", "http://localhost:8006").rstrip("/")
|
return os.getenv("VOICE_ADAPTER_SERVICE_URL", "http://localhost:8006").rstrip("/")
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from sqlalchemy import select
|
|||||||
from services.shared.core import utc_now_iso
|
from services.shared.core import utc_now_iso
|
||||||
from services.shared.db import get_session
|
from services.shared.db import get_session
|
||||||
from services.shared.models import (
|
from services.shared.models import (
|
||||||
|
EscalationOut,
|
||||||
|
EscalationRequestIn,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
VoiceAICallStateUpdateIn,
|
VoiceAICallStateUpdateIn,
|
||||||
VoiceAIHandoffRequestIn,
|
VoiceAIHandoffRequestIn,
|
||||||
@@ -95,6 +97,11 @@ def get_live_call_ai_summary(call_id: str) -> VoiceAISummaryOut | None:
|
|||||||
return bridge._voice_ai_summary_for_call(call_id)
|
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:
|
def voice_ai_handoff_call(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> VoiceLiveCallOut:
|
||||||
bridge = _bridge_app()
|
bridge = _bridge_app()
|
||||||
return bridge._request_voice_ai_handoff(call_id, body, actor)
|
return bridge._request_voice_ai_handoff(call_id, body, actor)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from sqlalchemy import select
|
|||||||
from services.shared.core import new_id, utc_now_iso
|
from services.shared.core import new_id, utc_now_iso
|
||||||
from services.shared.db import get_session
|
from services.shared.db import get_session
|
||||||
from services.shared.models import (
|
from services.shared.models import (
|
||||||
|
EscalationOut,
|
||||||
|
EscalationRequestIn,
|
||||||
VoiceAICallStateUpdateIn,
|
VoiceAICallStateUpdateIn,
|
||||||
VoiceAIHandoffRequestIn,
|
VoiceAIHandoffRequestIn,
|
||||||
VoiceAIMediaBridgeEventIn,
|
VoiceAIMediaBridgeEventIn,
|
||||||
@@ -17,7 +19,13 @@ from services.shared.models import (
|
|||||||
VoiceAISummaryTranscriptSegmentOut,
|
VoiceAISummaryTranscriptSegmentOut,
|
||||||
VoiceLiveCallOut,
|
VoiceLiveCallOut,
|
||||||
)
|
)
|
||||||
from services.shared.sql_models import AISessionRow, AsteriskCallLinkRow, VoiceAISessionRow, VoiceTranscriptSegmentRow
|
from services.shared.sql_models import (
|
||||||
|
AISessionRow,
|
||||||
|
AsteriskCallLinkRow,
|
||||||
|
EscalationRow,
|
||||||
|
VoiceAISessionRow,
|
||||||
|
VoiceTranscriptSegmentRow,
|
||||||
|
)
|
||||||
from services.shared.voice_transcripts import add_transcript_segment, next_transcript_sequence
|
from services.shared.voice_transcripts import add_transcript_segment, next_transcript_sequence
|
||||||
|
|
||||||
|
|
||||||
@@ -174,18 +182,74 @@ def _queue_code_for_queue_id(queue_id: str | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_handoff_extension(target_queue_id: str | None, fallback_queue_code: str | None) -> tuple[str, str]:
|
def _reserve_routing_agent(
|
||||||
|
*,
|
||||||
|
call_id: str,
|
||||||
|
level: str,
|
||||||
|
tenant_id: str | None,
|
||||||
|
required_skills: list[str] | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
bridge = _bridge_app()
|
||||||
|
try:
|
||||||
|
response = bridge._post_json(
|
||||||
|
f"{bridge._routing_service_url()}/internal/routing/reserve-agent",
|
||||||
|
{
|
||||||
|
"call_id": call_id,
|
||||||
|
"level": level,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"required_skills": required_skills or [],
|
||||||
|
"exclude_agent_ids": [],
|
||||||
|
},
|
||||||
|
timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
|
||||||
|
max_attempts=1,
|
||||||
|
retry_backoff_seconds=0.0,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
LOGGER.warning(
|
||||||
|
"bridge.routing_reserve_failed call_id=%s level=%s tenant_id=%s",
|
||||||
|
call_id,
|
||||||
|
level,
|
||||||
|
tenant_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
extension = str((response or {}).get("extension") or "").strip()
|
||||||
|
return extension or None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_handoff_extension(
|
||||||
|
target_queue_id: str | None,
|
||||||
|
fallback_queue_code: str | None,
|
||||||
|
*,
|
||||||
|
call_id: str | None = None,
|
||||||
|
target_level: str | None = None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
required_skills: list[str] | None = None,
|
||||||
|
) -> tuple[str, str, str | None, str | None]:
|
||||||
bridge = _bridge_app()
|
bridge = _bridge_app()
|
||||||
queue_code = _queue_code_for_queue_id(target_queue_id) or str(fallback_queue_code or "").strip()
|
queue_code = _queue_code_for_queue_id(target_queue_id) or str(fallback_queue_code or "").strip()
|
||||||
if not queue_code:
|
if not queue_code:
|
||||||
raise HTTPException(status_code=400, detail="Unable to resolve handoff queue_code")
|
raise HTTPException(status_code=400, detail="Unable to resolve handoff queue_code")
|
||||||
|
|
||||||
|
level = target_level or bridge._routing_level_for_queue_code(queue_code)
|
||||||
|
if level and call_id:
|
||||||
|
resolved_tenant_id = tenant_id if tenant_id is not None else bridge._routing_tenant_for_queue_code(queue_code)
|
||||||
|
reserved_extension = _reserve_routing_agent(
|
||||||
|
call_id=call_id,
|
||||||
|
level=level,
|
||||||
|
tenant_id=resolved_tenant_id,
|
||||||
|
required_skills=required_skills,
|
||||||
|
)
|
||||||
|
if reserved_extension:
|
||||||
|
return queue_code, reserved_extension, level, resolved_tenant_id
|
||||||
|
raise HTTPException(status_code=409, detail=f"No available {level} agent right now")
|
||||||
|
|
||||||
extension = bridge._transfer_target_map().get(queue_code)
|
extension = bridge._transfer_target_map().get(queue_code)
|
||||||
if not extension:
|
if not extension:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Unknown transfer queue_code: {queue_code}",
|
detail=f"Unknown transfer queue_code: {queue_code}",
|
||||||
)
|
)
|
||||||
return queue_code, extension
|
return queue_code, extension, None, None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_handoff_channel(session, link: AsteriskCallLinkRow) -> str:
|
def _resolve_handoff_channel(session, link: AsteriskCallLinkRow) -> str:
|
||||||
@@ -395,9 +459,10 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) ->
|
|||||||
if voice_session is None:
|
if voice_session is None:
|
||||||
raise HTTPException(status_code=404, detail="Voice AI session not found")
|
raise HTTPException(status_code=404, detail="Voice AI session not found")
|
||||||
|
|
||||||
queue_code, target_extension = _resolve_handoff_extension(
|
queue_code, target_extension, resolved_level, resolved_tenant_id = _resolve_handoff_extension(
|
||||||
body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
|
body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id,
|
||||||
fallback_queue_code=_queue_code_for_queue_id(link.queue_id),
|
fallback_queue_code=_queue_code_for_queue_id(link.queue_id),
|
||||||
|
call_id=call_id,
|
||||||
)
|
)
|
||||||
handoff_metadata = body.metadata or {}
|
handoff_metadata = body.metadata or {}
|
||||||
actor_user = str(actor.get("user") or actor.get("sub") or "ai-voice-runtime").strip()
|
actor_user = str(actor.get("user") or actor.get("sub") or "ai-voice-runtime").strip()
|
||||||
@@ -477,6 +542,9 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) ->
|
|||||||
link.claimed_by_user = None
|
link.claimed_by_user = None
|
||||||
link.claimed_at = None
|
link.claimed_at = None
|
||||||
link.operator_extension = None
|
link.operator_extension = None
|
||||||
|
if resolved_level:
|
||||||
|
link.current_level = resolved_level
|
||||||
|
link.tenant_id = resolved_tenant_id
|
||||||
if reuse_queue_id:
|
if reuse_queue_id:
|
||||||
link.queue_code = queue_code
|
link.queue_code = queue_code
|
||||||
link.queue_id = reuse_queue_id
|
link.queue_id = reuse_queue_id
|
||||||
@@ -573,6 +641,111 @@ def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) ->
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def release_routing_agent(call_id: str) -> None:
|
||||||
|
bridge = _bridge_app()
|
||||||
|
bridge._post_json(
|
||||||
|
f"{bridge._routing_service_url()}/internal/routing/release-agent",
|
||||||
|
{"call_id": call_id},
|
||||||
|
timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
|
||||||
|
max_attempts=1,
|
||||||
|
retry_backoff_seconds=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _escalation_to_out(row: EscalationRow) -> EscalationOut:
|
||||||
|
return EscalationOut(
|
||||||
|
escalation_id=row.escalation_id,
|
||||||
|
call_id=row.call_id,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
from_level=row.from_level,
|
||||||
|
to_level=row.to_level,
|
||||||
|
reason_code=row.reason_code,
|
||||||
|
required_skills=json.loads(row.required_skills_json or "[]"),
|
||||||
|
priority=row.priority,
|
||||||
|
topic=row.topic,
|
||||||
|
summary=row.summary,
|
||||||
|
status=row.status,
|
||||||
|
assigned_agent_id=row.assigned_agent_id,
|
||||||
|
requested_at=row.requested_at,
|
||||||
|
connected_at=row.connected_at,
|
||||||
|
completed_at=row.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_escalation(call_id: str, body: EscalationRequestIn, actor: dict) -> EscalationOut:
|
||||||
|
bridge = _bridge_app()
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
link = bridge._load_live_call_or_404(session, call_id)
|
||||||
|
if bridge._call_is_ended(link):
|
||||||
|
raise HTTPException(status_code=409, detail="Call is already ended")
|
||||||
|
|
||||||
|
existing = session.execute(
|
||||||
|
select(EscalationRow).where(
|
||||||
|
EscalationRow.call_id == call_id,
|
||||||
|
EscalationRow.status.in_(["requested", "ringing"]),
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="An active escalation already exists for this call")
|
||||||
|
|
||||||
|
from_level = str(link.current_level or "L1")
|
||||||
|
now = utc_now_iso()
|
||||||
|
escalation = EscalationRow(
|
||||||
|
escalation_id=new_id("esc"),
|
||||||
|
call_id=call_id,
|
||||||
|
tenant_id=link.tenant_id,
|
||||||
|
from_level=from_level,
|
||||||
|
to_level=body.target_level,
|
||||||
|
reason_code=body.reason_code,
|
||||||
|
required_skills_json=json.dumps(body.required_skills, ensure_ascii=False),
|
||||||
|
priority=body.priority,
|
||||||
|
topic=body.topic,
|
||||||
|
summary=body.summary,
|
||||||
|
status="requested",
|
||||||
|
requested_at=now,
|
||||||
|
)
|
||||||
|
session.add(escalation)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
channel = _resolve_handoff_channel(session, link)
|
||||||
|
reserved_extension = _reserve_routing_agent(
|
||||||
|
call_id=call_id,
|
||||||
|
level=body.target_level,
|
||||||
|
tenant_id=link.tenant_id,
|
||||||
|
required_skills=body.required_skills,
|
||||||
|
)
|
||||||
|
if not reserved_extension:
|
||||||
|
escalation.status = "failed"
|
||||||
|
escalation.completed_at = now
|
||||||
|
session.commit()
|
||||||
|
raise HTTPException(status_code=409, detail=f"No available {body.target_level} agent right now")
|
||||||
|
|
||||||
|
bridge._ami_action(
|
||||||
|
"Redirect",
|
||||||
|
{
|
||||||
|
"Channel": channel,
|
||||||
|
"Context": bridge._transfer_context(),
|
||||||
|
"Exten": reserved_extension,
|
||||||
|
"Priority": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
escalation.status = "ringing"
|
||||||
|
escalation.assigned_agent_id = reserved_extension
|
||||||
|
link.current_level = body.target_level
|
||||||
|
link.required_skills_json = json.dumps(body.required_skills, ensure_ascii=False)
|
||||||
|
link.priority = body.priority
|
||||||
|
link.claimed_by_user = None
|
||||||
|
link.claimed_at = None
|
||||||
|
link.operator_extension = None
|
||||||
|
link.updated_at = now
|
||||||
|
session.commit()
|
||||||
|
return _escalation_to_out(escalation)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def update_call_ai_state(call_id: str, body: VoiceAICallStateUpdateIn, actor: dict) -> VoiceLiveCallOut:
|
def update_call_ai_state(call_id: str, body: VoiceAICallStateUpdateIn, actor: dict) -> VoiceLiveCallOut:
|
||||||
bridge = _bridge_app()
|
bridge = _bridge_app()
|
||||||
assert_trusted_voice_runtime_actor(actor)
|
assert_trusted_voice_runtime_actor(actor)
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ import json
|
|||||||
from fastapi import Depends, FastAPI, HTTPException
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from services.routing_service import engine as routing_engine
|
||||||
from services.shared.core import Role, new_id, utc_now_iso
|
from services.shared.core import Role, new_id, utc_now_iso
|
||||||
from services.shared.db import engine, get_session
|
from services.shared.db import engine, get_session
|
||||||
from services.shared.models import HealthResponse, QueueCreate, QueueOut
|
from services.shared.models import (
|
||||||
|
AgentCreate,
|
||||||
|
AgentPoolOut,
|
||||||
|
AgentStatusUpdateIn,
|
||||||
|
EscalationOut,
|
||||||
|
HealthResponse,
|
||||||
|
QueueCreate,
|
||||||
|
QueueOut,
|
||||||
|
RoutingAgentReserveIn,
|
||||||
|
RoutingAgentReserveOut,
|
||||||
|
)
|
||||||
from services.shared.security import require_roles
|
from services.shared.security import require_roles
|
||||||
from services.shared.sql_init import init_sql_schema
|
from services.shared.sql_init import init_sql_schema
|
||||||
from services.shared.sql_models import IvrSessionRow, Queue, RoutingCounter
|
from services.shared.sql_models import AgentRow, EscalationRow, IvrSessionRow, Queue, RoutingCounter
|
||||||
|
|
||||||
app = FastAPI(title="routing-service", version="1.1.0")
|
app = FastAPI(title="routing-service", version="1.1.0")
|
||||||
|
|
||||||
@@ -230,3 +241,169 @@ def delete_queue(
|
|||||||
return {"queue_id": queue_id, "deleted": True}
|
return {"queue_id": queue_id, "deleted": True}
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_to_out(row: AgentRow) -> AgentPoolOut:
|
||||||
|
return AgentPoolOut(
|
||||||
|
agent_id=row.agent_id,
|
||||||
|
tenant_ids=routing_engine.parse_list_json(row.tenant_ids_json),
|
||||||
|
extension=row.extension,
|
||||||
|
endpoint=row.endpoint,
|
||||||
|
display_name=row.display_name,
|
||||||
|
level=row.level,
|
||||||
|
skills=routing_engine.parse_list_json(row.skills_json),
|
||||||
|
status=row.status,
|
||||||
|
current_call_id=row.current_call_id,
|
||||||
|
max_concurrent_calls=row.max_concurrent_calls,
|
||||||
|
enabled=row.enabled,
|
||||||
|
calls_handled_count=row.calls_handled_count,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/agents", response_model=AgentPoolOut)
|
||||||
|
def create_agent(
|
||||||
|
payload: AgentCreate,
|
||||||
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
|
||||||
|
) -> AgentPoolOut:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
now = utc_now_iso()
|
||||||
|
row = AgentRow(
|
||||||
|
agent_id=new_id("agt"),
|
||||||
|
tenant_ids_json=json.dumps(payload.tenant_ids, ensure_ascii=False),
|
||||||
|
extension=payload.extension,
|
||||||
|
endpoint=payload.endpoint,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
level=payload.level,
|
||||||
|
skills_json=json.dumps(payload.skills, ensure_ascii=False),
|
||||||
|
status="OFFLINE",
|
||||||
|
max_concurrent_calls=payload.max_concurrent_calls,
|
||||||
|
enabled=payload.enabled,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return _agent_to_out(row)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agents", response_model=list[AgentPoolOut])
|
||||||
|
def list_agents(
|
||||||
|
level: str | None = None,
|
||||||
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
||||||
|
) -> list[AgentPoolOut]:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
stmt = select(AgentRow).order_by(AgentRow.id.desc())
|
||||||
|
if level:
|
||||||
|
stmt = stmt.where(AgentRow.level == level)
|
||||||
|
rows = session.execute(stmt).scalars().all()
|
||||||
|
return [_agent_to_out(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/agents/{agent_id}/status", response_model=AgentPoolOut)
|
||||||
|
def update_agent_status(
|
||||||
|
agent_id: str,
|
||||||
|
payload: AgentStatusUpdateIn,
|
||||||
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
||||||
|
) -> AgentPoolOut:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
row = session.execute(select(AgentRow).where(AgentRow.agent_id == agent_id)).scalar_one_or_none()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Agent not found")
|
||||||
|
row.status = payload.status
|
||||||
|
if payload.status == "AVAILABLE":
|
||||||
|
row.current_call_id = None
|
||||||
|
row.updated_at = utc_now_iso()
|
||||||
|
session.commit()
|
||||||
|
return _agent_to_out(row)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _escalation_to_out(row: EscalationRow) -> EscalationOut:
|
||||||
|
return EscalationOut(
|
||||||
|
escalation_id=row.escalation_id,
|
||||||
|
call_id=row.call_id,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
from_level=row.from_level,
|
||||||
|
to_level=row.to_level,
|
||||||
|
reason_code=row.reason_code,
|
||||||
|
required_skills=routing_engine.parse_list_json(row.required_skills_json),
|
||||||
|
priority=row.priority,
|
||||||
|
topic=row.topic,
|
||||||
|
summary=row.summary,
|
||||||
|
status=row.status,
|
||||||
|
assigned_agent_id=row.assigned_agent_id,
|
||||||
|
requested_at=row.requested_at,
|
||||||
|
connected_at=row.connected_at,
|
||||||
|
completed_at=row.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/escalations", response_model=list[EscalationOut])
|
||||||
|
def list_escalations(
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
_: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
|
||||||
|
) -> list[EscalationOut]:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
stmt = select(EscalationRow).order_by(EscalationRow.id.desc()).limit(max(1, min(limit, 200)))
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(EscalationRow.status == status)
|
||||||
|
rows = session.execute(stmt).scalars().all()
|
||||||
|
return [_escalation_to_out(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/routing/reserve-agent", response_model=RoutingAgentReserveOut)
|
||||||
|
def reserve_agent_endpoint(
|
||||||
|
payload: RoutingAgentReserveIn,
|
||||||
|
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||||
|
) -> RoutingAgentReserveOut:
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
agent = routing_engine.reserve_agent(
|
||||||
|
session,
|
||||||
|
call_id=payload.call_id,
|
||||||
|
level=payload.level,
|
||||||
|
tenant_id=payload.tenant_id,
|
||||||
|
required_skills=payload.required_skills,
|
||||||
|
exclude_agent_ids=payload.exclude_agent_ids,
|
||||||
|
)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(status_code=409, detail=f"No available {payload.level} agent")
|
||||||
|
return RoutingAgentReserveOut(
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
extension=agent.extension,
|
||||||
|
endpoint=agent.endpoint,
|
||||||
|
display_name=agent.display_name,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/internal/routing/release-agent")
|
||||||
|
def release_agent_endpoint(
|
||||||
|
payload: dict,
|
||||||
|
_: dict = Depends(require_roles(Role.ADMIN)),
|
||||||
|
) -> dict:
|
||||||
|
call_id = str(payload.get("call_id") or "").strip()
|
||||||
|
if not call_id:
|
||||||
|
raise HTTPException(status_code=400, detail="call_id is required")
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
agent = routing_engine.release_agent_by_call_id(session, call_id=call_id)
|
||||||
|
return {"call_id": call_id, "released_agent_id": agent.agent_id if agent else None}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
from services.shared.core import utc_now_iso
|
||||||
|
from services.shared.sql_models import AgentRow
|
||||||
|
|
||||||
|
|
||||||
|
def parse_list_json(raw: str | None) -> list[str]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [str(item) for item in data]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_matches(
|
||||||
|
agent: AgentRow,
|
||||||
|
*,
|
||||||
|
level: str,
|
||||||
|
tenant_id: str | None,
|
||||||
|
required_skills: list[str],
|
||||||
|
exclude_agent_ids: set[str],
|
||||||
|
) -> bool:
|
||||||
|
if agent.agent_id in exclude_agent_ids:
|
||||||
|
return False
|
||||||
|
if not agent.enabled:
|
||||||
|
return False
|
||||||
|
if agent.level != level:
|
||||||
|
return False
|
||||||
|
if agent.status != "AVAILABLE":
|
||||||
|
return False
|
||||||
|
tenant_ids = parse_list_json(agent.tenant_ids_json)
|
||||||
|
if tenant_id and tenant_ids and tenant_id not in tenant_ids:
|
||||||
|
return False
|
||||||
|
skills = set(parse_list_json(agent.skills_json))
|
||||||
|
if required_skills and not set(required_skills).issubset(skills):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def select_candidate_agents(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
level: str,
|
||||||
|
tenant_id: str | None,
|
||||||
|
required_skills: list[str] | None = None,
|
||||||
|
exclude_agent_ids: list[str] | None = None,
|
||||||
|
) -> list[AgentRow]:
|
||||||
|
exclude = set(exclude_agent_ids or [])
|
||||||
|
skills = required_skills or []
|
||||||
|
rows = session.execute(
|
||||||
|
select(AgentRow).where(AgentRow.level == level, AgentRow.enabled.is_(True))
|
||||||
|
).scalars().all()
|
||||||
|
candidates = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if _agent_matches(row, level=level, tenant_id=tenant_id, required_skills=skills, exclude_agent_ids=exclude)
|
||||||
|
]
|
||||||
|
candidates.sort(key=lambda a: (a.updated_at, a.calls_handled_count))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_agent(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
call_id: str,
|
||||||
|
level: str,
|
||||||
|
tenant_id: str | None,
|
||||||
|
required_skills: list[str] | None = None,
|
||||||
|
exclude_agent_ids: list[str] | None = None,
|
||||||
|
) -> AgentRow | None:
|
||||||
|
candidates = select_candidate_agents(
|
||||||
|
session,
|
||||||
|
level=level,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
required_skills=required_skills,
|
||||||
|
exclude_agent_ids=exclude_agent_ids,
|
||||||
|
)
|
||||||
|
now = utc_now_iso()
|
||||||
|
for candidate in candidates:
|
||||||
|
result = session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE agents SET status='RESERVED', current_call_id=:call_id, updated_at=:now
|
||||||
|
WHERE agent_id=:agent_id AND status='AVAILABLE'
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"call_id": call_id, "now": now, "agent_id": candidate.agent_id},
|
||||||
|
)
|
||||||
|
if result.rowcount == 1:
|
||||||
|
session.commit()
|
||||||
|
session.refresh(candidate)
|
||||||
|
return candidate
|
||||||
|
session.rollback()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def release_agent_by_call_id(session, *, call_id: str) -> AgentRow | None:
|
||||||
|
agent = session.execute(
|
||||||
|
select(AgentRow).where(AgentRow.current_call_id == call_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if agent is None:
|
||||||
|
return None
|
||||||
|
now = utc_now_iso()
|
||||||
|
agent.status = "AVAILABLE"
|
||||||
|
agent.current_call_id = None
|
||||||
|
agent.calls_handled_count = (agent.calls_handled_count or 0) + 1
|
||||||
|
agent.updated_at = now
|
||||||
|
session.commit()
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def release_agent_by_id(session, *, agent_id: str, next_status: str = "AVAILABLE") -> AgentRow | None:
|
||||||
|
agent = session.execute(
|
||||||
|
select(AgentRow).where(AgentRow.agent_id == agent_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if agent is None:
|
||||||
|
return None
|
||||||
|
now = utc_now_iso()
|
||||||
|
agent.status = next_status
|
||||||
|
agent.current_call_id = None
|
||||||
|
agent.updated_at = now
|
||||||
|
session.commit()
|
||||||
|
return agent
|
||||||
@@ -1253,6 +1253,84 @@ class AgentStateOut(AgentStateIn):
|
|||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
AgentLevel = Literal["L2", "L3"]
|
||||||
|
AgentStatus = Literal["OFFLINE", "AVAILABLE", "RESERVED", "RINGING", "TALKING", "AFTER_CALL_WORK", "PAUSED"]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCreate(BaseModel):
|
||||||
|
tenant_ids: list[str] = Field(default_factory=list)
|
||||||
|
extension: str = Field(min_length=1)
|
||||||
|
endpoint: str | None = None
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
level: AgentLevel
|
||||||
|
skills: list[str] = Field(default_factory=list)
|
||||||
|
max_concurrent_calls: int = Field(default=1, ge=1)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AgentStatusUpdateIn(BaseModel):
|
||||||
|
status: AgentStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AgentPoolOut(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
tenant_ids: list[str] = Field(default_factory=list)
|
||||||
|
extension: str
|
||||||
|
endpoint: str | None = None
|
||||||
|
display_name: str
|
||||||
|
level: AgentLevel
|
||||||
|
skills: list[str] = Field(default_factory=list)
|
||||||
|
status: AgentStatus
|
||||||
|
current_call_id: str | None = None
|
||||||
|
max_concurrent_calls: int
|
||||||
|
enabled: bool
|
||||||
|
calls_handled_count: int = 0
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationRequestIn(BaseModel):
|
||||||
|
target_level: AgentLevel
|
||||||
|
reason_code: str = Field(min_length=1)
|
||||||
|
topic: str | None = None
|
||||||
|
required_skills: list[str] = Field(default_factory=list)
|
||||||
|
priority: int = Field(default=3, ge=1, le=5)
|
||||||
|
summary: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationOut(BaseModel):
|
||||||
|
escalation_id: str
|
||||||
|
call_id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
from_level: str
|
||||||
|
to_level: str
|
||||||
|
reason_code: str
|
||||||
|
required_skills: list[str] = Field(default_factory=list)
|
||||||
|
priority: int
|
||||||
|
topic: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
status: str
|
||||||
|
assigned_agent_id: str | None = None
|
||||||
|
requested_at: str
|
||||||
|
connected_at: str | None = None
|
||||||
|
completed_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingAgentReserveIn(BaseModel):
|
||||||
|
call_id: str = Field(min_length=1)
|
||||||
|
level: AgentLevel
|
||||||
|
tenant_id: str | None = None
|
||||||
|
required_skills: list[str] = Field(default_factory=list)
|
||||||
|
exclude_agent_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingAgentReserveOut(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
extension: str
|
||||||
|
endpoint: str | None = None
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
class AIAnalyticsWindowOut(BaseModel):
|
class AIAnalyticsWindowOut(BaseModel):
|
||||||
from_ts: str
|
from_ts: str
|
||||||
to_ts: str
|
to_ts: str
|
||||||
|
|||||||
@@ -471,6 +471,25 @@ def _apply_runtime_schema_compatibility() -> None:
|
|||||||
"ON asterisk_call_links(customer_name_resolved_at)"
|
"ON asterisk_call_links(customer_name_resolved_at)"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_add_column_if_missing(conn, columns, "asterisk_call_links", "tenant_id", "VARCHAR(64)")
|
||||||
|
_add_column_if_missing(conn, columns, "asterisk_call_links", "current_level", "VARCHAR(16)")
|
||||||
|
_add_column_if_missing(conn, columns, "asterisk_call_links", "required_skills_json", "TEXT DEFAULT '[]'")
|
||||||
|
_add_column_if_missing(conn, columns, "asterisk_call_links", "priority", "INTEGER DEFAULT 3")
|
||||||
|
indexes = _table_indexes(inspector, "asterisk_call_links")
|
||||||
|
if "idx_asterisk_call_links_tenant_id" not in indexes:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_tenant_id "
|
||||||
|
"ON asterisk_call_links(tenant_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "idx_asterisk_call_links_current_level" not in indexes:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_current_level "
|
||||||
|
"ON asterisk_call_links(current_level)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if "voice_ai_sessions" in table_names:
|
if "voice_ai_sessions" in table_names:
|
||||||
columns = _table_columns(inspector, "voice_ai_sessions")
|
columns = _table_columns(inspector, "voice_ai_sessions")
|
||||||
|
|||||||
@@ -280,6 +280,10 @@ class AsteriskCallLinkRow(Base):
|
|||||||
connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
current_level: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||||
|
required_skills_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
|
||||||
|
|
||||||
class AsteriskCallActionLogRow(Base):
|
class AsteriskCallActionLogRow(Base):
|
||||||
@@ -804,3 +808,66 @@ class SupervisorQueueSnapshotRow(Base):
|
|||||||
in_queue: Mapped[int] = mapped_column(Integer, default=0)
|
in_queue: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
avg_wait_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
avg_wait_seconds: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRow(Base):
|
||||||
|
__tablename__ = "agents"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
agent_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
tenant_ids_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
extension: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
endpoint: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(256))
|
||||||
|
level: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
skills_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), index=True, default="OFFLINE")
|
||||||
|
current_call_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
max_concurrent_calls: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
calls_handled_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[str] = mapped_column(String(64))
|
||||||
|
updated_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EscalationRow(Base):
|
||||||
|
__tablename__ = "escalations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
escalation_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
call_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
from_level: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
to_level: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
reason_code: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
required_skills_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
topic: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), index=True, default="requested")
|
||||||
|
assigned_agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
requested_at: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
completed_at: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"idx_escalations_call_id_status",
|
||||||
|
"call_id",
|
||||||
|
"status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingRuleRow(Base):
|
||||||
|
__tablename__ = "routing_rules"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
rule_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
from_level: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
to_level: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
priority: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
created_at: Mapped[str] = mapped_column(String(64))
|
||||||
|
updated_at: Mapped[str] = mapped_column(String(64))
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from services.routing_service import engine as routing_engine
|
||||||
|
from services.shared.core import new_id, utc_now_iso
|
||||||
|
from services.shared.db import get_session
|
||||||
|
from services.shared.sql_init import init_sql_schema
|
||||||
|
from services.shared.sql_models import AgentRow
|
||||||
|
|
||||||
|
|
||||||
|
def _make_agent(session, *, level="L2", tenant_ids=None, skills=None, status="AVAILABLE", enabled=True):
|
||||||
|
now = utc_now_iso()
|
||||||
|
row = AgentRow(
|
||||||
|
agent_id=new_id("agt"),
|
||||||
|
tenant_ids_json=json.dumps(tenant_ids or []),
|
||||||
|
extension="2001",
|
||||||
|
endpoint=None,
|
||||||
|
display_name="Test Agent",
|
||||||
|
level=level,
|
||||||
|
skills_json=json.dumps(skills or []),
|
||||||
|
status=status,
|
||||||
|
max_concurrent_calls=1,
|
||||||
|
enabled=enabled,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserve_agent_picks_available_agent_of_requested_level():
|
||||||
|
init_sql_schema()
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
agent_l2 = _make_agent(session, level="L2")
|
||||||
|
_make_agent(session, level="L3")
|
||||||
|
|
||||||
|
reserved = routing_engine.reserve_agent(
|
||||||
|
session,
|
||||||
|
call_id="call-1",
|
||||||
|
level="L2",
|
||||||
|
tenant_id=None,
|
||||||
|
required_skills=[],
|
||||||
|
)
|
||||||
|
assert reserved is not None
|
||||||
|
assert reserved.agent_id == agent_l2.agent_id
|
||||||
|
assert reserved.status == "RESERVED"
|
||||||
|
assert reserved.current_call_id == "call-1"
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserve_agent_does_not_double_reserve():
|
||||||
|
init_sql_schema()
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
_make_agent(session, level="L2")
|
||||||
|
|
||||||
|
first = routing_engine.reserve_agent(session, call_id="call-a", level="L2", tenant_id=None, required_skills=[])
|
||||||
|
second = routing_engine.reserve_agent(session, call_id="call-b", level="L2", tenant_id=None, required_skills=[])
|
||||||
|
assert first is not None
|
||||||
|
assert second is None
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserve_agent_filters_by_tenant_and_skills():
|
||||||
|
init_sql_schema()
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
wrong_tenant = _make_agent(session, level="L2", tenant_ids=["other"])
|
||||||
|
no_skill = _make_agent(session, level="L2", tenant_ids=["konturai"], skills=[])
|
||||||
|
matching = _make_agent(session, level="L2", tenant_ids=["konturai"], skills=["billing"])
|
||||||
|
|
||||||
|
reserved = routing_engine.reserve_agent(
|
||||||
|
session,
|
||||||
|
call_id="call-skill",
|
||||||
|
level="L2",
|
||||||
|
tenant_id="konturai",
|
||||||
|
required_skills=["billing"],
|
||||||
|
)
|
||||||
|
assert reserved is not None
|
||||||
|
assert reserved.agent_id == matching.agent_id
|
||||||
|
assert reserved.agent_id != wrong_tenant.agent_id
|
||||||
|
assert reserved.agent_id != no_skill.agent_id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_agent_by_call_id_makes_agent_available_again():
|
||||||
|
init_sql_schema()
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
agent = _make_agent(session, level="L2")
|
||||||
|
reserved = routing_engine.reserve_agent(session, call_id="call-z", level="L2", tenant_id=None, required_skills=[])
|
||||||
|
assert reserved is not None
|
||||||
|
|
||||||
|
released = routing_engine.release_agent_by_call_id(session, call_id="call-z")
|
||||||
|
assert released is not None
|
||||||
|
assert released.agent_id == agent.agent_id
|
||||||
|
assert released.status == "AVAILABLE"
|
||||||
|
assert released.current_call_id is None
|
||||||
|
assert released.calls_handled_count == 1
|
||||||
|
|
||||||
|
again = routing_engine.reserve_agent(session, call_id="call-again", level="L2", tenant_id=None, required_skills=[])
|
||||||
|
assert again is not None
|
||||||
|
assert again.agent_id == agent.agent_id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserve_agent_excludes_disabled_and_excluded_ids():
|
||||||
|
init_sql_schema()
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
disabled = _make_agent(session, level="L2", enabled=False)
|
||||||
|
excluded = _make_agent(session, level="L2")
|
||||||
|
available = _make_agent(session, level="L2")
|
||||||
|
|
||||||
|
reserved = routing_engine.reserve_agent(
|
||||||
|
session,
|
||||||
|
call_id="call-exc",
|
||||||
|
level="L2",
|
||||||
|
tenant_id=None,
|
||||||
|
required_skills=[],
|
||||||
|
exclude_agent_ids=[excluded.agent_id],
|
||||||
|
)
|
||||||
|
assert reserved is not None
|
||||||
|
assert reserved.agent_id == available.agent_id
|
||||||
|
assert reserved.agent_id != disabled.agent_id
|
||||||
|
assert reserved.agent_id != excluded.agent_id
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
@@ -5987,3 +5987,66 @@ body.analytics-drilldown-open {
|
|||||||
padding: 10px 16px 0;
|
padding: 10px 16px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-pool-item,
|
||||||
|
.escalation-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-pool-item strong,
|
||||||
|
.escalation-item strong {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge::before {
|
||||||
|
content: "";
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-success {
|
||||||
|
background: var(--success-soft);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-warning {
|
||||||
|
background: var(--warning-soft);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-danger {
|
||||||
|
background: var(--danger-soft);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.is-muted {
|
||||||
|
background: #eef1f6;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-pool-meta,
|
||||||
|
.escalation-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|||||||
+189
-1
@@ -698,6 +698,178 @@ async function setAgentState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AGENT_STATUS_LABELS = {
|
||||||
|
AVAILABLE: 'Свободен',
|
||||||
|
RESERVED: 'Зарезервирован',
|
||||||
|
RINGING: 'Звонок идёт',
|
||||||
|
TALKING: 'На линии',
|
||||||
|
AFTER_CALL_WORK: 'Постобработка',
|
||||||
|
PAUSED: 'Пауза',
|
||||||
|
OFFLINE: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AGENT_STATUS_TONE = {
|
||||||
|
AVAILABLE: 'is-success',
|
||||||
|
RESERVED: 'is-warning',
|
||||||
|
RINGING: 'is-warning',
|
||||||
|
TALKING: 'is-success',
|
||||||
|
AFTER_CALL_WORK: 'is-warning',
|
||||||
|
PAUSED: 'is-muted',
|
||||||
|
OFFLINE: 'is-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESCALATION_STATUS_LABELS = {
|
||||||
|
requested: 'Запрошена',
|
||||||
|
ringing: 'Дозвон оператора',
|
||||||
|
completed: 'Завершена',
|
||||||
|
failed: 'Не удалась',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESCALATION_STATUS_TONE = {
|
||||||
|
requested: 'is-warning',
|
||||||
|
ringing: 'is-warning',
|
||||||
|
completed: 'is-success',
|
||||||
|
failed: 'is-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusBadge(labels, tones, value) {
|
||||||
|
const label = labels[value] || value || 'неизвестно';
|
||||||
|
const tone = tones[value] || 'is-muted';
|
||||||
|
return `<span class="status-badge ${tone}">${escapeHtml(label)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCallAgents(items) {
|
||||||
|
const byStatus = {};
|
||||||
|
items.forEach((item) => {
|
||||||
|
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||||
|
});
|
||||||
|
$('callAgentsSummary').innerHTML = [
|
||||||
|
renderSummaryCard('Всего в пуле', items.length, 'Операторы L2/L3'),
|
||||||
|
renderSummaryCard('Свободны', byStatus.AVAILABLE || 0, 'Могут принять звонок сейчас'),
|
||||||
|
renderSummaryCard('На линии', (byStatus.TALKING || 0) + (byStatus.RINGING || 0) + (byStatus.RESERVED || 0), 'Заняты звонком или дозвоном'),
|
||||||
|
renderSummaryCard('Offline', byStatus.OFFLINE || 0, 'Недоступны'),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
$('callAgentsList').innerHTML = '<li class="empty-state">Пул пуст. Добавьте оператора ниже.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('callAgentsList').innerHTML = items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<li class="agent-pool-item">
|
||||||
|
<strong>${escapeHtml(item.display_name)} · вн. ${escapeHtml(item.extension)}</strong>
|
||||||
|
${statusBadge(AGENT_STATUS_LABELS, AGENT_STATUS_TONE, item.status)}
|
||||||
|
<span class="status-badge is-muted">${escapeHtml(item.level)}</span>
|
||||||
|
${item.enabled ? '' : '<span class="status-badge is-danger">отключён</span>'}
|
||||||
|
<span class="agent-pool-meta">tenant: ${escapeHtml((item.tenant_ids || []).join(', ') || '-')}</span>
|
||||||
|
<span class="agent-pool-meta">навыки: ${escapeHtml((item.skills || []).join(', ') || '-')}</span>
|
||||||
|
<span class="agent-pool-meta">принято звонков: ${Number(item.calls_handled_count || 0)}</span>
|
||||||
|
${item.current_call_id ? `<span class="agent-pool-meta">текущий звонок: ${escapeHtml(item.current_call_id)}</span>` : ''}
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCallAgents(silent = false) {
|
||||||
|
try {
|
||||||
|
const data = await api('routing', 'agents');
|
||||||
|
renderCallAgents(Array.isArray(data) ? data : []);
|
||||||
|
if (!silent) {
|
||||||
|
log('Пул L2-операторов обновлён', { count: data.length });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
$('callAgentsList').innerHTML = '<li class="empty-state">Не удалось загрузить пул операторов.</li>';
|
||||||
|
if (!silent) {
|
||||||
|
log('Не удалось загрузить пул операторов', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCallAgent() {
|
||||||
|
try {
|
||||||
|
const extension = $('newAgentExtension').value.trim();
|
||||||
|
if (!extension) {
|
||||||
|
log('Укажите внутренний номер оператора');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const skills = $('newAgentSkills').value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const tenantId = $('newAgentTenant').value.trim();
|
||||||
|
const data = await api('routing', 'agents', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
extension,
|
||||||
|
display_name: $('newAgentDisplayName').value.trim() || `Оператор ${extension}`,
|
||||||
|
level: $('newAgentLevel').value,
|
||||||
|
tenant_ids: tenantId ? [tenantId] : [],
|
||||||
|
skills,
|
||||||
|
max_concurrent_calls: 1,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
log('Оператор добавлен в пул', { agent_id: data.agent_id, extension: data.extension });
|
||||||
|
$('newAgentExtension').value = '';
|
||||||
|
$('newAgentDisplayName').value = '';
|
||||||
|
$('newAgentSkills').value = '';
|
||||||
|
await loadCallAgents();
|
||||||
|
} catch (err) {
|
||||||
|
log('Не удалось добавить оператора', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEscalations(items) {
|
||||||
|
const byStatus = {};
|
||||||
|
items.forEach((item) => {
|
||||||
|
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||||
|
});
|
||||||
|
$('escalationsSummary').innerHTML = [
|
||||||
|
renderSummaryCard('Активных', (byStatus.requested || 0) + (byStatus.ringing || 0), 'Ждут/дозваниваются до оператора'),
|
||||||
|
renderSummaryCard('Завершено', byStatus.completed || 0, 'Успешно приняты оператором'),
|
||||||
|
renderSummaryCard('Не удалось', byStatus.failed || 0, 'Свободного оператора не нашлось'),
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
$('escalationsList').innerHTML = '<li class="empty-state">Эскалаций пока не было.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('escalationsList').innerHTML = items
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<li class="escalation-item">
|
||||||
|
<strong>звонок ${escapeHtml(item.call_id)}</strong>
|
||||||
|
${statusBadge(ESCALATION_STATUS_LABELS, ESCALATION_STATUS_TONE, item.status)}
|
||||||
|
<span class="status-badge is-muted">${escapeHtml(item.from_level)} → ${escapeHtml(item.to_level)}</span>
|
||||||
|
<span class="escalation-meta">причина: ${escapeHtml(item.reason_code || '-')}</span>
|
||||||
|
<span class="escalation-meta">оператор: ${escapeHtml(item.assigned_agent_id || 'не назначен')}</span>
|
||||||
|
<span class="escalation-meta">запрошена: ${formatTime(item.requested_at)}</span>
|
||||||
|
${item.summary ? `<span class="escalation-meta">тема: ${escapeHtml(item.summary)}</span>` : ''}
|
||||||
|
</li>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEscalations(silent = false) {
|
||||||
|
try {
|
||||||
|
const data = await api('routing', 'escalations?limit=30');
|
||||||
|
renderEscalations(Array.isArray(data) ? data : []);
|
||||||
|
if (!silent) {
|
||||||
|
log('Лента эскалаций обновлена', { count: data.length });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
$('escalationsList').innerHTML = '<li class="empty-state">Не удалось загрузить эскалации.</li>';
|
||||||
|
if (!silent) {
|
||||||
|
log('Не удалось загрузить эскалации', { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveQueueMetrics() {
|
async function saveQueueMetrics() {
|
||||||
try {
|
try {
|
||||||
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
|
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
|
||||||
@@ -973,7 +1145,16 @@ function wire() {
|
|||||||
$('loginBtn').addEventListener('click', login);
|
$('loginBtn').addEventListener('click', login);
|
||||||
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
|
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
|
||||||
$('refreshBtn').addEventListener('click', async () => {
|
$('refreshBtn').addEventListener('click', async () => {
|
||||||
await Promise.all([loadRealtime(), loadKpi(), loadLiveCalls(), loadAgents(), loadRecordings(), refreshKnowledgeBase(true)]);
|
await Promise.all([
|
||||||
|
loadRealtime(),
|
||||||
|
loadKpi(),
|
||||||
|
loadLiveCalls(),
|
||||||
|
loadAgents(),
|
||||||
|
loadCallAgents(),
|
||||||
|
loadEscalations(),
|
||||||
|
loadRecordings(),
|
||||||
|
refreshKnowledgeBase(true),
|
||||||
|
]);
|
||||||
log('Данные супервизора обновлены');
|
log('Данные супервизора обновлены');
|
||||||
});
|
});
|
||||||
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
|
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
|
||||||
@@ -981,6 +1162,9 @@ function wire() {
|
|||||||
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
|
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
|
||||||
$('loadAgentsBtn').addEventListener('click', loadAgents);
|
$('loadAgentsBtn').addEventListener('click', loadAgents);
|
||||||
$('setAgentBtn').addEventListener('click', setAgentState);
|
$('setAgentBtn').addEventListener('click', setAgentState);
|
||||||
|
$('loadCallAgentsBtn').addEventListener('click', () => loadCallAgents());
|
||||||
|
$('createCallAgentBtn').addEventListener('click', createCallAgent);
|
||||||
|
$('loadEscalationsBtn').addEventListener('click', () => loadEscalations());
|
||||||
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
|
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
|
||||||
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
|
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
|
||||||
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
|
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
|
||||||
@@ -1015,11 +1199,15 @@ async function init() {
|
|||||||
loadKpi(),
|
loadKpi(),
|
||||||
loadLiveCalls(true),
|
loadLiveCalls(true),
|
||||||
loadAgents(),
|
loadAgents(),
|
||||||
|
loadCallAgents(true),
|
||||||
|
loadEscalations(true),
|
||||||
loadRecordings(),
|
loadRecordings(),
|
||||||
refreshKnowledgeBase(true),
|
refreshKnowledgeBase(true),
|
||||||
]);
|
]);
|
||||||
window.setInterval(() => {
|
window.setInterval(() => {
|
||||||
void loadLiveCalls(true);
|
void loadLiveCalls(true);
|
||||||
|
void loadCallAgents(true);
|
||||||
|
void loadEscalations(true);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
log('Пульт супервизора готов');
|
log('Пульт супервизора готов');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
<a class="nav-link" href="#realtime"><span class="nav-bullet"></span>Оперативный срез</a>
|
<a class="nav-link" href="#realtime"><span class="nav-bullet"></span>Оперативный срез</a>
|
||||||
<a class="nav-link" href="#liveCalls"><span class="nav-bullet"></span>Живые звонки</a>
|
<a class="nav-link" href="#liveCalls"><span class="nav-bullet"></span>Живые звонки</a>
|
||||||
<a class="nav-link" href="#agents"><span class="nav-bullet"></span>Агенты</a>
|
<a class="nav-link" href="#agents"><span class="nav-bullet"></span>Агенты</a>
|
||||||
|
<a class="nav-link" href="#callRoutingAgents"><span class="nav-bullet"></span>Пул L2 (звонки)</a>
|
||||||
|
<a class="nav-link" href="#escalations"><span class="nav-bullet"></span>Эскалации</a>
|
||||||
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
<a class="nav-link" href="#queues"><span class="nav-bullet"></span>Очереди</a>
|
||||||
<a class="nav-link" href="#knowledge"><span class="nav-bullet"></span>База знаний</a>
|
<a class="nav-link" href="#knowledge"><span class="nav-bullet"></span>База знаний</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,6 +155,45 @@
|
|||||||
<ul id="agentsList" class="list"></ul>
|
<ul id="agentsList" class="list"></ul>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="panel reveal" id="callRoutingAgents">
|
||||||
|
<h2>Пул операторов L2 (звонки)</h2>
|
||||||
|
<p class="hint">Реальный пул для эскалации звонков с Voice AI (L1) на живых операторов +7 747 645 6048. Отдельно от демо-статусов в блоке «Агенты» выше — здесь показан фактический пул Routing Engine.</p>
|
||||||
|
<div class="actions compact-actions">
|
||||||
|
<button id="loadCallAgentsBtn" class="btn ghost">Обновить пул</button>
|
||||||
|
</div>
|
||||||
|
<div id="callAgentsSummary" class="summary-grid"></div>
|
||||||
|
<ul id="callAgentsList" class="list"></ul>
|
||||||
|
|
||||||
|
<details class="details-block">
|
||||||
|
<summary>Добавить оператора в пул</summary>
|
||||||
|
<div class="inline-form split-two">
|
||||||
|
<input id="newAgentExtension" placeholder="Внутренний номер (напр. 2001)" />
|
||||||
|
<input id="newAgentDisplayName" placeholder="Имя оператора" />
|
||||||
|
</div>
|
||||||
|
<div class="inline-form split-two">
|
||||||
|
<select id="newAgentLevel">
|
||||||
|
<option value="L2" selected>L2 — оператор</option>
|
||||||
|
<option value="L3">L3 — технический специалист</option>
|
||||||
|
</select>
|
||||||
|
<input id="newAgentTenant" placeholder="Tenant ID" value="konturai" />
|
||||||
|
</div>
|
||||||
|
<div class="inline-form compact">
|
||||||
|
<input id="newAgentSkills" placeholder="Навыки через запятую (необязательно)" />
|
||||||
|
<button id="createCallAgentBtn" class="btn">Добавить в пул</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel reveal" id="escalations">
|
||||||
|
<h2>Эскалации AI → оператор</h2>
|
||||||
|
<p class="hint">Живая лента: какие звонки Voice AI передал(-ает) на L2 и кто из операторов их принял.</p>
|
||||||
|
<div class="actions compact-actions">
|
||||||
|
<button id="loadEscalationsBtn" class="btn ghost">Обновить эскалации</button>
|
||||||
|
</div>
|
||||||
|
<div id="escalationsSummary" class="summary-grid"></div>
|
||||||
|
<ul id="escalationsList" class="list"></ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="panel reveal" id="queues">
|
<article class="panel reveal" id="queues">
|
||||||
<h2>Очередь</h2>
|
<h2>Очередь</h2>
|
||||||
<div class="inline-form split-two">
|
<div class="inline-form split-two">
|
||||||
|
|||||||
Reference in New Issue
Block a user