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:
Hermes Agent
2026-08-28 16:22:32 +05:00
parent b474c35608
commit 2243f305b8
14 changed files with 1057 additions and 6 deletions
+78
View File
@@ -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