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:
@@ -5,12 +5,22 @@ import json
|
||||
from fastapi import Depends, FastAPI, HTTPException
|
||||
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.db import engine, get_session
|
||||
from services.shared.models import HealthResponse, QueueCreate, QueueOut
|
||||
from services.shared.models import (
|
||||
AgentCreate,
|
||||
AgentPoolOut,
|
||||
AgentStatusUpdateIn,
|
||||
HealthResponse,
|
||||
QueueCreate,
|
||||
QueueOut,
|
||||
RoutingAgentReserveIn,
|
||||
RoutingAgentReserveOut,
|
||||
)
|
||||
from services.shared.security import require_roles
|
||||
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, IvrSessionRow, Queue, RoutingCounter
|
||||
|
||||
app = FastAPI(title="routing-service", version="1.1.0")
|
||||
|
||||
@@ -230,3 +240,132 @@ def delete_queue(
|
||||
return {"queue_id": queue_id, "deleted": True}
|
||||
finally:
|
||||
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()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
Reference in New Issue
Block a user