from __future__ import annotations import json from datetime import datetime 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 set_agent_status(session, *, agent_id: str, status: str) -> AgentRow | None: agent = session.execute( select(AgentRow).where(AgentRow.agent_id == agent_id) ).scalar_one_or_none() if agent is None: return None agent.status = status agent.updated_at = utc_now_iso() session.commit() return agent def sweep_after_call_work(session, *, older_than_seconds: int) -> list[AgentRow]: cutoff = utc_now_iso() rows = session.execute( select(AgentRow).where(AgentRow.status == "AFTER_CALL_WORK") ).scalars().all() swept: list[AgentRow] = [] for agent in rows: try: age_seconds = ( datetime.fromisoformat(cutoff) - datetime.fromisoformat(str(agent.updated_at)) ).total_seconds() except (TypeError, ValueError): continue if age_seconds >= older_than_seconds: agent.status = "AVAILABLE" agent.current_call_id = None agent.updated_at = cutoff swept.append(agent) if swept: session.commit() return swept 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