new changes in the api

This commit is contained in:
2026-07-06 15:15:51 +05:30
parent c742ef0e53
commit 871981035a
43 changed files with 414 additions and 1975 deletions

Binary file not shown.

View File

@@ -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,

View File

@@ -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,