/root/Routes-api on 31.97.228.132 is not a git repository. Work had been done directly on the box and existed nowhere else -- a single rm -rf from being lost, and impossible to review or roll back. Deploying the previous HEAD over it would have silently reverted all of this. Most visibly the Valhalla road-backend probe in main.py, whose own comment explains why it exists: road sequencing degrades to aerial silently by design, so an unreachable backend stays invisible, "which is exactly how the expired Google key went unnoticed". Overwriting it would have reintroduced precisely the failure it was written to catch, and the service would have kept answering 200 throughout. The server had also moved from Google Maps to Valhalla for road distance (VALHALLA_URL, road_backend_status, +190 lines in route_optimizer), extended docker-compose from 44 to 95 lines, and changed rider fetching, health, dynamic config and the cache layer. Only 10 files differ in substance. The other 27 that appeared to differ were CRLF-vs-LF noise -- the server writes CRLF -- and are normalised to LF here rather than committed as spurious whole-file rewrites. Committed as-is, before any change of mine, so the diff that follows is reviewable against what is actually running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
201 lines
7.6 KiB
Python
201 lines
7.6 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_*, VALHALLA_URL) 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
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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}")
|
|
|
|
# Probe the Valhalla matrix backend once. Road sequencing degrades to aerial
|
|
# silently by design, so without this an unreachable or still-building backend
|
|
# stays invisible — which is exactly how the expired Google key went unnoticed.
|
|
try:
|
|
from app.services.routing.route_optimizer import road_backend_status
|
|
_rb = await road_backend_status()
|
|
if not _rb.get("configured"):
|
|
logger.warning("[RoadMatrix] VALHALLA_URL not set - aerial sequencing only.")
|
|
elif _rb.get("reachable"):
|
|
logger.info(
|
|
f"[RoadMatrix] Valhalla ready at {_rb.get('url')} "
|
|
f"(version={_rb.get('version')}, tiles={_rb.get('tileset_last_modified')})"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"[RoadMatrix] Valhalla at {_rb.get('url')} unreachable "
|
|
f"({_rb.get('detail')}) - aerial fallback until it answers."
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"[RoadMatrix] Backend probe 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.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)
|