""" 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.) Endpoints: GET /api/v1/ml/status – quality trend, analytics DB + history stats GET /api/v1/ml/analytics – hourly stats, zone stats, histogram 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 GET /api/v1/ml/rider-affinity – learned vs configured rider→kitchen affinity GET /api/v1/ml/export – download assignment log as CSV GET /api/v1/ml/history – delivery-history FAISS store stats """ import json import logging import os from fastapi import APIRouter, Body, HTTPException from fastapi.responses import PlainTextResponse, FileResponse logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/v1/ml", tags=["Analytics & ML"], responses={500: {"description": "Internal server error"}}, ) web_router = APIRouter(tags=["ML Monitor Web Dashboard"]) # --------------------------------------------------------------------------- # Dashboard (HTML) # --------------------------------------------------------------------------- @web_router.get("/ml-ops", summary="Visual ML monitoring dashboard") def ml_dashboard(): path = os.path.join(os.getcwd(), "app/templates/ml_dashboard.html") if not os.path.isfile(path): raise HTTPException(status_code=404, detail=f"Dashboard template not found at {path}") return FileResponse(path) # --------------------------------------------------------------------------- # GET /status # --------------------------------------------------------------------------- @router.get("/status", summary="Assignment quality trend & store stats") def ml_status(): """ Returns: - How many assignment events are logged - Recent quality score trend (last 50 calls) - Delivery history FAISS store record count - Active config values """ try: from app.services.ml.ml_data_collector import get_collector from app.services.vector.delivery_history_store import get_delivery_history_store from app.config.dynamic_config import get_config collector = get_collector() history = get_delivery_history_store() cfg = get_config() return { "status": "ok", "db_records": collector.count_records(), "quality_trend": collector.get_recent_quality_trend(last_n=50), "delivery_history": { "record_count": history.record_count(), "status": "ready" if history.record_count() > 0 else "empty", }, "config": cfg.get_all(), } except Exception as e: logger.error(f"[ML API] status: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # GET /analytics # --------------------------------------------------------------------------- @router.get("/analytics", summary="Hourly stats, zone stats, quality histogram") def ml_analytics(): """Operational analytics from historical assignment logs.""" try: from app.services.ml.ml_data_collector import get_collector collector = get_collector() return { "status": "ok", "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) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # GET /config # --------------------------------------------------------------------------- @router.get("/config", summary="Current active configuration values") def ml_config(): from app.config.dynamic_config import get_config, DEFAULTS try: cfg = get_config() all_values = cfg.get_all() cached_keys = set(cfg._cache.keys()) annotated = { k: {"value": v, "source": "override" if k in cached_keys else "default"} for k, v in all_values.items() } return { "status": "ok", "hyperparameters": annotated, "total_params": len(annotated), "override_count": sum(1 for x in annotated.values() if x["source"] == "override"), } except Exception as e: logger.error(f"[ML API] config: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/config", summary="Override specific config values") def ml_config_patch(payload: dict = Body(...)): """Manually set any config key, e.g. {\"road_factor\": 1.4}""" from app.config.dynamic_config import get_config try: get_config().set_bulk(payload, source="ml_admin") return {"status": "ok", "updated": list(payload.keys())} except Exception as e: logger.error(f"[ML API] config patch: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # POST /reset # --------------------------------------------------------------------------- @router.post("/reset", summary="Reset all config overrides to factory defaults") def ml_reset(): from app.config.dynamic_config import get_config try: get_config().reset_to_defaults() return {"status": "ok", "message": "All config values reset to factory defaults."} except Exception as e: logger.error(f"[ML API] reset: {e}", exc_info=True) 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)) # --------------------------------------------------------------------------- # GET /rider-efficiency # --------------------------------------------------------------------------- @router.get("/rider-efficiency", summary="Per-rider efficiency scores from 30-day CSV history") def ml_rider_efficiency(): """ Computes efficiency scores for each rider from the delivery_details.csv. Metrics: delivery_count — total deliveries in the 30-day window avg_km — average km per delivery unique_zones — number of distinct 1.1 km delivery cells served efficiency_score — normalised 0..1 composite (high = efficient) Used internally as tiebreaker during solo rider consolidation and Phase-0 pattern pre-assignment host selection. """ try: from app.services.vector.delivery_history_store import get_delivery_history_store scores = get_delivery_history_store().get_rider_efficiency_scores() ranked = sorted(scores.items(), key=lambda x: x[1]["efficiency_score"], reverse=True) return { "status": "ok", "rider_count": len(scores), "riders": [{"rider_id": rid, **data} for rid, data in ranked], } except Exception as e: logger.error(f"[ML API] rider-efficiency: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # GET /history # --------------------------------------------------------------------------- @router.get("/history", summary="Delivery-history FAISS store stats + pattern table") def ml_history(patterns: bool = False): """ Shows the delivery history store status. Add ?patterns=true to include the full pattern table (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 ) try: store = get_delivery_history_store() meta = {} if os.path.isfile(_META_PATH): with open(_META_PATH, "r", encoding="utf-8") as f: meta = json.load(f) resp = { "status": "ok", "record_count": store.record_count(), "pattern_count": store.pattern_count(), "index_ready": store.record_count() > 0, "disk_index": os.path.isfile(_INDEX_PATH), "csv_path": CSV_PATH, "csv_exists": os.path.isfile(CSV_PATH), "saved_meta": meta, } if patterns: resp["patterns"] = store.get_pattern_stats() return resp except Exception as e: logger.error(f"[ML API] history: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # POST /inject-corrections # --------------------------------------------------------------------------- @router.post("/inject-corrections", summary="Inject manually-corrected delivery CSV as high-weight priors") def ml_inject_corrections( corrections_path: str = Body(default="delivery_corrections.csv", embed=True), weight: int = Body(default=5, embed=True), ): """ Loads a manually-corrected delivery CSV and injects it into the live delivery-history pattern table as high-weight prior evidence. Each record in the corrections file is counted `weight` times (default 5), so 5 correction votes easily override 1-2 noise votes from regular history. The correction CSV must have the same columns as delivery_details.csv: pickupcustomer, pickuplat, pickuplon, deliverylat, deliverylong, userid, ridername After injection the pattern table is rebuilt in-memory and saved to disk. No server restart needed. Parameters: corrections_path Path inside the container (default: delivery_corrections.csv) weight How many times each correction record is counted (default: 5) """ from app.services.vector.delivery_history_store import get_delivery_history_store try: store = get_delivery_history_store() result = store.inject_corrections(corrections_path, weight=weight) return {"status": "ok", **result} except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: logger.error(f"[ML API] inject-corrections: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # POST /reload-history # --------------------------------------------------------------------------- @router.post("/reload-history", summary="Rebuild FAISS delivery-history index from CSV") def ml_reload_history(): """ Forces a full rebuild of the delivery-history FAISS index from delivery_details.csv, then saves the new index to disk. Call this whenever you update the CSV with a fresh 30-day export. No server restart needed — the in-memory store is hot-swapped. Returns the number of records now loaded. """ from app.services.vector.delivery_history_store import get_delivery_history_store try: store = get_delivery_history_store() n = store.reload_from_csv() return { "status": "ok", "record_count": n, "message": f"FAISS history index rebuilt from CSV — {n} records loaded and saved to disk.", } except Exception as e: logger.error(f"[ML API] reload-history: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # POST /refresh-eta # --------------------------------------------------------------------------- @router.post("/refresh-eta", summary="Sync nearledb -> local mirror and rebuild empirical ETA stats") def ml_refresh_eta( days: int = Body(default=None, embed=True), tenant_id: int = Body(default=916, embed=True), full: bool = Body(default=False, embed=True), ): """ Manually trigger the empirical-ETA pipeline (the autonomous agent does this on a schedule too): incremental READ-ONLY pull of new completed deliveries from nearledb into the local mirror, then rebuild the learned medians. `days` defaults to `eta_history_days`. Set `full=true` to force a full backfill of the window instead of an incremental sync. """ from app.services.routing.delivery_history_service import get_delivery_history_service from app.config.dynamic_config import get_config try: window = int(days if days is not None else get_config().get("eta_history_days", 14)) result = get_delivery_history_service().refresh_eta_stats( days=window, tenant_id=tenant_id, full=full ) return {"status": "ok", "result": result} except Exception as e: logger.error(f"[ML API] refresh-eta: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # GET /eta-accuracy # --------------------------------------------------------------------------- @router.get("/eta-accuracy", summary="Backtest: formula ETA MAE vs empirical ETA MAE") def ml_eta_accuracy(days: int = None, tenant_id: int = 916): """ Honest time-split backtest. Trains empirical medians on the older 80% of the history window and reports mean-absolute-error (minutes) of the formula vs the empirical model on the held-out recent 20%. Use this as the gate before trusting empirical ETAs: if `interpretation` is not "empirical_better", leave `eta_empirical_enabled=false` and investigate. """ from app.services.routing.delivery_history_service import get_delivery_history_service from app.config.dynamic_config import get_config try: cfg = get_config() window = int(days if days is not None else cfg.get("eta_history_days", 14)) result = get_delivery_history_service().backtest( days=window, tenant_id=tenant_id, min_samples=int(cfg.get("eta_min_samples", 20)), stat=str(cfg.get("eta_stat", "median")), ) return {"status": "ok", "backtest": result} except Exception as e: logger.error(f"[ML API] eta-accuracy: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # GET/POST /road-eval (autonomous road-sequencing decision agent) # --------------------------------------------------------------------------- @router.get("/road-eval", summary="Last autonomous road-sequencing decision") def ml_road_eval_get(): """ Show the road-sequencing agent's most recent decision: whether road-aware ordering beat straight-line ordering on real batches, the measured mean travel-time gain, and whether it auto-enabled/disabled `routing_use_road_distance`. """ from app.services.routing.road_sequencing_agent import get_road_agent from app.config.dynamic_config import get_config try: agent = get_road_agent() return { "status": "ok", "road_distance_enabled": bool(get_config().get("routing_use_road_distance", False)), "auto_manage": bool(get_config().get("routing_auto_manage", True)), "last_decision": agent.last_decision or get_config().get("routing_road_eval", {}), } except Exception as e: logger.error(f"[ML API] road-eval get: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/road-eval", summary="Run the road-sequencing decision now") def ml_road_eval_run(): """ Trigger an immediate evaluation + autonomous decision (the agent also does this on a schedule). Measures road vs aerial sequencing on sampled real batches and may flip `routing_use_road_distance` based on the measured gain. Also reports whether we beat the riders' actual delivered order. """ from app.services.routing.road_sequencing_agent import get_road_agent try: return {"status": "ok", "decision": get_road_agent().decide_and_apply()} except Exception as e: logger.error(f"[ML API] road-eval run: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # GET /rider-affinity (learned vs configured rider→kitchen affinity) # --------------------------------------------------------------------------- @router.get("/rider-affinity", summary="Learned vs configured rider→kitchen affinity") def ml_rider_affinity(refresh: bool = False): """ Show the learned rider→kitchen affinity (from real delivery history) merged with the curated config. `?refresh=true` recomputes from the local mirror first. Learned data only augments SOFT steering — hard kitchen locks and BLOCKED_RIDERS stay sourced from the curated config. """ from app.services.routing.rider_affinity_service import get_rider_affinity try: aff = get_rider_affinity() if refresh: aff.refresh() return {"status": "ok", "affinity": aff.get_summary()} except Exception as e: logger.error(f"[ML API] rider-affinity: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # GET /export # --------------------------------------------------------------------------- @router.get("/export", summary="Download all assignment logs as CSV") def ml_export(): from app.services.ml.ml_data_collector import get_collector try: csv_data = get_collector().export_csv() response = PlainTextResponse(content=csv_data, media_type="text/csv") response.headers["Content-Disposition"] = 'attachment; filename="assignment_log.csv"' return response except Exception as e: logger.error(f"[ML API] export: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e))