POST /api/v1/optimization/doormile/sequence. Same optimizer, same riders, same kitchens, same step semantics as the provider flow -- it only differs in the shape of the payload. Doormile keys on bookingid and sends pickuplatitude; the provider flow keys on deliveryid and sends pickuplat. Rather than making Doormile translate itself into jupiter's vocabulary on the way in and back out, it gets an endpoint in its own terms. The routing is not reimplemented: this maps onto the internal shape, calls the same optimize_provider_payload, and maps back. start_coords is passed through, so a run starts from the kitchen instead of being inferred from whichever stop happens to be first. /optimization/createdeliveries is deliberately untouched -- jupiter is live on it and tightening its body would break real deliveries. But that endpoint takes list[dict], so a payload with the wrong field names returns HTTP 200 "Success" with every coordinate defaulted to 0.0, no reordering and all distances zero. Nothing depends on the new endpoint yet, so it validates strictly and rejects that outright. It also rejects 0,0 coordinates: inside the valid range, but a point in the Atlantic that drags an entire route toward it. Single stop short-circuits to step 1 rather than spending an OR-Tools solve. Duplicate bookingids are refused, since results are keyed back by bookingid and a duplicate makes the caller's mapping ambiguous. Steps for bookings the caller never sent are dropped -- callers write these onto their own rows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.7 KiB
Python
181 lines
6.7 KiB
Python
"""FastAPI application — Daily Grubs Rider Dispatch API."""
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
|
|
# Load .env (DB_*, REDIS_*, GOOGLE_MAPS_API_KEY) into the environment early,
|
|
# before any service reads os.getenv at import time.
|
|
try:
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
except Exception:
|
|
pass
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from app.core.exception_handlers import (
|
|
api_exception_handler,
|
|
general_exception_handler,
|
|
http_exception_handler,
|
|
validation_exception_handler,
|
|
)
|
|
from app.core.exceptions import APIException
|
|
from app.middleware.request_id import RequestIDMiddleware
|
|
from app.routes import cache_router, health_router, ml_router, ml_web_router, optimization_router, batch_analytics_router, riders_router, doormile_router
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging
|
|
# ---------------------------------------------------------------------------
|
|
from app.core.log_context import RequestIdFilter
|
|
|
|
_log_level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
|
_handler = logging.StreamHandler(sys.stdout)
|
|
_handler.addFilter(RequestIdFilter())
|
|
logging.basicConfig(
|
|
level=_log_level,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - [req:%(request_id)s] - [%(filename)s:%(lineno)d] - %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
handlers=[_handler],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
for _lib in ("httpx", "uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
logging.getLogger(_lib).setLevel(_log_level)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifespan (startup / shutdown)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
logger.info("[START] Route Optimization API starting up...")
|
|
|
|
# Warm up the delivery-history FAISS store in the background
|
|
try:
|
|
from app.services.vector.delivery_history_store import get_delivery_history_store
|
|
store = get_delivery_history_store()
|
|
logger.info(
|
|
f"[DeliveryHistory] FAISS store ready — {store.record_count()} historical records loaded."
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"[DeliveryHistory] Store warm-up failed (non-fatal): {e}")
|
|
|
|
# Warm up the customer-coord FAISS store
|
|
try:
|
|
from app.services.vector.faiss_customer_store import get_faiss_store
|
|
cs = get_faiss_store()
|
|
logger.info(f"[FAISSCoords] Customer coord store ready — {cs.record_count()} records.")
|
|
except Exception as e:
|
|
logger.warning(f"[FAISSCoords] Store warm-up failed (non-fatal): {e}")
|
|
|
|
# Log how many historical assignment events we have for analytics
|
|
try:
|
|
from app.services.ml.ml_data_collector import get_collector
|
|
n = get_collector().count_records()
|
|
logger.info(f"[Analytics] {n} assignment events in the analytics DB.")
|
|
except Exception as e:
|
|
logger.warning(f"[Analytics] DB check failed (non-fatal): {e}")
|
|
|
|
# Start the autonomous empirical-ETA sync agent (daemon thread).
|
|
# It mirrors completed deliveries locally and rebuilds learned ETAs on a
|
|
# schedule, so the request path never touches Postgres.
|
|
try:
|
|
from app.services.routing.delivery_history_service import get_delivery_history_service
|
|
from app.config.dynamic_config import get_config
|
|
cfg = get_config()
|
|
get_delivery_history_service().ensure_background_sync(
|
|
interval_hours=int(cfg.get("eta_sync_interval_hours", 6)),
|
|
days=int(cfg.get("eta_history_days", 14)),
|
|
)
|
|
logger.info("[ETA-Agent] autonomous empirical-ETA sync scheduled.")
|
|
except Exception as e:
|
|
logger.warning(f"[ETA-Agent] Sync agent start failed (non-fatal): {e}")
|
|
|
|
# Start the autonomous road-sequencing decision agent. It measures road vs
|
|
# aerial sequencing on real batches and turns routing_use_road_distance on/off
|
|
# by itself — no manual flag flip needed.
|
|
try:
|
|
from app.services.routing.road_sequencing_agent import get_road_agent
|
|
from app.config.dynamic_config import get_config
|
|
get_road_agent().ensure_background_agent(
|
|
interval_hours=int(get_config().get("routing_eval_interval_hours", 24)),
|
|
)
|
|
logger.info("[RoadAgent] autonomous road-sequencing decision agent scheduled.")
|
|
except Exception as e:
|
|
logger.warning(f"[RoadAgent] Decision agent start failed (non-fatal): {e}")
|
|
|
|
logger.info("[OK] Application ready.")
|
|
yield
|
|
logger.info("[STOP] Route Optimization API shutting down.")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = FastAPI(
|
|
title="Route Optimization API",
|
|
version="2.0.0",
|
|
docs_url="/docs",
|
|
redoc_url="/redoc",
|
|
openapi_url="/api/v1/openapi.json",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.add_middleware(RequestIDMiddleware)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
allow_headers=["*"],
|
|
expose_headers=["X-Request-ID", "X-Process-Time"],
|
|
)
|
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def add_process_time_header(request: Request, call_next):
|
|
start = time.time()
|
|
response = await call_next(request)
|
|
response.headers["X-Process-Time"] = str(round(time.time() - start, 4))
|
|
response.headers["X-API-Version"] = "2.0.0"
|
|
return response
|
|
|
|
|
|
app.add_exception_handler(APIException, api_exception_handler)
|
|
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
|
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
app.add_exception_handler(Exception, general_exception_handler)
|
|
|
|
app.include_router(optimization_router)
|
|
app.include_router(health_router)
|
|
app.include_router(cache_router)
|
|
app.include_router(ml_router)
|
|
app.include_router(ml_web_router)
|
|
app.include_router(batch_analytics_router)
|
|
app.include_router(riders_router)
|
|
app.include_router(doormile_router)
|
|
|
|
|
|
@app.get("/", tags=["Root"])
|
|
async def root(request: Request):
|
|
return {
|
|
"service": "Route Optimization API",
|
|
"version": "2.0.0",
|
|
"status": "operational",
|
|
"docs": "/docs",
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run("app.main:app", host="0.0.0.0", port=8002, reload=True)
|