Initial commit
This commit is contained in:
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}"
|
||||
Reference in New Issue
Block a user