/root/Routes-api on 31.97.228.132 is not a git repository. Work had been done directly on the box and existed nowhere else -- a single rm -rf from being lost, and impossible to review or roll back. Deploying the previous HEAD over it would have silently reverted all of this. Most visibly the Valhalla road-backend probe in main.py, whose own comment explains why it exists: road sequencing degrades to aerial silently by design, so an unreachable backend stays invisible, "which is exactly how the expired Google key went unnoticed". Overwriting it would have reintroduced precisely the failure it was written to catch, and the service would have kept answering 200 throughout. The server had also moved from Google Maps to Valhalla for road distance (VALHALLA_URL, road_backend_status, +190 lines in route_optimizer), extended docker-compose from 44 to 95 lines, and changed rider fetching, health, dynamic config and the cache layer. Only 10 files differ in substance. The other 27 that appeared to differ were CRLF-vs-LF noise -- the server writes CRLF -- and are normalised to LF here rather than committed as spurious whole-file rewrites. Committed as-is, before any change of mine, so the diff that follows is reviewable against what is actually running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
234 lines
9.6 KiB
Python
234 lines
9.6 KiB
Python
"""
|
|
Road-Sequencing Decision Agent
|
|
==============================
|
|
|
|
Autonomous controller for the `routing_use_road_distance` feature. Instead of a
|
|
human flipping a config flag, this agent periodically *measures* whether road-aware
|
|
sequencing (road travel-time matrix + OR-Tools open-TSP) actually beats the default
|
|
straight-line ordering on REAL recent batches, and turns the feature on or off by
|
|
itself — re-checking every cycle so it self-corrects if the gain ever disappears.
|
|
|
|
Decision (with hysteresis, so it doesn't flap):
|
|
mean travel-time gain >= routing_auto_enable_gain_pct -> enable
|
|
mean travel-time gain < routing_auto_disable_gain_pct -> disable
|
|
in between -> leave as-is
|
|
|
|
Every decision is stored (auditable) in DynamicConfig under `routing_road_eval`
|
|
and exposed via GET /api/v1/ml/road-eval. Cost is bounded: it evaluates only
|
|
`routing_eval_sample_batches` batches once per `routing_eval_interval_hours`.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import numpy as np
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _total(order_idx: List[int], matrix: List[List[float]]) -> float:
|
|
"""Total open-route cost: origin(0) -> drops in `order_idx` (1-based into matrix)."""
|
|
seq = [0] + [i + 1 for i in order_idx]
|
|
return sum(matrix[seq[k]][seq[k + 1]] for k in range(len(seq) - 1))
|
|
|
|
|
|
class RoadSequencingAgent:
|
|
"""Measures road-vs-aerial sequencing on real batches and auto-toggles the flag."""
|
|
|
|
def __init__(self):
|
|
self._opt = None # lazy RouteOptimizer
|
|
self._scheduler_started = False
|
|
self._lock = threading.Lock()
|
|
self.last_decision: Dict[str, Any] = {}
|
|
|
|
def _optimizer(self):
|
|
if self._opt is None:
|
|
from app.services.routing.route_optimizer import RouteOptimizer
|
|
self._opt = RouteOptimizer()
|
|
return self._opt
|
|
|
|
async def evaluate(self, sample_batches: int = 8, days: int = 14) -> Dict[str, Any]:
|
|
"""
|
|
Compare aerial-order vs road-order travel time on sampled real batches.
|
|
Returns mean gain % and per-batch detail. No flag changes here.
|
|
"""
|
|
from app.services.routing.delivery_history_service import get_delivery_history_service
|
|
from app.core.arrow_utils import calculate_haversine_matrix_vectorized
|
|
|
|
opt = self._optimizer()
|
|
if not opt.use_road_matrix:
|
|
return {"evaluated": 0, "reason": "no_matrix_backend", "mean_gain_pct": 0.0}
|
|
|
|
batches = get_delivery_history_service().sample_batches(
|
|
days=days, limit=sample_batches
|
|
)
|
|
if not batches:
|
|
return {"evaluated": 0, "reason": "no_local_batches", "mean_gain_pct": 0.0}
|
|
|
|
per_batch: List[Dict[str, Any]] = []
|
|
for b in batches:
|
|
origin, drops = b["origin"], b["drops"]
|
|
locs = [origin] + drops
|
|
matrix = await opt._road_duration_matrix(locs)
|
|
if matrix is None:
|
|
continue
|
|
# aerial order (current production behaviour)
|
|
lats = np.array([p[0] for p in locs])
|
|
lons = np.array([p[1] for p in locs])
|
|
aerial = calculate_haversine_matrix_vectorized(lats, lons)
|
|
aer = [i - 1 for i in opt._two_opt_improve(opt._solve_greedy(locs, aerial), aerial) if i != 0]
|
|
# road order (OR-Tools open-TSP on the road travel-time matrix)
|
|
road = [i - 1 for i in opt._solve_tsp_ortools(locs, matrix) if i != 0]
|
|
|
|
# Human's ACTUAL route: drops are already in delivered order
|
|
# (_load_raw_rows is ORDER BY deliverytime), so identity = what the
|
|
# rider really drove. This is the "do we beat the humans?" baseline.
|
|
human = list(range(len(drops)))
|
|
|
|
t_aer = _total(aer, matrix)
|
|
t_road = _total(road, matrix)
|
|
t_human = _total(human, matrix)
|
|
gain = (100.0 * (t_aer - t_road) / t_aer) if t_aer > 0 else 0.0
|
|
gain_vs_human = (100.0 * (t_human - t_road) / t_human) if t_human > 0 else 0.0
|
|
per_batch.append({
|
|
"rider": b["rider"], "day": b["day"], "drops": len(drops),
|
|
"aerial_min": round(t_aer, 1), "road_min": round(t_road, 1),
|
|
"human_min": round(t_human, 1),
|
|
"gain_pct": round(gain, 1),
|
|
"gain_vs_human_pct": round(gain_vs_human, 1),
|
|
"beat_human": t_road <= t_human + 1e-9,
|
|
})
|
|
|
|
if not per_batch:
|
|
return {"evaluated": 0, "reason": "matrix_unavailable", "mean_gain_pct": 0.0}
|
|
|
|
n = len(per_batch)
|
|
mean_gain = sum(x["gain_pct"] for x in per_batch) / n
|
|
mean_vs_human = sum(x["gain_vs_human_pct"] for x in per_batch) / n
|
|
beats = sum(1 for x in per_batch if x["beat_human"])
|
|
return {
|
|
"evaluated": n,
|
|
"mean_gain_pct": round(mean_gain, 2),
|
|
"median_gain_pct": round(sorted(x["gain_pct"] for x in per_batch)[n // 2], 2),
|
|
"human_beat_rate_pct": round(100.0 * beats / n, 1),
|
|
"human_beat_count": f"{beats}/{n}",
|
|
"mean_gain_vs_human_pct": round(mean_vs_human, 2),
|
|
"per_batch": per_batch,
|
|
}
|
|
|
|
def decide_and_apply(self) -> Dict[str, Any]:
|
|
"""Run an evaluation and autonomously enable/disable road sequencing."""
|
|
from app.config.dynamic_config import get_config
|
|
cfg = get_config()
|
|
|
|
sample = int(cfg.get("routing_eval_sample_batches", 8))
|
|
days = int(cfg.get("routing_eval_days", 14))
|
|
enable_thr = float(cfg.get("routing_auto_enable_gain_pct", 3.0))
|
|
disable_thr = float(cfg.get("routing_auto_disable_gain_pct", 1.0))
|
|
min_batches = int(cfg.get("routing_eval_min_batches", 3))
|
|
auto_manage = bool(cfg.get("routing_auto_manage", True))
|
|
|
|
try:
|
|
result = asyncio.run(self.evaluate(sample_batches=sample, days=days))
|
|
except RuntimeError:
|
|
# An event loop is already running in this thread — use a fresh one.
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
result = loop.run_until_complete(self.evaluate(sample_batches=sample, days=days))
|
|
finally:
|
|
loop.close()
|
|
|
|
current = bool(cfg.get("routing_use_road_distance", False))
|
|
evaluated = result.get("evaluated", 0)
|
|
gain = result.get("mean_gain_pct", 0.0)
|
|
action = "kept"
|
|
new_state = current
|
|
|
|
if not auto_manage:
|
|
action = "auto_manage_off"
|
|
elif evaluated < min_batches:
|
|
action = "insufficient_data"
|
|
else:
|
|
if gain >= enable_thr and not current:
|
|
new_state = True
|
|
cfg.set("routing_use_road_distance", True, source="road_agent")
|
|
action = "enabled"
|
|
elif gain < disable_thr and current:
|
|
new_state = False
|
|
cfg.set("routing_use_road_distance", False, source="road_agent")
|
|
action = "disabled"
|
|
|
|
decision = {
|
|
"decided_at": datetime.utcnow().isoformat(),
|
|
"action": action,
|
|
"flag_before": current,
|
|
"flag_after": new_state,
|
|
"mean_gain_pct": gain,
|
|
"enable_threshold_pct": enable_thr,
|
|
"disable_threshold_pct": disable_thr,
|
|
"evaluation": result,
|
|
}
|
|
self.last_decision = decision
|
|
try:
|
|
# Persist a compact copy for audit (without the full per-batch list).
|
|
compact = {k: v for k, v in decision.items() if k != "evaluation"}
|
|
compact["evaluated"] = evaluated
|
|
compact["human_beat_rate_pct"] = result.get("human_beat_rate_pct")
|
|
compact["mean_gain_vs_human_pct"] = result.get("mean_gain_vs_human_pct")
|
|
cfg.set("routing_road_eval", compact, source="road_agent")
|
|
except Exception:
|
|
pass
|
|
|
|
logger.info(
|
|
f"[RoadAgent] action={action} gain_vs_aerial={gain}% evaluated={evaluated} "
|
|
f"flag {current}->{new_state}"
|
|
)
|
|
if evaluated:
|
|
logger.info(
|
|
f"[RoadAgent] beat humans on {result.get('human_beat_count', '?')} batches "
|
|
f"(avg {result.get('mean_gain_vs_human_pct', 0)}% faster than actual delivered order)"
|
|
)
|
|
return decision
|
|
|
|
def ensure_background_agent(self, interval_hours: int = 24,
|
|
warmup_seconds: int = 120) -> bool:
|
|
"""Start the autonomous decision loop once (daemon thread)."""
|
|
with self._lock:
|
|
if self._scheduler_started:
|
|
return False
|
|
self._scheduler_started = True
|
|
|
|
def _loop():
|
|
# Let the ETA sync agent populate the local mirror first.
|
|
time.sleep(max(0, warmup_seconds))
|
|
logger.info(f"[RoadAgent] autonomous road-sequencing decision loop started "
|
|
f"(interval={interval_hours}h)")
|
|
while True:
|
|
try:
|
|
self.decide_and_apply()
|
|
except Exception as e:
|
|
logger.warning(f"[RoadAgent] decision cycle failed (will retry): {e}")
|
|
from app.config.dynamic_config import get_config
|
|
hrs = int(get_config().get("routing_eval_interval_hours", interval_hours))
|
|
time.sleep(max(1, hrs) * 3600)
|
|
|
|
threading.Thread(target=_loop, daemon=True, name="road-seq-agent").start()
|
|
return True
|
|
|
|
|
|
_agent: Optional[RoadSequencingAgent] = None
|
|
_agent_lock = threading.Lock()
|
|
|
|
|
|
def get_road_agent() -> RoadSequencingAgent:
|
|
global _agent
|
|
with _agent_lock:
|
|
if _agent is None:
|
|
_agent = RoadSequencingAgent()
|
|
return _agent
|