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