Files
routesapi/app/routes/ml_admin.py
2026-07-06 15:15:51 +05:30

452 lines
19 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, 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
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/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(),
}
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))
# ---------------------------------------------------------------------------
# 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, _paths_for, _pattern_source, CSV_PATH
)
try:
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(vectors_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))