new changes in the api
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/routes/__pycache__/riders.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/riders.cpython-312.pyc
Normal file
Binary file not shown.
@@ -3,8 +3,8 @@ Analytics & ML Admin API
|
||||
=========================
|
||||
Exposes historical assignment quality data, the empirical-ETA pipeline, and the
|
||||
autonomous road-sequencing / rider-affinity agents. (The XGBoost/Optuna hypertuner,
|
||||
ID3 risk tree, and profit predictor have all been retired — they never affected
|
||||
assignment.)
|
||||
ID3 risk tree, profit predictor, and Thompson-Sampling strategy bandit have all
|
||||
been retired — none of them ever affected assignment or routing behavior.)
|
||||
|
||||
Endpoints:
|
||||
GET /api/v1/ml/status – quality trend, analytics DB + history stats
|
||||
@@ -12,7 +12,6 @@ Endpoints:
|
||||
GET /api/v1/ml/config – active config values
|
||||
PATCH /api/v1/ml/config – manual config override
|
||||
POST /api/v1/ml/reset – reset config to defaults
|
||||
POST /api/v1/ml/strategy – change optimization strategy
|
||||
POST /api/v1/ml/refresh-eta – sync nearledb + rebuild empirical ETA stats
|
||||
GET /api/v1/ml/eta-accuracy – formula vs empirical ETA backtest
|
||||
GET/POST /api/v1/ml/road-eval – autonomous road-sequencing decision
|
||||
@@ -103,7 +102,6 @@ def ml_analytics():
|
||||
"hourly_stats": collector.get_hourly_stats(),
|
||||
"zone_stats": collector.get_zone_stats(),
|
||||
"quality_histogram": collector.get_quality_histogram(),
|
||||
"strategy_comparison": collector.get_strategy_comparison(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] analytics: {e}", exc_info=True)
|
||||
@@ -163,142 +161,6 @@ def ml_reset():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /strategy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/strategy", summary="Change the optimization strategy")
|
||||
def ml_strategy(strategy: str = Body(default="balanced", embed=True)):
|
||||
"""
|
||||
Choices: balanced | fuel_saver | aggressive_speed | zone_strict
|
||||
Affects how the quality score is computed in analytics only.
|
||||
"""
|
||||
valid = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"]
|
||||
if strategy not in valid:
|
||||
raise HTTPException(400, f"Invalid strategy. Choose from {valid}")
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
get_config().set("ml_strategy", strategy)
|
||||
return {"status": "ok", "strategy": strategy}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] strategy: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auto-tune
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/auto-tune", summary="Run SQL-based strategy auto-tuner")
|
||||
def ml_auto_tune():
|
||||
"""
|
||||
Analyses the assignment log and picks the best-performing ml_strategy
|
||||
based on average quality score across all recorded calls.
|
||||
|
||||
Rules:
|
||||
- A strategy needs ≥ 10 calls to be considered.
|
||||
- At least 2 strategies must have enough data to compare.
|
||||
- If a better strategy is found it is written to DynamicConfig
|
||||
immediately and takes effect on the next /riderassign call.
|
||||
- Also returns per-hour breakdown so you can see peak-hour patterns.
|
||||
|
||||
Safe to call any time. Runs in < 100ms.
|
||||
"""
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
from app.config.dynamic_config import get_config, DEFAULTS
|
||||
try:
|
||||
collector = get_collector()
|
||||
cfg = get_config()
|
||||
comparison = collector.get_strategy_comparison()
|
||||
hourly = collector.get_hourly_stats()
|
||||
total_records = collector.count_records()
|
||||
|
||||
if total_records == 0:
|
||||
return {
|
||||
"status": "no_data",
|
||||
"message": "No assignment events logged yet. "
|
||||
"Call /riderassign a few times first.",
|
||||
"total_records": 0,
|
||||
}
|
||||
|
||||
qualified = [s for s in comparison if s["call_count"] >= 10]
|
||||
current_strategy = cfg.get("ml_strategy", "balanced")
|
||||
action = "no_change"
|
||||
recommendation = None
|
||||
|
||||
if len(qualified) >= 2:
|
||||
best = max(qualified, key=lambda x: x["avg_quality"])
|
||||
recommendation = best["strategy"]
|
||||
if best["strategy"] != current_strategy:
|
||||
cfg.set("ml_strategy", best["strategy"], source="auto_tuner")
|
||||
action = "updated"
|
||||
logger.info(
|
||||
f"[AutoTune API] Strategy: '{current_strategy}' → "
|
||||
f"'{best['strategy']}' (quality={best['avg_quality']:.1f})"
|
||||
)
|
||||
elif len(comparison) > 0:
|
||||
action = "insufficient_data"
|
||||
recommendation = comparison[0]["strategy"] # best so far even if < 10 calls
|
||||
|
||||
# Per-hour best strategy (informational — not auto-applied)
|
||||
# Shows which strategy logged the highest quality at each hour
|
||||
hour_best: list = []
|
||||
if hourly:
|
||||
for h in hourly:
|
||||
# Find which strategy performed best in this hour block
|
||||
# (simple: use the dominant strategy for that hour from comparison)
|
||||
hour_best.append({
|
||||
"hour": h["hour"],
|
||||
"avg_quality": h["avg_quality"],
|
||||
"call_count": h["call_count"],
|
||||
"sla_breaches": h["sla_breaches"],
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"current_strategy": cfg.get("ml_strategy", "balanced"),
|
||||
"recommendation": recommendation,
|
||||
"total_records": total_records,
|
||||
"strategy_comparison": comparison,
|
||||
"hourly_quality": hour_best,
|
||||
"message": (
|
||||
f"Strategy updated to '{recommendation}'."
|
||||
if action == "updated"
|
||||
else "Current strategy is already optimal."
|
||||
if action == "no_change"
|
||||
else "More data needed (≥ 10 calls per strategy to compare)."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] auto-tune: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /bandit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/bandit", summary="Thompson Sampling bandit — posterior stats per context")
|
||||
def ml_bandit():
|
||||
"""
|
||||
Shows the current state of the RL strategy bandit.
|
||||
|
||||
Each context (time_band|load_band) has 4 arms (strategies).
|
||||
For each arm:
|
||||
mean_reward — expected quality / 100 based on posterior mean
|
||||
observations — number of observed calls (excluding prior)
|
||||
alpha / beta — Beta distribution parameters
|
||||
|
||||
The bandit uses Thompson Sampling to select strategies automatically
|
||||
on every /riderassign call, balancing exploration vs exploitation.
|
||||
"""
|
||||
try:
|
||||
from app.services.ml.strategy_bandit import get_bandit
|
||||
return {"status": "ok", **get_bandit().get_stats()}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] bandit: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -346,21 +208,24 @@ def ml_history(patterns: bool = False):
|
||||
(all clear dominant-rider zones, sorted by pattern score).
|
||||
"""
|
||||
from app.services.vector.delivery_history_store import (
|
||||
get_delivery_history_store, _INDEX_PATH, _META_PATH, CSV_PATH
|
||||
get_delivery_history_store, _paths_for, _pattern_source, CSV_PATH
|
||||
)
|
||||
try:
|
||||
store = get_delivery_history_store()
|
||||
meta = {}
|
||||
if os.path.isfile(_META_PATH):
|
||||
with open(_META_PATH, "r", encoding="utf-8") as f:
|
||||
store = get_delivery_history_store()
|
||||
source = _pattern_source()
|
||||
vectors_path, _records_path, meta_path = _paths_for(source)
|
||||
meta = {}
|
||||
if os.path.isfile(meta_path):
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
|
||||
resp = {
|
||||
"status": "ok",
|
||||
"pattern_source": source,
|
||||
"record_count": store.record_count(),
|
||||
"pattern_count": store.pattern_count(),
|
||||
"index_ready": store.record_count() > 0,
|
||||
"disk_index": os.path.isfile(_INDEX_PATH),
|
||||
"disk_index": os.path.isfile(vectors_path),
|
||||
"csv_path": CSV_PATH,
|
||||
"csv_exists": os.path.isfile(CSV_PATH),
|
||||
"saved_meta": meta,
|
||||
|
||||
@@ -11,7 +11,6 @@ from fastapi import APIRouter, Body, Request, Depends, status, HTTPException, Qu
|
||||
|
||||
from app.controllers.route_controller import RouteController
|
||||
from app.core.exceptions import APIException
|
||||
from app.core.arrow_utils import save_optimized_route_parquet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,68 +84,19 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
return float("inf")
|
||||
|
||||
|
||||
def _auto_tune_strategy(collector) -> None:
|
||||
"""
|
||||
SQL-based strategy auto-tuner.
|
||||
|
||||
Reads quality_score statistics from the assignment log grouped by
|
||||
ml_strategy. If one strategy clearly outperforms the rest (minimum
|
||||
10 calls, ≥ 2 strategies compared) it is written to DynamicConfig so
|
||||
all future requests benefit automatically.
|
||||
|
||||
Safe to call at any time — DynamicConfig write is idempotent.
|
||||
"""
|
||||
try:
|
||||
comparison = collector.get_strategy_comparison()
|
||||
if not comparison or len(comparison) < 2:
|
||||
return # not enough variety to compare
|
||||
|
||||
# Only consider strategies with at least 10 real observations
|
||||
qualified = [s for s in comparison if s["call_count"] >= 10]
|
||||
if not qualified:
|
||||
return
|
||||
|
||||
best = max(qualified, key=lambda x: x["avg_quality"])
|
||||
from app.config.dynamic_config import get_config as _gc
|
||||
cfg = _gc()
|
||||
current = cfg.get("ml_strategy", "balanced")
|
||||
if best["strategy"] != current:
|
||||
cfg.set("ml_strategy", best["strategy"], source="auto_tuner")
|
||||
logger.info(
|
||||
f"[AutoTune] Strategy updated: '{current}' → '{best['strategy']}' "
|
||||
f"(avg_quality={best['avg_quality']:.1f}, n={best['call_count']})"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[AutoTune] Strategy '{current}' already optimal "
|
||||
f"(avg_quality={best['avg_quality']:.1f})"
|
||||
)
|
||||
except Exception as _ate:
|
||||
logger.warning(f"[AutoTune] Failed (non-fatal): {_ate}")
|
||||
|
||||
|
||||
def _bg_log_and_maybe_retrain(
|
||||
def _bg_log_assignment(
|
||||
num_orders: int,
|
||||
num_riders: int,
|
||||
hyperparams: dict,
|
||||
assignments: dict,
|
||||
unassigned_count: int,
|
||||
elapsed_ms: float,
|
||||
bandit_context: "tuple | None" = None, # (time_band, load_band)
|
||||
) -> None:
|
||||
"""
|
||||
Background worker: log event → update RL bandit → auto-retrain ID3
|
||||
→ auto-tune strategy. Runs in _ml_executor (never blocks the response).
|
||||
|
||||
Thresholds:
|
||||
every call → bandit posterior update (Thompson Sampling RL)
|
||||
every 100 evts → greedy SQL auto-tuner (backup check, rarely fires now)
|
||||
"""
|
||||
"""Background worker: log the assignment event. Runs in _ml_executor so
|
||||
it never blocks the API response."""
|
||||
try:
|
||||
from app.services.ml.ml_data_collector import get_collector as _gc
|
||||
collector = _gc()
|
||||
|
||||
# ── 1. Log event, get back quality_score ──────────────────────────
|
||||
quality_score = collector.log_assignment_event(
|
||||
num_orders=num_orders,
|
||||
num_riders=num_riders,
|
||||
@@ -155,29 +105,8 @@ def _bg_log_and_maybe_retrain(
|
||||
unassigned_count=unassigned_count,
|
||||
elapsed_ms=elapsed_ms,
|
||||
) or 50.0
|
||||
|
||||
count = collector.count_records()
|
||||
logger.debug(f"[ML BG] Logged event #{count}, quality={quality_score:.1f}")
|
||||
|
||||
# ── 2. Thompson Sampling bandit update (RL feedback) ──────────────
|
||||
if bandit_context:
|
||||
try:
|
||||
from app.services.ml.strategy_bandit import get_bandit as _gb
|
||||
_t_band, _l_band = bandit_context
|
||||
_strategy = hyperparams.get("ml_strategy", "balanced")
|
||||
get_bandit_fn = _gb
|
||||
get_bandit_fn().update(_t_band, _l_band, _strategy, quality_score)
|
||||
logger.debug(
|
||||
f"[Bandit] Updated ctx={_t_band}|{_l_band} "
|
||||
f"arm={_strategy} reward={quality_score/100:.3f}"
|
||||
)
|
||||
except Exception as _be:
|
||||
logger.debug(f"[Bandit] Update failed (non-fatal): {_be}")
|
||||
|
||||
# ── 3. Greedy SQL auto-tune every 100 events (backup) ────────────
|
||||
if count >= 20 and count % 100 == 0:
|
||||
_auto_tune_strategy(collector)
|
||||
|
||||
except Exception as _e:
|
||||
logger.warning(f"[ML BG] Background task failed (non-fatal): {_e}")
|
||||
|
||||
@@ -242,18 +171,6 @@ async def provider_optimize_forward(
|
||||
try:
|
||||
url = "https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries"
|
||||
result = await controller.optimize_and_forward_provider_payload(body, url)
|
||||
|
||||
# Parquet snapshot — offloaded so it never blocks the response
|
||||
def _snap():
|
||||
try:
|
||||
os.makedirs("data/snapshots", exist_ok=True)
|
||||
snapshot_path = f"data/snapshots/route_{int(time.time())}.parquet"
|
||||
save_optimized_route_parquet(body, snapshot_path)
|
||||
logger.info(f"Apache Arrow: Snapshot saved to {snapshot_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not save Arrow snapshot: {e}")
|
||||
_ml_executor.submit(_snap)
|
||||
|
||||
return result
|
||||
except APIException:
|
||||
raise
|
||||
@@ -554,7 +471,6 @@ async def assign_orders_to_riders(
|
||||
request: Request,
|
||||
body: Any = Body(default=None),
|
||||
reshuffle: bool = Query(False, alias="reshuffle"),
|
||||
hypertuning_params: str = None,
|
||||
):
|
||||
"""
|
||||
Smart assignment of orders to riders.
|
||||
@@ -653,11 +569,10 @@ async def assign_orders_to_riders(
|
||||
|
||||
# 3. Log summary after all data is in hand
|
||||
mode_str = "reshuffle" if do_reshuffle else "normal"
|
||||
tuning_str = hypertuning_params if hypertuning_params else "null"
|
||||
logger.info(
|
||||
f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"[API HIT] POST /api/v1/optimization/riderassign\n"
|
||||
f"[CONFIG] Mode: {mode_str.upper()} | Hypertuning: {tuning_str} | Active Riders: {len(riders)}\n"
|
||||
f"[CONFIG] Mode: {mode_str.upper()} | Active Riders: {len(riders)}\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
)
|
||||
logger.info(f"[API] riderassign ▶ orders={len(orders)} riders={len(riders)} mode={mode_str}")
|
||||
@@ -811,44 +726,10 @@ async def assign_orders_to_riders(
|
||||
vrp_orders = [o for o in orders if id(o) not in history_pre_assigned_ids]
|
||||
|
||||
# 3. Run Assignment (AssignmentService)
|
||||
# -- Per-request strategy override (thread-safe via contextvars) --
|
||||
# Old approach mutated _cfg._cache directly — a race condition when two
|
||||
# concurrent requests set different strategies simultaneously.
|
||||
# New approach: each async task gets its OWN copy of the ContextVar,
|
||||
# so overrides never leak between concurrent requests.
|
||||
from app.config.dynamic_config import get_config, set_request_strategy
|
||||
from app.services.ml.behavior_analyzer import time_band as _tb, load_band as _lb
|
||||
from datetime import datetime as _DT
|
||||
from app.config.dynamic_config import get_config
|
||||
|
||||
_cfg = get_config()
|
||||
|
||||
# ── Bandit context (used both for selection + background update) ──
|
||||
_bandit_t_band = _tb(_DT.now().isoformat())
|
||||
_bandit_l_band = _lb(len(vrp_orders) / max(1, len(riders)))
|
||||
_bandit_context = (_bandit_t_band, _bandit_l_band)
|
||||
|
||||
valid_strategies = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"]
|
||||
if hypertuning_params and hypertuning_params in valid_strategies:
|
||||
# Explicit caller override always wins
|
||||
set_request_strategy(hypertuning_params)
|
||||
logger.info(f"[HYPERTUNE] Per-request strategy override: {hypertuning_params}")
|
||||
else:
|
||||
# ── Thompson Sampling: let the bandit choose ──────────────────
|
||||
# The bandit samples from Beta posteriors for this (time, load)
|
||||
# context and picks the arm with highest sample. It naturally
|
||||
# explores when uncertain and exploits known-good strategies as
|
||||
# data accumulates. Replaces the one-size-fits-all greedy picker.
|
||||
try:
|
||||
from app.services.ml.strategy_bandit import get_bandit as _gb
|
||||
_bandit_strategy = _gb().select(_bandit_t_band, _bandit_l_band)
|
||||
set_request_strategy(_bandit_strategy)
|
||||
logger.info(
|
||||
f"[Bandit] Strategy='{_bandit_strategy}' "
|
||||
f"ctx={_bandit_t_band}|{_bandit_l_band}"
|
||||
)
|
||||
except Exception as _bse:
|
||||
logger.debug(f"[Bandit] Selection failed (non-fatal): {_bse}")
|
||||
|
||||
optimizer = RouteOptimizer()
|
||||
|
||||
# ── PHASE 3a: TRUE VRP (primary solver) ──────────────────────────────
|
||||
@@ -863,7 +744,7 @@ async def assign_orders_to_riders(
|
||||
if not do_reshuffle: # VRP not meaningful during reshuffle (intentional exploration)
|
||||
try:
|
||||
# Build minimal rider info for VRP
|
||||
from app.services.routing.kalman_filter import smooth_rider_locations
|
||||
from app.services.routing.gps_smoother import smooth_rider_locations
|
||||
_smooth_riders = smooth_rider_locations(list(riders))
|
||||
|
||||
from app.services.core.assignment_service import AssignmentService as _AS
|
||||
@@ -1272,21 +1153,19 @@ async def assign_orders_to_riders(
|
||||
risk_meta: dict = {"label": "n/a", "model_trained": False, "deprecated": True}
|
||||
|
||||
# ── BACKGROUND ML LOGGING ────────────────────────────────────────────
|
||||
# Fire-and-forget: log this assignment event to SQLite, auto-retrain
|
||||
# ID3 every 50 events, auto-tune strategy every 100 events.
|
||||
# Fire-and-forget: log this assignment event to SQLite.
|
||||
# Uses _ml_executor so the API response is never delayed.
|
||||
try:
|
||||
_elapsed_ms = (time.time() - _t0) * 1000
|
||||
_hyp_snapshot = get_config().get_all() # frozen copy for this call
|
||||
_ml_executor.submit(
|
||||
_bg_log_and_maybe_retrain,
|
||||
_bg_log_assignment,
|
||||
len(orders),
|
||||
len(riders),
|
||||
_hyp_snapshot,
|
||||
{rid: list(ords) for rid, ords in assignments.items()},
|
||||
len(unassigned_orders),
|
||||
round(_elapsed_ms, 1),
|
||||
_bandit_context, # (time_band, load_band) for RL update
|
||||
)
|
||||
except Exception as _mle:
|
||||
logger.debug(f"[ML BG] Submit failed (non-fatal): {_mle}")
|
||||
@@ -1354,7 +1233,6 @@ async def assign_orders_to_riders(
|
||||
},
|
||||
"reshuffle_mode": do_reshuffle,
|
||||
"solver_mode": "vrp_optimal" if used_vrp else "2phase_heuristic",
|
||||
"hypertuning_params": hypertuning_params or "default",
|
||||
"faiss_coord_corrections": _faiss_corrected,
|
||||
"faiss_customer_records": _faiss_store.record_count() if _faiss_store else 0,
|
||||
"faiss_history_preassigned": _history_hits,
|
||||
|
||||
Reference in New Issue
Block a user