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
+67
View File
@@ -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))