Files
routesapi/app/routes/ml_admin.py
2026-06-22 17:40:08 +05:30

587 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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))