Initial commit

This commit is contained in:
2026-06-22 17:40:08 +05:30
commit c742ef0e53
308 changed files with 68519 additions and 0 deletions

586
app/routes/ml_admin.py Normal file
View File

@@ -0,0 +1,586 @@
"""
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))