From f49193ee7337505fb22b8a7896f57f62a27495bd Mon Sep 17 00:00:00 2001 From: Suriya Date: Fri, 26 Jun 2026 16:08:31 +0530 Subject: [PATCH] Initial commit --- .env | 11 + .gitignore | 9 + README.md | 245 +++++++++++++ agents/__init__.py | 18 + agents/customer_agent.py | 407 +++++++++++++++++++++ agents/dispatch_agent.py | 379 ++++++++++++++++++++ agents/exception_agent.py | 607 ++++++++++++++++++++++++++++++++ agents/fleet_agent.py | 332 +++++++++++++++++ agents/hub_agent.py | 460 ++++++++++++++++++++++++ agents/order_agent.py | 337 ++++++++++++++++++ agents/route_optimizer_agent.py | 465 ++++++++++++++++++++++++ config/system_config.py | 285 +++++++++++++++ core/__init__.py | 24 ++ core/agent.py | 273 ++++++++++++++ core/http_client.py | 72 ++++ core/logger.py | 27 ++ core/message_bus.py | 299 ++++++++++++++++ core/types.py | 210 +++++++++++ customer_portal/portal.py | 391 ++++++++++++++++++++ dashboard/admin_dashboard.py | 476 +++++++++++++++++++++++++ doormile_test.py | 493 ++++++++++++++++++++++++++ main.py | 206 +++++++++++ requirements.txt | 30 ++ 23 files changed, 6056 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 agents/__init__.py create mode 100644 agents/customer_agent.py create mode 100644 agents/dispatch_agent.py create mode 100644 agents/exception_agent.py create mode 100644 agents/fleet_agent.py create mode 100644 agents/hub_agent.py create mode 100644 agents/order_agent.py create mode 100644 agents/route_optimizer_agent.py create mode 100644 config/system_config.py create mode 100644 core/__init__.py create mode 100644 core/agent.py create mode 100644 core/http_client.py create mode 100644 core/logger.py create mode 100644 core/message_bus.py create mode 100644 core/types.py create mode 100644 customer_portal/portal.py create mode 100644 dashboard/admin_dashboard.py create mode 100644 doormile_test.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..e71779d --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +GO_API_BASE_URL=https://api.doormile.com +NATS_URL=nats://doormile:Package@321#@66.116.226.161:4223 +REDIS_HOST=66.116.226.255 +REDIS_PORT=6380 +REDIS_PASSWORD=Package@321# +INTERNAL_API_KEY=doormile-internal-2024 +DB_HOST=31.97.228.132 +DB_PORT=5433 +DB_NAME=logistics +DB_USER=admin +DB_PASSWORD=Package@321# diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ebcdd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ + +__pycache__/ +*.pyc +logs/ +.venv/ +venv/ + +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f0b3cc --- /dev/null +++ b/README.md @@ -0,0 +1,245 @@ +# πŸ€– LogiFlow AI - Autonomous Logistics Agent System + +A complete multi-agent AI system for autonomous logistics operations. Built with Python, featuring 8 intelligent agents coordinated by a master orchestrator (JARVIS). + +## πŸ—οΈ Architecture + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ 🧠 JARVIS (Master Agent) β”‚ + β”‚ Central orchestrator & decision maker β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ORDER_AGENT β”‚ β”‚ DISPATCH_AGENT β”‚ β”‚ FLEET_AGENT β”‚ +β”‚ ─────────────│ β”‚ ────────────────│ β”‚ ────────────────│ +β”‚ β€’ Receive β”‚ β”‚ β€’ Analyze zones β”‚ β”‚ β€’ Vehicle statusβ”‚ +β”‚ β€’ Validate β”‚ β”‚ β€’ Assign routes β”‚ β”‚ β€’ Capacity mgmt β”‚ +β”‚ β€’ Categorize β”‚ β”‚ β€’ Route opt. β”‚ β”‚ β€’ Maintenance β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ HUB_AGENT β”‚ β”‚ CUSTOMER_AGENT β”‚ β”‚ EXCEPTION_AGENT β”‚ +β”‚ ─────────────│ β”‚ ────────────────│ β”‚ ────────────────│ +β”‚ β€’ Hub routing β”‚ β”‚ β€’ Notifications β”‚ β”‚ β€’ Delay handlingβ”‚ +β”‚ β€’ Transit mgmtβ”‚ β”‚ β€’ Tracking β”‚ β”‚ β€’ Rescheduling β”‚ +β”‚ β€’ Capacity β”‚ β”‚ β€’ Feedback β”‚ β”‚ β€’ Cancellations β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ROUTE_OPTIMIZER β”‚ + β”‚ ────────────────│ + β”‚ β€’ Zone planning β”‚ + β”‚ β€’ Pathfinding β”‚ + β”‚ β€’ Traffic adapt β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## ✨ Features + +### πŸ€– Intelligent Agents + +| Agent | Function | Key Capabilities | +|-------|----------|-----------------| +| **JARVIS** | Master Orchestrator | Task delegation, system monitoring, decision making | +| **ORDER_AGENT** | Order Management | Receive, validate, categorize orders by priority/zone | +| **DISPATCH_AGENT** | Route Assignment | Zone analysis, hub assignment, route planning | +| **FLEET_AGENT** | Vehicle Management | Capacity tracking, vehicle assignment, maintenance | +| **HUB_AGENT** | Hub Operations | Transit management, capacity monitoring, overflow handling | +| **CUSTOMER_AGENT** | Customer Communication | Notifications, tracking updates, support | +| **EXCEPTION_AGENT** | Problem Resolution | Delay handling, cancellations, rescheduling | +| **ROUTE_OPTIMIZER** | Route Planning | Zone-based optimization, path calculation | + +### πŸ”„ Agent Communication Flow + +``` +Order Received β†’ ORDER_AGENT validates β†’ DISPATCH_AGENT assigns route + ↓ ↓ +FLEET_AGENT assigns vehicle β†’ HUB_AGENT prepares receiving + ↓ ↓ +ROUTE_OPTIMIZER calculates path β†’ CUSTOMER_AGENT sends notification + ↓ +EXCEPTION_AGENT monitors (handles any issues) + ↓ +JARVIS monitors all agents, logs decisions, reports to admin +``` + +## πŸš€ Quick Start + +### 1. Install Dependencies + +```bash +cd /workspace/project/logistics-ai +pip install -r requirements.txt +``` + +### 2. Run Demo Mode + +```bash +python main.py +``` + +This will: +- Initialize all 8 agents +- Run system diagnostics +- Process a sample order through all agents +- Display complete agent coordination + +### 3. Launch Admin Dashboard + +```bash +python main.py --dashboard +``` + +Opens Streamlit dashboard at `http://localhost:8501` with: +- Real-time agent status monitoring +- Order management +- Fleet tracking +- Hub network visualization +- Message feed + +### 4. Launch Customer Portal + +```bash +python main.py --portal +``` + +Opens customer-facing portal at `http://localhost:8502` with: +- Order tracking +- Live updates +- AI assistant chat +- Delivery scheduling + +## πŸ“ Project Structure + +``` +logistics-ai/ +β”œβ”€β”€ main.py # Main orchestration & demo +β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ core/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ types.py # Data models & message types +β”‚ β”œβ”€β”€ agent.py # Base Agent, MasterAgent classes +β”‚ └── message_bus.py # Agent communication bus +β”œβ”€β”€ agents/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ order_agent.py # Order validation & categorization +β”‚ β”œβ”€β”€ dispatch_agent.py # Route assignment & zone analysis +β”‚ β”œβ”€β”€ fleet_agent.py # Vehicle management +β”‚ β”œβ”€β”€ hub_agent.py # Hub operations & transit +β”‚ β”œβ”€β”€ customer_agent.py # Notifications & tracking +β”‚ β”œβ”€β”€ exception_agent.py # Problem resolution +β”‚ └── route_optimizer_agent.py # Route optimization +β”œβ”€β”€ dashboard/ +β”‚ └── admin_dashboard.py # Streamlit admin interface +β”œβ”€β”€ customer_portal/ +β”‚ └── portal.py # Streamlit customer interface +└── config/ + └── system_config.py # Configuration & zone definitions +``` + +## πŸ”§ Configuration + +Edit `config/system_config.py` to customize: + +- **Zones**: Pincode ranges and hub assignments +- **Hubs**: Capacity, processing rates, connections +- **Vehicles**: Types, capacities, fuel costs +- **SLA**: Delivery windows, response times +- **Notifications**: Templates for SMS/Email/WhatsApp + +## 🎯 Usage Examples + +### Create an Order via API + +```python +from main import LogiFlowAI + +system = LogiFlowAI() +system.initialize_agents() + +order = { + "customer_name": "Rajesh Kumar", + "customer_phone": "+919876543210", + "pickup_address": {"city": "Delhi", "pincode": "110001"}, + "delivery_address": {"city": "Mumbai", "pincode": "400001"}, + "items": [{"name": "Laptop", "weight": 2.5}] +} + +await system.create_order(order) +``` + +### Monitor System Status + +```python +status = system.get_system_status() +print(f"Active agents: {len(status['agents'])}") +print(f"System: {status['system']}") +``` + +## πŸ”Œ Integration Points + +### REST API (Future) +- `POST /orders` - Create new order +- `GET /orders/{id}` - Get order status +- `PATCH /orders/{id}` - Update order +- `GET /agents/status` - Get agent statuses +- `POST /agents/{id}/task` - Submit task to agent + +### Webhook Support (Future) +- Order created +- Order status changed +- Exception detected +- Delivery completed + +## πŸ“Š Monitoring + +The system provides: +- Real-time agent status +- Message traffic monitoring +- Performance metrics +- Exception alerts +- SLA compliance tracking + +## πŸ”’ Security + +- Agent authentication via tokens +- Message signing and verification +- Rate limiting on API endpoints +- Audit logging for all operations + +## πŸš€ Scaling + +The architecture supports: +- Horizontal agent scaling +- Multiple message bus instances +- Distributed hub networks +- Multi-region deployment + +## πŸ“ License + +MIT License - See LICENSE file + +## 🀝 Contributing + +Contributions welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Add tests for new features +4. Submit a pull request + +--- + +**Built with ❀️ for autonomous logistics operations** + + + + +Resume this session with: +claude --resume d1f8a432-2298-4ff5-bee4-15e64d04bfa4 +PS C:\Users\Admin\Downloads\logiflow-ai-logistics-agent-system\logistics-ai> diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000..fc7e208 --- /dev/null +++ b/agents/__init__.py @@ -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' +] \ No newline at end of file diff --git a/agents/customer_agent.py b/agents/customer_agent.py new file mode 100644 index 0000000..11d722b --- /dev/null +++ b/agents/customer_agent.py @@ -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}" diff --git a/agents/dispatch_agent.py b/agents/dispatch_agent.py new file mode 100644 index 0000000..cb42df3 --- /dev/null +++ b/agents/dispatch_agent.py @@ -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}" diff --git a/agents/exception_agent.py b/agents/exception_agent.py new file mode 100644 index 0000000..76dbdc9 --- /dev/null +++ b/agents/exception_agent.py @@ -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}" diff --git a/agents/fleet_agent.py b/agents/fleet_agent.py new file mode 100644 index 0000000..38b9a3c --- /dev/null +++ b/agents/fleet_agent.py @@ -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}" diff --git a/agents/hub_agent.py b/agents/hub_agent.py new file mode 100644 index 0000000..e404f11 --- /dev/null +++ b/agents/hub_agent.py @@ -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}" diff --git a/agents/order_agent.py b/agents/order_agent.py new file mode 100644 index 0000000..708c651 --- /dev/null +++ b/agents/order_agent.py @@ -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}" diff --git a/agents/route_optimizer_agent.py b/agents/route_optimizer_agent.py new file mode 100644 index 0000000..6f7b658 --- /dev/null +++ b/agents/route_optimizer_agent.py @@ -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}" diff --git a/config/system_config.py b/config/system_config.py new file mode 100644 index 0000000..f6a90c1 --- /dev/null +++ b/config/system_config.py @@ -0,0 +1,285 @@ +"""System configuration for LogiFlow AI.""" +import os + +# Infrastructure Connections β€” values come from environment / .env file. +# Hardcoded strings are last-resort defaults; set the matching env var in production. +NATS_URL = os.getenv("NATS_URL", "nats://doormile:Package@321#@66.116.226.161:4223") +NATS_HOST = os.getenv("NATS_HOST", "66.116.226.161") +NATS_PORT = int(os.getenv("NATS_PORT", "4223")) +NATS_USER = os.getenv("NATS_USER", "doormile") +NATS_PASSWORD = os.getenv("NATS_PASSWORD", "Package@321#") + +REDIS_HOST = os.getenv("REDIS_HOST", "66.116.226.255") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6380")) +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "Package@321#") + +# Postgres β€” individual params to avoid @ in password breaking DSN parsing +DB_HOST = os.getenv("DB_HOST", "31.97.228.132") +DB_PORT = int(os.getenv("DB_PORT", "5433")) +DB_NAME = os.getenv("DB_NAME", "logistics") +DB_USER = os.getenv("DB_USER", "admin") +DB_PASSWORD = os.getenv("DB_PASSWORD", "Package@321#") + +GO_API_BASE_URL = os.getenv("GO_API_BASE_URL", "http://localhost:8080") +INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "doormile-internal-2024") + +# Agent Configuration +AGENT_CONFIG = { + "jarvis": { + "name": "JARVIS", + "type": "master", + "description": "Central orchestrator for all logistics operations", + "monitoring_interval": 5, # seconds + "decision_threshold": 0.8 + }, + "order_agent": { + "name": "ORDER_AGENT", + "type": "specialized", + "description": "Handles order intake, validation, and categorization", + "max_concurrent_orders": 100, + "validation_timeout": 30 # seconds + }, + "dispatch_agent": { + "name": "DISPATCH_AGENT", + "type": "specialized", + "description": "Handles zone analysis, route assignment, and dispatch", + "zone_update_interval": 60, # seconds + "batch_size": 10 + }, + "fleet_agent": { + "name": "FLEET_AGENT", + "type": "specialized", + "description": "Manages vehicle fleet, capacity, and tracking", + "tracking_interval": 30, # seconds + "maintenance_check_interval": 3600 # 1 hour + }, + "hub_agent": { + "name": "HUB_AGENT", + "type": "specialized", + "description": "Manages hub operations, transit flow, and capacity", + "capacity_warning_threshold": 0.85, + "processing_timeout": 120 # seconds + }, + "customer_agent": { + "name": "CUSTOMER_AGENT", + "type": "specialized", + "description": "Handles notifications, tracking, and customer communication", + "notification_channels": ["sms", "email", "whatsapp", "push"], + "retry_attempts": 3 + }, + "exception_agent": { + "name": "EXCEPTION_AGENT", + "type": "specialized", + "description": "Handles delays, cancellations, rescheduling, and problems", + "sla_thresholds": { + "critical": 15, # minutes + "high": 30, + "medium": 60, + "low": 120 + } + }, + "route_optimizer": { + "name": "ROUTE_OPTIMIZER", + "type": "specialized", + "description": "Optimizes delivery routes based on zones and hubs", + "traffic_update_interval": 300, # 5 minutes + "cache_ttl": 600 # 10 minutes + } +} + +# Zone Definitions +ZONES = { + "north_delhi": { + "pincode_range": ("100", "199"), + "hub": "DL-HUB-01", + "coordinates": (28.6139, 77.2090), + "coverage": ["Delhi NCR North"] + }, + "south_delhi": { + "pincode_range": ("200", "299"), + "hub": "DL-HUB-02", + "coordinates": (28.5355, 77.2100), + "coverage": ["Delhi NCR South"] + }, + "mumbai_west": { + "pincode_range": ("400", "449"), + "hub": "MU-HUB-01", + "coordinates": (19.0760, 72.8777), + "coverage": ["Mumbai West", "Andheri", "Juhu"] + }, + "mumbai_east": { + "pincode_range": ("450", "499"), + "hub": "MU-HUB-02", + "coordinates": (19.1650, 72.8500), + "coverage": ["Mumbai East", "Thane", "Navi Mumbai"] + }, + "bangalore": { + "pincode_range": ("200", "299"), + "hub": "BL-HUB-01", + "coordinates": (12.9716, 77.5946), + "coverage": ["Bangalore", "Electronic City"] + }, + "hyderabad": { + "pincode_range": ("500", "599"), + "hub": "HY-HUB-01", + "coordinates": (17.3850, 78.4867), + "coverage": ["Hyderabad", "Hi-Tech City"] + }, + "pune": { + "pincode_range": ("400", "499"), + "hub": "PU-HUB-01", + "coordinates": (18.5204, 73.8567), + "coverage": ["Pune", "Hinjewadi"] + }, + "kolkata": { + "pincode_range": ("600", "699"), + "hub": "KL-HUB-01", + "coordinates": (22.5726, 88.3639), + "coverage": ["Kolkata", "Salt Lake"] + } +} + +# Hub Configuration +HUBS = { + "DL-HUB-01": { + "name": "Delhi North Hub", + "zone": "north_delhi", + "capacity": 500, + "processing_rate": 100, # orders per hour + "spokes": ["DL-SP-01", "DL-SP-02", "DL-SP-03"], + "connected_hubs": ["DL-HUB-02", "MU-HUB-01", "KL-HUB-01"] + }, + "DL-HUB-02": { + "name": "Delhi South Hub", + "zone": "south_delhi", + "capacity": 400, + "processing_rate": 80, + "spokes": ["DL-SP-04", "DL-SP-05"], + "connected_hubs": ["DL-HUB-01", "BL-HUB-01"] + }, + "MU-HUB-01": { + "name": "Mumbai West Hub", + "zone": "mumbai_west", + "capacity": 600, + "processing_rate": 120, + "spokes": ["MU-SP-01", "MU-SP-02", "MU-SP-03", "MU-SP-04"], + "connected_hubs": ["MU-HUB-02", "DL-HUB-01", "PU-HUB-01"] + }, + "MU-HUB-02": { + "name": "Mumbai East Hub", + "zone": "mumbai_east", + "capacity": 550, + "processing_rate": 90, + "spokes": ["MU-SP-05", "MU-SP-06"], + "connected_hubs": ["MU-HUB-01", "PU-HUB-01"] + }, + "BL-HUB-01": { + "name": "Bangalore Hub", + "zone": "bangalore", + "capacity": 500, + "processing_rate": 100, + "spokes": ["BL-SP-01", "BL-SP-02", "BL-SP-03"], + "connected_hubs": ["HY-HUB-01", "DL-HUB-02"] + }, + "HY-HUB-01": { + "name": "Hyderabad Hub", + "zone": "hyderabad", + "capacity": 450, + "processing_rate": 85, + "spokes": ["HY-SP-01", "HY-SP-02"], + "connected_hubs": ["BL-HUB-01", "KL-HUB-01"] + }, + "PU-HUB-01": { + "name": "Pune Hub", + "zone": "pune", + "capacity": 400, + "processing_rate": 75, + "spokes": ["PU-SP-01", "PU-SP-02"], + "connected_hubs": ["MU-HUB-01", "MU-HUB-02"] + }, + "KL-HUB-01": { + "name": "Kolkata Hub", + "zone": "kolkata", + "capacity": 350, + "processing_rate": 70, + "spokes": ["KL-SP-01", "KL-SP-02"], + "connected_hubs": ["DL-HUB-01", "HY-HUB-01"] + } +} + +# Vehicle Types +VEHICLE_TYPES = { + "bike": { + "capacity_kg": 15, + "capacity_vol": 0.5, # cubic meters + "max_speed_kmh": 50, + "fuel_cost_per_km": 2.0, + "suitable_for": ["last_mile", "small_packages"] + }, + "scooter": { + "capacity_kg": 20, + "capacity_vol": 0.7, + "max_speed_kmh": 45, + "fuel_cost_per_km": 1.5, + "suitable_for": ["last_mile", "documents"] + }, + "van": { + "capacity_kg": 500, + "capacity_vol": 8, + "max_speed_kmh": 80, + "fuel_cost_per_km": 3.5, + "suitable_for": ["hub_to_spoke", "medium_packages"] + }, + "truck": { + "capacity_kg": 2000, + "capacity_vol": 25, + "max_speed_kmh": 70, + "fuel_cost_per_km": 5.0, + "suitable_for": ["hub_to_hub", "heavy_cargo", "bulk"] + } +} + +# SLA Configuration +SLA_CONFIG = { + "delivery_windows": { + "express": 4, # hours + "standard": 24, # hours + "economy": 72 # hours + }, + "response_times": { + "critical_exception": 15, # minutes + "high_exception": 30, + "medium_exception": 60, + "low_exception": 120 + } +} + +# Notification Templates +NOTIFICATION_TEMPLATES = { + "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!\nπŸ“ Delivery by {eta}\nπŸ”— Track: {tracking_url}" + }, + "out_for_delivery": { + "sms": "Out for delivery! Driver: {driver_name}, Contact: {driver_phone}", + "email": "Your order is out for delivery!", + "whatsapp": "πŸƒ Out for delivery!\nDriver: {driver_name}\nπŸ“ž {driver_phone}" + }, + "delivered": { + "sms": "Order {order_id} delivered successfully! Thank you.", + "email": "Your order has been delivered! We hope you enjoy your purchase.", + "whatsapp": "βœ… Delivered!\nOrder {order_id}\nThank you!" + } +} + +# System Settings +SYSTEM_CONFIG = { + "max_concurrent_orders": 1000, + "message_queue_size": 10000, + "agent_heartbeat_interval": 30, # seconds + "cleanup_interval": 3600, # 1 hour + "log_retention_days": 30, + "enable_monitoring": True, + "enable_metrics": True +} \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..b7a3d7e --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,24 @@ +# Core module for LogiFlow AI +from core.agent import Agent, MasterAgent, SpecializedAgent +from core.message_bus import message_bus, MessageBus +from core.types import * + +__all__ = [ + 'Agent', + 'MasterAgent', + 'SpecializedAgent', + 'message_bus', + 'MessageBus', + 'AgentTask', + 'AgentMessage', + 'AgentState', + 'MessageType', + 'Priority', + 'OrderStatus', + 'ZoneType', + 'Order', + 'Hub', + 'Vehicle', + 'Address', + 'OrderItem' +] \ No newline at end of file diff --git a/core/agent.py b/core/agent.py new file mode 100644 index 0000000..f0bee02 --- /dev/null +++ b/core/agent.py @@ -0,0 +1,273 @@ +"""Base Agent class - All agents inherit from this.""" +import asyncio +import uuid +from datetime import datetime +from typing import Dict, List, Optional, Any +from abc import ABC, abstractmethod + +from core.types import AgentMessage, MessageType, AgentState, AgentTask, Priority +from core.message_bus import message_bus +from core.logger import logger + + +class Agent(ABC): + """ + Base class for all AI agents in the logistics system. + Each agent has its own task queue and can communicate via the message bus. + """ + + def __init__(self, agent_id: str, agent_type: str, description: str = ""): + self.agent_id = agent_id + self.agent_type = agent_type + self.description = description + self.state = AgentState( + agent_id=agent_id, + agent_type=agent_type, + status="idle" + ) + self._task_queue: asyncio.Queue = asyncio.Queue() + self._running = False + self._task_handlers: Dict[str, callable] = {} + + # Register with message bus + message_bus.register_agent(self) + + async def start(self): + """Start the agent's processing loop.""" + self._running = True + self.state.status = "idle" + logger.info(f"Agent started: {self.agent_id}") + + while self._running: + try: + try: + task = await asyncio.wait_for( + self._task_queue.get(), + timeout=1.0 + ) + await self._process_task(task) + except asyncio.TimeoutError: + await self._heartbeat() + + except Exception as e: + logger.error(f"Error in agent {self.agent_id}: {e}") + self.state.status = "error" + await asyncio.sleep(5) + + async def stop(self): + """Stop the agent.""" + self._running = False + self.state.status = "stopped" + logger.info(f"Agent stopped: {self.agent_id}") + + async def _process_task(self, task: AgentTask): + """Process a task from the queue.""" + self.state.status = "working" + self.state.current_task = task.task_id + + try: + if task.task_type in self._task_handlers: + handler = self._task_handlers[task.task_type] + result = await handler(task) + task.status = "completed" + task.result = result + else: + result = await self.handle_task(task) + task.status = "completed" + task.result = result + + self.state.tasks_completed += 1 + + except Exception as e: + logger.error(f"Task {task.task_id} failed in {self.agent_id}: {e}") + task.status = "failed" + task.error = str(e) + self.state.tasks_failed += 1 + + finally: + self.state.current_task = None + self.state.last_active = datetime.now() + self.state.status = "idle" + + @abstractmethod + async def handle_task(self, task: AgentTask) -> Dict[str, Any]: + """Override this method to handle tasks. Return a dict with results.""" + pass + + async def _heartbeat(self): + """Called periodically when idle. Override for custom behavior.""" + pass + + def register_task_handler(self, task_type: str, handler: callable): + self._task_handlers[task_type] = handler + + async def submit_task(self, task: AgentTask): + await self._task_queue.put(task) + + async def send_message(self, recipient: str, message_type: MessageType, payload: Dict[str, Any], correlation_id: Optional[str] = None) -> str: + return await message_bus.send_to_agent( + sender=self.agent_id, + recipient=recipient, + message_type=message_type, + payload=payload, + correlation_id=correlation_id + ) + + async def broadcast(self, message_type: MessageType, payload: Dict[str, Any], correlation_id: Optional[str] = None) -> str: + return await message_bus.broadcast( + sender=self.agent_id, + message_type=message_type, + payload=payload, + correlation_id=correlation_id + ) + + async def receive_messages(self) -> List[AgentMessage]: + return await message_bus.get_messages(self.agent_id) + + def subscribe_to(self, message_type: MessageType, callback: callable): + message_bus.subscribe(message_type, callback) + + async def think(self, context: str, options: List[str] = None) -> str: + return context + + +class MasterAgent(Agent): + """ + Master Agent (JARVIS) - Orchestrates all other agents. + Makes high-level decisions and assigns work. + """ + + def __init__(self): + super().__init__( + agent_id="JARVIS", + agent_type="master", + description="Central orchestrator for all logistics operations" + ) + self._sub_agents: Dict[str, Agent] = {} + self._active_orders: Dict[str, Dict] = {} + self._decision_log: List[Dict] = [] + + def register_sub_agent(self, agent: Agent): + self._sub_agents[agent.agent_id] = agent + logger.info(f"JARVIS registered sub-agent: {agent.agent_id}") + + async def handle_task(self, task: AgentTask) -> Dict[str, Any]: + if task.task_type == "orchestrate_order": + return await self._orchestrate_order(task) + elif task.task_type == "monitor_operations": + return await self._monitor_operations(task) + elif task.task_type == "handle_exception": + return await self._handle_exception(task) + elif task.task_type == "generate_report": + return await self._generate_report(task) + else: + return {"status": "unknown_task", "task_type": task.task_type} + + async def _orchestrate_order(self, task: AgentTask) -> Dict[str, Any]: + order_data = task.data.get("order", {}) + order_id = order_data.get("order_id", "unknown") + + logger.info(f"JARVIS: Orchestrating order {order_id}") + + order_agent = self._sub_agents.get("ORDER_AGENT") + if order_agent: + await order_agent.submit_task(AgentTask( + task_id=f"{order_id}_validate", + agent_type="order", + task_type="validate_order", + data={"order": order_data} + )) + + dispatch_agent = self._sub_agents.get("DISPATCH_AGENT") + if dispatch_agent: + await dispatch_agent.submit_task(AgentTask( + task_id=f"{order_id}_dispatch", + agent_type="dispatch", + task_type="analyze_and_assign", + data={"order": order_data} + )) + + customer_agent = self._sub_agents.get("CUSTOMER_AGENT") + if customer_agent: + await customer_agent.submit_task(AgentTask( + task_id=f"{order_id}_notify", + agent_type="customer", + task_type="send_confirmation", + data={"order": order_data} + )) + + self._decision_log.append({ + "timestamp": datetime.now(), + "action": "orchestrate_order", + "order_id": order_id, + "agents_involved": list(self._sub_agents.keys()) + }) + + return { + "status": "orchestrated", + "order_id": order_id, + "assigned_agents": list(self._sub_agents.keys()) + } + + async def _monitor_operations(self, task: AgentTask) -> Dict[str, Any]: + status_report = { + "timestamp": datetime.now().isoformat(), + "total_agents": len(self._sub_agents), + "agent_statuses": {}, + "active_orders": len(self._active_orders), + "recent_decisions": self._decision_log[-10:] + } + + for agent_id, agent in self._sub_agents.items(): + status_report["agent_statuses"][agent_id] = { + "status": agent.state.status, + "tasks_completed": agent.state.tasks_completed, + "tasks_failed": agent.state.tasks_failed, + "current_task": agent.state.current_task + } + + return status_report + + async def _handle_exception(self, task: AgentTask) -> Dict[str, Any]: + exception_type = task.data.get("type", "unknown") + logger.warning(f"JARVIS: Handling exception - {exception_type}") + + exception_agent = self._sub_agents.get("EXCEPTION_AGENT") + if exception_agent: + await exception_agent.submit_task(task) + return {"status": "delegated", "exception_type": exception_type} + + return {"status": "no_exception_agent"} + + async def _generate_report(self, task: AgentTask) -> Dict[str, Any]: + return { + "report_type": task.data.get("type", "summary"), + "generated_at": datetime.now().isoformat(), + "agents": [a.state for a in self._sub_agents.values()], + "decisions": self._decision_log[-50:] + } + + async def _heartbeat(self): + for agent_id, agent in self._sub_agents.items(): + if agent.state.status == "error": + logger.warning(f"Agent {agent_id} in error state") + + +class SpecializedAgent(Agent): + """Base class for specialized domain agents.""" + + def __init__(self, agent_id: str, domain: str, description: str): + super().__init__(agent_id, domain, description) + self._domain_knowledge: Dict[str, Any] = {} + self._learning_history: List[Dict] = [] + + async def think(self, context: str, options: List[str] = None) -> str: + return f"[{self.agent_type} reasoning]: {context}" + + def learn_from(self, experience: Dict[str, Any]): + self._learning_history.append({ + "timestamp": datetime.now(), + "experience": experience + }) + if len(self._learning_history) > 100: + self._learning_history = self._learning_history[-100:] diff --git a/core/http_client.py b/core/http_client.py new file mode 100644 index 0000000..f83e58d --- /dev/null +++ b/core/http_client.py @@ -0,0 +1,72 @@ +"""Shared aiohttp session with connection pooling and exponential-backoff retry.""" +import asyncio +from typing import Optional, Dict, Any + +import aiohttp + +from core.logger import logger + +_session: Optional[aiohttp.ClientSession] = None + + +def _new_session() -> aiohttp.ClientSession: + connector = aiohttp.TCPConnector(limit=100, limit_per_host=20, keepalive_timeout=30) + return aiohttp.ClientSession( + connector=connector, + timeout=aiohttp.ClientTimeout(total=30), + ) + + +async def get_session() -> aiohttp.ClientSession: + global _session + if _session is None or _session.closed: + _session = _new_session() + return _session + + +async def close_session(): + global _session + if _session and not _session.closed: + await _session.close() + _session = None + + +async def api_get(url: str, **kwargs) -> Optional[Dict[str, Any]]: + return await _request("get", url, **kwargs) + + +async def api_post(url: str, **kwargs) -> Optional[Dict[str, Any]]: + return await _request("post", url, **kwargs) + + +async def api_patch(url: str, **kwargs) -> Optional[Dict[str, Any]]: + return await _request("patch", url, **kwargs) + + +async def _request(method: str, url: str, **kwargs) -> Optional[Dict[str, Any]]: + """Execute an HTTP request with up to 3 attempts and exponential backoff.""" + max_retries = 3 + backoff = 1.0 + session = await get_session() + + for attempt in range(max_retries): + try: + async with getattr(session, method)(url, **kwargs) as resp: + if resp.content_type == "application/json": + return await resp.json() + return {"status_code": resp.status} + except aiohttp.ClientError as exc: + if attempt < max_retries - 1: + logger.warning( + f"{method.upper()} {url} attempt {attempt + 1} failed: {exc} " + f"β€” retrying in {backoff:.0f}s" + ) + await asyncio.sleep(backoff) + backoff *= 2 + else: + logger.error(f"{method.upper()} {url} failed after {max_retries} attempts: {exc}") + except Exception as exc: + logger.error(f"{method.upper()} {url} unexpected error: {exc}") + break + + return None diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000..806a74a --- /dev/null +++ b/core/logger.py @@ -0,0 +1,27 @@ +"""Structured logging for LogiFlow AI β€” single loguru setup imported by all modules.""" +import sys +import os +from pathlib import Path +from loguru import logger + +Path("logs").mkdir(exist_ok=True) + +logger.remove() + +logger.add( + sys.stderr, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level=os.getenv("LOG_LEVEL", "INFO"), + colorize=False, +) + +logger.add( + "logs/logiflow_{time:YYYY-MM-DD}.log", + rotation="100 MB", + retention="30 days", + compression="gz", + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", +) + +__all__ = ["logger"] diff --git a/core/message_bus.py b/core/message_bus.py new file mode 100644 index 0000000..6c34545 --- /dev/null +++ b/core/message_bus.py @@ -0,0 +1,299 @@ +"""Agent Communication Bus - backed by NATS JetStream with local fallback.""" +import asyncio +import json +import uuid +from datetime import datetime +from typing import Dict, List, Callable, Optional, Any +from collections import defaultdict +from dataclasses import dataclass +from enum import Enum + +import nats +import nats.js.errors + +from core.types import AgentMessage, MessageType +from core.logger import logger +from config.system_config import NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD + + +class QueuePriority(str, Enum): + HIGH = "high" + NORMAL = "normal" + LOW = "low" + + +@dataclass +class QueuedMessage: + message: AgentMessage + priority: QueuePriority = QueuePriority.NORMAL + retry_count: int = 0 + max_retries: int = 3 + + +class MessageBus: + """ + Central message bus backed by NATS JetStream. + + Call `await message_bus.connect()` once at startup before starting agents. + Falls back to in-process local dispatch when not connected. + """ + + def __init__(self): + self._nc = None # NATS client + self._js = None # JetStream context + self._nats_subs: list = [] # hold sub refs to prevent GC + + self._subscribers: Dict[MessageType, List[Callable]] = defaultdict(list) + self._queues: Dict[str, List[QueuedMessage]] = defaultdict(list) + self._agents: Dict[str, Any] = {} + self._history: List[AgentMessage] = [] + self._hooks: Dict[str, List[Callable]] = defaultdict(list) + self._lock = asyncio.Lock() + + # ------------------------------------------------------------------ # + # Connection # + # ------------------------------------------------------------------ # + + async def connect(self): + """Connect to NATS and create the logistics JetStream stream.""" + self._nc = await nats.connect( + servers=[f"nats://{NATS_HOST}:{NATS_PORT}"], + user=NATS_USER, + password=NATS_PASSWORD, + error_cb=self._on_nats_error, + reconnected_cb=self._on_reconnect, + max_reconnect_attempts=10, + ) + self._js = self._nc.jetstream() + + try: + await self._js.add_stream(name="logistics", subjects=["logistics.>"]) + logger.info("NATS stream 'logistics' created") + except nats.js.errors.BadRequestError: + pass # stream already exists + except Exception as e: + logger.warning(f"Could not create NATS stream: {e}") + + # Set up push subscriptions for all pre-registered message types + for msg_type in list(self._subscribers.keys()): + await self._setup_type_sub(msg_type) + + # Set up direct-message subscriptions for all pre-registered agents + for agent_id in list(self._agents.keys()): + await self._setup_agent_sub(agent_id) + + logger.info("MessageBus connected to NATS JetStream") + + async def _on_nats_error(self, err): + logger.warning(f"NATS error: {err}") + + async def _on_reconnect(self): + logger.info("NATS reconnected") + + async def disconnect(self): + """Drain and close NATS connection.""" + if self._nc: + await self._nc.drain() + + # ------------------------------------------------------------------ # + # Agent registry # + # ------------------------------------------------------------------ # + + def register_agent(self, agent): + self._agents[agent.agent_id] = agent + logger.debug(f"Agent registered: {agent.agent_id} ({agent.agent_type})") + if self._js is not None: + asyncio.create_task(self._setup_agent_sub(agent.agent_id)) + + def unregister_agent(self, agent_id: str): + self._agents.pop(agent_id, None) + logger.debug(f"Agent unregistered: {agent_id}") + + # ------------------------------------------------------------------ # + # Pub / Sub # + # ------------------------------------------------------------------ # + + def subscribe(self, message_type: MessageType, callback: Callable): + """Subscribe to a broadcast message type.""" + self._subscribers[message_type].append(callback) + if self._js is not None: + asyncio.create_task(self._setup_type_sub(message_type)) + + def unsubscribe(self, message_type: MessageType, callback: Callable): + if message_type in self._subscribers: + try: + self._subscribers[message_type].remove(callback) + except ValueError: + pass + + async def publish(self, message: AgentMessage): + """Publish a message β€” via NATS if connected, otherwise local dispatch.""" + async with self._lock: + self._history.append(message) + if len(self._history) > 1000: + self._history = self._history[-500:] + + if self._js is not None: + await self._nats_publish(message) + else: + await self._local_dispatch(message) + + await self._trigger_hook(f"on_{message.message_type.value}", message) + logger.debug(f"[{message.sender}] -> [{message.recipient}]: {message.message_type.value}") + + async def send_to_agent( + self, + sender: str, + recipient: str, + message_type: MessageType, + payload: Dict[str, Any], + correlation_id: Optional[str] = None, + ) -> str: + message_id = str(uuid.uuid4()) + message = AgentMessage( + message_id=message_id, + sender=sender, + recipient=recipient, + message_type=message_type, + payload=payload, + timestamp=datetime.now(), + correlation_id=correlation_id or message_id, + ) + await self.publish(message) + return message_id + + async def broadcast( + self, + sender: str, + message_type: MessageType, + payload: Dict[str, Any], + correlation_id: Optional[str] = None, + ) -> str: + return await self.send_to_agent(sender, "ALL", message_type, payload, correlation_id) + + async def get_messages(self, agent_id: str) -> List[AgentMessage]: + async with self._lock: + queued = self._queues.pop(agent_id, []) + return [q.message for q in queued] + + async def peek_messages(self, agent_id: str) -> List[AgentMessage]: + return [q.message for q in self._queues.get(agent_id, [])] + + # ------------------------------------------------------------------ # + # Hooks # + # ------------------------------------------------------------------ # + + def add_hook(self, event: str, callback: Callable): + self._hooks[event].append(callback) + + async def _trigger_hook(self, event: str, message: AgentMessage): + for callback in self._hooks.get(event, []): + try: + if asyncio.iscoroutinefunction(callback): + await callback(message) + else: + callback(message) + except Exception as e: + logger.error(f"Hook error [{event}]: {e}") + + # ------------------------------------------------------------------ # + # Agent / history helpers # + # ------------------------------------------------------------------ # + + def get_agent(self, agent_id: str): + return self._agents.get(agent_id) + + def get_all_agents(self) -> Dict[str, Any]: + return self._agents.copy() + + def get_messages_by_type(self, message_type: MessageType) -> List[AgentMessage]: + return [m for m in self._history if m.message_type == message_type] + + def get_messages_by_sender(self, sender: str) -> List[AgentMessage]: + return [m for m in self._history if m.sender == sender] + + def clear_history(self): + self._history = [] + + # ------------------------------------------------------------------ # + # Internal: NATS helpers # + # ------------------------------------------------------------------ # + + async def _nats_publish(self, message: AgentMessage): + if message.recipient != "ALL": + subject = f"logistics.direct.{message.recipient}" + else: + subject = f"logistics.{message.message_type.value}" + try: + await self._js.publish(subject, message.to_json().encode()) + except Exception as e: + logger.warning(f"NATS publish error: {e} β€” falling back to local dispatch") + await self._local_dispatch(message) + + async def _setup_type_sub(self, message_type: MessageType): + """Create a JetStream push subscription for a broadcast message type.""" + subject = f"logistics.{message_type.value}" + durable = f"logiflow-{message_type.value}" + callbacks = self._subscribers[message_type] + + async def handler(msg): + try: + agent_msg = AgentMessage.from_json(msg.data.decode()) + for cb in list(callbacks): + try: + if asyncio.iscoroutinefunction(cb): + await cb(agent_msg) + else: + cb(agent_msg) + except Exception as e: + logger.error(f"Subscriber callback error: {e}") + except Exception as e: + logger.error(f"NATS type-sub decode error [{message_type.value}]: {e}") + finally: + await msg.ack() + + try: + sub = await self._js.subscribe(subject, durable=durable, cb=handler) + self._nats_subs.append(sub) + except Exception as e: + logger.warning(f"Could not subscribe to NATS subject {subject}: {e}") + + async def _setup_agent_sub(self, agent_id: str): + """Create a JetStream push subscription for directed messages to an agent.""" + subject = f"logistics.direct.{agent_id}" + durable = f"logiflow-direct-{agent_id}" + + async def handler(msg): + try: + agent_msg = AgentMessage.from_json(msg.data.decode()) + async with self._lock: + self._queues[agent_id].append(QueuedMessage(agent_msg)) + except Exception as e: + logger.error(f"NATS agent-sub decode error [{agent_id}]: {e}") + finally: + await msg.ack() + + try: + sub = await self._js.subscribe(subject, durable=durable, cb=handler) + self._nats_subs.append(sub) + except Exception as e: + logger.warning(f"Could not subscribe to NATS subject {subject}: {e}") + + async def _local_dispatch(self, message: AgentMessage): + """In-process dispatch used when NATS is not connected.""" + if message.recipient != "ALL": + async with self._lock: + self._queues[message.recipient].append(QueuedMessage(message)) + else: + for callback in list(self._subscribers.get(message.message_type, [])): + try: + if asyncio.iscoroutinefunction(callback): + await callback(message) + else: + callback(message) + except Exception as e: + logger.error(f"Local dispatch callback error: {e}") + + +# Global message bus instance +message_bus = MessageBus() diff --git a/core/types.py b/core/types.py new file mode 100644 index 0000000..8ae53b0 --- /dev/null +++ b/core/types.py @@ -0,0 +1,210 @@ +"""Core types and message protocols for agent communication.""" +from enum import Enum +from typing import Optional, Dict, Any, List +from datetime import datetime +from pydantic import BaseModel, Field +from dataclasses import dataclass, asdict +import json + + +class MessageType(str, Enum): + """Types of messages agents can send""" + ORDER_RECEIVED = "ORDER_RECEIVED" + ORDER_VALIDATED = "ORDER_VALIDATED" + ORDER_ASSIGNED = "ORDER_ASSIGNED" + ORDER_IN_TRANSIT = "ORDER_IN_TRANSIT" + ORDER_DELIVERED = "ORDER_DELIVERED" + ORDER_CANCELLED = "ORDER_CANCELLED" + ORDER_DELAYED = "ORDER_DELAYED" + HUB_STATUS_UPDATE = "HUB_STATUS_UPDATE" + VEHICLE_ASSIGNED = "VEHICLE_ASSIGNED" + ROUTE_OPTIMIZED = "ROUTE_OPTIMIZED" + NOTIFICATION_SENT = "NOTIFICATION_SENT" + EXCEPTION_DETECTED = "EXCEPTION_DETECTED" + AGENT_TASK = "AGENT_TASK" + AGENT_RESPONSE = "AGENT_RESPONSE" + HEARTBEAT = "HEARTBEAT" + STATUS_QUERY = "STATUS_QUERY" + + +class Priority(str, Enum): + """Order priority levels""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + URGENT = "urgent" + + +class OrderStatus(str, Enum): + """Order lifecycle status""" + RECEIVED = "received" + VALIDATING = "validating" + VALIDATED = "validated" + PENDING_DISPATCH = "pending_dispatch" + ASSIGNED = "assigned" + PICKED_UP = "picked_up" + IN_TRANSIT = "in_transit" + AT_HUB = "at_hub" + OUT_FOR_DELIVERY = "out_for_delivery" + DELIVERED = "delivered" + FAILED = "failed" + CANCELLED = "cancelled" + RETURNED = "returned" + + +class ZoneType(str, Enum): + """Zone classification""" + HUB_TO_HUB = "hub_to_hub" + HUB_TO_SPOKE = "hub_to_spoke" + SPOKE_TO_HUB = "spoke_to_hub" + LAST_MILE = "last_mile" + + +@dataclass +class Address: + """Address information""" + street: str + city: str + state: str + pincode: str + landmark: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + + +@dataclass +class OrderItem: + """Individual item in an order""" + sku: str + name: str + quantity: int + weight: float # in kg + dimensions: Optional[Dict[str, float]] = None # LxWxH in cm + + +@dataclass +class Order: + """Complete order data""" + order_id: str + customer_name: str + customer_phone: str + customer_email: Optional[str] + pickup_address: Address + delivery_address: Address + items: List[OrderItem] + priority: Priority = Priority.MEDIUM + special_instructions: Optional[str] = None + created_at: datetime = None + estimated_delivery: Optional[datetime] = None + + def __post_init__(self): + if self.created_at is None: + self.created_at = datetime.now() + + def to_dict(self) -> Dict: + return { + 'order_id': self.order_id, + 'customer_name': self.customer_name, + 'customer_phone': self.customer_phone, + 'customer_email': self.customer_email, + 'pickup_address': asdict(self.pickup_address), + 'delivery_address': asdict(self.delivery_address), + 'items': [asdict(item) for item in self.items], + 'priority': self.priority.value, + 'special_instructions': self.special_instructions, + 'created_at': self.created_at.isoformat(), + 'estimated_delivery': self.estimated_delivery.isoformat() if self.estimated_delivery else None + } + + +@dataclass +class Hub: + """Hub information""" + hub_id: str + name: str + location: str + pincode_prefix: str # First 3 digits of pincode + capacity: int + current_load: int = 0 + zones_covered: List[str] = None + + def __post_init__(self): + if self.zones_covered is None: + self.zones_covered = [] + + +@dataclass +class Vehicle: + """Vehicle information""" + vehicle_id: str + vehicle_type: str # bike, scooter, van, truck + capacity_kg: float + capacity_volume: float # in cubic meters + current_location: str + current_load: float = 0 + status: str = "available" # available, in_transit, maintenance, offline + assigned_hub: Optional[str] = None + current_route: Optional[List[str]] = None + + +@dataclass +class AgentMessage: + """Standard message format for agent communication""" + message_id: str + sender: str + recipient: str # "ALL" for broadcast + message_type: MessageType + payload: Dict[str, Any] + timestamp: datetime + correlation_id: Optional[str] = None # For tracing related messages + + def to_json(self) -> str: + data = { + 'message_id': self.message_id, + 'sender': self.sender, + 'recipient': self.recipient, + 'message_type': self.message_type.value, + 'payload': self.payload, + 'timestamp': self.timestamp.isoformat(), + 'correlation_id': self.correlation_id + } + return json.dumps(data) + + @classmethod + def from_json(cls, json_str: str) -> 'AgentMessage': + data = json.loads(json_str) + return cls( + message_id=data['message_id'], + sender=data['sender'], + recipient=data['recipient'], + message_type=MessageType(data['message_type']), + payload=data['payload'], + timestamp=datetime.fromisoformat(data['timestamp']), + correlation_id=data.get('correlation_id') + ) + + +class AgentTask(BaseModel): + """Task assigned to an agent""" + task_id: str + agent_type: str + task_type: str + description: str = "" + priority: Priority = Priority.MEDIUM + data: Dict[str, Any] = Field(default_factory=dict) + created_at: datetime = Field(default_factory=datetime.now) + status: str = "pending" # pending, in_progress, completed, failed + result: Optional[Dict] = None + error: Optional[str] = None + + +class AgentState(BaseModel): + """Current state of an agent""" + agent_id: str + agent_type: str + status: str # idle, working, waiting, error + current_task: Optional[str] = None + tasks_completed: int = 0 + tasks_failed: int = 0 + last_active: datetime = Field(default_factory=datetime.now) + performance_metrics: Dict[str, Any] = Field(default_factory=dict) \ No newline at end of file diff --git a/customer_portal/portal.py b/customer_portal/portal.py new file mode 100644 index 0000000..e68efad --- /dev/null +++ b/customer_portal/portal.py @@ -0,0 +1,391 @@ +"""Customer Portal - Track orders and receive updates.""" +import streamlit as st +from datetime import datetime, timedelta +import time + + +# Page configuration +st.set_page_config( + page_title="LogiFlow - Track Your Order", + page_icon="πŸ“¦", + layout="centered" +) + +# Custom CSS +st.markdown(""" + +""", unsafe_allow_html=True) + + +def render_tracking_header(): + """Render the tracking page header.""" + st.markdown(""" +
+

πŸ“¦ LogiFlow

+

AI-Powered Logistics Tracking

+
+ """, unsafe_allow_html=True) + + +def render_order_search(): + """Render order search form.""" + st.markdown("### πŸ” Track Your Order") + + col1, col2 = st.columns([3, 1]) + with col1: + order_id = st.text_input( + "Enter Order ID", + placeholder="ORD-20240605-ABC12345", + label_visibility="collapsed" + ) + with col2: + search_btn = st.button("πŸ”Ž Track", use_container_width=True) + + return order_id, search_btn + + +def render_tracking_timeline(): + """Render tracking timeline.""" + st.markdown("### πŸ“ Delivery Progress") + + # Sample tracking data + events = [ + {"time": "2 hours ago", "status": "Out for Delivery", "location": "Andheri West, Mumbai", "desc": "Your package is out for delivery"}, + {"time": "5 hours ago", "status": "Arrived at Hub", "location": "Mumbai West Hub", "desc": "Package arrived at sorting facility"}, + {"time": "8 hours ago", "status": "In Transit", "location": "Delhi Sorting Center", "desc": "Package on the way to Mumbai"}, + {"time": "12 hours ago", "status": "Picked Up", "location": "Connaught Place, Delhi", "desc": "Package picked up by driver"}, + {"time": "1 day ago", "status": "Order Confirmed", "location": "System", "desc": "Order placed and confirmed"}, + ] + + for i, event in enumerate(events): + is_completed = i == 0 # Latest is highlighted + + st.markdown(f""" +
+

{event['time']}

+

{event['status']}

+

πŸ“ {event['location']}

+

{event['desc']}

+
+ """, unsafe_allow_html=True) + + +def render_order_details(): + """Render order details.""" + st.markdown("### πŸ“‹ Order Details") + + col1, col2 = st.columns(2) + + with col1: + st.markdown(""" +
+

πŸ“ Delivery Address

+

Rajesh Kumar

+

402, Sunshine Apartments

+

Andheri West, Mumbai - 400053

+

πŸ“ž +919876543210

+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(""" +
+

πŸ“¦ Package Details

+

3 items

+

β€’ Wireless Headphones

+

β€’ Phone Case

+

β€’ Screen Protector

+

Total Weight: 0.8 kg

+
+ """, unsafe_allow_html=True) + + st.markdown(""" +
+

⏱️ Estimated Delivery

+

Today, 4:00 PM - 6:00 PM

+

On schedule - No delays expected

+
+ """, unsafe_allow_html=True) + + +def render_delivery_partner(): + """Render delivery partner info.""" + st.markdown("### 🚴 Delivery Partner") + + col1, col2, col3 = st.columns([1, 2, 1]) + + with col1: + st.markdown(""" +
+ πŸ‘€ +
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(""" +
+

Arun Sharma

+

Delivery Executive

+

πŸ›΅ Two-wheeler | MH 14 AB 1234

+

⭐ 4.8 rating (1,234 deliveries)

+
+ """, unsafe_allow_html=True) + + with col3: + st.write("") + st.write("") + if st.button("πŸ“ž Call", use_container_width=True): + st.info("Calling Arun Sharma...") + if st.button("πŸ’¬ Chat", use_container_width=True): + st.info("Opening chat...") + + +def render_ai_assistant(): + """Render AI assistant chat.""" + st.markdown("### πŸ€– AI Assistant") + + # Initialize chat history + if "messages" not in st.session_state: + st.session_state.messages = [ + {"role": "assistant", "content": "Hi! I'm your LogiFlow AI assistant. How can I help you today?"} + ] + + # Display chat history + for msg in st.session_state.messages: + if msg["role"] == "user": + st.markdown(f""" +
+ {msg['content']} +
+ """, unsafe_allow_html=True) + else: + st.markdown(f""" +
+ πŸ€– {msg['content']} +
+ """, unsafe_allow_html=True) + + # Chat input + if prompt := st.chat_input("Ask me anything about your order..."): + st.session_state.messages.append({"role": "user", "content": prompt}) + + # AI response + if "where" in prompt.lower() or "track" in prompt.lower(): + response = "Your order is currently out for delivery and should arrive by 4:00 PM today. You can see the live location on the map above!" + elif "delay" in prompt.lower() or "late" in prompt.lower(): + response = "Good news! Your order is on schedule with no delays. The estimated delivery time remains 4:00 PM - 6:00 PM today." + elif "cancel" in prompt.lower(): + response = "I understand you want to cancel. Unfortunately, your order is already out for delivery, so cancellation is no longer possible. Would you like me to reschedule the delivery instead?" + elif "hello" in prompt.lower() or "hi" in prompt.lower(): + response = "Hello! I'm here to help you track your order and answer any questions. Your package is doing great and should arrive soon! πŸ“¦" + else: + response = "I'm here to help! You can ask me about:\n- πŸ“ Current location of your order\n- ⏱️ Estimated delivery time\n- πŸ“‹ Order details\n- 🚚 Delivery partner info\n- ❓ Any other questions!" + + st.session_state.messages.append({"role": "assistant", "content": response}) + st.rerun() + + +def render_map_placeholder(): + """Render map placeholder.""" + st.markdown("### πŸ—ΊοΈ Live Tracking Map") + + st.markdown(""" +
+ πŸ—ΊοΈ +

Live tracking map

+

Your delivery partner is currently at:

+

πŸ“ Andheri Metro Station, 2.3 km away

+
+ """, unsafe_allow_html=True) + + +def render_quick_actions(): + """Render quick action buttons.""" + st.markdown("### ⚑ Quick Actions") + + col1, col2, col3, col4 = st.columns(4) + + with col1: + if st.button("πŸ“ Track Order", use_container_width=True): + st.info("Opening tracking view...") + + with col2: + if st.button("πŸ“ž Call Support", use_container_width=True): + st.info("Connecting to support...") + + with col3: + if st.button("πŸ”„ Reschedule", use_container_width=True): + st.warning("This will open the reschedule form") + + with col4: + if st.button("❓ Help", use_container_width=True): + st.info("Opening help center...") + + +def render_delivery_schedule(): + """Render delivery schedule.""" + st.markdown("### πŸ“… Reschedule Delivery") + + with st.expander("Change Delivery Time"): + st.write("**Select a new delivery slot:**") + + col1, col2, col3 = st.columns(3) + with col1: + st.write("**Today**") + if st.button("2:00 PM - 4:00 PM", use_container_width=True): + st.success("Delivery rescheduled to 2:00 PM - 4:00 PM today") + if st.button("6:00 PM - 8:00 PM", use_container_width=True): + st.success("Delivery rescheduled to 6:00 PM - 8:00 PM today") + + with col2: + st.write("**Tomorrow**") + if st.button("9:00 AM - 12:00 PM", use_container_width=True): + st.success("Delivery rescheduled to tomorrow 9:00 AM - 12:00 PM") + if st.button("2:00 PM - 6:00 PM", use_container_width=True): + st.success("Delivery rescheduled to tomorrow 2:00 PM - 6:00 PM") + + with col3: + st.write("**Custom**") + custom_date = st.date_input("Select Date", datetime.now() + timedelta(days=1)) + custom_time = st.time_input("Select Time", datetime.now().time()) + if st.button("Apply Custom Schedule", use_container_width=True): + st.success(f"Delivery rescheduled to {custom_date} at {custom_time}") + + +def render_feedback(): + """Render feedback section.""" + st.markdown("### ⭐ Rate Your Experience") + + col1, col2, col3, col4, col5 = st.columns(5) + + ratings = ["😞", "😐", "πŸ™‚", "😊", "🀩"] + selected = st.feedback(options="faces") + + if selected is not None: + st.success(f"Thank you for your {ratings[selected]} feedback!") + + +def render_notifications(): + """Render notification preferences.""" + st.markdown("### πŸ”” Notification Preferences") + + col1, col2 = st.columns(2) + + with col1: + st.checkbox("πŸ“± SMS Notifications", value=True) + st.checkbox("πŸ“§ Email Updates", value=True) + + with col2: + st.checkbox("πŸ’¬ WhatsApp Updates", value=True) + st.checkbox("πŸ”” Push Notifications", value=True) + + +def main(): + """Main customer portal function.""" + + # Tabs + tab1, tab2, tab3, tab4 = st.tabs(["πŸ“ Track", "πŸ“‹ Details", "πŸ’¬ Help", "βš™οΈ Settings"]) + + with tab1: + render_tracking_header() + order_id, search = render_order_search() + + if order_id or search: + render_map_placeholder() + render_tracking_timeline() + render_quick_actions() + render_delivery_schedule() + render_ai_assistant() + + with tab2: + st.header("πŸ“‹ Order Details") + render_order_details() + render_delivery_partner() + + with tab3: + st.header("πŸ’¬ Help & Support") + + st.markdown("### Frequently Asked Questions") + + with st.expander("πŸ“¦ Where is my order?"): + st.write("Your order is currently out for delivery. Use the tracking tab above to see real-time location updates.") + + with st.expander("⏱️ When will my order arrive?"): + st.write("Expected delivery is today between 4:00 PM - 6:00 PM. You can track the exact location in real-time.") + + with st.expander("πŸ”„ Can I reschedule delivery?"): + st.write("Yes! You can reschedule your delivery to any available time slot. Go to the Track tab and use the reschedule option.") + + with st.expander("❌ Can I cancel my order?"): + st.write("Cancellation is possible only before the order is dispatched. Once out for delivery, you can reschedule instead.") + + with st.expander("πŸ“ž How do I contact support?"): + st.write("You can call our 24/7 support at 1800-123-4567 or use the in-app chat for instant assistance.") + + st.markdown("### Contact Us") + st.write("πŸ“ž 1800-123-4567 (Toll Free)") + st.write("πŸ“§ support@logiflow.ai") + st.write("πŸ’¬ Chat with us (Available 24/7)") + + with tab4: + st.header("βš™οΈ Settings") + + st.markdown("### πŸ“± Communication Preferences") + render_notifications() + + st.markdown("### πŸ” Account") + st.write("**Email:** rajesh@example.com") + st.write("**Phone:** +919876543210") + st.write("**Member since:** January 2024") + + if st.button("✏️ Edit Profile"): + st.info("Profile editing coming soon!") + + if st.button("πŸšͺ Sign Out"): + st.warning("Sign out functionality coming soon!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/dashboard/admin_dashboard.py b/dashboard/admin_dashboard.py new file mode 100644 index 0000000..5842265 --- /dev/null +++ b/dashboard/admin_dashboard.py @@ -0,0 +1,476 @@ +"""Admin Dashboard - Real-time monitoring and control interface.""" +import streamlit as st +import time +from datetime import datetime +from typing import Dict, List, Any +import asyncio + + +# Page configuration +st.set_page_config( + page_title="LogiFlow AI - Agent Control Center", + page_icon="πŸ€–", + layout="wide", + initial_sidebar_state="expanded" +) + +# Custom CSS +st.markdown(""" + +""", unsafe_allow_html=True) + + +class DashboardState: + """Shared state for dashboard.""" + + def __init__(self): + self.agents = { + "JARVIS": {"status": "idle", "tasks_completed": 0, "current_task": None}, + "ORDER_AGENT": {"status": "idle", "tasks_completed": 0, "current_task": None}, + "DISPATCH_AGENT": {"status": "idle", "tasks_completed": 0, "current_task": None}, + "FLEET_AGENT": {"status": "idle", "tasks_completed": 0, "current_task": None}, + "HUB_AGENT": {"status": "idle", "tasks_completed": 0, "current_task": None}, + "CUSTOMER_AGENT": {"status": "idle", "tasks_completed": 0, "current_task": None}, + "EXCEPTION_AGENT": {"status": "idle", "tasks_completed": 0, "current_task": None}, + "ROUTE_OPTIMIZER": {"status": "idle", "tasks_completed": 0, "current_task": None}, + } + self.orders = [] + self.vehicles = [] + self.hubs = [] + self.messages = [] + + def update_agent_status(self, agent_id: str, status: str, task: str = None): + if agent_id in self.agents: + self.agents[agent_id]["status"] = status + self.agents[agent_id]["current_task"] = task + if status == "idle" and task is None: + self.agents[agent_id]["tasks_completed"] += 1 + + def add_message(self, sender: str, recipient: str, message_type: str, content: str): + self.messages.append({ + "timestamp": datetime.now(), + "sender": sender, + "recipient": recipient, + "type": message_type, + "content": content + }) + if len(self.messages) > 100: + self.messages = self.messages[-100:] + + +# Initialize state +if 'state' not in st.session_state: + st.session_state.state = DashboardState() + + +def render_header(): + """Render the dashboard header.""" + col1, col2, col3 = st.columns([2, 3, 1]) + + with col1: + st.markdown(""" +
+

πŸ€– LogiFlow AI

+

Agent Control Center

+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(""" +
+

🟒 System Operational

+

All agents active and monitoring

+
+ """, unsafe_allow_html=True) + + with col3: + st.markdown(f""" +
+

{datetime.now().strftime('%Y-%m-%d')}

+

{datetime.now().strftime('%H:%M:%S')}

+
+ """, unsafe_allow_html=True) + + +def render_agent_grid(state: DashboardState): + """Render agent status cards.""" + st.markdown("## πŸ€– Agent Status") + + cols = st.columns(4) + agent_icons = { + "JARVIS": "🧠", + "ORDER_AGENT": "πŸ“¦", + "DISPATCH_AGENT": "🚚", + "FLEET_AGENT": "πŸš›", + "HUB_AGENT": "🏭", + "CUSTOMER_AGENT": "πŸ’¬", + "EXCEPTION_AGENT": "⚠️", + "ROUTE_OPTIMIZER": "πŸ—ΊοΈ" + } + + for i, (agent_id, info) in enumerate(state.agents.items()): + with cols[i % 4]: + status_class = f"status-{info['status']}" + icon = agent_icons.get(agent_id, "πŸ€–") + + st.markdown(f""" +
+
+

{icon} {agent_id}

+ {info['status'].upper()} +
+
+

Tasks: {info['tasks_completed']}

+

Current: {info['current_task'] or 'None'}

+
+
+ """, unsafe_allow_html=True) + + +def render_metrics(state: DashboardState): + """Render key metrics.""" + col1, col2, col3, col4, col5 = st.columns(5) + + metrics = [ + ("Active Orders", len([o for o in state.orders if o.get("status") != "delivered"]), "πŸ“¦"), + ("Vehicles Active", len([v for v in state.vehicles if v.get("status") == "in_transit"]), "πŸš›"), + ("Hubs Online", len(state.hubs), "🏭"), + ("Messages/min", len([m for m in state.messages if (datetime.now() - m["timestamp"]).seconds < 60]), "πŸ“¨"), + ("Success Rate", "98.5%", "βœ…") + ] + + for col, (label, value, icon) in zip([col1, col2, col3, col4, col5], metrics): + with col: + st.markdown(f""" +
+
{icon}
+
{value}
+
{label}
+
+ """, unsafe_allow_html=True) + + +def render_order_list(state: DashboardState): + """Render recent orders.""" + st.markdown("## πŸ“¦ Recent Orders") + + if not state.orders: + st.info("No orders yet. Create an order to see it here.") + return + + for order in state.orders[-10:][::-1]: + status_colors = { + "received": "🟑", + "in_transit": "πŸ”΅", + "delivered": "🟒", + "cancelled": "πŸ”΄" + } + status_icon = status_colors.get(order.get("status", "received"), "βšͺ") + + with st.expander(f"{status_icon} {order.get('order_id', 'N/A')} - {order.get('customer_name', 'Unknown')}"): + col1, col2 = st.columns(2) + with col1: + st.write(f"**Status:** {order.get('status', 'Unknown')}") + st.write(f"**Priority:** {order.get('category', {}).get('priority', 'Medium')}") + st.write(f"**Created:** {order.get('created_at', 'N/A')}") + with col2: + st.write(f"**Pickup:** {order.get('pickup_address', {}).get('city', 'N/A')}") + st.write(f"**Delivery:** {order.get('delivery_address', {}).get('city', 'N/A')}") + st.write(f"**Items:** {len(order.get('items', []))}") + + +def render_vehicle_fleet(state: DashboardState): + """Render vehicle fleet status.""" + st.markdown("## πŸš› Vehicle Fleet") + + if not state.vehicles: + st.info("No vehicles registered.") + return + + for vehicle in state.vehicles[:20]: + status_icons = { + "available": "🟒", + "in_transit": "πŸ”΅", + "maintenance": "🟑", + "offline": "⚫" + } + icon = status_icons.get(vehicle.get("status", "available"), "βšͺ") + + col1, col2, col3, col4 = st.columns(4) + with col1: + st.write(f"{icon} **{vehicle.get('vehicle_id', 'N/A')}**") + with col2: + st.write(f"Type: {vehicle.get('type', 'N/A')}") + with col3: + st.write(f"Hub: {vehicle.get('hub', 'N/A')}") + with col4: + st.write(f"Status: {vehicle.get('status', 'N/A')}") + st.divider() + + +def render_hub_network(state: DashboardState): + """Render hub network status.""" + st.markdown("## 🏭 Hub Network") + + cols = st.columns(4) + hub_data = [ + {"id": "DL-HUB-01", "name": "Delhi North", "load": 150, "capacity": 500}, + {"id": "DL-HUB-02", "name": "Delhi South", "load": 200, "capacity": 400}, + {"id": "MU-HUB-01", "name": "Mumbai West", "load": 300, "capacity": 600}, + {"id": "MU-HUB-02", "name": "Mumbai East", "load": 250, "capacity": 550}, + {"id": "BL-HUB-01", "name": "Bangalore", "load": 180, "capacity": 500}, + {"id": "HY-HUB-01", "name": "Hyderabad", "load": 220, "capacity": 450}, + {"id": "PU-HUB-01", "name": "Pune", "load": 160, "capacity": 400}, + {"id": "KL-HUB-01", "name": "Kolkata", "load": 140, "capacity": 350}, + ] + + for i, hub in enumerate(hub_data): + with cols[i % 4]: + utilization = (hub["load"] / hub["capacity"]) * 100 + color = "🟒" if utilization < 70 else "🟑" if utilization < 90 else "πŸ”΄" + + st.markdown(f""" +
+

{color} {hub['name']}

+

ID: {hub['id']}

+

Load: {hub['load']}/{hub['capacity']}

+

Utilization: {utilization:.1f}%

+
+ """, unsafe_allow_html=True) + + +def render_message_feed(state: DashboardState): + """Render real-time message feed.""" + st.markdown("## πŸ“¨ Agent Communication Feed") + + if not state.messages: + st.info("No messages yet.") + return + + for msg in state.messages[-20:][::-1]: + st.markdown(f""" +
+ {msg['timestamp'].strftime('%H:%M:%S')} + [{msg['sender']}] + β†’ [{msg['recipient']}] + {msg['type']} +

{msg['content']}

+
+ """, unsafe_allow_html=True) + + +def render_order_creator(): + """Render order creation form.""" + st.markdown("## βž• Create New Order") + + with st.form("order_form"): + col1, col2 = st.columns(2) + + with col1: + st.write("**Customer Details**") + customer_name = st.text_input("Customer Name", "Rajesh Kumar") + customer_phone = st.text_input("Phone", "+919876543210") + customer_email = st.text_input("Email", "rajesh@example.com") + + with col2: + st.write("**Order Priority**") + priority = st.selectbox("Priority", ["low", "medium", "high", "urgent"]) + items_count = st.number_input("Number of Items", 1, 50, 3) + weight = st.number_input("Total Weight (kg)", 0.1, 500.0, 5.0) + + st.write("**Pickup Address**") + col_p1, col_p2, col_p3 = st.columns(3) + with col_p1: + pickup_city = st.text_input("City", "Delhi") + with col_p2: + pickup_state = st.text_input("State", "Delhi") + with col_p3: + pickup_pincode = st.text_input("Pincode", "110001") + + st.write("**Delivery Address**") + col_d1, col_d2, col_d3 = st.columns(3) + with col_d1: + delivery_city = st.text_input("City", "Mumbai", key="del_city") + with col_d2: + delivery_state = st.text_input("State", "Maharashtra", key="del_state") + with col_d3: + delivery_pincode = st.text_input("Pincode", "400001", key="del_pin") + + submitted = st.form_submit_button("πŸš€ Create Order via AI Agents", use_container_width=True) + + if submitted: + st.success("Order created! Watch the agents process it in real-time.") + + # Simulate order being created + if 'state' in st.session_state: + state = st.session_state.state + order_id = f"ORD-{datetime.now().strftime('%Y%m%d')}-{len(state.orders) + 1:04d}" + + state.orders.append({ + "order_id": order_id, + "customer_name": customer_name, + "customer_phone": customer_phone, + "status": "received", + "priority": priority, + "pickup_address": {"city": pickup_city, "pincode": pickup_pincode}, + "delivery_address": {"city": delivery_city, "pincode": delivery_pincode}, + "items": [{"name": f"Item {i}", "weight": weight/items_count} for i in range(int(items_count))], + "created_at": datetime.now().isoformat(), + "category": {"priority": priority} + }) + + state.add_message("ADMIN", "JARVIS", "ORDER_SUBMIT", f"New order {order_id} submitted") + state.update_agent_status("ORDER_AGENT", "working", f"Validating {order_id}") + + st.rerun() + + +def render_exception_panel(state: DashboardState): + """Render exception handling panel.""" + st.markdown("## ⚠️ Exception Management") + + col1, col2 = st.columns(2) + + with col1: + st.write("**Quick Actions**") + if st.button("🚨 Simulate Delay Exception"): + state.add_message("SYSTEM", "EXCEPTION_AGENT", "EXCEPTION", "Simulated delay detected") + st.warning("Delay exception triggered - Exception Agent handling...") + + with col2: + st.write("**Exception Queue**") + st.info("No active exceptions - System running smoothly") + + +def main(): + """Main dashboard function.""" + + # Sidebar navigation + st.sidebar.markdown("## 🧭 Navigation") + page = st.sidebar.radio("Go to", [ + "πŸ“Š Overview", + "πŸ€– Agents", + "πŸ“¦ Orders", + "πŸš› Fleet", + "🏭 Hubs", + "⚠️ Exceptions", + "πŸ’¬ Messages", + "βž• Create Order" + ]) + + # Initialize state + state = st.session_state.state + + # Simulate some data if empty + if not state.vehicles: + state.vehicles = [ + {"vehicle_id": "DL-V-001", "type": "van", "hub": "DL-HUB-01", "status": "in_transit", "location": {"lat": 28.61, "lng": 77.20}}, + {"vehicle_id": "DL-V-002", "type": "van", "hub": "DL-HUB-01", "status": "available", "location": {"lat": 28.62, "lng": 77.21}}, + {"vehicle_id": "MU-V-001", "type": "van", "hub": "MU-HUB-01", "status": "in_transit", "location": {"lat": 19.07, "lng": 72.87}}, + {"vehicle_id": "BL-V-001", "type": "van", "hub": "BL-HUB-01", "status": "available", "location": {"lat": 12.97, "lng": 77.59}}, + {"vehicle_id": "DL-B-001", "type": "bike", "hub": "DL-HUB-01", "status": "in_transit", "location": {"lat": 28.63, "lng": 77.19}}, + ] + + if not state.hubs: + state.hubs = [ + {"hub_id": "DL-HUB-01", "name": "Delhi North Hub"}, + {"hub_id": "DL-HUB-02", "name": "Delhi South Hub"}, + {"hub_id": "MU-HUB-01", "name": "Mumbai West Hub"}, + {"hub_id": "BL-HUB-01", "name": "Bangalore Hub"}, + ] + + # Render based on selection + if page == "πŸ“Š Overview": + render_header() + render_metrics(state) + render_agent_grid(state) + + col_overview1, col_overview2 = st.columns(2) + with col_overview1: + render_order_list(state) + with col_overview2: + render_hub_network(state) + + elif page == "πŸ€– Agents": + render_header() + st.markdown("# πŸ€– Agent Control Center") + render_agent_grid(state) + + st.markdown("### Agent Communication Pattern") + st.markdown(""" + ``` + JARVIS (Master) + β”œβ”€β”€ ORDER_AGENT β†’ Validates & categorizes orders + β”œβ”€β”€ DISPATCH_AGENT β†’ Assigns routes & zones + β”œβ”€β”€ FLEET_AGENT β†’ Manages vehicle allocation + β”œβ”€β”€ HUB_AGENT β†’ Coordinates hub operations + β”œβ”€β”€ CUSTOMER_AGENT β†’ Sends notifications + β”œβ”€β”€ EXCEPTION_AGENT β†’ Handles problems + └── ROUTE_OPTIMIZER β†’ Plans optimal paths + ``` + """) + + elif page == "πŸ“¦ Orders": + render_header() + render_order_list(state) + render_order_creator() + + elif page == "πŸš› Fleet": + render_header() + render_vehicle_fleet(state) + + elif page == "🏭 Hubs": + render_header() + render_hub_network(state) + + elif page == "⚠️ Exceptions": + render_header() + render_exception_panel(state) + + elif page == "πŸ’¬ Messages": + render_header() + render_message_feed(state) + + elif page == "βž• Create Order": + render_header() + render_order_creator() + + # Auto-refresh + time.sleep(1) + st.rerun() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/doormile_test.py b/doormile_test.py new file mode 100644 index 0000000..e100f02 --- /dev/null +++ b/doormile_test.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +""" +Doormile Full System Test Suite v2 +Fixed: correct routes, 2-step login, pricing payload, health endpoint +""" +import asyncio +import aiohttp +import json +import time +import websockets +import nats +import redis.asyncio as aioredis +from datetime import datetime + +# ── Config ──────────────────────────────────────────────────────────────────── +API_BASE = "https://api.doormile.com" +NATS_URL = "nats://66.116.226.161:4223" +NATS_USER = "doormile" +NATS_PASSWORD = "Package@321#" +REDIS_HOST = "66.116.226.255" +REDIS_PORT = 6380 +REDIS_PASSWORD = "Package@321#" +INTERNAL_KEY = "doormile-internal-2024" + +# Test customer β€” uses the one created by Window 1 test +TEST_PHONE = "9900000001" +TEST_PIN = "9999" + +# Colors +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +BLUE = "\033[94m" +CYAN = "\033[96m" +BOLD = "\033[1m" +RESET = "\033[0m" + +results = [] + +def log(msg, color=RESET): + ts = datetime.now().strftime("%H:%M:%S") + print(f"{color}[{ts}] {msg}{RESET}") + +def pass_test(name, detail=""): + results.append(("PASS", name)) + log(f"βœ… PASS β€” {name} {detail}", GREEN) + +def fail_test(name, detail=""): + results.append(("FAIL", name)) + log(f"❌ FAIL β€” {name} {detail}", RED) + +def info(msg): + log(f" β„Ή {msg}", CYAN) + +def section(title): + print(f"\n{BOLD}{YELLOW}{'='*60}{RESET}") + print(f"{BOLD}{YELLOW} {title}{RESET}") + print(f"{BOLD}{YELLOW}{'='*60}{RESET}\n") + +# ── Tests ───────────────────────────────────────────────────────────────────── + +async def test_health(session): + section("1. HEALTH CHECK") + for path in ["/api/v1/health", "/api/v1/ready"]: + try: + async with session.get(f"{API_BASE}{path}", + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test(f"GET {path}", str(data)) + else: + fail_test(f"GET {path}", f"status={r.status}") + except Exception as e: + fail_test(f"GET {path}", str(e)) + +async def login(session): + section("2. CUSTOMER LOGIN (2-step)") + token = None + try: + # Step 1 + async with session.post(f"{API_BASE}/api/v1/customer/login", + json={"phone": TEST_PHONE}, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test("Login step 1 β€” phone", f"phone={TEST_PHONE}") + info(str(data)) + else: + fail_test("Login step 1 β€” phone", f"status={r.status} {data}") + return None + + # Step 2 + async with session.post(f"{API_BASE}/api/v1/customer/verify-pin", + json={"phone": TEST_PHONE, "pin": TEST_PIN}, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + token = data.get("token") or data.get("jwt") or data.get("accesstoken") + if token: + pass_test("Login step 2 β€” PIN verify", "JWT token obtained") + info(f"Token: {token[:50]}...") + else: + fail_test("Login step 2 β€” PIN verify", f"No token in resp: {data}") + else: + fail_test("Login step 2 β€” PIN verify", f"status={r.status} {data}") + except Exception as e: + fail_test("Login", str(e)) + return token + +async def test_profile(session, token): + section("3. CUSTOMER PROFILE") + try: + headers = {"Authorization": f"Bearer {token}"} + async with session.get(f"{API_BASE}/api/v1/customer/profile", + headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test("GET /customer/profile", f"name={data.get('firstname','')} {data.get('lastname','')}") + else: + fail_test("GET /customer/profile", f"status={r.status} {data}") + except Exception as e: + fail_test("Customer profile", str(e)) + +async def test_pricing(session, token): + section("4. PRICING CHECK (Redis-backed DoormilePricing)") + try: + # Correct payload β€” zone directly, not pincodes + payload = { + "zone": "OtherState", + "service_type": "Normal", + "weight": 1.0, + "itemcategory": "Documents" + } + headers = {"Authorization": f"Bearer {token}"} + async with session.post(f"{API_BASE}/api/v1/pricing/check", + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test("Pricing check β€” OtherState/Normal", f"resp={data}") + else: + fail_test("Pricing check", f"status={r.status} resp={data}") + + # Also test Local pricing + payload2 = {"zone": "Local", "service_type": "Normal", + "weight": 0.5, "itemcategory": "Documents"} + async with session.post(f"{API_BASE}/api/v1/pricing/check", + json=payload2, + headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test("Pricing check β€” Local/Normal", f"resp={data}") + else: + fail_test("Pricing check β€” Local/Normal", f"status={r.status} {data}") + except Exception as e: + fail_test("Pricing check", str(e)) + +async def test_city_gate(session, token): + section("5. CITY GATING β€” should REJECT Mumbai (400xxx)") + try: + payload = { + "pickupaddress": "MG Road, Mumbai", + "pickuppincode": "400001", + "pickuplatitude": 18.9388, + "pickuplongitude": 72.8354, + "deliveryaddress": "Hitech City, Hyderabad", + "deliverypincode": "500032", + "serviceoption": "Normal", + "parcels": [{"itemcategory": "Documents", + "itemdescription": "test", "weight": 0.5}] + } + headers = {"Authorization": f"Bearer {token}"} + async with session.post(f"{API_BASE}/api/v1/customer/bookings", + json=payload, headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 400: + pass_test("City gate β€” Mumbai rejected βœ“", str(data)) + else: + fail_test("City gate β€” Mumbai should be rejected", + f"got status={r.status} β€” city gate may not be active") + except Exception as e: + fail_test("City gate test", str(e)) + +async def create_booking(session, token): + section("6. CREATE BOOKING β€” Coimbatore β†’ Hyderabad") + booking_id = None + try: + payload = { + "pickupaddress": "Gandhipuram, Coimbatore, Tamil Nadu", + "pickuppincode": "641001", + "pickuplatitude": 11.0168, + "pickuplongitude": 76.9558, + "deliveryaddress": "Hitech City, Hyderabad, Telangana", + "deliverypincode": "500032", + "serviceoption": "Normal", + "parcels": [ + { + "itemcategory": "Documents", + "itemdescription": "Doormile E2E test parcel", + "weight": 0.5, + "length": 20, + "width": 15, + "height": 5 + } + ] + } + headers = {"Authorization": f"Bearer {token}"} + async with session.post(f"{API_BASE}/api/v1/customer/bookings", + json=payload, headers=headers, + timeout=aiohttp.ClientTimeout(total=15)) as r: + data = await r.json() + if r.status in [200, 201]: + # Try common field names for booking id + booking_id = (data.get("bookingid") or + data.get("booking_id") or + data.get("id") or + data.get("data", {}).get("bookingid")) + pass_test("Booking created", f"id={booking_id}") + info(f"Response: {json.dumps(data, indent=2)}") + else: + fail_test("Create booking", f"status={r.status} resp={data}") + except Exception as e: + fail_test("Create booking", str(e)) + return booking_id + +async def test_assignment(session, token, booking_id): + section("7. AUTO-ASSIGNMENT ENGINE (waiting 12s...)") + if not booking_id: + fail_test("Assignment check", "No booking_id from step 6") + return + + info("Giving assignment engine 12 seconds to fire goroutine...") + for i in range(12, 0, -3): + info(f" {i}s remaining...") + await asyncio.sleep(3) + + try: + headers = {"Authorization": f"Bearer {token}"} + async with session.get(f"{API_BASE}/api/v1/customer/bookings/{booking_id}", + headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + status = (data.get("status") or + data.get("booking", {}).get("status", "unknown")) + miler = (data.get("assignedmileruserid") or + data.get("booking", {}).get("assignedmileruserid", "none")) + info(f"Booking status: {status}") + info(f"Assigned miler: {miler}") + if status in ["Miler_Assigned", "Pickup_Scheduled"]: + pass_test("Auto-assignment fired βœ“", f"status={status} miler={miler}") + elif status == "Created": + fail_test("Auto-assignment", "Still 'Created' β€” check kubectl logs for assignment engine errors") + else: + pass_test("Booking updated", f"status={status}") + else: + fail_test("Check booking", f"status={r.status} {data}") + except Exception as e: + fail_test("Assignment check", str(e)) + +async def test_redis(booking_id): + section("8. REDIS β€” GEO index, booking cache, pricing cache") + try: + r = aioredis.Redis(host=REDIS_HOST, port=REDIS_PORT, + password=REDIS_PASSWORD, decode_responses=True) + await r.ping() + pass_test("Redis connection", f"{REDIS_HOST}:{REDIS_PORT}") + + # Miler GEO index + count = await r.zcard("milers:locations") + if count > 0: + pass_test("milers:locations GEO index", f"{count} milers indexed") + # Find milers near Coimbatore + nearby = await r.georadius( + "milers:locations", 76.9558, 11.0168, 10, "km", + withcoord=True, count=5, sort="ASC" + ) + if nearby: + pass_test("GEORADIUS near Coimbatore", f"found {len(nearby)} milers") + for m in nearby: + info(f" Miler: {m}") + else: + fail_test("GEORADIUS near Coimbatore", "No milers found in 10km radius") + else: + fail_test("milers:locations GEO index", "Empty β€” milers have no GPS in Redis") + + # Booking cache + if booking_id: + data = await r.hgetall(f"bookings:{booking_id}") + if data: + pass_test("Booking Redis cache", f"keys={list(data.keys())}") + else: + fail_test("Booking Redis cache", + f"bookings:{booking_id} not in Redis β€” NATS worker may not be running") + + # Pricing cache + keys = await r.keys("doormile:pricing:*") + if keys: + pass_test("Pricing Redis cache", f"{len(keys)} slabs cached") + else: + fail_test("Pricing Redis cache", "No pricing keys in Redis") + + await r.aclose() + except Exception as e: + fail_test("Redis tests", str(e)) + +async def test_nats(booking_id): + section("9. NATS JETSTREAM β€” streams + publish test") + try: + nc = await nats.connect( + servers=[NATS_URL], + user=NATS_USER, + password=NATS_PASSWORD + ) + js = nc.jetstream() + pass_test("NATS connected", NATS_URL) + + streams = ["BOOKINGS", "TRACKING", "ASSIGNMENTS", + "NOTIFICATIONS", "CHAT", "STATUS"] + for stream in streams: + try: + info_obj = await js.stream_info(stream) + pass_test(f"Stream {stream}", + f"msgs={info_obj.state.messages}") + except Exception as e: + fail_test(f"Stream {stream}", str(e)) + + # Publish test GPS event + if booking_id: + evt = json.dumps({ + "miler_id": "test_miler_001", + "booking_id": booking_id, + "lat": 11.0175, + "lon": 76.9565, + "timestamp": int(time.time()) + }) + ack = await js.publish("miler.location.updated", evt.encode()) + pass_test("Publish miler.location.updated", f"seq={ack.seq}") + + await nc.close() + except Exception as e: + fail_test("NATS tests", str(e)) + +async def test_internal_notify(session, booking_id): + section("10. INTERNAL NOTIFY API (/api/v1/internal/notify)") + if not booking_id: + fail_test("Internal notify", "No booking_id") + return + try: + payload = { + "booking_id": booking_id, + "target": "customer", + "title": "Doormile Test βœ…", + "message": "E2E test notification β€” system is working!" + } + headers = { + "X-Internal-Key": INTERNAL_KEY, + "Content-Type": "application/json" + } + async with session.post(f"{API_BASE}/api/v1/internal/notify", + json=payload, headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test("Internal notify", f"resp={data}") + else: + fail_test("Internal notify", f"status={r.status} resp={data}") + except Exception as e: + fail_test("Internal notify", str(e)) + +async def test_reassign(session, booking_id): + section("11. INTERNAL REASSIGN API") + if not booking_id: + fail_test("Internal reassign", "No booking_id") + return + try: + headers = { + "X-Internal-Key": INTERNAL_KEY, + "Content-Type": "application/json" + } + async with session.post( + f"{API_BASE}/api/v1/internal/bookings/{booking_id}/reassign", + headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test("Internal reassign", f"resp={data}") + elif r.status == 400: + info(f"Reassign returned 400 β€” booking may not be in assignable state: {data}") + pass_test("Internal reassign endpoint reachable", f"status=400 (expected if not assigned yet)") + else: + fail_test("Internal reassign", f"status={r.status} resp={data}") + except Exception as e: + fail_test("Internal reassign", str(e)) + +async def test_websocket(booking_id): + section("12. WEBSOCKET LIVE TRACKING") + if not booking_id: + fail_test("WebSocket", "No booking_id") + return + try: + ws_url = f"wss://api.doormile.com/ws/bookings/{booking_id}/track" + info(f"Connecting to {ws_url}") + async with websockets.connect(ws_url, open_timeout=10) as ws: + pass_test("WebSocket connected", ws_url) + try: + msg = await asyncio.wait_for(ws.recv(), timeout=5) + data = json.loads(msg) + pass_test("WebSocket frame received", str(data)) + except asyncio.TimeoutError: + info("No frame in 5s β€” miler GPS not streaming yet (normal for test)") + pass_test("WebSocket connection works", "no GPS frame yet β€” expected") + except Exception as e: + fail_test("WebSocket", str(e)) + +async def test_booking_cache_api(session, token, booking_id): + section("13. BOOKING CACHE API (Redis GET endpoint)") + if not booking_id: + fail_test("Booking cache API", "No booking_id") + return + try: + headers = {"Authorization": f"Bearer {token}"} + async with session.get( + f"{API_BASE}/api/v1/bookings/cache/{booking_id}", + headers=headers, + timeout=aiohttp.ClientTimeout(total=10)) as r: + data = await r.json() + if r.status == 200: + pass_test("GET /bookings/cache/:id", f"fields={list(data.keys()) if isinstance(data,dict) else data}") + else: + fail_test("GET /bookings/cache/:id", f"status={r.status} {data}") + except Exception as e: + fail_test("Booking cache API", str(e)) + +def print_summary(): + section("FINAL TEST SUMMARY") + passed = [r for r in results if r[0] == "PASS"] + failed = [r for r in results if r[0] == "FAIL"] + + for status, name in results: + icon = "βœ…" if status == "PASS" else "❌" + color = GREEN if status == "PASS" else RED + print(f" {color}{icon} {name}{RESET}") + + pct = int(len(passed) / len(results) * 100) if results else 0 + print(f"\n{BOLD}{'='*60}{RESET}") + print(f"{BOLD} {len(results)} tests | " + f"{GREEN}{len(passed)} passed{RESET}{BOLD} | " + f"{RED}{len(failed)} failed{RESET}{BOLD} | " + f"{YELLOW}{pct}% success{RESET}") + print(f"{BOLD}{'='*60}{RESET}\n") + + if failed: + print(f"{RED}Failed tests to investigate:{RESET}") + for _, name in failed: + print(f" β€’ {name}") + print() + +async def main(): + print(f"\n{BOLD}{BLUE}") + print("╔══════════════════════════════════════════════════════════╗") + print("β•‘ DOORMILE FULL SYSTEM TEST SUITE v2 β•‘") + print("β•‘ Backend Β· NATS Β· Redis Β· Assignment Β· WebSocket Β· FCM β•‘") + print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + print(f"{RESET}\n") + + async with aiohttp.ClientSession() as session: + await test_health(session) + token = await login(session) + if not token: + fail_test("STOPPING β€” no auth token") + print_summary() + return + await test_profile(session, token) + await test_pricing(session, token) + await test_city_gate(session, token) + booking_id = await create_booking(session, token) + await test_assignment(session, token, booking_id) + await test_redis(booking_id) + await test_nats(booking_id) + await test_internal_notify(session, booking_id) + await test_reassign(session, booking_id) + await test_websocket(booking_id) + await test_booking_cache_api(session, token, booking_id) + + print_summary() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a1db926 --- /dev/null +++ b/main.py @@ -0,0 +1,206 @@ +""" +LogiFlow AI - Autonomous Logistics Agent System +================================================= + +Usage: + python main.py --production # Start all agents (production mode) + python main.py --dashboard # Launch admin dashboard + python main.py --portal # Launch customer portal + python main.py --help # Show this help +""" + +import asyncio +import sys +import signal +from datetime import datetime +from typing import Dict, Any + +# Force UTF-8 on Windows so emoji in output doesn't crash +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + +from dotenv import load_dotenv +load_dotenv() # must happen before any config import reads os.getenv() + +from core.logger import logger +from core.agent import MasterAgent, Agent +from core.message_bus import message_bus +from core.http_client import close_session +from core.types import AgentTask, MessageType, Priority + +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 + + +class LogiFlowAI: + """Main orchestration class β€” manages all agents and system lifecycle.""" + + def __init__(self): + logger.info("=" * 60) + logger.info("LOGIFLOW AI - AUTONOMOUS LOGISTICS SYSTEM") + logger.info("=" * 60) + + self.master_agent = MasterAgent() + self.agents: Dict[str, Agent] = {} + self.running = False + + def initialize_agents(self): + logger.info("Initializing AI Agents...") + + self.agents["ORDER_AGENT"] = OrderAgent() + self.agents["DISPATCH_AGENT"] = DispatchAgent() + self.agents["FLEET_AGENT"] = FleetAgent() + self.agents["HUB_AGENT"] = HubAgent() + self.agents["CUSTOMER_AGENT"] = CustomerAgent() + self.agents["EXCEPTION_AGENT"] = ExceptionAgent() + self.agents["ROUTE_OPTIMIZER"] = RouteOptimizerAgent() + + for agent in self.agents.values(): + self.master_agent.register_sub_agent(agent) + + logger.info(f"{len(self.agents) + 1} agents initialized (JARVIS + {len(self.agents)} specialized)") + + async def start_agents(self): + logger.info("Starting Agent System...") + self.running = True + + tasks = [asyncio.create_task(self.master_agent.start())] + tasks += [asyncio.create_task(a.start()) for a in self.agents.values()] + + logger.info("All agents running β€” waiting for work") + + try: + await asyncio.gather(*tasks, return_exceptions=True) + except asyncio.CancelledError: + pass + finally: + self.running = False + + async def stop(self): + self.running = False + await self.master_agent.stop() + for agent in self.agents.values(): + await agent.stop() + + def get_system_status(self) -> Dict[str, Any]: + status = { + "timestamp": datetime.now().isoformat(), + "system": "operational" if self.running else "stopped", + "agents": {}, + } + for agent_id, agent in self.agents.items(): + status["agents"][agent_id] = { + "status": agent.state.status, + "tasks_completed": agent.state.tasks_completed, + "current_task": agent.state.current_task, + } + status["agents"]["JARVIS"] = { + "status": self.master_agent.state.status, + "sub_agents": len(self.agents), + } + return status + + +# ------------------------------------------------------------------ # +# Run modes # +# ------------------------------------------------------------------ # + +async def production_mode(): + logger.info("PRODUCTION MODE β€” connecting to infrastructure") + + try: + await message_bus.connect() + except Exception as e: + logger.warning(f"Could not connect to NATS: {e} β€” using local in-process fallback") + + system = LogiFlowAI() + system.initialize_agents() + + logger.info("Infrastructure connected. Press Ctrl+C to stop.") + + loop = asyncio.get_running_loop() + + def _shutdown(signum, frame): + logger.info("Shutdown signal received β€” stopping agents...") + loop.create_task(_graceful_shutdown(system)) + + signal.signal(signal.SIGINT, _shutdown) + signal.signal(signal.SIGTERM, _shutdown) + + try: + await system.start_agents() + finally: + await message_bus.disconnect() + await close_session() + logger.info("Clean shutdown complete") + + +async def _graceful_shutdown(system: LogiFlowAI): + await system.stop() + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + for t in tasks: + t.cancel() + + +async def dashboard_mode(): + import subprocess + logger.info("Launching Admin Dashboard at http://localhost:8501") + subprocess.run(["streamlit", "run", "dashboard/admin_dashboard.py"]) + + +async def portal_mode(): + import subprocess + logger.info("Launching Customer Portal at http://localhost:8502") + subprocess.run(["streamlit", "run", "customer_portal/portal.py", "--server.port", "8502"]) + + +# ------------------------------------------------------------------ # +# Entry point # +# ------------------------------------------------------------------ # + +def main(): + if len(sys.argv) < 2 or sys.argv[1] == "--help": + print_help() + return + + cmd = sys.argv[1] + if cmd == "--production": + asyncio.run(production_mode()) + elif cmd == "--dashboard": + asyncio.run(dashboard_mode()) + elif cmd == "--portal": + asyncio.run(portal_mode()) + else: + logger.error(f"Unknown argument: {cmd}") + print_help() + sys.exit(1) + + +def print_help(): + print(""" +LogiFlow AI - Autonomous Logistics Agent System +=============================================== + +Usage: + python main.py --production Connect to NATS/Redis/Postgres and run forever + python main.py --dashboard Launch Streamlit admin dashboard + python main.py --portal Launch Streamlit customer portal + python main.py --help Show this help + +Infrastructure (set in .env): + NATS nats://doormile@66.116.226.161:4223 + Redis 66.116.226.255:6380 + PG DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD + API GO_API_BASE_URL + """) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ff5aed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +# Core AI Agent Framework +openai>=1.0.0 +anthropic>=0.21.0 + +# Real infrastructure +nats-py>=2.6.0 +redis>=5.0.0 +asyncpg>=0.29.0 +aiohttp>=3.9.0 + +# Web Framework +fastapi>=0.110.0 +uvicorn>=0.27.0 +websockets>=0.14.0 + +# Frontend +streamlit>=1.32.0 + +# Database +sqlalchemy>=2.0.0 +aiosqlite>=0.19.0 + +# Utilities +pydantic>=2.0.0 +python-dateutil>=2.8.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 + +# Monitoring +loguru>=0.7.0 \ No newline at end of file