Initial commit
This commit is contained in:
18
agents/__init__.py
Normal file
18
agents/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Agents module for LogiFlow AI
|
||||
from agents.order_agent import OrderAgent
|
||||
from agents.dispatch_agent import DispatchAgent
|
||||
from agents.fleet_agent import FleetAgent
|
||||
from agents.hub_agent import HubAgent
|
||||
from agents.customer_agent import CustomerAgent
|
||||
from agents.exception_agent import ExceptionAgent
|
||||
from agents.route_optimizer_agent import RouteOptimizerAgent
|
||||
|
||||
__all__ = [
|
||||
'OrderAgent',
|
||||
'DispatchAgent',
|
||||
'FleetAgent',
|
||||
'HubAgent',
|
||||
'CustomerAgent',
|
||||
'ExceptionAgent',
|
||||
'RouteOptimizerAgent'
|
||||
]
|
||||
407
agents/customer_agent.py
Normal file
407
agents/customer_agent.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Customer Agent - Handles notifications, tracking, and customer communication."""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
import aiohttp
|
||||
|
||||
from core.agent import SpecializedAgent
|
||||
from core.types import AgentTask, MessageType, OrderStatus
|
||||
from core.logger import logger
|
||||
from core.http_client import api_post, api_get
|
||||
from config.system_config import GO_API_BASE_URL, INTERNAL_API_KEY
|
||||
|
||||
|
||||
class NotificationChannel(str, Enum):
|
||||
SMS = "sms"
|
||||
EMAIL = "email"
|
||||
PUSH = "push"
|
||||
WHATSAPP = "whatsapp"
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
ORDER_CONFIRMED = "order_confirmed"
|
||||
PICKUP_SCHEDULED = "pickup_scheduled"
|
||||
PICKED_UP = "picked_up"
|
||||
IN_TRANSIT = "in_transit"
|
||||
ARRIVED_AT_HUB = "arrived_at_hub"
|
||||
OUT_FOR_DELIVERY = "out_for_delivery"
|
||||
DELIVERED = "delivered"
|
||||
DELAYED = "delayed"
|
||||
RESCHEDULED = "rescheduled"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
notification_id: str
|
||||
order_id: str
|
||||
customer_id: str
|
||||
channel: NotificationChannel
|
||||
notification_type: NotificationType
|
||||
message: str
|
||||
sent_at: datetime
|
||||
delivered_at: Optional[datetime]
|
||||
status: str # pending, sent, delivered, failed
|
||||
|
||||
|
||||
class CustomerAgent(SpecializedAgent):
|
||||
"""
|
||||
Customer Agent - Manages customer communications and notifications.
|
||||
Sends via Go backend (POST /api/v1/internal/notify → real FCM/SMS/WhatsApp).
|
||||
Keeps local notification history for audit / query.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
agent_id="CUSTOMER_AGENT",
|
||||
domain="customer_communication",
|
||||
description="Handles notifications, tracking updates, and customer communication"
|
||||
)
|
||||
self._customers: Dict[str, Dict] = {}
|
||||
self._notifications: Dict[str, List[Notification]] = {}
|
||||
self._templates = self._init_templates()
|
||||
self._preferences: Dict[str, Dict] = {}
|
||||
|
||||
def _init_templates(self) -> Dict[str, Dict]:
|
||||
return {
|
||||
NotificationType.ORDER_CONFIRMED: {
|
||||
"sms": "Your order {order_id} has been confirmed! Estimated delivery: {eta}",
|
||||
"email": "Hi {customer_name}, your order {order_id} is confirmed. Track at: {tracking_url}",
|
||||
"whatsapp": "Order {order_id} confirmed! Delivery by {eta}. Track: {tracking_url}"
|
||||
},
|
||||
NotificationType.PICKUP_SCHEDULED: {
|
||||
"sms": "Pickup scheduled for {pickup_time}. Driver will arrive within 30 mins.",
|
||||
"email": "Your pickup is scheduled for {pickup_time}",
|
||||
"whatsapp": "Pickup scheduled for {pickup_time}. Driver arrives in 30 mins"
|
||||
},
|
||||
NotificationType.PICKED_UP: {
|
||||
"sms": "Order {order_id} picked up and on its way!",
|
||||
"email": "Your package has been picked up and is in transit.",
|
||||
"whatsapp": "Order {order_id} picked up! Heading to {destination}"
|
||||
},
|
||||
NotificationType.IN_TRANSIT: {
|
||||
"sms": "Your order {order_id} is now in transit.",
|
||||
"email": "Update: Your order is on the move!",
|
||||
"whatsapp": "Order {order_id} in transit. Current stop: {current_location}"
|
||||
},
|
||||
NotificationType.ARRIVED_AT_HUB: {
|
||||
"sms": "Your order {order_id} has arrived at {hub_name}.",
|
||||
"email": "Your package has arrived at the {hub_name} facility.",
|
||||
"whatsapp": "Order {order_id} arrived at {hub_name}"
|
||||
},
|
||||
NotificationType.OUT_FOR_DELIVERY: {
|
||||
"sms": "Out for delivery! Driver: {driver_name}, Contact: {driver_phone}",
|
||||
"email": "Your order is out for delivery!",
|
||||
"whatsapp": "Out for delivery! Driver: {driver_name} | {driver_phone}"
|
||||
},
|
||||
NotificationType.DELIVERED: {
|
||||
"sms": "Order {order_id} delivered successfully! Thank you for choosing us.",
|
||||
"email": "Your order has been delivered! We hope you enjoy your purchase.",
|
||||
"whatsapp": "Delivered! Order {order_id}. Thank you for shopping with us!"
|
||||
},
|
||||
NotificationType.DELAYED: {
|
||||
"sms": "Delay alert: Order {order_id} may arrive later than expected. New ETA: {new_eta}",
|
||||
"email": "We apologize for the delay. Your order's new ETA is {new_eta}",
|
||||
"whatsapp": "Delay update: Order {order_id}. New ETA: {new_eta}"
|
||||
},
|
||||
NotificationType.RESCHEDULED: {
|
||||
"sms": "Delivery rescheduled. New date: {new_date}",
|
||||
"email": "Your delivery has been rescheduled to {new_date}",
|
||||
"whatsapp": "Rescheduled! New delivery: {new_date}"
|
||||
},
|
||||
NotificationType.CANCELLED: {
|
||||
"sms": "Order {order_id} has been cancelled. Refund processing: {refund_info}",
|
||||
"email": "Your order has been cancelled. {refund_info}",
|
||||
"whatsapp": "Cancelled: Order {order_id}. {refund_info}"
|
||||
}
|
||||
}
|
||||
|
||||
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
handlers = {
|
||||
"send_confirmation": self._send_confirmation,
|
||||
"send_notification": self._send_notification,
|
||||
"update_tracking": self._update_tracking,
|
||||
"get_tracking": self._get_tracking,
|
||||
"send_bulk_notification": self._send_bulk_notification,
|
||||
"get_customer_history": self._get_customer_history,
|
||||
"handle_query": self._handle_query,
|
||||
}
|
||||
handler = handlers.get(task.task_type, self._unknown_task)
|
||||
return await handler(task)
|
||||
|
||||
async def _send_confirmation(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_data = task.data.get("order", {})
|
||||
order_id = order_data.get("order_id")
|
||||
customer_phone = order_data.get("customer_phone")
|
||||
customer_email = order_data.get("customer_email")
|
||||
customer_name = order_data.get("customer_name", "Customer")
|
||||
eta = task.data.get("eta", "tomorrow")
|
||||
|
||||
logger.info(f"Customer Agent: Sending confirmation for order {order_id}")
|
||||
|
||||
if customer_phone and customer_phone not in self._customers:
|
||||
self._customers[customer_phone] = {
|
||||
"name": customer_name,
|
||||
"email": customer_email,
|
||||
"orders": []
|
||||
}
|
||||
|
||||
if customer_phone in self._customers:
|
||||
self._customers[customer_phone]["orders"].append(order_id)
|
||||
|
||||
template_vars = {
|
||||
"order_id": order_id,
|
||||
"customer_name": customer_name,
|
||||
"eta": eta,
|
||||
"tracking_url": f"https://track.doormile.com/{order_id}"
|
||||
}
|
||||
|
||||
notifications_sent = []
|
||||
|
||||
if customer_phone:
|
||||
notifications_sent.append(await self._send_via_channel(
|
||||
order_id=order_id, customer_id=customer_phone,
|
||||
channel=NotificationChannel.SMS,
|
||||
notification_type=NotificationType.ORDER_CONFIRMED,
|
||||
template_vars=template_vars,
|
||||
))
|
||||
notifications_sent.append(await self._send_via_channel(
|
||||
order_id=order_id, customer_id=customer_phone,
|
||||
channel=NotificationChannel.WHATSAPP,
|
||||
notification_type=NotificationType.ORDER_CONFIRMED,
|
||||
template_vars=template_vars,
|
||||
))
|
||||
|
||||
if customer_email:
|
||||
notifications_sent.append(await self._send_via_channel(
|
||||
order_id=order_id, customer_id=customer_email,
|
||||
channel=NotificationChannel.EMAIL,
|
||||
notification_type=NotificationType.ORDER_CONFIRMED,
|
||||
template_vars=template_vars,
|
||||
))
|
||||
|
||||
return {"status": "sent", "order_id": order_id, "notifications": notifications_sent}
|
||||
|
||||
async def _send_notification(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
notification_type = task.data.get("notification_type")
|
||||
template_vars = task.data.get("template_vars", {})
|
||||
customer_id = task.data.get("customer_id", "unknown")
|
||||
|
||||
logger.info(f"Customer Agent: Sending {notification_type} for order {order_id}")
|
||||
|
||||
notification = await self._send_via_channel(
|
||||
order_id=order_id,
|
||||
customer_id=customer_id,
|
||||
channel=NotificationChannel.SMS,
|
||||
notification_type=NotificationType(notification_type),
|
||||
template_vars=template_vars,
|
||||
)
|
||||
|
||||
return {"status": "sent", "notification": notification}
|
||||
|
||||
async def _update_tracking(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
status = task.data.get("status")
|
||||
location = task.data.get("location")
|
||||
eta = task.data.get("eta")
|
||||
|
||||
logger.info(f"Customer Agent: Updating tracking for order {order_id} | status={status} location={location}")
|
||||
|
||||
status_notification_map = {
|
||||
"picked_up": NotificationType.PICKED_UP,
|
||||
"in_transit": NotificationType.IN_TRANSIT,
|
||||
"at_hub": NotificationType.ARRIVED_AT_HUB,
|
||||
"out_for_delivery": NotificationType.OUT_FOR_DELIVERY,
|
||||
"delivered": NotificationType.DELIVERED,
|
||||
"delayed": NotificationType.DELAYED,
|
||||
}
|
||||
|
||||
notification_type = status_notification_map.get(status)
|
||||
if notification_type:
|
||||
await self._send_via_channel(
|
||||
order_id=order_id,
|
||||
customer_id=task.data.get("customer_id", ""),
|
||||
channel=NotificationChannel.SMS,
|
||||
notification_type=notification_type,
|
||||
template_vars={
|
||||
"order_id": order_id,
|
||||
"current_location": location,
|
||||
"new_eta": eta,
|
||||
"hub_name": location,
|
||||
},
|
||||
)
|
||||
|
||||
return {"status": "updated", "order_id": order_id, "new_status": status, "location": location}
|
||||
|
||||
async def _get_tracking(self, task: AgentTask) -> Dict[str, Any]:
|
||||
booking_id = task.data.get("booking_id") or task.data.get("order_id")
|
||||
|
||||
result = await api_get(
|
||||
f"{GO_API_BASE_URL}/api/v1/bookings/cache/{booking_id}",
|
||||
headers={"X-Internal-Key": INTERNAL_API_KEY},
|
||||
)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"Tracking lookup failed for booking {booking_id}")
|
||||
return {"status": "not_found", "order_id": booking_id}
|
||||
|
||||
data = result.get("data") or result
|
||||
return {
|
||||
"order_id": booking_id,
|
||||
"current_status": data.get("status", "unknown"),
|
||||
"miler_id": data.get("assignedmileruserid"),
|
||||
"miler_name": data.get("milername"),
|
||||
"miler_phone": data.get("milerphone"),
|
||||
"estimated_delivery": data.get("estimateddelivery"),
|
||||
"pickup_address": data.get("pickupaddress"),
|
||||
"delivery_address": data.get("deliveryaddress"),
|
||||
"last_update": data.get("updatedat") or datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
async def _send_bulk_notification(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_ids = task.data.get("order_ids", [])
|
||||
notification_type = task.data.get("notification_type")
|
||||
template_vars = task.data.get("template_vars", {})
|
||||
|
||||
logger.info(f"Customer Agent: Sending bulk notification to {len(order_ids)} customers")
|
||||
|
||||
results = []
|
||||
for order_id in order_ids:
|
||||
result = await self._send_notification(AgentTask(
|
||||
task_id=f"bulk_{order_id}",
|
||||
agent_type="customer",
|
||||
task_type="send_notification",
|
||||
data={
|
||||
"order_id": order_id,
|
||||
"notification_type": notification_type,
|
||||
"template_vars": template_vars,
|
||||
}
|
||||
))
|
||||
results.append(result)
|
||||
|
||||
return {
|
||||
"status": "bulk_sent",
|
||||
"total": len(order_ids),
|
||||
"successful": sum(1 for r in results if r.get("status") == "sent"),
|
||||
"failed": sum(1 for r in results if r.get("status") == "failed"),
|
||||
}
|
||||
|
||||
async def _get_customer_history(self, task: AgentTask) -> Dict[str, Any]:
|
||||
customer_id = task.data.get("customer_id")
|
||||
notifications = self._notifications.get(customer_id, [])
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"total_notifications": len(notifications),
|
||||
"notifications": [self._format_notification(n) for n in notifications[-20:]],
|
||||
}
|
||||
|
||||
async def _handle_query(self, task: AgentTask) -> Dict[str, Any]:
|
||||
query = task.data.get("query", "").lower()
|
||||
order_id = task.data.get("order_id")
|
||||
|
||||
logger.info(f"Customer Agent: Handling query '{query}' for order {order_id}")
|
||||
|
||||
if "where" in query or "track" in query or "status" in query:
|
||||
return await self._get_tracking(AgentTask(
|
||||
task_id="query_tracking",
|
||||
agent_type="customer",
|
||||
task_type="get_tracking",
|
||||
data={"order_id": order_id},
|
||||
))
|
||||
|
||||
if "delay" in query or "late" in query:
|
||||
return {
|
||||
"response": "I understand your concern about the delay. Let me check the current status and get back to you with updated information.",
|
||||
"action": "check_status",
|
||||
}
|
||||
|
||||
if "cancel" in query:
|
||||
return {
|
||||
"response": f"I can help you with cancellation. Please confirm you want to cancel order {order_id}. Note: Cancellation is only possible before dispatch.",
|
||||
"action": "await_confirmation",
|
||||
}
|
||||
|
||||
return {
|
||||
"response": "I'm here to help! You can ask about your order status, tracking, delays, or cancellation. How can I assist you?",
|
||||
"action": "provide_options",
|
||||
}
|
||||
|
||||
async def _send_via_channel(
|
||||
self,
|
||||
order_id: str,
|
||||
customer_id: str,
|
||||
channel: NotificationChannel,
|
||||
notification_type: NotificationType,
|
||||
template_vars: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Render template and call Go backend (POST /api/v1/internal/notify)."""
|
||||
template = self._templates.get(notification_type, {}).get(channel.value, "")
|
||||
message = template
|
||||
for key, value in template_vars.items():
|
||||
message = message.replace(f"{{{key}}}", str(value))
|
||||
|
||||
notification_id = f"NOTIF-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
result = await api_post(
|
||||
f"{GO_API_BASE_URL}/api/v1/internal/notify",
|
||||
json={
|
||||
"notification_id": notification_id,
|
||||
"order_id": order_id,
|
||||
"customer_id": customer_id,
|
||||
"channel": channel.value,
|
||||
"notification_type": notification_type.value,
|
||||
"message": message,
|
||||
},
|
||||
headers={"X-Internal-Key": INTERNAL_API_KEY},
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
)
|
||||
|
||||
if result is None:
|
||||
status = "failed"
|
||||
logger.warning(f"Notify API call failed for {notification_id}")
|
||||
else:
|
||||
status = "sent"
|
||||
|
||||
notification = Notification(
|
||||
notification_id=notification_id,
|
||||
order_id=order_id,
|
||||
customer_id=customer_id,
|
||||
channel=channel,
|
||||
notification_type=notification_type,
|
||||
message=message,
|
||||
sent_at=datetime.now(),
|
||||
delivered_at=None,
|
||||
status=status,
|
||||
)
|
||||
if customer_id not in self._notifications:
|
||||
self._notifications[customer_id] = []
|
||||
self._notifications[customer_id].append(notification)
|
||||
|
||||
logger.info(f"[{channel.value.upper()}] {notification_type.value} -> {customer_id[:20]} [{status}]")
|
||||
|
||||
return {
|
||||
"notification_id": notification_id,
|
||||
"channel": channel.value,
|
||||
"status": status,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def _format_notification(self, notification: Notification) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": notification.notification_id,
|
||||
"type": notification.notification_type.value,
|
||||
"channel": notification.channel.value,
|
||||
"message": notification.message,
|
||||
"sent_at": notification.sent_at.isoformat(),
|
||||
"status": notification.status,
|
||||
}
|
||||
|
||||
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
|
||||
|
||||
async def think(self, context: str, options: List[str] = None) -> str:
|
||||
return f"[CUSTOMER_AGENT reasoning]: {context}"
|
||||
379
agents/dispatch_agent.py
Normal file
379
agents/dispatch_agent.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""Dispatch Agent - Handles zone analysis, route assignment, and order dispatching."""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from core.agent import SpecializedAgent
|
||||
from core.types import AgentTask, MessageType, Priority, ZoneType
|
||||
from core.logger import logger
|
||||
from core.http_client import api_post
|
||||
from config.system_config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, GO_API_BASE_URL, INTERNAL_API_KEY
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZoneInfo:
|
||||
zone_id: str
|
||||
zone_name: str
|
||||
pincode_range: tuple
|
||||
hub_id: str
|
||||
vehicle_types: List[str]
|
||||
avg_delivery_time: int
|
||||
current_load: int = 0
|
||||
max_capacity: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteAssignment:
|
||||
route_id: str
|
||||
order_id: str
|
||||
from_hub: str
|
||||
to_hub: str
|
||||
vehicle_id: Optional[str]
|
||||
estimated_pickup: datetime
|
||||
estimated_delivery: datetime
|
||||
route_type: str
|
||||
|
||||
|
||||
class DispatchAgent(SpecializedAgent):
|
||||
"""
|
||||
Dispatch Agent - Orchestrates order dispatching and route assignment.
|
||||
|
||||
Uses Redis GEO (GEORADIUS on milers:locations) to find nearest milers
|
||||
for a given pickup/delivery coordinate. Falls back to pincode-based
|
||||
zone lookup when no milers are found within range.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
agent_id="DISPATCH_AGENT",
|
||||
domain="dispatch",
|
||||
description="Handles zone analysis, route assignment, and dispatch coordination"
|
||||
)
|
||||
|
||||
self._redis = aioredis.Redis(
|
||||
host=REDIS_HOST,
|
||||
port=REDIS_PORT,
|
||||
password=REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
self._zones = self._init_zones()
|
||||
self._hubs = self._init_hubs()
|
||||
self._active_routes: Dict[str, RouteAssignment] = {}
|
||||
self._dispatch_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
def _init_zones(self) -> Dict[str, ZoneInfo]:
|
||||
return {
|
||||
"north_delhi": ZoneInfo("north_delhi", "North Delhi", ("100", "199"), "DL-HUB-01", ["bike", "scooter", "van"], 45),
|
||||
"south_delhi": ZoneInfo("south_delhi", "South Delhi", ("200", "299"), "DL-HUB-02", ["bike", "scooter", "van"], 50),
|
||||
"mumbai_west": ZoneInfo("mumbai_west", "West Mumbai", ("400", "449"), "MU-HUB-01", ["bike", "scooter", "van", "truck"], 60),
|
||||
"mumbai_east": ZoneInfo("mumbai_east", "East Mumbai", ("450", "499"), "MU-HUB-02", ["bike", "scooter", "van", "truck"], 55),
|
||||
"bangalore": ZoneInfo("bangalore", "Bangalore", ("560", "562"), "BL-HUB-01", ["bike", "scooter", "van"], 40),
|
||||
"hyderabad": ZoneInfo("hyderabad", "Hyderabad", ("500", "509"), "HY-HUB-01", ["bike", "scooter", "van", "truck"], 50),
|
||||
"pune": ZoneInfo("pune", "Pune", ("411", "415"), "PU-HUB-01", ["bike", "scooter", "van"], 45),
|
||||
"kolkata": ZoneInfo("kolkata", "Kolkata", ("700", "700"), "KL-HUB-01", ["bike", "scooter", "van"], 55),
|
||||
}
|
||||
|
||||
def _init_hubs(self) -> Dict[str, Dict]:
|
||||
return {
|
||||
"DL-HUB-01": {"name": "Delhi North Hub", "capacity": 500, "current_load": 150},
|
||||
"DL-HUB-02": {"name": "Delhi South Hub", "capacity": 400, "current_load": 200},
|
||||
"MU-HUB-01": {"name": "Mumbai West Hub", "capacity": 600, "current_load": 300},
|
||||
"MU-HUB-02": {"name": "Mumbai East Hub", "capacity": 550, "current_load": 250},
|
||||
"BL-HUB-01": {"name": "Bangalore Hub", "capacity": 500, "current_load": 180},
|
||||
"HY-HUB-01": {"name": "Hyderabad Hub", "capacity": 450, "current_load": 220},
|
||||
"PU-HUB-01": {"name": "Pune Hub", "capacity": 400, "current_load": 160},
|
||||
"KL-HUB-01": {"name": "Kolkata Hub", "capacity": 350, "current_load": 140},
|
||||
}
|
||||
|
||||
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
handlers = {
|
||||
"analyze_and_assign": self._analyze_and_assign,
|
||||
"find_route": self._find_route,
|
||||
"optimize_routes": self._optimize_routes,
|
||||
"assign_vehicle": self._assign_vehicle,
|
||||
"get_zone_status": self._get_zone_status,
|
||||
"reschedule_route": self._reschedule_route,
|
||||
"cancel_route": self._cancel_route,
|
||||
}
|
||||
handler = handlers.get(task.task_type, self._unknown_task)
|
||||
return await handler(task)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Redis GEO zone lookup #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _find_zone(self, lat: float, lon: float) -> Optional[Dict[str, Any]]:
|
||||
"""Find the nearest miler using GEORADIUS on milers:locations."""
|
||||
try:
|
||||
results = await self._redis.georadius(
|
||||
"milers:locations",
|
||||
lon, lat,
|
||||
10, "km",
|
||||
sort="ASC",
|
||||
count=5,
|
||||
)
|
||||
if not results:
|
||||
return None
|
||||
|
||||
nearest_miler = results[0]
|
||||
miler_info = await self._redis.hgetall(f"miler:{nearest_miler}")
|
||||
|
||||
return {
|
||||
"miler_id": nearest_miler,
|
||||
"hub_id": miler_info.get("hub_id", "UNKNOWN"),
|
||||
"zone_id": miler_info.get("zone_id", "unknown"),
|
||||
"avg_delivery_time": int(miler_info.get("avg_delivery_time", 60)),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis GEORADIUS error: {e}")
|
||||
return None
|
||||
|
||||
def _find_zone_by_pincode(self, pincode_prefix: str) -> Optional[ZoneInfo]:
|
||||
for zone in self._zones.values():
|
||||
start, end = zone.pincode_range
|
||||
if start <= pincode_prefix <= end:
|
||||
return zone
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Core dispatch logic #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _analyze_and_assign(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_data = task.data.get("order", {})
|
||||
order_id = order_data.get("order_id", "unknown")
|
||||
|
||||
logger.info(f"Dispatch Agent: Analyzing order {order_id}")
|
||||
|
||||
pickup_addr = order_data.get("pickup_address", {})
|
||||
delivery_addr = order_data.get("delivery_address", {})
|
||||
|
||||
pickup_lat = pickup_addr.get("lat") or pickup_addr.get("latitude")
|
||||
pickup_lon = pickup_addr.get("lng") or pickup_addr.get("longitude")
|
||||
delivery_lat = delivery_addr.get("lat") or delivery_addr.get("latitude")
|
||||
delivery_lon = delivery_addr.get("lng") or delivery_addr.get("longitude")
|
||||
|
||||
pickup_zone_geo = await self._find_zone(pickup_lat, pickup_lon) if (pickup_lat and pickup_lon) else None
|
||||
delivery_zone_geo = await self._find_zone(delivery_lat, delivery_lon) if (delivery_lat and delivery_lon) else None
|
||||
|
||||
pickup_pincode = pickup_addr.get("pincode", "")[:3]
|
||||
delivery_pincode = delivery_addr.get("pincode", "")[:3]
|
||||
pickup_zone_pc = self._find_zone_by_pincode(pickup_pincode)
|
||||
delivery_zone_pc = self._find_zone_by_pincode(delivery_pincode)
|
||||
|
||||
from_hub = (
|
||||
pickup_zone_geo.get("hub_id") if pickup_zone_geo
|
||||
else (pickup_zone_pc.hub_id if pickup_zone_pc else "UNKNOWN")
|
||||
)
|
||||
to_hub = (
|
||||
delivery_zone_geo.get("hub_id") if delivery_zone_geo
|
||||
else (delivery_zone_pc.hub_id if delivery_zone_pc else "UNKNOWN")
|
||||
)
|
||||
|
||||
if pickup_pincode == delivery_pincode:
|
||||
route_type = ZoneType.LAST_MILE.value
|
||||
elif from_hub == to_hub:
|
||||
route_type = ZoneType.HUB_TO_SPOKE.value
|
||||
else:
|
||||
route_type = ZoneType.HUB_TO_HUB.value
|
||||
|
||||
avg_time = (
|
||||
(pickup_zone_geo or {}).get("avg_delivery_time") or
|
||||
(pickup_zone_pc.avg_delivery_time if pickup_zone_pc else 60)
|
||||
)
|
||||
now = datetime.now()
|
||||
estimated_pickup = now + timedelta(minutes=30)
|
||||
estimated_delivery = now + timedelta(minutes=avg_time * 2)
|
||||
|
||||
route_id = f"RT-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
|
||||
route_assignment = RouteAssignment(
|
||||
route_id=route_id,
|
||||
order_id=order_id,
|
||||
from_hub=from_hub,
|
||||
to_hub=to_hub,
|
||||
vehicle_id=None,
|
||||
estimated_pickup=estimated_pickup,
|
||||
estimated_delivery=estimated_delivery,
|
||||
route_type=route_type,
|
||||
)
|
||||
self._active_routes[route_id] = route_assignment
|
||||
|
||||
logger.info(
|
||||
f"Route assigned: {from_hub} -> {to_hub} | type={route_type} "
|
||||
f"pickup={estimated_pickup.strftime('%H:%M')} delivery={estimated_delivery.strftime('%H:%M')}"
|
||||
)
|
||||
|
||||
# Assign miler in Postgres via Go internal API.
|
||||
# booking_id is the Go API's integer ID (set by order_agent after FIX 4).
|
||||
booking_id = order_data.get("booking_id") or order_id
|
||||
nearest_miler = (pickup_zone_geo or {}).get("miler_id")
|
||||
if nearest_miler:
|
||||
logger.debug(f"Nearest miler from GEORADIUS: {nearest_miler}")
|
||||
assign_result = await api_post(
|
||||
f"{GO_API_BASE_URL}/api/v1/internal/bookings/{booking_id}/reassign",
|
||||
json={"reason": "initial_assignment"},
|
||||
headers={"X-Internal-Key": INTERNAL_API_KEY},
|
||||
)
|
||||
if assign_result is not None:
|
||||
logger.info(f"Miler assigned to booking {booking_id} via Go API")
|
||||
else:
|
||||
logger.warning(f"Go API miler assignment failed for booking {booking_id} — booking may stay unassigned")
|
||||
|
||||
await self.send_message(
|
||||
recipient="HUB_AGENT",
|
||||
message_type=MessageType.AGENT_TASK,
|
||||
payload={"task_type": "prepare_receiving", "route_id": route_id, "hub_id": to_hub, "order_id": order_id},
|
||||
correlation_id=order_id,
|
||||
)
|
||||
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT",
|
||||
message_type=MessageType.NOTIFICATION_SENT,
|
||||
payload={
|
||||
"order_id": order_id,
|
||||
"message_type": "dispatch_assigned",
|
||||
"data": {
|
||||
"route_id": route_id,
|
||||
"estimated_pickup": estimated_pickup.isoformat(),
|
||||
"estimated_delivery": estimated_delivery.isoformat(),
|
||||
"route_type": route_type,
|
||||
},
|
||||
},
|
||||
correlation_id=order_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "assigned",
|
||||
"route_id": route_id,
|
||||
"order_id": order_id,
|
||||
"booking_id": booking_id,
|
||||
"from_hub": from_hub,
|
||||
"to_hub": to_hub,
|
||||
"route_type": route_type,
|
||||
"nearest_miler": nearest_miler,
|
||||
"miler_assigned": assign_result is not None,
|
||||
"estimated_pickup": estimated_pickup.isoformat(),
|
||||
"estimated_delivery": estimated_delivery.isoformat(),
|
||||
}
|
||||
|
||||
async def _find_route(self, task: AgentTask) -> Dict[str, Any]:
|
||||
from_pincode = task.data.get("from_pincode", "")[:3]
|
||||
to_pincode = task.data.get("to_pincode", "")[:3]
|
||||
|
||||
from_zone = self._find_zone_by_pincode(from_pincode)
|
||||
to_zone = self._find_zone_by_pincode(to_pincode)
|
||||
|
||||
if not from_zone or not to_zone:
|
||||
return {"status": "error", "message": "Zone not found for one or both pincodes"}
|
||||
|
||||
route_type = (
|
||||
ZoneType.HUB_TO_SPOKE.value
|
||||
if from_zone.zone_id == to_zone.zone_id
|
||||
else ZoneType.HUB_TO_HUB.value
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "found",
|
||||
"from_hub": from_zone.hub_id,
|
||||
"to_hub": to_zone.hub_id,
|
||||
"route_type": route_type,
|
||||
"estimated_time_minutes": from_zone.avg_delivery_time + to_zone.avg_delivery_time,
|
||||
}
|
||||
|
||||
async def _optimize_routes(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_ids = task.data.get("order_ids", [])
|
||||
logger.info(f"Dispatch Agent: Optimizing {len(order_ids)} routes")
|
||||
|
||||
zone_groups: Dict[str, List[str]] = {}
|
||||
for order_id in order_ids:
|
||||
for route in self._active_routes.values():
|
||||
if route.order_id == order_id:
|
||||
zone_groups.setdefault(route.from_hub, []).append(order_id)
|
||||
|
||||
optimized = [
|
||||
{"zone": zone, "orders": orders, "saved_km": len(orders) * 2.5}
|
||||
for zone, orders in zone_groups.items()
|
||||
]
|
||||
|
||||
return {"status": "optimized", "groups": optimized, "total_orders": len(order_ids)}
|
||||
|
||||
async def _assign_vehicle(self, task: AgentTask) -> Dict[str, Any]:
|
||||
route_id = task.data.get("route_id")
|
||||
if route_id not in self._active_routes:
|
||||
return {"status": "error", "message": f"Route {route_id} not found"}
|
||||
self._active_routes[route_id].vehicle_id = task.data.get("vehicle_id")
|
||||
return {"status": "vehicle_assigned", "route_id": route_id}
|
||||
|
||||
async def _get_zone_status(self, task: AgentTask) -> Dict[str, Any]:
|
||||
zone_status = []
|
||||
for zone_id, zone in self._zones.items():
|
||||
hub = self._hubs.get(zone.hub_id, {})
|
||||
zone_status.append({
|
||||
"zone_id": zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"hub_id": zone.hub_id,
|
||||
"current_load": hub.get("current_load", 0),
|
||||
"max_capacity": hub.get("capacity", 0),
|
||||
"utilization_percent": (hub.get("current_load", 0) / max(hub.get("capacity", 1), 1)) * 100,
|
||||
})
|
||||
return {"zones": zone_status, "total_zones": len(zone_status)}
|
||||
|
||||
async def _reschedule_route(self, task: AgentTask) -> Dict[str, Any]:
|
||||
route_id = task.data.get("route_id")
|
||||
if route_id not in self._active_routes:
|
||||
return {"status": "error", "message": f"Route {route_id} not found"}
|
||||
|
||||
route = self._active_routes[route_id]
|
||||
if task.data.get("new_pickup_time"):
|
||||
route.estimated_pickup = datetime.fromisoformat(task.data["new_pickup_time"])
|
||||
if task.data.get("new_delivery_time"):
|
||||
route.estimated_delivery = datetime.fromisoformat(task.data["new_delivery_time"])
|
||||
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT",
|
||||
message_type=MessageType.NOTIFICATION_SENT,
|
||||
payload={
|
||||
"order_id": route.order_id,
|
||||
"message_type": "schedule_changed",
|
||||
"data": {
|
||||
"new_pickup": route.estimated_pickup.isoformat(),
|
||||
"new_delivery": route.estimated_delivery.isoformat(),
|
||||
},
|
||||
},
|
||||
correlation_id=route_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "rescheduled",
|
||||
"route_id": route_id,
|
||||
"new_pickup": route.estimated_pickup.isoformat(),
|
||||
"new_delivery": route.estimated_delivery.isoformat(),
|
||||
}
|
||||
|
||||
async def _cancel_route(self, task: AgentTask) -> Dict[str, Any]:
|
||||
route_id = task.data.get("route_id")
|
||||
reason = task.data.get("reason", "Route cancelled")
|
||||
|
||||
if route_id not in self._active_routes:
|
||||
return {"status": "error", "message": f"Route {route_id} not found"}
|
||||
|
||||
route = self._active_routes.pop(route_id)
|
||||
|
||||
await self.send_message(
|
||||
recipient="FLEET_AGENT",
|
||||
message_type=MessageType.AGENT_TASK,
|
||||
payload={"task_type": "release_vehicle", "vehicle_id": route.vehicle_id},
|
||||
)
|
||||
|
||||
return {"status": "cancelled", "route_id": route_id, "reason": reason}
|
||||
|
||||
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
|
||||
|
||||
async def think(self, context: str, options: List[str] = None) -> str:
|
||||
return f"[DISPATCH_AGENT reasoning]: {context}"
|
||||
607
agents/exception_agent.py
Normal file
607
agents/exception_agent.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""
|
||||
Exception Agent — Miler stall detection + general exception handling.
|
||||
|
||||
Stall detection flow:
|
||||
JetStream TRACKING/miler.location.updated (pull) -> Redis HSET -> stall check -> miler.stalled
|
||||
JetStream TRACKING/miler.stalled (pull) -> POST reassign + POST notify
|
||||
|
||||
StallDetector sweep (every 60 s):
|
||||
Postgres pickupbookings (active status) -> Redis movement.updated_at age -> miler.stalled
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Set
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
import asyncpg
|
||||
import nats
|
||||
import nats.errors
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from core.agent import SpecializedAgent
|
||||
from core.types import AgentTask, MessageType
|
||||
from core.logger import logger
|
||||
from core.http_client import api_post
|
||||
from config.system_config import (
|
||||
GO_API_BASE_URL, INTERNAL_API_KEY,
|
||||
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD,
|
||||
NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD,
|
||||
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD,
|
||||
)
|
||||
|
||||
STALL_MINUTES = 10
|
||||
ACTIVE_STATUSES = ["Miler_Assigned", "Pickup_Scheduled"]
|
||||
TRACKING_STREAM = "TRACKING"
|
||||
|
||||
|
||||
class ExceptionType(str, Enum):
|
||||
DELAY = "delay"
|
||||
CANCELLATION = "cancellation"
|
||||
RESCHEDULE = "reschedule"
|
||||
REROUTE = "reroute"
|
||||
FAILED_DELIVERY = "failed_delivery"
|
||||
VEHICLE_BREAKDOWN = "vehicle_breakdown"
|
||||
HUB_OVERFLOW = "hub_overflow"
|
||||
WEATHER_IMPACT = "weather_impact"
|
||||
CUSTOMER_UNAVAILABLE = "customer_unavailable"
|
||||
MILER_STALLED = "miler_stalled"
|
||||
|
||||
|
||||
class ExceptionSeverity(str, Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
CRITICAL = "critical"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExceptionRecord:
|
||||
exception_id: str
|
||||
order_id: str
|
||||
exception_type: ExceptionType
|
||||
severity: ExceptionSeverity
|
||||
description: str
|
||||
detected_at: datetime
|
||||
resolved_at: Optional[datetime]
|
||||
resolution: Optional[str]
|
||||
assigned_to: Optional[str]
|
||||
status: str
|
||||
actions_taken: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ExceptionAgent(SpecializedAgent):
|
||||
"""
|
||||
Handles miler stall detection via JetStream pull-subscribe + Postgres + Redis,
|
||||
and all existing order exception types.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
agent_id="EXCEPTION_AGENT",
|
||||
domain="exception_handling",
|
||||
description="Stall detection, exception resolution, reassignment",
|
||||
)
|
||||
|
||||
self._nc: Optional[nats.aio.client.Client] = None
|
||||
self._js = None
|
||||
self._pg: Optional[asyncpg.Pool] = None
|
||||
self._redis = aioredis.Redis(
|
||||
host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
self._location_sub = None
|
||||
self._stalled_sub = None
|
||||
self._notified_stalls: Set[str] = set()
|
||||
self._exceptions: Dict[str, ExceptionRecord] = {}
|
||||
self._strategies = self._init_strategies()
|
||||
self._escalation_rules = self._init_escalation_rules()
|
||||
|
||||
# ── Startup ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def start(self):
|
||||
await self._connect_infra()
|
||||
await self._setup_nats_pull_subs()
|
||||
asyncio.create_task(self._pull_location_loop())
|
||||
asyncio.create_task(self._pull_stalled_loop())
|
||||
asyncio.create_task(self._stall_detector_loop())
|
||||
await super().start()
|
||||
|
||||
async def _connect_infra(self):
|
||||
try:
|
||||
self._nc = await nats.connect(
|
||||
servers=[f"nats://{NATS_HOST}:{NATS_PORT}"],
|
||||
user=NATS_USER,
|
||||
password=NATS_PASSWORD,
|
||||
max_reconnect_attempts=10,
|
||||
)
|
||||
self._js = self._nc.jetstream()
|
||||
logger.info("EXCEPTION_AGENT connected to NATS JetStream")
|
||||
except Exception as e:
|
||||
logger.warning(f"EXCEPTION_AGENT NATS connect failed: {e}")
|
||||
|
||||
try:
|
||||
self._pg = await asyncpg.create_pool(
|
||||
host=DB_HOST, port=DB_PORT, database=DB_NAME,
|
||||
user=DB_USER, password=DB_PASSWORD,
|
||||
min_size=1, max_size=5,
|
||||
)
|
||||
logger.info("EXCEPTION_AGENT connected to Postgres")
|
||||
except Exception as e:
|
||||
logger.warning(f"EXCEPTION_AGENT Postgres connect failed: {e}")
|
||||
|
||||
async def _setup_nats_pull_subs(self):
|
||||
if not self._js:
|
||||
logger.warning("EXCEPTION_AGENT: skipping NATS pull subs (no JetStream connection)")
|
||||
return
|
||||
|
||||
try:
|
||||
self._location_sub = await self._js.pull_subscribe(
|
||||
"miler.location.updated",
|
||||
"tracking_location_consumer",
|
||||
stream=TRACKING_STREAM,
|
||||
)
|
||||
logger.info("EXCEPTION_AGENT pull-subscribed: miler.location.updated")
|
||||
except Exception as e:
|
||||
logger.warning(f"pull_subscribe miler.location.updated failed: {e}")
|
||||
|
||||
try:
|
||||
self._stalled_sub = await self._js.pull_subscribe(
|
||||
"miler.stalled",
|
||||
"tracking_stall_consumer",
|
||||
stream=TRACKING_STREAM,
|
||||
)
|
||||
logger.info("EXCEPTION_AGENT pull-subscribed: miler.stalled")
|
||||
except Exception as e:
|
||||
logger.warning(f"pull_subscribe miler.stalled failed: {e}")
|
||||
|
||||
# ── Pull loops ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _pull_location_loop(self):
|
||||
while self._running:
|
||||
if not self._location_sub:
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
try:
|
||||
msgs = await self._location_sub.fetch(batch=20, timeout=1.0)
|
||||
for msg in msgs:
|
||||
try:
|
||||
await self._on_location_update(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Location handler error: {e}")
|
||||
finally:
|
||||
await msg.ack()
|
||||
except nats.errors.TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Location pull loop error: {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _pull_stalled_loop(self):
|
||||
while self._running:
|
||||
if not self._stalled_sub:
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
try:
|
||||
msgs = await self._stalled_sub.fetch(batch=10, timeout=1.0)
|
||||
for msg in msgs:
|
||||
try:
|
||||
await self._on_miler_stalled(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"Stalled handler error: {e}")
|
||||
finally:
|
||||
await msg.ack()
|
||||
except nats.errors.TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Stalled pull loop error: {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# ── Stall detection: per location ping ───────────────────────────────────
|
||||
|
||||
async def _on_location_update(self, msg):
|
||||
try:
|
||||
data = json.loads(msg.data.decode())
|
||||
except Exception:
|
||||
return
|
||||
|
||||
miler_id = str(data.get("miler_id", ""))
|
||||
lat = str(round(float(data.get("lat", 0)), 6))
|
||||
lon = str(round(float(data.get("lon", 0)), 6))
|
||||
now_ts = datetime.now().isoformat()
|
||||
redis_key = f"miler:{miler_id}:movement"
|
||||
|
||||
prev = await self._redis.hgetall(redis_key)
|
||||
prev_lat = prev.get("lat", "")
|
||||
prev_lon = prev.get("lon", "")
|
||||
|
||||
if lat != prev_lat or lon != prev_lon:
|
||||
await self._redis.hset(redis_key, mapping={
|
||||
"lat": lat, "lon": lon,
|
||||
"updated_at": now_ts,
|
||||
"position_unchanged_since": now_ts,
|
||||
})
|
||||
else:
|
||||
await self._redis.hset(redis_key, mapping={"lat": lat, "lon": lon, "updated_at": now_ts})
|
||||
|
||||
unchanged_since_str = prev.get("position_unchanged_since", now_ts)
|
||||
try:
|
||||
unchanged_since = datetime.fromisoformat(unchanged_since_str)
|
||||
except ValueError:
|
||||
unchanged_since = datetime.now()
|
||||
|
||||
minutes_stalled = (datetime.now() - unchanged_since).total_seconds() / 60
|
||||
|
||||
if minutes_stalled >= STALL_MINUTES:
|
||||
booking = await self._get_active_booking(miler_id)
|
||||
if booking:
|
||||
booking_id = booking["booking_id"]
|
||||
if booking_id not in self._notified_stalls:
|
||||
await self._publish_stall(miler_id, booking_id, minutes_stalled)
|
||||
|
||||
# ── Stall detection: background sweep ────────────────────────────────────
|
||||
|
||||
async def _stall_detector_loop(self):
|
||||
while self._running:
|
||||
await asyncio.sleep(60)
|
||||
try:
|
||||
await self._sweep_active_bookings()
|
||||
except Exception as e:
|
||||
logger.error(f"StallDetector error: {e}")
|
||||
|
||||
async def _sweep_active_bookings(self):
|
||||
if not self._pg:
|
||||
return
|
||||
|
||||
logger.debug("StallDetector: sweeping active bookings")
|
||||
|
||||
try:
|
||||
async with self._pg.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
assignedmileruserid::text AS miler_id,
|
||||
bookingid::text AS booking_id
|
||||
FROM pickupbookings
|
||||
WHERE status = ANY($1)
|
||||
""",
|
||||
ACTIVE_STATUSES,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"StallDetector Postgres error: {e}")
|
||||
return
|
||||
|
||||
now = datetime.now()
|
||||
for row in rows:
|
||||
miler_id = row["miler_id"]
|
||||
booking_id = row["booking_id"]
|
||||
|
||||
if booking_id in self._notified_stalls:
|
||||
continue
|
||||
|
||||
movement = await self._redis.hgetall(f"miler:{miler_id}:movement")
|
||||
if not movement or "updated_at" not in movement:
|
||||
continue
|
||||
|
||||
try:
|
||||
updated_at = datetime.fromisoformat(movement["updated_at"])
|
||||
minutes_stale = (now - updated_at).total_seconds() / 60
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if minutes_stale >= STALL_MINUTES:
|
||||
logger.warning(f"StallDetector: miler {miler_id} stale {minutes_stale:.1f} min (booking {booking_id})")
|
||||
await self._publish_stall(miler_id, booking_id, minutes_stale)
|
||||
|
||||
# ── Stall: publish + handle ───────────────────────────────────────────────
|
||||
|
||||
async def _publish_stall(self, miler_id: str, booking_id: str, minutes_stalled: float):
|
||||
self._notified_stalls.add(booking_id)
|
||||
payload = json.dumps({
|
||||
"miler_id": miler_id,
|
||||
"booking_id": booking_id,
|
||||
"minutes_stalled": round(minutes_stalled, 1),
|
||||
}).encode()
|
||||
try:
|
||||
await self._js.publish("miler.stalled", payload)
|
||||
logger.info(f"miler.stalled published: miler={miler_id} booking={booking_id} ({minutes_stalled:.1f} min)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish miler.stalled: {e}")
|
||||
|
||||
async def _on_miler_stalled(self, msg):
|
||||
"""Reassign booking and notify customer via Go API."""
|
||||
try:
|
||||
data = json.loads(msg.data.decode())
|
||||
except Exception:
|
||||
return
|
||||
|
||||
miler_id = data.get("miler_id", "")
|
||||
booking_id = data.get("booking_id", "")
|
||||
minutes_stalled = data.get("minutes_stalled", 0)
|
||||
|
||||
logger.warning(f"EXCEPTION_AGENT stall: miler={miler_id} booking={booking_id} ({minutes_stalled} min)")
|
||||
|
||||
headers = {"X-Internal-Key": INTERNAL_API_KEY}
|
||||
|
||||
reassign_result = await api_post(
|
||||
f"{GO_API_BASE_URL}/api/v1/internal/bookings/{booking_id}/reassign",
|
||||
json={"reason": "miler_stalled"},
|
||||
headers=headers,
|
||||
)
|
||||
logger.info(f"Reassign {'OK' if reassign_result is not None else 'FAILED'} for booking {booking_id}")
|
||||
|
||||
notify_result = await api_post(
|
||||
f"{GO_API_BASE_URL}/api/v1/internal/notify",
|
||||
json={
|
||||
"booking_id": booking_id,
|
||||
"message": "We detected a delay, finding you a new miler",
|
||||
"target": "customer",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
logger.info(f"Notify {'OK' if notify_result is not None else 'FAILED'} for booking {booking_id}")
|
||||
|
||||
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
|
||||
self._exceptions[exc_id] = ExceptionRecord(
|
||||
exception_id=exc_id, order_id=booking_id,
|
||||
exception_type=ExceptionType.MILER_STALLED,
|
||||
severity=ExceptionSeverity.HIGH,
|
||||
description=f"Miler {miler_id} stalled {minutes_stalled} min",
|
||||
detected_at=datetime.now(), resolved_at=datetime.now(),
|
||||
resolution="Reassignment triggered + customer notified",
|
||||
assigned_to=None, status="resolved",
|
||||
actions_taken=["reassign", "notify_customer"],
|
||||
)
|
||||
|
||||
# ── Postgres helper ───────────────────────────────────────────────────────
|
||||
|
||||
async def _get_active_booking(self, miler_id: str) -> Optional[Dict]:
|
||||
if not self._pg:
|
||||
return None
|
||||
try:
|
||||
async with self._pg.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT bookingid::text AS booking_id, status
|
||||
FROM pickupbookings
|
||||
WHERE assignedmileruserid::text = $1
|
||||
AND status = ANY($2)
|
||||
ORDER BY createdat DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
miler_id,
|
||||
ACTIVE_STATUSES,
|
||||
)
|
||||
if row:
|
||||
return {"booking_id": row["booking_id"], "status": row["status"]}
|
||||
except Exception as e:
|
||||
logger.error(f"Postgres get_active_booking error: {e}")
|
||||
return None
|
||||
|
||||
# ── Task handler ──────────────────────────────────────────────────────────
|
||||
|
||||
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
handlers = {
|
||||
"detect_exception": self._detect_exception,
|
||||
"analyze_exception": self._analyze_exception,
|
||||
"resolve_exception": self._resolve_exception,
|
||||
"cancel_order": self._cancel_order,
|
||||
"reschedule_delivery": self._reschedule_delivery,
|
||||
"handle_delay": self._handle_delay,
|
||||
"get_exception_status": self._get_exception_status,
|
||||
"get_exception_history": self._get_exception_history,
|
||||
"escalate_exception": self._escalate_exception,
|
||||
}
|
||||
handler = handlers.get(task.task_type, self._unknown_task)
|
||||
return await handler(task)
|
||||
|
||||
async def _detect_exception(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
exception_type = task.data.get("exception_type")
|
||||
severity = task.data.get("severity", "medium")
|
||||
description = task.data.get("description", "")
|
||||
|
||||
logger.warning(f"Exception Agent: {exception_type} for {order_id} (severity={severity})")
|
||||
|
||||
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
|
||||
record = ExceptionRecord(
|
||||
exception_id=exc_id, order_id=order_id,
|
||||
exception_type=ExceptionType(exception_type),
|
||||
severity=ExceptionSeverity(severity), description=description,
|
||||
detected_at=datetime.now(), resolved_at=None,
|
||||
resolution=None, assigned_to=None, status="detected",
|
||||
)
|
||||
self._exceptions[exc_id] = record
|
||||
|
||||
if severity in ("high", "critical"):
|
||||
await self._analyze_exception(AgentTask(
|
||||
task_id=f"{exc_id}_analyze", agent_type="exception",
|
||||
task_type="analyze_exception", data={"exception_id": exc_id},
|
||||
))
|
||||
|
||||
await self.send_message(
|
||||
recipient="JARVIS", message_type=MessageType.EXCEPTION_DETECTED,
|
||||
payload={"exception_id": exc_id, "order_id": order_id,
|
||||
"exception_type": exception_type, "severity": severity},
|
||||
)
|
||||
|
||||
return {"status": "detected", "exception_id": exc_id,
|
||||
"exception_type": exception_type, "severity": severity}
|
||||
|
||||
async def _analyze_exception(self, task: AgentTask) -> Dict[str, Any]:
|
||||
exc_id = task.data.get("exception_id")
|
||||
if exc_id not in self._exceptions:
|
||||
return {"status": "error", "message": f"Exception {exc_id} not found"}
|
||||
record = self._exceptions[exc_id]
|
||||
record.status = "analyzing"
|
||||
strategy = self._strategies.get(record.exception_type, {})
|
||||
auto_actions = strategy.get("auto_actions", [])
|
||||
record.actions_taken.append("analyzed")
|
||||
return {"status": "analyzed", "exception_id": exc_id, "strategy": auto_actions}
|
||||
|
||||
async def _resolve_exception(self, task: AgentTask) -> Dict[str, Any]:
|
||||
exc_id = task.data.get("exception_id")
|
||||
if exc_id not in self._exceptions:
|
||||
return {"status": "error", "message": f"Exception {exc_id} not found"}
|
||||
record = self._exceptions[exc_id]
|
||||
record.status = "resolving"
|
||||
for action in self._strategies.get(record.exception_type, {}).get("auto_actions", []):
|
||||
record.actions_taken.append(action)
|
||||
record.status = "resolved"
|
||||
record.resolved_at = datetime.now()
|
||||
record.resolution = f"Completed {len(record.actions_taken)} actions"
|
||||
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT", message_type=MessageType.NOTIFICATION_SENT,
|
||||
payload={"order_id": record.order_id, "message_type": "exception_resolved",
|
||||
"data": {"exception_type": record.exception_type.value}},
|
||||
)
|
||||
return {"status": "resolved", "exception_id": exc_id, "resolution": record.resolution}
|
||||
|
||||
async def _cancel_order(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
reason = task.data.get("reason", "Customer requested cancellation")
|
||||
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
|
||||
self._exceptions[exc_id] = ExceptionRecord(
|
||||
exception_id=exc_id, order_id=order_id,
|
||||
exception_type=ExceptionType.CANCELLATION, severity=ExceptionSeverity.MEDIUM,
|
||||
description=reason, detected_at=datetime.now(), resolved_at=datetime.now(),
|
||||
resolution="Order cancelled", assigned_to=None, status="resolved",
|
||||
actions_taken=["release_vehicle", "notify_customer"],
|
||||
)
|
||||
await self.send_message(
|
||||
recipient="FLEET_AGENT", message_type=MessageType.AGENT_TASK,
|
||||
payload={"task_type": "release_vehicle_for_cancel", "order_id": order_id},
|
||||
)
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT", message_type=MessageType.ORDER_CANCELLED,
|
||||
payload={"order_id": order_id, "reason": reason,
|
||||
"refund_info": "Refund within 5-7 business days"},
|
||||
)
|
||||
return {"status": "cancelled", "exception_id": exc_id, "order_id": order_id}
|
||||
|
||||
async def _reschedule_delivery(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
new_date = task.data.get("new_date")
|
||||
reason = task.data.get("reason", "Operational constraints")
|
||||
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
|
||||
self._exceptions[exc_id] = ExceptionRecord(
|
||||
exception_id=exc_id, order_id=order_id,
|
||||
exception_type=ExceptionType.RESCHEDULE, severity=ExceptionSeverity.MEDIUM,
|
||||
description=f"Rescheduled to {new_date}: {reason}",
|
||||
detected_at=datetime.now(), resolved_at=None, resolution=None,
|
||||
assigned_to=None, status="resolving",
|
||||
)
|
||||
await self.send_message(
|
||||
recipient="DISPATCH_AGENT", message_type=MessageType.AGENT_TASK,
|
||||
payload={"task_type": "reschedule_route", "order_id": order_id, "new_date": new_date},
|
||||
)
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT", message_type=MessageType.NOTIFICATION_SENT,
|
||||
payload={"order_id": order_id, "message_type": "rescheduled",
|
||||
"data": {"new_date": new_date, "reason": reason}},
|
||||
)
|
||||
return {"status": "rescheduled", "exception_id": exc_id, "order_id": order_id, "new_date": new_date}
|
||||
|
||||
async def _handle_delay(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
delay_reason = task.data.get("reason", "Unknown")
|
||||
new_eta = task.data.get("new_eta")
|
||||
severity = task.data.get("severity", "medium")
|
||||
exc_id = f"EXC-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:6].upper()}"
|
||||
self._exceptions[exc_id] = ExceptionRecord(
|
||||
exception_id=exc_id, order_id=order_id,
|
||||
exception_type=ExceptionType.DELAY, severity=ExceptionSeverity(severity),
|
||||
description=f"Delay: {delay_reason}", detected_at=datetime.now(),
|
||||
resolved_at=None, resolution=None, assigned_to=None, status="resolving",
|
||||
)
|
||||
if new_eta:
|
||||
await self.send_message(
|
||||
recipient="DISPATCH_AGENT", message_type=MessageType.ORDER_DELAYED,
|
||||
payload={"order_id": order_id, "new_eta": new_eta, "reason": delay_reason},
|
||||
)
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT", message_type=MessageType.NOTIFICATION_SENT,
|
||||
payload={"order_id": order_id, "message_type": "delayed",
|
||||
"data": {"delay_reason": delay_reason, "new_eta": new_eta}},
|
||||
)
|
||||
return {"status": "delay_handled", "exception_id": exc_id,
|
||||
"order_id": order_id, "new_eta": new_eta, "customer_notified": True}
|
||||
|
||||
async def _get_exception_status(self, task: AgentTask) -> Dict[str, Any]:
|
||||
exc_id = task.data.get("exception_id")
|
||||
if exc_id not in self._exceptions:
|
||||
return {"status": "error", "message": f"Exception {exc_id} not found"}
|
||||
r = self._exceptions[exc_id]
|
||||
return {
|
||||
"exception_id": exc_id, "order_id": r.order_id,
|
||||
"type": r.exception_type.value, "severity": r.severity.value,
|
||||
"status": r.status, "description": r.description,
|
||||
"detected_at": r.detected_at.isoformat(),
|
||||
"resolved_at": r.resolved_at.isoformat() if r.resolved_at else None,
|
||||
"actions_taken": r.actions_taken,
|
||||
}
|
||||
|
||||
async def _get_exception_history(self, task: AgentTask) -> Dict[str, Any]:
|
||||
exc_type = task.data.get("exception_type")
|
||||
status_f = task.data.get("status")
|
||||
items = list(self._exceptions.values())
|
||||
if exc_type:
|
||||
items = [e for e in items if e.exception_type.value == exc_type]
|
||||
if status_f:
|
||||
items = [e for e in items if e.status == status_f]
|
||||
return {
|
||||
"total_exceptions": len(items),
|
||||
"exceptions": [
|
||||
{"exception_id": e.exception_id, "order_id": e.order_id,
|
||||
"type": e.exception_type.value, "severity": e.severity.value,
|
||||
"status": e.status, "detected_at": e.detected_at.isoformat()}
|
||||
for e in items[-50:]
|
||||
],
|
||||
}
|
||||
|
||||
async def _escalate_exception(self, task: AgentTask) -> Dict[str, Any]:
|
||||
exc_id = task.data.get("exception_id")
|
||||
if exc_id not in self._exceptions:
|
||||
return {"status": "error", "message": f"Exception {exc_id} not found"}
|
||||
record = self._exceptions[exc_id]
|
||||
escalate_to = self._escalation_rules.get(record.exception_type, {}).get("escalate_to", "JARVIS")
|
||||
record.actions_taken.append(f"escalated_to_{escalate_to}")
|
||||
await self.send_message(
|
||||
recipient=escalate_to, message_type=MessageType.EXCEPTION_DETECTED,
|
||||
payload={"exception_id": exc_id, "order_id": record.order_id,
|
||||
"exception_type": record.exception_type.value,
|
||||
"severity": record.severity.value, "urgency": "high"},
|
||||
)
|
||||
return {"status": "escalated", "exception_id": exc_id, "escalated_to": escalate_to}
|
||||
|
||||
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
|
||||
|
||||
def _init_strategies(self) -> Dict[str, Any]:
|
||||
return {
|
||||
ExceptionType.DELAY: {"auto_actions": ["notify_customer", "update_eta", "reroute_if_possible"]},
|
||||
ExceptionType.CANCELLATION: {"auto_actions": ["release_vehicle", "refund_check", "notify_customer"]},
|
||||
ExceptionType.RESCHEDULE: {"auto_actions": ["find_new_slot", "update_route", "notify_customer"]},
|
||||
ExceptionType.REROUTE: {"auto_actions": ["recalculate_route", "update_vehicle", "notify_customer"]},
|
||||
ExceptionType.FAILED_DELIVERY: {"auto_actions": ["retry_attempt", "return_to_hub"]},
|
||||
ExceptionType.VEHICLE_BREAKDOWN: {"auto_actions": ["find_replacement_vehicle", "reassign_route", "notify_customer"]},
|
||||
ExceptionType.HUB_OVERFLOW: {"auto_actions": ["reroute_to_alternate", "increase_processing"]},
|
||||
ExceptionType.WEATHER_IMPACT: {"auto_actions": ["delay_notification", "route_avoidance"]},
|
||||
ExceptionType.MILER_STALLED: {"auto_actions": ["reassign", "notify_customer"]},
|
||||
}
|
||||
|
||||
def _init_escalation_rules(self) -> Dict[str, Any]:
|
||||
return {
|
||||
ExceptionType.VEHICLE_BREAKDOWN: {"escalate_to": "FLEET_AGENT"},
|
||||
ExceptionType.HUB_OVERFLOW: {"escalate_to": "HUB_AGENT"},
|
||||
ExceptionType.FAILED_DELIVERY: {"escalate_to": "DISPATCH_AGENT"},
|
||||
ExceptionType.DELAY: {"escalate_to": "JARVIS"},
|
||||
ExceptionType.MILER_STALLED: {"escalate_to": "JARVIS"},
|
||||
}
|
||||
|
||||
async def think(self, context: str, options: List[str] = None) -> str:
|
||||
return f"[EXCEPTION_AGENT reasoning]: {context}"
|
||||
332
agents/fleet_agent.py
Normal file
332
agents/fleet_agent.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Fleet Agent - Manages vehicles, capacity, and real-time tracking."""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from core.agent import SpecializedAgent
|
||||
from core.types import AgentTask, MessageType, Vehicle
|
||||
from core.logger import logger
|
||||
|
||||
|
||||
class VehicleStatus(str, Enum):
|
||||
AVAILABLE = "available"
|
||||
ASSIGNED = "assigned"
|
||||
IN_TRANSIT = "in_transit"
|
||||
AT_HUB = "at_hub"
|
||||
MAINTENANCE = "maintenance"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
@dataclass
|
||||
class VehicleAssignment:
|
||||
assignment_id: str
|
||||
vehicle_id: str
|
||||
route_id: str
|
||||
order_ids: List[str]
|
||||
assigned_at: datetime
|
||||
estimated_return: datetime
|
||||
current_location: Dict[str, float]
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class FleetAgent(SpecializedAgent):
|
||||
"""Fleet Agent - Manages vehicle fleet and logistics operations."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
agent_id="FLEET_AGENT",
|
||||
domain="fleet_management",
|
||||
description="Manages vehicle fleet, capacity, and real-time tracking"
|
||||
)
|
||||
|
||||
self._vehicles = self._init_fleet()
|
||||
self._assignments: Dict[str, VehicleAssignment] = {}
|
||||
self._maintenance_schedule: Dict[str, datetime] = {}
|
||||
self._hub_capacity: Dict[str, Dict] = {}
|
||||
|
||||
# BUG FIX: hub_capacity init was dead code inside _init_fleet (after return).
|
||||
# Moved here so it actually runs after _vehicles is populated.
|
||||
for vehicle in self._vehicles.values():
|
||||
hub = vehicle["hub"]
|
||||
if hub not in self._hub_capacity:
|
||||
self._hub_capacity[hub] = {"available": 0, "total": 0}
|
||||
self._hub_capacity[hub]["total"] += 1
|
||||
if vehicle["status"] == "available":
|
||||
self._hub_capacity[hub]["available"] += 1
|
||||
|
||||
def _init_fleet(self) -> Dict[str, Dict]:
|
||||
return {
|
||||
# Delhi
|
||||
"DL-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "DL-HUB-01", "location": {"lat": 28.6139, "lng": 77.2090}},
|
||||
"DL-V-002": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "DL-HUB-01", "location": {"lat": 28.6139, "lng": 77.2090}},
|
||||
"DL-V-003": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "DL-HUB-02", "location": {"lat": 28.5355, "lng": 77.2100}},
|
||||
"DL-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "DL-HUB-01", "location": {"lat": 28.6139, "lng": 77.2090}},
|
||||
"DL-B-002": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "DL-HUB-02", "location": {"lat": 28.5355, "lng": 77.2100}},
|
||||
# Mumbai
|
||||
"MU-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "MU-HUB-01", "location": {"lat": 19.0760, "lng": 72.8777}},
|
||||
"MU-V-002": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "MU-HUB-01", "location": {"lat": 19.0760, "lng": 72.8777}},
|
||||
"MU-V-003": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "MU-HUB-02", "location": {"lat": 19.1650, "lng": 72.8500}},
|
||||
"MU-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "MU-HUB-02", "location": {"lat": 19.1650, "lng": 72.8500}},
|
||||
"MU-T-001": {"type": "truck", "capacity_kg": 2000, "capacity_vol": 25, "status": "available", "hub": "MU-HUB-01", "location": {"lat": 19.0760, "lng": 72.8777}},
|
||||
# Bangalore
|
||||
"BL-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "BL-HUB-01", "location": {"lat": 12.9716, "lng": 77.5946}},
|
||||
"BL-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "BL-HUB-01", "location": {"lat": 12.9716, "lng": 77.5946}},
|
||||
"BL-B-002": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "BL-HUB-01", "location": {"lat": 12.9716, "lng": 77.5946}},
|
||||
# Hyderabad
|
||||
"HY-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "HY-HUB-01", "location": {"lat": 17.3850, "lng": 78.4867}},
|
||||
"HY-T-001": {"type": "truck", "capacity_kg": 2000, "capacity_vol": 25, "status": "available", "hub": "HY-HUB-01", "location": {"lat": 17.3850, "lng": 78.4867}},
|
||||
# Pune
|
||||
"PU-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "PU-HUB-01", "location": {"lat": 18.5204, "lng": 73.8567}},
|
||||
"PU-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "PU-HUB-01", "location": {"lat": 18.5204, "lng": 73.8567}},
|
||||
# Kolkata
|
||||
"KL-V-001": {"type": "van", "capacity_kg": 500, "capacity_vol": 8, "status": "available", "hub": "KL-HUB-01", "location": {"lat": 22.5726, "lng": 88.3639}},
|
||||
"KL-B-001": {"type": "bike", "capacity_kg": 15, "capacity_vol": 0.5, "status": "available", "hub": "KL-HUB-01", "location": {"lat": 22.5726, "lng": 88.3639}},
|
||||
}
|
||||
|
||||
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
handlers = {
|
||||
"assign_vehicle": self._assign_vehicle,
|
||||
"release_vehicle": self._release_vehicle,
|
||||
"get_availability": self._get_availability,
|
||||
"track_vehicle": self._track_vehicle,
|
||||
"update_location": self._update_location,
|
||||
"schedule_maintenance": self._schedule_maintenance,
|
||||
"get_fleet_status": self._get_fleet_status,
|
||||
"optimize_allocation": self._optimize_allocation,
|
||||
}
|
||||
handler = handlers.get(task.task_type, self._unknown_task)
|
||||
return await handler(task)
|
||||
|
||||
async def _assign_vehicle(self, task: AgentTask) -> Dict[str, Any]:
|
||||
route_id = task.data.get("route_id")
|
||||
route_type = task.data.get("route_type", "last_mile")
|
||||
from_hub = task.data.get("from_hub")
|
||||
order_weight = task.data.get("order_weight", 0)
|
||||
|
||||
logger.info(f"Fleet Agent: Assigning vehicle for route {route_id}")
|
||||
|
||||
suitable_vehicles = self._find_suitable_vehicles(from_hub, order_weight, route_type)
|
||||
if not suitable_vehicles:
|
||||
suitable_vehicles = self._find_nearest_available_vehicles(from_hub, order_weight)
|
||||
|
||||
if not suitable_vehicles:
|
||||
logger.warning(f"No suitable vehicles available for route {route_id}")
|
||||
return {"status": "no_vehicle_available", "route_id": route_id, "message": "No suitable vehicles available"}
|
||||
|
||||
vehicle_id = suitable_vehicles[0]
|
||||
vehicle = self._vehicles[vehicle_id]
|
||||
vehicle["status"] = "assigned"
|
||||
|
||||
if vehicle["hub"] in self._hub_capacity:
|
||||
self._hub_capacity[vehicle["hub"]]["available"] -= 1
|
||||
|
||||
assignment_id = f"ASN-{uuid.uuid4().hex[:8].upper()}"
|
||||
assignment = VehicleAssignment(
|
||||
assignment_id=assignment_id,
|
||||
vehicle_id=vehicle_id,
|
||||
route_id=route_id,
|
||||
order_ids=[task.data.get("order_id", "")],
|
||||
assigned_at=datetime.now(),
|
||||
estimated_return=datetime.now() + timedelta(hours=2),
|
||||
current_location=vehicle["location"],
|
||||
status="active"
|
||||
)
|
||||
self._assignments[assignment_id] = assignment
|
||||
|
||||
logger.info(f"Vehicle {vehicle_id} ({vehicle['type']}) assigned to route {route_id}")
|
||||
|
||||
await self.send_message(
|
||||
recipient="DISPATCH_AGENT",
|
||||
message_type=MessageType.VEHICLE_ASSIGNED,
|
||||
payload={"route_id": route_id, "vehicle_id": vehicle_id, "assignment_id": assignment_id},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "assigned",
|
||||
"assignment_id": assignment_id,
|
||||
"vehicle_id": vehicle_id,
|
||||
"vehicle_type": vehicle["type"],
|
||||
"capacity_kg": vehicle["capacity_kg"],
|
||||
"capacity_vol": vehicle["capacity_vol"],
|
||||
"current_location": vehicle["location"],
|
||||
}
|
||||
|
||||
async def _release_vehicle(self, task: AgentTask) -> Dict[str, Any]:
|
||||
vehicle_id = task.data.get("vehicle_id")
|
||||
|
||||
if vehicle_id not in self._vehicles:
|
||||
return {"status": "error", "message": f"Vehicle {vehicle_id} not found"}
|
||||
|
||||
vehicle = self._vehicles[vehicle_id]
|
||||
vehicle["status"] = "available"
|
||||
|
||||
if vehicle["hub"] in self._hub_capacity:
|
||||
self._hub_capacity[vehicle["hub"]]["available"] += 1
|
||||
|
||||
logger.info(f"Vehicle {vehicle_id} released")
|
||||
|
||||
return {"status": "released", "vehicle_id": vehicle_id, "hub": vehicle["hub"]}
|
||||
|
||||
async def _get_availability(self, task: AgentTask) -> Dict[str, Any]:
|
||||
hub_id = task.data.get("hub_id")
|
||||
vehicle_type = task.data.get("vehicle_type")
|
||||
|
||||
available = []
|
||||
for vid, vehicle in self._vehicles.items():
|
||||
if vehicle["hub"] == hub_id and vehicle["status"] == "available":
|
||||
if vehicle_type is None or vehicle["type"] == vehicle_type:
|
||||
available.append({
|
||||
"vehicle_id": vid,
|
||||
"type": vehicle["type"],
|
||||
"capacity_kg": vehicle["capacity_kg"],
|
||||
"capacity_vol": vehicle["capacity_vol"],
|
||||
})
|
||||
|
||||
return {"hub_id": hub_id, "available_vehicles": available, "total_available": len(available)}
|
||||
|
||||
async def _track_vehicle(self, task: AgentTask) -> Dict[str, Any]:
|
||||
vehicle_id = task.data.get("vehicle_id")
|
||||
|
||||
if vehicle_id not in self._vehicles:
|
||||
return {"status": "error", "message": f"Vehicle {vehicle_id} not found"}
|
||||
|
||||
vehicle = self._vehicles[vehicle_id]
|
||||
active_assignment = next(
|
||||
(a for a in self._assignments.values() if a.vehicle_id == vehicle_id and a.status == "active"),
|
||||
None,
|
||||
)
|
||||
|
||||
return {
|
||||
"vehicle_id": vehicle_id,
|
||||
"type": vehicle["type"],
|
||||
"status": vehicle["status"],
|
||||
"location": vehicle["location"],
|
||||
"hub": vehicle["hub"],
|
||||
"current_assignment": {
|
||||
"assignment_id": active_assignment.assignment_id,
|
||||
"route_id": active_assignment.route_id,
|
||||
"estimated_return": active_assignment.estimated_return.isoformat(),
|
||||
} if active_assignment else None,
|
||||
}
|
||||
|
||||
async def _update_location(self, task: AgentTask) -> Dict[str, Any]:
|
||||
vehicle_id = task.data.get("vehicle_id")
|
||||
new_location = task.data.get("location")
|
||||
|
||||
if vehicle_id not in self._vehicles:
|
||||
return {"status": "error", "message": f"Vehicle {vehicle_id} not found"}
|
||||
|
||||
self._vehicles[vehicle_id]["location"] = new_location
|
||||
|
||||
for assignment in self._assignments.values():
|
||||
if assignment.vehicle_id == vehicle_id and assignment.status == "active":
|
||||
assignment.current_location = new_location
|
||||
break
|
||||
|
||||
return {"status": "updated", "vehicle_id": vehicle_id, "new_location": new_location}
|
||||
|
||||
async def _schedule_maintenance(self, task: AgentTask) -> Dict[str, Any]:
|
||||
vehicle_id = task.data.get("vehicle_id")
|
||||
maintenance_date = task.data.get("date")
|
||||
|
||||
if vehicle_id not in self._vehicles:
|
||||
return {"status": "error", "message": f"Vehicle {vehicle_id} not found"}
|
||||
|
||||
if maintenance_date:
|
||||
self._maintenance_schedule[vehicle_id] = datetime.fromisoformat(maintenance_date)
|
||||
else:
|
||||
self._maintenance_schedule[vehicle_id] = datetime.now() + timedelta(days=3)
|
||||
|
||||
self._vehicles[vehicle_id]["status"] = "maintenance"
|
||||
|
||||
return {
|
||||
"status": "scheduled",
|
||||
"vehicle_id": vehicle_id,
|
||||
"maintenance_date": self._maintenance_schedule[vehicle_id].isoformat(),
|
||||
}
|
||||
|
||||
async def _get_fleet_status(self, task: AgentTask) -> Dict[str, Any]:
|
||||
status_summary = {
|
||||
"total_vehicles": len(self._vehicles),
|
||||
"by_type": {},
|
||||
"by_status": {},
|
||||
"by_hub": {},
|
||||
"vehicles": [],
|
||||
}
|
||||
|
||||
for vehicle_id, vehicle in self._vehicles.items():
|
||||
vtype = vehicle["type"]
|
||||
vstatus = vehicle["status"]
|
||||
hub = vehicle["hub"]
|
||||
|
||||
status_summary["by_type"][vtype] = status_summary["by_type"].get(vtype, 0) + 1
|
||||
status_summary["by_status"][vstatus] = status_summary["by_status"].get(vstatus, 0) + 1
|
||||
|
||||
if hub not in status_summary["by_hub"]:
|
||||
status_summary["by_hub"][hub] = {"total": 0, "available": 0}
|
||||
status_summary["by_hub"][hub]["total"] += 1
|
||||
if vstatus == "available":
|
||||
status_summary["by_hub"][hub]["available"] += 1
|
||||
|
||||
status_summary["vehicles"].append({
|
||||
"vehicle_id": vehicle_id,
|
||||
"type": vtype,
|
||||
"status": vstatus,
|
||||
"hub": hub,
|
||||
"location": vehicle["location"],
|
||||
})
|
||||
|
||||
return status_summary
|
||||
|
||||
async def _optimize_allocation(self, task: AgentTask) -> Dict[str, Any]:
|
||||
logger.info("Fleet Agent: Optimizing vehicle allocation")
|
||||
|
||||
recommendations = []
|
||||
for hub_id, capacity in self._hub_capacity.items():
|
||||
utilization = (capacity["total"] - capacity["available"]) / capacity["total"] if capacity["total"] > 0 else 0
|
||||
|
||||
if utilization < 0.3:
|
||||
recommendations.append({
|
||||
"action": "redistribute",
|
||||
"from_hub": hub_id,
|
||||
"reason": f"Low utilization ({utilization*100:.0f}%)",
|
||||
"vehicles_to_move": 1,
|
||||
})
|
||||
elif utilization > 0.9:
|
||||
recommendations.append({
|
||||
"action": "request_reinforcement",
|
||||
"to_hub": hub_id,
|
||||
"reason": f"High utilization ({utilization*100:.0f}%)",
|
||||
"vehicles_needed": 2,
|
||||
})
|
||||
|
||||
return {"status": "optimized", "recommendations": recommendations}
|
||||
|
||||
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
|
||||
|
||||
def _find_suitable_vehicles(self, hub_id: str, weight_kg: float, route_type: str) -> List[str]:
|
||||
suitable = []
|
||||
for vehicle_id, vehicle in self._vehicles.items():
|
||||
if vehicle["hub"] != hub_id:
|
||||
continue
|
||||
if vehicle["status"] != "available":
|
||||
continue
|
||||
if vehicle["capacity_kg"] < weight_kg:
|
||||
continue
|
||||
if route_type == "last_mile" and vehicle["type"] in ["van", "truck"]:
|
||||
continue
|
||||
suitable.append(vehicle_id)
|
||||
return suitable
|
||||
|
||||
def _find_nearest_available_vehicles(self, hub_id: str, weight_kg: float) -> List[str]:
|
||||
available = [
|
||||
vid for vid, v in self._vehicles.items()
|
||||
if v["status"] == "available" and v["capacity_kg"] >= weight_kg
|
||||
]
|
||||
return available[:1]
|
||||
|
||||
async def think(self, context: str, options: List[str] = None) -> str:
|
||||
return f"[FLEET_AGENT reasoning]: {context}"
|
||||
460
agents/hub_agent.py
Normal file
460
agents/hub_agent.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""Hub Agent - Manages hub operations, transit, and capacity."""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from collections import deque
|
||||
|
||||
from core.agent import SpecializedAgent
|
||||
from core.types import AgentTask, MessageType, Hub
|
||||
from core.logger import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransitRecord:
|
||||
record_id: str
|
||||
order_id: str
|
||||
entry_time: datetime
|
||||
expected_exit: datetime
|
||||
actual_exit: Optional[datetime]
|
||||
destination: str
|
||||
status: str # waiting, processing, in_transit, delivered
|
||||
items_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class HubInventory:
|
||||
hub_id: str
|
||||
current_load: int
|
||||
capacity: int
|
||||
incoming_transits: int
|
||||
outgoing_transits: int
|
||||
pending_processing: int
|
||||
|
||||
|
||||
class HubAgent(SpecializedAgent):
|
||||
"""Hub Agent - Manages hub operations and transit flow."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
agent_id="HUB_AGENT",
|
||||
domain="hub_management",
|
||||
description="Manages hub operations, transit flow, and capacity"
|
||||
)
|
||||
|
||||
self._hubs = self._init_hubs()
|
||||
self._in_transit: Dict[str, List[TransitRecord]] = {h: [] for h in self._hubs.keys()}
|
||||
self._processing_queues: Dict[str, deque] = {h: deque() for h in self._hubs.keys()}
|
||||
self._metrics: Dict[str, Dict] = {}
|
||||
|
||||
# BUG FIX: metrics init was dead code inside _init_hubs (after return).
|
||||
# Moved here so it actually runs after _hubs is populated.
|
||||
for hub_id in self._hubs.keys():
|
||||
self._metrics[hub_id] = {
|
||||
"processed_today": 0,
|
||||
"average_processing_time": 15,
|
||||
"efficiency": 95.0,
|
||||
"bottlenecks": [],
|
||||
}
|
||||
|
||||
def _init_hubs(self) -> Dict[str, Dict]:
|
||||
return {
|
||||
"DL-HUB-01": {
|
||||
"name": "Delhi North Hub",
|
||||
"location": {"lat": 28.6139, "lng": 77.2090},
|
||||
"address": "Sector 12, Narela, Delhi",
|
||||
"capacity": 500, "current_load": 150,
|
||||
"spokes": ["DL-SP-01", "DL-SP-02", "DL-SP-03"],
|
||||
"connected_hubs": ["DL-HUB-02", "MU-HUB-01", "KL-HUB-01"],
|
||||
"processing_rate": 100, "operating_hours": "24/7",
|
||||
},
|
||||
"DL-HUB-02": {
|
||||
"name": "Delhi South Hub",
|
||||
"location": {"lat": 28.5355, "lng": 77.2100},
|
||||
"address": "Nehru Place, Delhi",
|
||||
"capacity": 400, "current_load": 200,
|
||||
"spokes": ["DL-SP-04", "DL-SP-05"],
|
||||
"connected_hubs": ["DL-HUB-01", "BL-HUB-01"],
|
||||
"processing_rate": 80, "operating_hours": "24/7",
|
||||
},
|
||||
"MU-HUB-01": {
|
||||
"name": "Mumbai West Hub",
|
||||
"location": {"lat": 19.0760, "lng": 72.8777},
|
||||
"address": "Andheri West, Mumbai",
|
||||
"capacity": 600, "current_load": 300,
|
||||
"spokes": ["MU-SP-01", "MU-SP-02", "MU-SP-03", "MU-SP-04"],
|
||||
"connected_hubs": ["MU-HUB-02", "DL-HUB-01", "PU-HUB-01"],
|
||||
"processing_rate": 120, "operating_hours": "24/7",
|
||||
},
|
||||
"MU-HUB-02": {
|
||||
"name": "Mumbai East Hub",
|
||||
"location": {"lat": 19.1650, "lng": 72.8500},
|
||||
"address": "Thane, Mumbai",
|
||||
"capacity": 550, "current_load": 250,
|
||||
"spokes": ["MU-SP-05", "MU-SP-06"],
|
||||
"connected_hubs": ["MU-HUB-01", "PU-HUB-01"],
|
||||
"processing_rate": 90, "operating_hours": "24/7",
|
||||
},
|
||||
"BL-HUB-01": {
|
||||
"name": "Bangalore Hub",
|
||||
"location": {"lat": 12.9716, "lng": 77.5946},
|
||||
"address": "Whitefield, Bangalore",
|
||||
"capacity": 500, "current_load": 180,
|
||||
"spokes": ["BL-SP-01", "BL-SP-02", "BL-SP-03"],
|
||||
"connected_hubs": ["HY-HUB-01", "DL-HUB-02"],
|
||||
"processing_rate": 100, "operating_hours": "24/7",
|
||||
},
|
||||
"HY-HUB-01": {
|
||||
"name": "Hyderabad Hub",
|
||||
"location": {"lat": 17.3850, "lng": 78.4867},
|
||||
"address": "Hi-Tech City, Hyderabad",
|
||||
"capacity": 450, "current_load": 220,
|
||||
"spokes": ["HY-SP-01", "HY-SP-02"],
|
||||
"connected_hubs": ["BL-HUB-01", "KL-HUB-01"],
|
||||
"processing_rate": 85, "operating_hours": "24/7",
|
||||
},
|
||||
"PU-HUB-01": {
|
||||
"name": "Pune Hub",
|
||||
"location": {"lat": 18.5204, "lng": 73.8567},
|
||||
"address": "Hinjewadi, Pune",
|
||||
"capacity": 400, "current_load": 160,
|
||||
"spokes": ["PU-SP-01", "PU-SP-02"],
|
||||
"connected_hubs": ["MU-HUB-01", "MU-HUB-02"],
|
||||
"processing_rate": 75, "operating_hours": "24/7",
|
||||
},
|
||||
"KL-HUB-01": {
|
||||
"name": "Kolkata Hub",
|
||||
"location": {"lat": 22.5726, "lng": 88.3639},
|
||||
"address": "Salt Lake, Kolkata",
|
||||
"capacity": 350, "current_load": 140,
|
||||
"spokes": ["KL-SP-01", "KL-SP-02"],
|
||||
"connected_hubs": ["DL-HUB-01", "HY-HUB-01"],
|
||||
"processing_rate": 70, "operating_hours": "24/7",
|
||||
},
|
||||
}
|
||||
|
||||
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
handlers = {
|
||||
"prepare_receiving": self._prepare_receiving,
|
||||
"process_incoming": self._process_incoming,
|
||||
"dispatch_outgoing": self._dispatch_outgoing,
|
||||
"get_hub_status": self._get_hub_status,
|
||||
"transfer_between_hubs": self._transfer_between_hubs,
|
||||
"get_transit_status": self._get_transit_status,
|
||||
"optimize_hub_operations": self._optimize_hub_operations,
|
||||
"escalate_overflow": self._escalate_overflow,
|
||||
}
|
||||
handler = handlers.get(task.task_type, self._unknown_task)
|
||||
return await handler(task)
|
||||
|
||||
async def _prepare_receiving(self, task: AgentTask) -> Dict[str, Any]:
|
||||
hub_id = task.data.get("hub_id")
|
||||
order_id = task.data.get("order_id")
|
||||
route_id = task.data.get("route_id")
|
||||
|
||||
if hub_id not in self._hubs:
|
||||
return {"status": "error", "message": f"Hub {hub_id} not found"}
|
||||
|
||||
hub = self._hubs[hub_id]
|
||||
available_capacity = hub["capacity"] - hub["current_load"]
|
||||
|
||||
logger.info(f"Hub Agent: Preparing {hub['name']} to receive order {order_id}")
|
||||
|
||||
if available_capacity < 10:
|
||||
await self.send_message(
|
||||
recipient="JARVIS",
|
||||
message_type=MessageType.EXCEPTION_DETECTED,
|
||||
payload={
|
||||
"type": "capacity_warning",
|
||||
"hub_id": hub_id,
|
||||
"current_load": hub["current_load"],
|
||||
"capacity": hub["capacity"],
|
||||
"order_id": order_id,
|
||||
},
|
||||
)
|
||||
logger.warning(f"Hub {hub_id} near capacity: {hub['current_load']}/{hub['capacity']}")
|
||||
|
||||
self._processing_queues[hub_id].append({
|
||||
"order_id": order_id,
|
||||
"route_id": route_id,
|
||||
"arrival_time": datetime.now(),
|
||||
"priority": "normal",
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "prepared",
|
||||
"hub_id": hub_id,
|
||||
"hub_name": hub["name"],
|
||||
"queue_position": len(self._processing_queues[hub_id]),
|
||||
"estimated_wait": len(self._processing_queues[hub_id]) * 5,
|
||||
}
|
||||
|
||||
async def _process_incoming(self, task: AgentTask) -> Dict[str, Any]:
|
||||
hub_id = task.data.get("hub_id")
|
||||
order_id = task.data.get("order_id")
|
||||
|
||||
if hub_id not in self._hubs:
|
||||
return {"status": "error", "message": f"Hub {hub_id} not found"}
|
||||
|
||||
hub = self._hubs[hub_id]
|
||||
hub["current_load"] += 1
|
||||
|
||||
record_id = f"TR-{hub_id}-{uuid.uuid4().hex[:6].upper()}"
|
||||
record = TransitRecord(
|
||||
record_id=record_id,
|
||||
order_id=order_id,
|
||||
entry_time=datetime.now(),
|
||||
expected_exit=datetime.now() + timedelta(minutes=15), # 15 min avg processing
|
||||
actual_exit=None,
|
||||
destination=task.data.get("destination", "unknown"),
|
||||
status="processing",
|
||||
items_count=task.data.get("items_count", 1),
|
||||
)
|
||||
self._in_transit[hub_id].append(record)
|
||||
self._metrics[hub_id]["processed_today"] += 1
|
||||
|
||||
logger.info(f"Order {order_id} received at {hub['name']} | load={hub['current_load']}/{hub['capacity']}")
|
||||
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT",
|
||||
message_type=MessageType.HUB_STATUS_UPDATE,
|
||||
payload={
|
||||
"order_id": order_id,
|
||||
"hub_id": hub_id,
|
||||
"hub_name": hub["name"],
|
||||
"status": "arrived_at_hub",
|
||||
"arrival_time": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
return {"status": "processed", "record_id": record_id, "hub_id": hub_id, "current_load": hub["current_load"]}
|
||||
|
||||
async def _dispatch_outgoing(self, task: AgentTask) -> Dict[str, Any]:
|
||||
hub_id = task.data.get("hub_id")
|
||||
order_id = task.data.get("order_id")
|
||||
|
||||
if hub_id not in self._hubs:
|
||||
return {"status": "error", "message": f"Hub {hub_id} not found"}
|
||||
|
||||
hub = self._hubs[hub_id]
|
||||
record = None
|
||||
for r in self._in_transit[hub_id]:
|
||||
if r.order_id == order_id and r.status in ["processing", "waiting"]:
|
||||
r.actual_exit = datetime.now()
|
||||
r.status = "in_transit"
|
||||
record = r
|
||||
break
|
||||
|
||||
if record:
|
||||
hub["current_load"] -= 1
|
||||
logger.info(f"Order {order_id} dispatched from {hub['name']}")
|
||||
return {
|
||||
"status": "dispatched",
|
||||
"record_id": record.record_id,
|
||||
"hub_id": hub_id,
|
||||
"exit_time": record.actual_exit.isoformat(),
|
||||
"destination": record.destination,
|
||||
}
|
||||
|
||||
return {"status": "error", "message": "Transit record not found"}
|
||||
|
||||
async def _get_hub_status(self, task: AgentTask) -> Dict[str, Any]:
|
||||
hub_id = task.data.get("hub_id")
|
||||
|
||||
if hub_id:
|
||||
if hub_id not in self._hubs:
|
||||
return {"status": "error", "message": f"Hub {hub_id} not found"}
|
||||
return self._format_hub_status(hub_id)
|
||||
|
||||
return {
|
||||
"hubs": {hid: self._format_hub_status(hid) for hid in self._hubs.keys()},
|
||||
"total_hubs": len(self._hubs),
|
||||
}
|
||||
|
||||
def _format_hub_status(self, hub_id: str) -> Dict[str, Any]:
|
||||
hub = self._hubs[hub_id]
|
||||
metrics = self._metrics[hub_id]
|
||||
utilization = (hub["current_load"] / hub["capacity"]) * 100 if hub["capacity"] > 0 else 0
|
||||
|
||||
return {
|
||||
"hub_id": hub_id,
|
||||
"name": hub["name"],
|
||||
"location": hub["location"],
|
||||
"capacity": hub["capacity"],
|
||||
"current_load": hub["current_load"],
|
||||
"utilization_percent": round(utilization, 1),
|
||||
"spokes": hub["spokes"],
|
||||
"connected_hubs": hub["connected_hubs"],
|
||||
"processing_queue": len(self._processing_queues[hub_id]),
|
||||
"in_transit": len([r for r in self._in_transit[hub_id] if r.status == "in_transit"]),
|
||||
"metrics": {
|
||||
"processed_today": metrics["processed_today"],
|
||||
"avg_processing_time": metrics["average_processing_time"],
|
||||
"efficiency": metrics["efficiency"],
|
||||
},
|
||||
"status": "operational" if utilization < 90 else "high_load",
|
||||
}
|
||||
|
||||
async def _transfer_between_hubs(self, task: AgentTask) -> Dict[str, Any]:
|
||||
from_hub = task.data.get("from_hub")
|
||||
to_hub = task.data.get("to_hub")
|
||||
order_id = task.data.get("order_id")
|
||||
|
||||
if from_hub not in self._hubs or to_hub not in self._hubs:
|
||||
return {"status": "error", "message": "Invalid hub ID(s)"}
|
||||
|
||||
if to_hub not in self._hubs[from_hub]["connected_hubs"]:
|
||||
return {
|
||||
"status": "no_direct_route",
|
||||
"message": f"No direct route from {from_hub} to {to_hub}",
|
||||
"suggested_route": self._find_route(from_hub, to_hub),
|
||||
}
|
||||
|
||||
transfer_id = f"XF-{uuid.uuid4().hex[:8].upper()}"
|
||||
logger.info(f"Hub transfer {transfer_id}: {self._hubs[from_hub]['name']} -> {self._hubs[to_hub]['name']} | order={order_id}")
|
||||
|
||||
await self.send_message(
|
||||
recipient="ROUTE_OPTIMIZER",
|
||||
message_type=MessageType.AGENT_TASK,
|
||||
payload={
|
||||
"task_type": "plan_inter_hub_route",
|
||||
"transfer_id": transfer_id,
|
||||
"from_hub": from_hub,
|
||||
"to_hub": to_hub,
|
||||
"order_id": order_id,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "transfer_initiated",
|
||||
"transfer_id": transfer_id,
|
||||
"from_hub": from_hub,
|
||||
"to_hub": to_hub,
|
||||
"estimated_time": 180,
|
||||
"route": [from_hub, to_hub],
|
||||
}
|
||||
|
||||
async def _get_transit_status(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
transit_info = []
|
||||
|
||||
for hub_id, records in self._in_transit.items():
|
||||
for record in records:
|
||||
if record.order_id == order_id:
|
||||
transit_info.append({
|
||||
"record_id": record.record_id,
|
||||
"hub_id": hub_id,
|
||||
"hub_name": self._hubs[hub_id]["name"],
|
||||
"status": record.status,
|
||||
"entry_time": record.entry_time.isoformat(),
|
||||
"expected_exit": record.expected_exit.isoformat(),
|
||||
"actual_exit": record.actual_exit.isoformat() if record.actual_exit else None,
|
||||
"destination": record.destination,
|
||||
})
|
||||
|
||||
return {
|
||||
"order_id": order_id,
|
||||
"transit_records": transit_info,
|
||||
"current_location": transit_info[-1]["hub_name"] if transit_info else "unknown",
|
||||
}
|
||||
|
||||
async def _optimize_hub_operations(self, task: AgentTask) -> Dict[str, Any]:
|
||||
hub_id = task.data.get("hub_id")
|
||||
|
||||
if hub_id not in self._hubs:
|
||||
return {"status": "error", "message": f"Hub {hub_id} not found"}
|
||||
|
||||
hub = self._hubs[hub_id]
|
||||
metrics = self._metrics[hub_id]
|
||||
recommendations = []
|
||||
|
||||
utilization = (hub["current_load"] / hub["capacity"]) * 100
|
||||
if utilization > 80:
|
||||
recommendations.append({
|
||||
"type": "capacity", "priority": "high",
|
||||
"message": f"High utilization at {utilization:.0f}% - consider overflow routing",
|
||||
"action": "request_additional_vehicle",
|
||||
})
|
||||
|
||||
queue_length = len(self._processing_queues[hub_id])
|
||||
if queue_length > 20:
|
||||
recommendations.append({
|
||||
"type": "queue", "priority": "medium",
|
||||
"message": f"Long processing queue ({queue_length} items)",
|
||||
"action": "increase_processing_capacity",
|
||||
})
|
||||
|
||||
if metrics["average_processing_time"] > 30:
|
||||
recommendations.append({
|
||||
"type": "efficiency", "priority": "medium",
|
||||
"message": "Above average processing time",
|
||||
"action": "review_sorting_operations",
|
||||
})
|
||||
|
||||
logger.info(f"Hub {hub['name']}: util={utilization:.1f}% queue={queue_length} recommendations={len(recommendations)}")
|
||||
|
||||
return {
|
||||
"hub_id": hub_id,
|
||||
"recommendations": recommendations,
|
||||
"potential_savings": {
|
||||
"time_minutes": len(recommendations) * 10,
|
||||
"cost_percent": len(recommendations) * 2,
|
||||
},
|
||||
}
|
||||
|
||||
async def _escalate_overflow(self, task: AgentTask) -> Dict[str, Any]:
|
||||
from_hub = task.data.get("from_hub")
|
||||
order_id = task.data.get("order_id")
|
||||
|
||||
if from_hub not in self._hubs:
|
||||
return {"status": "error", "message": f"Hub {from_hub} not found"}
|
||||
|
||||
hub = self._hubs[from_hub]
|
||||
alternatives = []
|
||||
for connected in hub["connected_hubs"]:
|
||||
alt_hub = self._hubs[connected]
|
||||
utilization = (alt_hub["current_load"] / alt_hub["capacity"]) * 100
|
||||
if utilization < 70:
|
||||
alternatives.append({
|
||||
"hub_id": connected,
|
||||
"name": alt_hub["name"],
|
||||
"utilization": utilization,
|
||||
})
|
||||
|
||||
if not alternatives:
|
||||
return {"status": "no_alternatives", "message": "All connected hubs at capacity"}
|
||||
|
||||
best_alt = alternatives[0]
|
||||
logger.warning(f"Hub overflow: routing to {best_alt['name']}")
|
||||
|
||||
return await self._transfer_between_hubs(AgentTask(
|
||||
task_id=f"overflow_{order_id}",
|
||||
agent_type="hub",
|
||||
task_type="transfer_between_hubs",
|
||||
data={"from_hub": from_hub, "to_hub": best_alt["hub_id"], "order_id": order_id},
|
||||
))
|
||||
|
||||
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
|
||||
|
||||
def _find_route(self, from_hub: str, to_hub: str) -> List[str]:
|
||||
"""BFS route between hubs."""
|
||||
if from_hub == to_hub:
|
||||
return [from_hub]
|
||||
|
||||
visited = {from_hub}
|
||||
queue = [(from_hub, [from_hub])]
|
||||
|
||||
while queue:
|
||||
current, path = queue.pop(0)
|
||||
for neighbor in self._hubs[current]["connected_hubs"]:
|
||||
if neighbor == to_hub:
|
||||
return path + [to_hub]
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append((neighbor, path + [neighbor]))
|
||||
|
||||
return []
|
||||
|
||||
async def think(self, context: str, options: List[str] = None) -> str:
|
||||
return f"[HUB_AGENT reasoning]: {context}"
|
||||
337
agents/order_agent.py
Normal file
337
agents/order_agent.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""Order Agent - Handles order intake, validation, and categorization."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
from core.agent import SpecializedAgent
|
||||
from core.types import (
|
||||
AgentTask, MessageType, Priority, OrderStatus, ZoneType
|
||||
)
|
||||
from core.logger import logger
|
||||
from core.http_client import api_get, api_post, api_patch
|
||||
from config.system_config import GO_API_BASE_URL
|
||||
|
||||
|
||||
class OrderAgent(SpecializedAgent):
|
||||
"""
|
||||
Order Agent - Manages the entire order lifecycle from intake to validation.
|
||||
|
||||
Writes go to the Doormile Go backend (POST /api/v1/admin/crmbooking).
|
||||
Reads and status updates also go through the Go API.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
agent_id="ORDER_AGENT",
|
||||
domain="order_management",
|
||||
description="Handles order intake, validation, and categorization"
|
||||
)
|
||||
self._validation_rules = self._init_validation_rules()
|
||||
self._pincode_zones = self._init_pincode_mapping()
|
||||
|
||||
def _init_validation_rules(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"required_fields": ["customer_name", "customer_phone", "pickup_address", "delivery_address", "items"],
|
||||
"phone_pattern": r'^\+?[1-9]\d{9,14}$',
|
||||
"email_pattern": r'^[\w\.-]+@[\w\.-]+\.\w+$',
|
||||
"pincode_length": 6,
|
||||
"max_items_per_order": 100,
|
||||
"max_weight_kg": 500,
|
||||
}
|
||||
|
||||
def _init_pincode_mapping(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"100": {"zone": "north", "region": "delhi_ncr", "hub_prefix": "DL"},
|
||||
"101": {"zone": "north", "region": "delhi_ncr", "hub_prefix": "DL"},
|
||||
"200": {"zone": "south", "region": "hyderabad", "hub_prefix": "HY"},
|
||||
"201": {"zone": "south", "region": "bangalore", "hub_prefix": "BL"},
|
||||
"400": {"zone": "west", "region": "mumbai", "hub_prefix": "MU"},
|
||||
"401": {"zone": "west", "region": "pune", "hub_prefix": "PU"},
|
||||
"500": {"zone": "central", "region": "bhopal", "hub_prefix": "BH"},
|
||||
"600": {"zone": "east", "region": "kolkata", "hub_prefix": "KL"},
|
||||
"700": {"zone": "northeast", "region": "guwahati", "hub_prefix": "GH"},
|
||||
"800": {"zone": "northwest", "region": "jaipur", "hub_prefix": "JP"},
|
||||
}
|
||||
|
||||
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
handlers = {
|
||||
"receive_order": self._receive_order,
|
||||
"validate_order": self._validate_order,
|
||||
"categorize_order": self._categorize_order,
|
||||
"update_status": self._update_status,
|
||||
"cancel_order": self._cancel_order,
|
||||
"get_order": self._get_order,
|
||||
"list_orders": self._list_orders,
|
||||
}
|
||||
handler = handlers.get(task.task_type, self._unknown_task)
|
||||
return await handler(task)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Go API helpers (use shared session + retry from http_client) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _api_post(self, path: str, payload: Dict) -> Optional[Dict]:
|
||||
result = await api_post(f"{GO_API_BASE_URL}{path}", json=payload)
|
||||
if result is None:
|
||||
logger.error(f"Go API POST {path} returned no response")
|
||||
return result
|
||||
|
||||
async def _api_get(self, path: str, params: Dict = None) -> Optional[Dict]:
|
||||
result = await api_get(f"{GO_API_BASE_URL}{path}", params=params)
|
||||
if result is None:
|
||||
logger.error(f"Go API GET {path} returned no response")
|
||||
return result
|
||||
|
||||
async def _api_patch(self, path: str, payload: Dict) -> Optional[Dict]:
|
||||
result = await api_patch(f"{GO_API_BASE_URL}{path}", json=payload)
|
||||
if result is None:
|
||||
logger.error(f"Go API PATCH {path} returned no response")
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Task handlers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _receive_order(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_data = task.data.get("order", {})
|
||||
|
||||
order_id = f"ORD-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8].upper()}"
|
||||
order_data["order_id"] = order_id
|
||||
order_data["status"] = OrderStatus.RECEIVED.value
|
||||
order_data["created_at"] = datetime.now().isoformat()
|
||||
|
||||
logger.info(f"Order Agent: Received order {order_id} | customer={order_data.get('customer_name')} items={len(order_data.get('items', []))}")
|
||||
|
||||
validation_result = await self._validate_order_data(order_data)
|
||||
order_data["validation"] = validation_result
|
||||
|
||||
category = self._categorize_order_data(order_data)
|
||||
order_data["category"] = category
|
||||
|
||||
api_result = await self._api_post("/api/v1/admin/crmbooking", order_data)
|
||||
if api_result is None:
|
||||
logger.warning(f"Failed to persist order {order_id} to Go backend")
|
||||
else:
|
||||
go_id = (api_result.get("data") or {}).get("bookingid")
|
||||
if go_id:
|
||||
order_id = str(go_id)
|
||||
order_data["order_id"] = order_id
|
||||
order_data["booking_id"] = order_id
|
||||
logger.info(f"Go API booking ID: {order_id}")
|
||||
|
||||
await self.send_message(
|
||||
recipient="JARVIS",
|
||||
message_type=MessageType.ORDER_RECEIVED,
|
||||
payload={
|
||||
"order_id": order_id,
|
||||
"booking_id": order_id,
|
||||
"customer": order_data.get("customer_name"),
|
||||
"priority": category.get("priority"),
|
||||
"zone": category.get("zone"),
|
||||
"validation_status": validation_result.get("status"),
|
||||
},
|
||||
correlation_id=order_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "received",
|
||||
"order_id": order_id,
|
||||
"validation": validation_result,
|
||||
"category": category,
|
||||
}
|
||||
|
||||
async def _validate_order(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
|
||||
order_data = await self._api_get(f"/api/v1/admin/crmbooking/{order_id}")
|
||||
if not order_data:
|
||||
return {"status": "error", "message": f"Order {order_id} not found"}
|
||||
|
||||
validation = await self._validate_order_data(order_data)
|
||||
|
||||
if validation.get("status") == "valid":
|
||||
await self._api_patch(
|
||||
f"/api/v1/admin/crmbooking/{order_id}",
|
||||
{"status": OrderStatus.VALIDATED.value, "validation": validation},
|
||||
)
|
||||
|
||||
return validation
|
||||
|
||||
async def _categorize_order(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
order_data = await self._api_get(f"/api/v1/admin/crmbooking/{order_id}")
|
||||
if not order_data:
|
||||
return {"status": "error", "message": f"Order {order_id} not found"}
|
||||
return self._categorize_order_data(order_data)
|
||||
|
||||
async def _update_status(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
new_status = task.data.get("status")
|
||||
|
||||
result = await self._api_patch(
|
||||
f"/api/v1/admin/crmbooking/{order_id}",
|
||||
{"status": new_status, "status_updated_at": datetime.now().isoformat()},
|
||||
)
|
||||
if not result:
|
||||
return {"status": "error", "message": f"Failed to update order {order_id}"}
|
||||
|
||||
logger.info(f"Order {order_id} status -> {new_status}")
|
||||
|
||||
await self.send_message(
|
||||
recipient="CUSTOMER_AGENT",
|
||||
message_type=MessageType.ORDER_STATUS_UPDATE,
|
||||
payload={"order_id": order_id, "new_status": new_status},
|
||||
)
|
||||
|
||||
return {"status": "updated", "order_id": order_id, "new_status": new_status}
|
||||
|
||||
async def _cancel_order(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
reason = task.data.get("reason", "Customer requested cancellation")
|
||||
|
||||
result = await self._api_patch(
|
||||
f"/api/v1/admin/crmbooking/{order_id}",
|
||||
{
|
||||
"status": OrderStatus.CANCELLED.value,
|
||||
"cancellation_reason": reason,
|
||||
"cancelled_at": datetime.now().isoformat(),
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
return {"status": "error", "message": f"Failed to cancel order {order_id}"}
|
||||
|
||||
logger.info(f"Order {order_id} cancelled: {reason}")
|
||||
|
||||
await self.broadcast(
|
||||
message_type=MessageType.ORDER_CANCELLED,
|
||||
payload={"order_id": order_id, "reason": reason},
|
||||
correlation_id=order_id,
|
||||
)
|
||||
|
||||
return {"status": "cancelled", "order_id": order_id, "reason": reason}
|
||||
|
||||
async def _get_order(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
order_data = await self._api_get(f"/api/v1/admin/crmbooking/{order_id}")
|
||||
if not order_data:
|
||||
return {"status": "error", "message": f"Order {order_id} not found"}
|
||||
return order_data
|
||||
|
||||
async def _list_orders(self, task: AgentTask) -> Dict[str, Any]:
|
||||
params = {}
|
||||
if task.data.get("status"):
|
||||
params["status"] = task.data["status"]
|
||||
if task.data.get("priority"):
|
||||
params["priority"] = task.data["priority"]
|
||||
|
||||
result = await self._api_get("/api/v1/admin/crmbooking", params=params)
|
||||
if not result:
|
||||
return {"total": 0, "orders": []}
|
||||
|
||||
orders = result if isinstance(result, list) else result.get("data", [])
|
||||
return {"total": len(orders), "orders": orders}
|
||||
|
||||
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": f"Unknown task type: {task.task_type}"}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Validation / categorization (local, no DB) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _validate_order_data(self, order_data: Dict) -> Dict[str, Any]:
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
for field in self._validation_rules["required_fields"]:
|
||||
if field not in order_data or not order_data[field]:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
phone = order_data.get("customer_phone", "")
|
||||
if phone and not self._is_valid_phone(phone):
|
||||
errors.append("Invalid phone number format")
|
||||
|
||||
email = order_data.get("customer_email")
|
||||
if email and not self._is_valid_email(email):
|
||||
warnings.append("Invalid email format")
|
||||
|
||||
pickup_pincode = order_data.get("pickup_address", {}).get("pincode", "")
|
||||
if not self._is_valid_pincode(pickup_pincode):
|
||||
errors.append("Invalid pickup pincode")
|
||||
|
||||
delivery_pincode = order_data.get("delivery_address", {}).get("pincode", "")
|
||||
if not self._is_valid_pincode(delivery_pincode):
|
||||
errors.append("Invalid delivery pincode")
|
||||
|
||||
items = order_data.get("items", [])
|
||||
if not items:
|
||||
errors.append("No items in order")
|
||||
elif len(items) > self._validation_rules["max_items_per_order"]:
|
||||
errors.append(f"Too many items (max {self._validation_rules['max_items_per_order']})")
|
||||
|
||||
total_weight = sum(item.get("weight", 0) for item in items)
|
||||
if total_weight > self._validation_rules["max_weight_kg"]:
|
||||
warnings.append(f"Heavy order ({total_weight}kg) - may require special handling")
|
||||
|
||||
return {
|
||||
"status": "valid" if not errors else "invalid",
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"total_weight_kg": total_weight,
|
||||
}
|
||||
|
||||
def _categorize_order_data(self, order_data: Dict) -> Dict[str, Any]:
|
||||
pickup_pincode = order_data.get("pickup_address", {}).get("pincode", "")[:3]
|
||||
delivery_pincode = order_data.get("delivery_address", {}).get("pincode", "")[:3]
|
||||
|
||||
if pickup_pincode == delivery_pincode:
|
||||
zone_type = ZoneType.LAST_MILE.value
|
||||
else:
|
||||
pickup_zone = self._pincode_zones.get(pickup_pincode, {})
|
||||
delivery_zone = self._pincode_zones.get(delivery_pincode, {})
|
||||
if pickup_zone.get("zone") == delivery_zone.get("zone"):
|
||||
zone_type = ZoneType.HUB_TO_SPOKE.value
|
||||
else:
|
||||
zone_type = ZoneType.HUB_TO_HUB.value
|
||||
|
||||
priority = order_data.get("priority", Priority.MEDIUM.value)
|
||||
total_weight = sum(item.get("weight", 0) for item in order_data.get("items", []))
|
||||
if total_weight > 50 or order_data.get("special_instructions"):
|
||||
priority = Priority.HIGH.value
|
||||
|
||||
pickup_info = self._pincode_zones.get(pickup_pincode, {"zone": "unknown", "region": "unknown"})
|
||||
|
||||
return {
|
||||
"priority": priority,
|
||||
"zone_type": zone_type,
|
||||
"pickup_zone": pickup_info.get("zone"),
|
||||
"delivery_zone": self._pincode_zones.get(delivery_pincode, {}).get("zone"),
|
||||
"pickup_region": pickup_info.get("region"),
|
||||
"estimated_hubs": self._get_nearest_hubs(pickup_pincode),
|
||||
"requires_cold_chain": self._check_cold_chain_requirement(order_data),
|
||||
"is_fragile": self._check_fragile_items(order_data),
|
||||
}
|
||||
|
||||
def _get_nearest_hubs(self, pincode: str) -> List[str]:
|
||||
zone = self._pincode_zones.get(pincode[:3], {})
|
||||
hub_prefix = zone.get("hub_prefix", "XX")
|
||||
return [f"{hub_prefix}-HUB-01", f"{hub_prefix}-HUB-02"]
|
||||
|
||||
def _check_cold_chain_requirement(self, order_data: Dict) -> bool:
|
||||
return any(item.get("requires_cold_chain") for item in order_data.get("items", []))
|
||||
|
||||
def _check_fragile_items(self, order_data: Dict) -> bool:
|
||||
return any(item.get("fragile") for item in order_data.get("items", []))
|
||||
|
||||
def _is_valid_phone(self, phone: str) -> bool:
|
||||
import re
|
||||
return bool(re.match(self._validation_rules["phone_pattern"], phone))
|
||||
|
||||
def _is_valid_email(self, email: str) -> bool:
|
||||
import re
|
||||
return bool(re.match(self._validation_rules["email_pattern"], email))
|
||||
|
||||
def _is_valid_pincode(self, pincode: str) -> bool:
|
||||
return len(pincode) == self._validation_rules["pincode_length"] and pincode.isdigit()
|
||||
|
||||
async def think(self, context: str, options: List[str] = None) -> str:
|
||||
return f"[ORDER_AGENT reasoning]: {context}"
|
||||
465
agents/route_optimizer_agent.py
Normal file
465
agents/route_optimizer_agent.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""Route Optimizer Agent - Optimizes delivery routes based on zones and available hubs."""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from time import monotonic
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from math import radians, cos, sin, asin, sqrt
|
||||
from collections import defaultdict
|
||||
|
||||
from core.agent import SpecializedAgent
|
||||
from core.types import AgentTask, MessageType, ZoneType
|
||||
from core.logger import logger
|
||||
|
||||
_CACHE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
@dataclass
|
||||
class Waypoint:
|
||||
location_id: str
|
||||
lat: float
|
||||
lng: float
|
||||
address: str
|
||||
type: str # pickup, delivery, hub, spoke
|
||||
order_id: Optional[str] = None
|
||||
time_window_start: Optional[datetime] = None
|
||||
time_window_end: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
route_id: str
|
||||
waypoints: List[Waypoint]
|
||||
total_distance_km: float
|
||||
estimated_duration_minutes: float
|
||||
vehicle_id: str
|
||||
zones_traversed: List[str]
|
||||
fuel_cost: float
|
||||
efficiency_score: float
|
||||
|
||||
|
||||
class RouteOptimizerAgent(SpecializedAgent):
|
||||
"""Route Optimizer Agent - Optimizes delivery routes based on zones, traffic, and constraints."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
agent_id="ROUTE_OPTIMIZER",
|
||||
domain="route_optimization",
|
||||
description="Optimizes delivery routes based on zones, hubs, and constraints"
|
||||
)
|
||||
|
||||
self._hubs = {
|
||||
"DL-HUB-01": (28.6139, 77.2090),
|
||||
"DL-HUB-02": (28.5355, 77.2100),
|
||||
"MU-HUB-01": (19.0760, 72.8777),
|
||||
"MU-HUB-02": (19.1650, 72.8500),
|
||||
"BL-HUB-01": (12.9716, 77.5946),
|
||||
"HY-HUB-01": (17.3850, 78.4867),
|
||||
"PU-HUB-01": (18.5204, 73.8567),
|
||||
"KL-HUB-01": (22.5726, 88.3639),
|
||||
}
|
||||
|
||||
self._zones = self._init_zones()
|
||||
# Cache stores (Route, created_at_monotonic) — evicted after _CACHE_TTL_SECONDS
|
||||
self._route_cache: Dict[str, Tuple[Route, float]] = {}
|
||||
self._traffic_patterns = self._init_traffic_patterns()
|
||||
self._route_history: List[Dict] = []
|
||||
|
||||
def _init_zones(self) -> Dict[str, Dict]:
|
||||
return {
|
||||
"north_delhi": {"pincode_range": ("100", "199"), "center": (28.6139, 77.2090), "hub": "DL-HUB-01", "typical_traffic": "medium"},
|
||||
"south_delhi": {"pincode_range": ("200", "299"), "center": (28.5355, 77.2100), "hub": "DL-HUB-02", "typical_traffic": "high"},
|
||||
"mumbai_west": {"pincode_range": ("400", "449"), "center": (19.0760, 72.8777), "hub": "MU-HUB-01", "typical_traffic": "high"},
|
||||
"mumbai_east": {"pincode_range": ("450", "499"), "center": (19.1650, 72.8500), "hub": "MU-HUB-02", "typical_traffic": "medium"},
|
||||
"bangalore": {"pincode_range": ("560", "562"), "center": (12.9716, 77.5946), "hub": "BL-HUB-01", "typical_traffic": "medium"},
|
||||
"hyderabad": {"pincode_range": ("500", "599"), "center": (17.3850, 78.4867), "hub": "HY-HUB-01", "typical_traffic": "medium"},
|
||||
"pune": {"pincode_range": ("400", "499"), "center": (18.5204, 73.8567), "hub": "PU-HUB-01", "typical_traffic": "medium"},
|
||||
"kolkata": {"pincode_range": ("600", "699"), "center": (22.5726, 88.3639), "hub": "KL-HUB-01", "typical_traffic": "low"},
|
||||
}
|
||||
|
||||
def _init_traffic_patterns(self) -> Dict[str, Dict]:
|
||||
return {
|
||||
"morning": {"multiplier": 1.2, "description": "7AM-10AM rush"},
|
||||
"midday": {"multiplier": 1.0, "description": "10AM-4PM normal"},
|
||||
"evening": {"multiplier": 1.5, "description": "4PM-8PM rush"},
|
||||
"night": {"multiplier": 0.8, "description": "8PM-7AM light"},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Cache helpers with TTL #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _cache_put(self, route_id: str, route: Route):
|
||||
self._route_cache[route_id] = (route, monotonic())
|
||||
|
||||
def _cache_get(self, route_id: str) -> Optional[Route]:
|
||||
entry = self._route_cache.get(route_id)
|
||||
if entry is None:
|
||||
return None
|
||||
route, ts = entry
|
||||
if monotonic() - ts >= _CACHE_TTL_SECONDS:
|
||||
del self._route_cache[route_id]
|
||||
return None
|
||||
return route
|
||||
|
||||
async def _heartbeat(self):
|
||||
"""Evict expired entries from route cache."""
|
||||
now = monotonic()
|
||||
expired = [rid for rid, (_, ts) in self._route_cache.items() if now - ts >= _CACHE_TTL_SECONDS]
|
||||
for rid in expired:
|
||||
del self._route_cache[rid]
|
||||
if expired:
|
||||
logger.debug(f"Route cache: evicted {len(expired)} expired entries ({len(self._route_cache)} remaining)")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Task dispatch #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
handlers = {
|
||||
"optimize_route": self._optimize_route,
|
||||
"plan_multi_stop": self._plan_multi_stop,
|
||||
"plan_inter_hub_route": self._plan_inter_hub_route,
|
||||
"calculate_eta": self._calculate_eta,
|
||||
"avoid_zone": self._avoid_zone,
|
||||
"reoptimize_route": self._reoptimize_route,
|
||||
"get_zone_routes": self._get_zone_routes,
|
||||
"batch_optimize": self._batch_optimize,
|
||||
}
|
||||
handler = handlers.get(task.task_type, self._unknown_task)
|
||||
return await handler(task)
|
||||
|
||||
async def _optimize_route(self, task: AgentTask) -> Dict[str, Any]:
|
||||
order_id = task.data.get("order_id")
|
||||
pickup = task.data.get("pickup", {})
|
||||
delivery = task.data.get("delivery", {})
|
||||
vehicle_type = task.data.get("vehicle_type", "van")
|
||||
|
||||
logger.info(f"Route Optimizer: Optimizing route for order {order_id}")
|
||||
|
||||
pickup_coords = (pickup.get("lat", 28.6139), pickup.get("lng", 77.2090))
|
||||
delivery_coords = (delivery.get("lat", 19.0760), delivery.get("lng", 72.8777))
|
||||
|
||||
direct_distance = self._haversine_distance(pickup_coords, delivery_coords)
|
||||
optimal_path = self._find_optimal_path(pickup_coords, delivery_coords)
|
||||
total_distance = self._calculate_total_distance(optimal_path)
|
||||
traffic_multiplier = self._get_traffic_multiplier()
|
||||
estimated_time = (total_distance / 30) * traffic_multiplier * 60
|
||||
|
||||
route_id = f"RT-OPT-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
waypoints = []
|
||||
for i, coords in enumerate(optimal_path):
|
||||
hub_id = self._find_nearest_hub(coords)
|
||||
waypoints.append(Waypoint(
|
||||
location_id=f"WPT-{i}",
|
||||
lat=coords[0],
|
||||
lng=coords[1],
|
||||
address=str(self._hubs.get(hub_id, ("Unknown",))[0]) if hub_id else "Route point",
|
||||
type="hub" if 0 < i < len(optimal_path) - 1 else ("pickup" if i == 0 else "delivery"),
|
||||
order_id=order_id,
|
||||
))
|
||||
|
||||
route = Route(
|
||||
route_id=route_id,
|
||||
waypoints=waypoints,
|
||||
total_distance_km=total_distance,
|
||||
estimated_duration_minutes=estimated_time,
|
||||
vehicle_id=task.data.get("vehicle_id", ""),
|
||||
zones_traversed=self._identify_zones(optimal_path),
|
||||
fuel_cost=total_distance * 3.5,
|
||||
efficiency_score=self._calculate_efficiency(total_distance, direct_distance),
|
||||
)
|
||||
|
||||
self._cache_put(route_id, route)
|
||||
|
||||
logger.info(
|
||||
f"Route {route_id}: {len(waypoints)} waypoints | {total_distance:.1f} km | "
|
||||
f"ETA {estimated_time:.0f} min | efficiency {route.efficiency_score:.0f}%"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "optimized",
|
||||
"route_id": route_id,
|
||||
"waypoints": [{"lat": w.lat, "lng": w.lng, "type": w.type, "address": w.address} for w in waypoints],
|
||||
"total_distance_km": total_distance,
|
||||
"estimated_duration_minutes": estimated_time,
|
||||
"zones_traversed": route.zones_traversed,
|
||||
"fuel_cost": route.fuel_cost,
|
||||
"efficiency_score": route.efficiency_score,
|
||||
}
|
||||
|
||||
async def _plan_multi_stop(self, task: AgentTask) -> Dict[str, Any]:
|
||||
stops = task.data.get("stops", [])
|
||||
vehicle_id = task.data.get("vehicle_id")
|
||||
|
||||
logger.info(f"Route Optimizer: Planning multi-stop route with {len(stops)} stops")
|
||||
|
||||
waypoints = [
|
||||
Waypoint(
|
||||
location_id=f"STOP-{i}",
|
||||
lat=stop.get("lat"),
|
||||
lng=stop.get("lng"),
|
||||
address=stop.get("address", ""),
|
||||
type=stop.get("type", "delivery"),
|
||||
order_id=stop.get("order_id"),
|
||||
)
|
||||
for i, stop in enumerate(stops)
|
||||
]
|
||||
|
||||
optimized_order = self._nearest_neighbor_optimization(waypoints)
|
||||
total_distance = self._calculate_route_distance(optimized_order)
|
||||
estimated_time = (total_distance / 25) * 60
|
||||
|
||||
route_id = f"RT-MULTI-{uuid.uuid4().hex[:8].upper()}"
|
||||
route = Route(
|
||||
route_id=route_id,
|
||||
waypoints=optimized_order,
|
||||
total_distance_km=total_distance,
|
||||
estimated_duration_minutes=estimated_time,
|
||||
vehicle_id=vehicle_id,
|
||||
zones_traversed=self._identify_zones([(w.lat, w.lng) for w in optimized_order]),
|
||||
fuel_cost=total_distance * 3.5,
|
||||
efficiency_score=85.0,
|
||||
)
|
||||
self._cache_put(route_id, route)
|
||||
|
||||
return {
|
||||
"status": "planned",
|
||||
"route_id": route_id,
|
||||
"stop_order": [{"order": i + 1, "lat": w.lat, "lng": w.lng, "type": w.type} for i, w in enumerate(optimized_order)],
|
||||
"total_distance_km": total_distance,
|
||||
"estimated_duration_minutes": estimated_time,
|
||||
}
|
||||
|
||||
async def _plan_inter_hub_route(self, task: AgentTask) -> Dict[str, Any]:
|
||||
from_hub = task.data.get("from_hub")
|
||||
to_hub = task.data.get("to_hub")
|
||||
order_id = task.data.get("order_id")
|
||||
|
||||
logger.info(f"Route Optimizer: Inter-hub route {from_hub} -> {to_hub}")
|
||||
|
||||
if from_hub not in self._hubs or to_hub not in self._hubs:
|
||||
return {"status": "error", "message": "Invalid hub ID(s)"}
|
||||
|
||||
from_coords = self._hubs[from_hub]
|
||||
to_coords = self._hubs[to_hub]
|
||||
direct_distance = self._haversine_distance(from_coords, to_coords)
|
||||
|
||||
intermediate_hub = None
|
||||
if direct_distance > 500:
|
||||
intermediate_hub = self._find_intermediate_hub(from_coords, to_coords)
|
||||
|
||||
route_coords = (
|
||||
[from_coords, self._hubs[intermediate_hub], to_coords]
|
||||
if intermediate_hub else
|
||||
[from_coords, to_coords]
|
||||
)
|
||||
total_distance = self._calculate_total_distance(route_coords)
|
||||
estimated_time = (total_distance / 40) * 60
|
||||
|
||||
route_id = f"RT-IHUB-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
return {
|
||||
"status": "planned",
|
||||
"route_id": route_id,
|
||||
"from_hub": from_hub,
|
||||
"to_hub": to_hub,
|
||||
"intermediate_hub": intermediate_hub,
|
||||
"waypoints": [{"hub": h, "coords": self._hubs.get(h, (0, 0))} for h in [from_hub, intermediate_hub, to_hub] if h],
|
||||
"total_distance_km": total_distance,
|
||||
"estimated_duration_minutes": estimated_time,
|
||||
"estimated_hours": estimated_time / 60,
|
||||
}
|
||||
|
||||
async def _calculate_eta(self, task: AgentTask) -> Dict[str, Any]:
|
||||
route_id = task.data.get("route_id")
|
||||
current_location = task.data.get("current_location")
|
||||
|
||||
cached = self._cache_get(route_id)
|
||||
if cached:
|
||||
return {
|
||||
"route_id": route_id,
|
||||
"total_eta_minutes": cached.estimated_duration_minutes,
|
||||
"remaining_distance_km": cached.total_distance_km,
|
||||
"current_eta": (datetime.now() + timedelta(minutes=cached.estimated_duration_minutes)).isoformat(),
|
||||
}
|
||||
|
||||
from_coords = (current_location.get("lat", 0), current_location.get("lng", 0))
|
||||
to_coords = task.data.get("destination", (0, 0))
|
||||
distance = self._haversine_distance(from_coords, to_coords)
|
||||
eta_minutes = (distance / 30) * self._get_traffic_multiplier() * 60
|
||||
|
||||
return {
|
||||
"distance_km": distance,
|
||||
"eta_minutes": eta_minutes,
|
||||
"current_eta": (datetime.now() + timedelta(minutes=eta_minutes)).isoformat(),
|
||||
}
|
||||
|
||||
async def _avoid_zone(self, task: AgentTask) -> Dict[str, Any]:
|
||||
route_id = task.data.get("route_id")
|
||||
avoid_zone = task.data.get("zone")
|
||||
|
||||
logger.info(f"Route Optimizer: Avoiding zone {avoid_zone}")
|
||||
|
||||
if self._cache_get(route_id):
|
||||
return {
|
||||
"status": "replanned",
|
||||
"route_id": route_id,
|
||||
"avoided_zone": avoid_zone,
|
||||
"additional_distance_km": 5.0,
|
||||
"additional_time_minutes": 15,
|
||||
}
|
||||
|
||||
return {"status": "error", "message": "Route not found"}
|
||||
|
||||
async def _reoptimize_route(self, task: AgentTask) -> Dict[str, Any]:
|
||||
route_id = task.data.get("route_id")
|
||||
new_stops = task.data.get("new_stops", [])
|
||||
|
||||
logger.info(f"Route Optimizer: Reoptimizing route {route_id}")
|
||||
|
||||
route = self._cache_get(route_id)
|
||||
if route:
|
||||
for stop in new_stops:
|
||||
route.waypoints.append(Waypoint(
|
||||
location_id=f"NEW-{len(route.waypoints)}",
|
||||
lat=stop.get("lat"),
|
||||
lng=stop.get("lng"),
|
||||
address=stop.get("address", ""),
|
||||
type="add_delivery",
|
||||
order_id=stop.get("order_id"),
|
||||
))
|
||||
coords = [(w.lat, w.lng) for w in route.waypoints]
|
||||
route.total_distance_km = self._calculate_total_distance(coords)
|
||||
route.estimated_duration_minutes = (route.total_distance_km / 25) * 60
|
||||
self._cache_put(route_id, route)
|
||||
|
||||
return {
|
||||
"status": "reoptimized",
|
||||
"route_id": route_id,
|
||||
"new_distance_km": route.total_distance_km,
|
||||
"new_eta_minutes": route.estimated_duration_minutes,
|
||||
}
|
||||
|
||||
return {"status": "error", "message": "Route not found"}
|
||||
|
||||
async def _get_zone_routes(self, task: AgentTask) -> Dict[str, Any]:
|
||||
zone = task.data.get("zone")
|
||||
now = monotonic()
|
||||
|
||||
zone_routes = []
|
||||
for route_id, (route, ts) in list(self._route_cache.items()):
|
||||
if now - ts >= _CACHE_TTL_SECONDS:
|
||||
continue
|
||||
if zone in route.zones_traversed:
|
||||
zone_routes.append({
|
||||
"route_id": route.route_id,
|
||||
"distance_km": route.total_distance_km,
|
||||
"duration_minutes": route.estimated_duration_minutes,
|
||||
})
|
||||
|
||||
return {"zone": zone, "total_routes": len(zone_routes), "routes": zone_routes}
|
||||
|
||||
async def _batch_optimize(self, task: AgentTask) -> Dict[str, Any]:
|
||||
orders = task.data.get("orders", [])
|
||||
logger.info(f"Route Optimizer: Batch optimizing {len(orders)} orders")
|
||||
|
||||
zone_groups: Dict[str, list] = defaultdict(list)
|
||||
for order in orders:
|
||||
zone = self._identify_zone_from_coords((order.get("lat", 0), order.get("lng", 0)))
|
||||
zone_groups[zone].append(order)
|
||||
|
||||
results = [await self._optimize_zone_routes(zone, zone_orders) for zone, zone_orders in zone_groups.items()]
|
||||
|
||||
return {
|
||||
"status": "batch_optimized",
|
||||
"zones_optimized": len(results),
|
||||
"total_orders": len(orders),
|
||||
"total_distance_km": sum(r["total_distance_km"] for r in results),
|
||||
"total_time_minutes": sum(r["estimated_time_minutes"] for r in results),
|
||||
"zone_results": results,
|
||||
}
|
||||
|
||||
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Geometry helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _haversine_distance(self, coord1: Tuple[float, float], coord2: Tuple[float, float]) -> float:
|
||||
lat1, lon1 = coord1
|
||||
lat2, lon2 = coord2
|
||||
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
||||
return 2 * asin(sqrt(a)) * 6371
|
||||
|
||||
def _find_optimal_path(self, start: Tuple[float, float], end: Tuple[float, float]) -> List[Tuple[float, float]]:
|
||||
start_hub = self._find_nearest_hub(start)
|
||||
end_hub = self._find_nearest_hub(end)
|
||||
if start_hub != end_hub:
|
||||
return [start, self._hubs[start_hub], self._hubs[end_hub], end]
|
||||
return [start, end]
|
||||
|
||||
def _find_nearest_hub(self, coords: Tuple[float, float]) -> Optional[str]:
|
||||
return min(self._hubs.keys(), key=lambda h: self._haversine_distance(coords, self._hubs[h]), default=None)
|
||||
|
||||
def _find_intermediate_hub(self, start: Tuple[float, float], end: Tuple[float, float]) -> Optional[str]:
|
||||
mid = ((start[0] + end[0]) / 2, (start[1] + end[1]) / 2)
|
||||
return self._find_nearest_hub(mid)
|
||||
|
||||
def _calculate_total_distance(self, coords: List[Tuple[float, float]]) -> float:
|
||||
return sum(self._haversine_distance(coords[i], coords[i + 1]) for i in range(len(coords) - 1))
|
||||
|
||||
def _calculate_route_distance(self, waypoints: List[Waypoint]) -> float:
|
||||
return self._calculate_total_distance([(w.lat, w.lng) for w in waypoints])
|
||||
|
||||
def _get_traffic_multiplier(self) -> float:
|
||||
hour = datetime.now().hour
|
||||
if 7 <= hour < 10:
|
||||
return self._traffic_patterns["morning"]["multiplier"]
|
||||
if 10 <= hour < 16:
|
||||
return self._traffic_patterns["midday"]["multiplier"]
|
||||
if 16 <= hour < 20:
|
||||
return self._traffic_patterns["evening"]["multiplier"]
|
||||
return self._traffic_patterns["night"]["multiplier"]
|
||||
|
||||
def _identify_zones(self, coords: List[Tuple[float, float]]) -> List[str]:
|
||||
return list({self._identify_zone_from_coords(c) for c in coords if self._identify_zone_from_coords(c)})
|
||||
|
||||
def _identify_zone_from_coords(self, coords: Tuple[float, float]) -> str:
|
||||
return min(self._zones.keys(), key=lambda z: self._haversine_distance(coords, self._zones[z]["center"]), default="unknown")
|
||||
|
||||
def _calculate_efficiency(self, actual_distance: float, direct_distance: float) -> float:
|
||||
if direct_distance == 0:
|
||||
return 100.0
|
||||
return min(100.0, (direct_distance / actual_distance) * 100)
|
||||
|
||||
def _nearest_neighbor_optimization(self, waypoints: List[Waypoint]) -> List[Waypoint]:
|
||||
if not waypoints:
|
||||
return []
|
||||
unvisited = waypoints[1:]
|
||||
ordered = [waypoints[0]]
|
||||
while unvisited:
|
||||
current = ordered[-1]
|
||||
nearest = min(unvisited, key=lambda w: self._haversine_distance((current.lat, current.lng), (w.lat, w.lng)))
|
||||
ordered.append(nearest)
|
||||
unvisited.remove(nearest)
|
||||
return ordered
|
||||
|
||||
async def _optimize_zone_routes(self, zone: str, orders: List[Dict]) -> Dict[str, Any]:
|
||||
total_distance = 0.0
|
||||
total_time = 0.0
|
||||
for i in range(0, len(orders), 5):
|
||||
batch = orders[i:i + 5]
|
||||
coords = [(o.get("lat", 0), o.get("lng", 0)) for o in batch]
|
||||
dist = self._calculate_total_distance(coords)
|
||||
total_distance += dist
|
||||
total_time += (dist / 25) * 60
|
||||
return {"zone": zone, "orders_in_zone": len(orders), "total_distance_km": total_distance, "estimated_time_minutes": total_time}
|
||||
|
||||
async def think(self, context: str, options: List[str] = None) -> str:
|
||||
return f"[ROUTE_OPTIMIZER reasoning]: {context}"
|
||||
Reference in New Issue
Block a user