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.
This commit is contained in:
@@ -1253,6 +1253,84 @@ class AgentStateOut(AgentStateIn):
|
||||
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):
|
||||
from_ts: str
|
||||
to_ts: str
|
||||
|
||||
@@ -471,6 +471,25 @@ def _apply_runtime_schema_compatibility() -> None:
|
||||
"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:
|
||||
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)
|
||||
ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, 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):
|
||||
@@ -804,3 +808,66 @@ class SupervisorQueueSnapshotRow(Base):
|
||||
in_queue: 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)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user