399 lines
18 KiB
Python
399 lines
18 KiB
Python
"""
|
|
Express Dispatch Agent — owns the DoormileExpress (console) batch-booking flow.
|
|
|
|
The normal B2C booking flow is untouched: each booking is auto-assigned by the Go
|
|
backend's per-booking engine (routemate AI + fallback). Express *batch* bookings
|
|
are different — a console operator drops N bookings for one tenant at once (e.g. a
|
|
DailyGrubs lunch rush across several kitchens), and those should be assigned as a
|
|
set to that tenant's own riders, then road-sequenced, not each picked off
|
|
independently by nearest-rider.
|
|
|
|
This agent is triggered by `express.dispatch_requested` — the console operator's
|
|
manual "dispatch" action, fired once orders have accumulated batch by batch (the
|
|
bulk endpoint only creates them, unassigned). It then:
|
|
|
|
1. reads the batch's bookings and the tenant's AVAILABLE riders (Go internal API),
|
|
2. distributes bookings across those riders (proximity + load, radius-guarded),
|
|
3. asks the Route Optimization API (routes.workolik.com, Valhalla road routing)
|
|
for each rider's stop order,
|
|
4. writes the assignments back through the Go internal API — which stays the
|
|
single writer of assignment state; this agent decides, Go persists.
|
|
|
|
Division of labour, on purpose: the agent chooses *who* and *what order*; every DB
|
|
invariant (already-assigned guard, transactional assign, notify) lives in Go.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from math import radians, cos, sin, asin, sqrt
|
|
from typing import Dict, List, Any, Optional
|
|
|
|
import nats
|
|
import nats.js.errors
|
|
|
|
from core.agent import SpecializedAgent
|
|
from core.types import AgentTask
|
|
from core.logger import logger
|
|
from core.http_client import api_get, api_post
|
|
from core.registry import registry
|
|
from config.system_config import (
|
|
NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD,
|
|
GO_API_BASE_URL, INTERNAL_API_KEY, ROUTE_OPTIMIZER_URL,
|
|
)
|
|
|
|
# When off, the agent computes and logs the full assignment plan but does NOT
|
|
# write it — an observation/dry-run mode to watch its decisions before it acts.
|
|
# Defaults on, because assigning an unassigned booking is the intended happy path
|
|
# (unlike the ExceptionAgent's irreversible reassignment, which defaults off).
|
|
EXPRESS_AGENT_AUTONOMOUS = os.getenv("EXPRESS_AGENT_AUTONOMOUS", "true").lower() == "true"
|
|
|
|
# The Go backend owns the EXPRESS stream carrying express.dispatch_requested;
|
|
# this agent is a pure consumer and must not create it. Empty = auto-discover.
|
|
EXPRESS_STREAM = os.getenv("EXPRESS_STREAM", "")
|
|
|
|
# A booking is only handed to a rider within this radius of it. Guards against a
|
|
# Coimbatore order landing on a Nagercoil rider without needing a zone-id mapping:
|
|
# they are >100 km apart, so the radius excludes it naturally.
|
|
MAX_RADIUS_KM = float(os.getenv("EXPRESS_MAX_RADIUS_KM", "30"))
|
|
|
|
# Ceiling on stops per rider in one batch, so the load spreads instead of piling
|
|
# onto whichever rider happens to be nearest every kitchen.
|
|
MAX_PER_RIDER = int(os.getenv("EXPRESS_MAX_PER_RIDER", "5"))
|
|
|
|
# Each stop already on a rider adds this much "virtual distance" when scoring, so
|
|
# an equally-near but emptier rider wins. Purely a load-balancing lever.
|
|
LOAD_PENALTY_KM = float(os.getenv("EXPRESS_LOAD_PENALTY_KM", "3"))
|
|
|
|
_INTERNAL_HEADERS = {"X-Internal-Key": INTERNAL_API_KEY}
|
|
_SEQUENCE_PATH = "/api/v1/optimization/doormile/sequence"
|
|
|
|
|
|
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
"""Great-circle distance in km. Same formula as the Go side's haversineKM."""
|
|
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.0
|
|
|
|
|
|
class ExpressDispatchAgent(SpecializedAgent):
|
|
"""Consumes express.dispatch_requested and runs tenant-scoped batch
|
|
assignment + route sequencing for DoormileExpress bookings."""
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
agent_id="EXPRESS_DISPATCH_AGENT",
|
|
domain="express_dispatch",
|
|
description="Assigns and sequences DoormileExpress batch bookings per tenant",
|
|
)
|
|
self._nats_nc = None
|
|
self._nats_js = None
|
|
self._nats_subs: list = []
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Lifecycle #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def start(self):
|
|
await self._connect_nats()
|
|
await super().start()
|
|
|
|
async def stop(self):
|
|
await super().stop()
|
|
for sub in self._nats_subs:
|
|
try:
|
|
await sub.unsubscribe()
|
|
except Exception:
|
|
pass
|
|
if self._nats_nc:
|
|
await self._nats_nc.drain()
|
|
|
|
async def _connect_nats(self):
|
|
try:
|
|
self._nats_nc = await nats.connect(
|
|
servers=[f"nats://{NATS_HOST}:{NATS_PORT}"],
|
|
user=NATS_USER,
|
|
password=NATS_PASSWORD,
|
|
max_reconnect_attempts=10,
|
|
)
|
|
self._nats_js = self._nats_nc.jetstream()
|
|
sub = await self._bind_consumer(
|
|
"express.dispatch_requested", "express-dispatch-requested",
|
|
self._on_nats_express_dispatch,
|
|
)
|
|
if sub is not None:
|
|
self._nats_subs.append(sub)
|
|
logger.info("ExpressDispatchAgent bound express.dispatch_requested consumer")
|
|
except Exception as e:
|
|
logger.error(f"ExpressDispatchAgent NATS connect error: {e}")
|
|
|
|
async def _bind_consumer(self, subject, durable, cb):
|
|
"""Bind a durable push consumer to whichever existing stream carries
|
|
`subject`. Returns None (with an actionable log) if no stream carries it,
|
|
never a swallowed error — mirrors DispatchAgent."""
|
|
try:
|
|
stream = EXPRESS_STREAM or await self._nats_js.find_stream_name_by_subject(subject)
|
|
sub = await self._nats_js.subscribe(subject, durable=durable, stream=stream, cb=cb)
|
|
logger.info(f"ExpressDispatchAgent bound '{subject}' on stream '{stream}' (durable={durable})")
|
|
return sub
|
|
except nats.js.errors.NotFoundError:
|
|
logger.error(
|
|
f"ExpressDispatchAgent: no JetStream stream carries '{subject}'. The Go "
|
|
f"backend must declare a stream covering it (db/streams.go EXPRESS), or set "
|
|
f"EXPRESS_STREAM. Not subscribing."
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"ExpressDispatchAgent failed to bind '{subject}': {e}")
|
|
return None
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Event handler #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def _on_nats_express_dispatch(self, msg):
|
|
# Ack first-thing safety: the handler is idempotent (the Go writeback
|
|
# rejects already-assigned bookings), so a redelivery cannot double-assign.
|
|
try:
|
|
data = json.loads(msg.data.decode())
|
|
tenant_id = data.get("tenantid")
|
|
booking_ids = data.get("booking_ids") or []
|
|
logger.info(
|
|
f"[EXPRESS] dispatch received — tenant={tenant_id} bookings={len(booking_ids)}"
|
|
)
|
|
if not registry.skill_enabled("express_batch_dispatch"):
|
|
logger.info("[EXPRESS] skill express_batch_dispatch is disabled in the agent registry; batch left for the console")
|
|
elif tenant_id and booking_ids:
|
|
await self._handle_batch(tenant_id, booking_ids)
|
|
except Exception as e:
|
|
logger.error(f"ExpressDispatchAgent batch handler error: {e}")
|
|
finally:
|
|
await msg.ack()
|
|
|
|
async def _handle_batch(self, tenant_id: int, booking_ids: List[int]):
|
|
bookings = await self._fetch_bookings(booking_ids)
|
|
# Only assign bookings that are genuinely open — a booking already picked
|
|
# up by the single-booking path, or cancelled, must be left alone.
|
|
pending = [
|
|
b for b in bookings
|
|
if not b.get("assignedmileruserid")
|
|
and b.get("status") not in ("Cancelled", "Converted_To_Consignment")
|
|
and _valid_coords(b)
|
|
]
|
|
if not pending:
|
|
logger.info(f"[EXPRESS] tenant={tenant_id}: no assignable bookings in batch")
|
|
return
|
|
|
|
riders = await self._fetch_riders(tenant_id)
|
|
if not riders:
|
|
logger.warning(
|
|
f"[EXPRESS] tenant={tenant_id}: {len(pending)} bookings but no available "
|
|
f"riders — leaving them for manual assignment."
|
|
)
|
|
return
|
|
|
|
# 1. Distribute bookings across riders (who carries what).
|
|
rider_stops = self._distribute(pending, riders)
|
|
assigned_count = sum(len(v) for v in rider_stops.values())
|
|
unassigned = len(pending) - assigned_count
|
|
logger.info(
|
|
f"[EXPRESS] tenant={tenant_id}: distributed {assigned_count}/{len(pending)} "
|
|
f"across {len(rider_stops)} rider(s), {unassigned} left unassigned"
|
|
)
|
|
|
|
# 2. Sequence each rider's stops (what order) and build the writeback.
|
|
assignments: List[Dict[str, Any]] = []
|
|
for miler_user_id, stops in rider_stops.items():
|
|
rider = next(r for r in riders if r["miler_user_id"] == miler_user_id)
|
|
sequenced = await self._sequence(tenant_id, rider, stops)
|
|
assignments.extend(sequenced)
|
|
|
|
if not assignments:
|
|
logger.warning(f"[EXPRESS] tenant={tenant_id}: nothing to write back")
|
|
return
|
|
|
|
# 3. Write back (or, in observation mode, only log the plan).
|
|
if not registry.autonomous("EXPRESS_DISPATCH_AGENT", EXPRESS_AGENT_AUTONOMOUS):
|
|
logger.info(
|
|
f"[EXPRESS] tenant={tenant_id} OBSERVE-ONLY plan "
|
|
f"(autonomy off): {json.dumps(assignments)}"
|
|
)
|
|
return
|
|
|
|
result = await api_post(
|
|
f"{GO_API_BASE_URL}/api/v1/internal/express/assign",
|
|
json={"assignments": assignments},
|
|
headers=_INTERNAL_HEADERS,
|
|
)
|
|
if result is None:
|
|
logger.error(
|
|
f"[EXPRESS] tenant={tenant_id}: writeback failed — bookings remain "
|
|
f"unassigned and assignable by hand from the console."
|
|
)
|
|
return
|
|
logger.info(
|
|
f"[EXPRESS] tenant={tenant_id}: wrote {result.get('assigned')}/"
|
|
f"{result.get('total')} assignments"
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Distribution — proximity + load, radius-guarded #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _distribute(self, bookings: List[Dict], riders: List[Dict]) -> Dict[int, List[Dict]]:
|
|
"""Greedy: each booking goes to the reachable, under-capacity rider with
|
|
the lowest (distance-to-pickup + load penalty). A booking with no rider
|
|
within MAX_RADIUS_KM is left unassigned rather than forced onto a distant
|
|
rider. Deterministic and explainable — the road optimizer does the clever
|
|
part (ordering); this only decides who."""
|
|
load: Dict[int, int] = {r["miler_user_id"]: 0 for r in riders}
|
|
rider_stops: Dict[int, List[Dict]] = {}
|
|
# Tuned in the agent registry (skill express_batch_dispatch), else env.
|
|
max_per_rider = int(registry.threshold("express_batch_dispatch", "maxPerRider", MAX_PER_RIDER))
|
|
max_radius_km = registry.threshold("express_batch_dispatch", "maxRadiusKm", MAX_RADIUS_KM)
|
|
load_penalty_km = registry.threshold("express_batch_dispatch", "loadPenaltyKm", LOAD_PENALTY_KM)
|
|
|
|
# Assign larger-pickup-cluster bookings first is unnecessary; simple
|
|
# stable order keeps it predictable and testable.
|
|
for b in bookings:
|
|
plat, plon = b["pickuplatitude"], b["pickuplongitude"]
|
|
best_rider = None
|
|
best_score = None
|
|
for r in riders:
|
|
if load[r["miler_user_id"]] >= max_per_rider:
|
|
continue
|
|
rlat, rlon = _rider_location(r, plat, plon)
|
|
dist = _haversine_km(rlat, rlon, plat, plon)
|
|
if dist > max_radius_km:
|
|
continue
|
|
score = dist + load_penalty_km * load[r["miler_user_id"]]
|
|
if best_score is None or score < best_score:
|
|
best_score = score
|
|
best_rider = r
|
|
if best_rider is None:
|
|
continue # unreachable / all full — leave for manual assignment
|
|
mid = best_rider["miler_user_id"]
|
|
load[mid] += 1
|
|
rider_stops.setdefault(mid, []).append(b)
|
|
return rider_stops
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Sequencing — routes.workolik.com (Valhalla road routing) #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def _sequence(self, tenant_id: int, rider: Dict, stops: List[Dict]) -> List[Dict[str, Any]]:
|
|
"""Return the writeback rows for one rider's stops, road-sequenced. A
|
|
single stop needs no solve — it is step 1. Two or more go to the optimizer;
|
|
if that call fails, the stops are still assigned, just at step 0
|
|
(unsequenced) so a slow/broken optimizer never blocks the assignment."""
|
|
mid = rider["miler_user_id"]
|
|
if len(stops) == 1:
|
|
b = stops[0]
|
|
return [_writeback_row(b["booking_id"], mid, 1, 0, 0, 0, 0)]
|
|
|
|
# Rider start = current GPS, or the first pickup if they have no live fix.
|
|
start_lat, start_lon = _rider_location(rider, stops[0]["pickuplatitude"], stops[0]["pickuplongitude"])
|
|
payload = {
|
|
"tenantid": tenant_id,
|
|
"mileruserid": mid,
|
|
"startlatitude": start_lat,
|
|
"startlongitude": start_lon,
|
|
"bookings": [
|
|
{
|
|
"bookingid": b["booking_id"],
|
|
"bookingno": b.get("booking_no"),
|
|
"pickuplatitude": b["pickuplatitude"],
|
|
"pickuplongitude": b["pickuplongitude"],
|
|
"deliverylatitude": b["deliverylatitude"],
|
|
"deliverylongitude": b["deliverylongitude"],
|
|
}
|
|
for b in stops
|
|
],
|
|
}
|
|
resp = await api_post(f"{ROUTE_OPTIMIZER_URL}{_SEQUENCE_PATH}", json=payload)
|
|
|
|
if not resp or not resp.get("success") or not resp.get("stops"):
|
|
logger.warning(
|
|
f"[EXPRESS] rider={mid}: optimizer returned no sequence — assigning "
|
|
f"{len(stops)} stops unsequenced (step 0)"
|
|
)
|
|
return [_writeback_row(b["booking_id"], mid, 0, 0, 0, 0, 0) for b in stops]
|
|
|
|
by_id = {b["booking_id"]: b for b in stops}
|
|
rows: List[Dict[str, Any]] = []
|
|
for s in resp["stops"]:
|
|
bid = s.get("bookingid")
|
|
if bid not in by_id:
|
|
continue # never write a step for a booking we did not send
|
|
rows.append(_writeback_row(
|
|
bid, mid,
|
|
int(s.get("step") or 0),
|
|
float(s.get("previouskms") or 0),
|
|
float(s.get("cumulativekms") or 0),
|
|
int(s.get("etaminutes") or 0),
|
|
int(s.get("cumulativeeta") or 0),
|
|
))
|
|
# Any stop the optimizer dropped is still assigned, unsequenced.
|
|
seen = {r["booking_id"] for r in rows}
|
|
for b in stops:
|
|
if b["booking_id"] not in seen:
|
|
rows.append(_writeback_row(b["booking_id"], mid, 0, 0, 0, 0, 0))
|
|
return rows
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Go internal API reads #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def _fetch_bookings(self, booking_ids: List[int]) -> List[Dict]:
|
|
ids = ",".join(str(i) for i in booking_ids)
|
|
resp = await api_get(
|
|
f"{GO_API_BASE_URL}/api/v1/internal/express/bookings",
|
|
params={"ids": ids},
|
|
headers=_INTERNAL_HEADERS,
|
|
)
|
|
return (resp or {}).get("bookings", []) if resp else []
|
|
|
|
async def _fetch_riders(self, tenant_id: int) -> List[Dict]:
|
|
resp = await api_get(
|
|
f"{GO_API_BASE_URL}/api/v1/internal/express/riders",
|
|
params={"tenantid": tenant_id},
|
|
headers=_INTERNAL_HEADERS,
|
|
)
|
|
return (resp or {}).get("riders", []) if resp else []
|
|
|
|
# SpecializedAgent requires a task handler; this agent is event-driven, so
|
|
# there is no queued-task path.
|
|
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
|
return {"status": "ignored", "reason": "event-driven agent"}
|
|
|
|
|
|
def _valid_coords(b: Dict) -> bool:
|
|
return bool(
|
|
b.get("pickuplatitude") and b.get("pickuplongitude")
|
|
and b.get("deliverylatitude") and b.get("deliverylongitude")
|
|
)
|
|
|
|
|
|
def _rider_location(r: Dict, fallback_lat: float, fallback_lon: float):
|
|
"""A rider freshly flipped Available may have no GPS fix yet (0,0). Assume
|
|
they are at the pickup they'd serve rather than off the coast of Africa, so
|
|
they stay assignable instead of being excluded by the radius guard."""
|
|
lat, lon = r.get("latitude") or 0, r.get("longitude") or 0
|
|
if lat == 0 or lon == 0:
|
|
return fallback_lat, fallback_lon
|
|
return lat, lon
|
|
|
|
|
|
def _writeback_row(booking_id, miler_user_id, step, previouskms, cumulativekms, etaminutes, cumulativeeta):
|
|
return {
|
|
"booking_id": booking_id,
|
|
"miler_user_id": miler_user_id,
|
|
"step": step,
|
|
"previouskms": previouskms,
|
|
"cumulativekms": cumulativekms,
|
|
"etaminutes": etaminutes,
|
|
"cumulativeeta": cumulativeeta,
|
|
}
|