Initial commit
This commit is contained in:
171
app/services/routing/rider_affinity_service.py
Normal file
171
app/services/routing/rider_affinity_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Rider Affinity Service (learned, agentic)
|
||||
=========================================
|
||||
|
||||
Learns each rider's real kitchen affinity and operating area from the local
|
||||
delivery mirror (`delivery_raw`) and exposes them MERGED with the curated config
|
||||
in `app/config/rider_preferences.py`:
|
||||
|
||||
* get_preferred_kitchens() = curated RIDER_PREFERRED_KITCHENS ∪ learned
|
||||
(rider served a kitchen >= `rider_affinity_min_deliveries` times). Union only —
|
||||
never drops a curated owner.
|
||||
* get_home_locations() = curated RIDER_HOME_LOCATIONS, with a learned drop
|
||||
centroid filled in ONLY for riders missing from the config.
|
||||
|
||||
These feed SOFT steering (preference discount / home bonus / distance bypass) in
|
||||
the optimizer + assignment service. HARD kitchen locks and BLOCKED_RIDERS remain
|
||||
sourced from the curated config — learned data can only broaden preference, never
|
||||
change who is *eligible* for a kitchen. Recomputed by the autonomous agent loop.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app.config.rider_preferences import RIDER_PREFERRED_KITCHENS, RIDER_HOME_LOCATIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_int_rid(v: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class RiderAffinityService:
|
||||
def __init__(self):
|
||||
self._learned_pref: Dict[int, List[str]] = {}
|
||||
self._learned_home: Dict[int, Tuple[float, float]] = {}
|
||||
self._last_refreshed: Optional[datetime] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def refresh(self, days: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Recompute learned affinity from the local delivery mirror (no DB hit)."""
|
||||
from app.config.dynamic_config import get_config
|
||||
from app.services.routing.delivery_history_service import (
|
||||
get_delivery_history_service, normalize_kitchen,
|
||||
)
|
||||
cfg = get_config()
|
||||
window = int(days if days is not None else cfg.get("eta_history_days", 14))
|
||||
min_n = int(cfg.get("rider_affinity_min_deliveries", 10))
|
||||
|
||||
rows = get_delivery_history_service()._load_raw_rows(window)
|
||||
counts: Dict[int, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
coords: Dict[int, List[Tuple[float, float]]] = defaultdict(list)
|
||||
for r in rows:
|
||||
rid = _to_int_rid(r.get("userid"))
|
||||
if rid is None:
|
||||
continue
|
||||
kitchen = normalize_kitchen(r.get("pickupcustomer"))
|
||||
if kitchen and kitchen != "?":
|
||||
counts[rid][kitchen] += 1
|
||||
try:
|
||||
la, lo = float(r.get("dlat")), float(r.get("dlon"))
|
||||
if la and lo:
|
||||
coords[rid].append((la, lo))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
learned_pref = {
|
||||
rid: [k for k, n in kc.items() if n >= min_n]
|
||||
for rid, kc in counts.items()
|
||||
}
|
||||
learned_pref = {rid: ks for rid, ks in learned_pref.items() if ks}
|
||||
learned_home = {
|
||||
rid: (round(sum(p[0] for p in pts) / len(pts), 6),
|
||||
round(sum(p[1] for p in pts) / len(pts), 6))
|
||||
for rid, pts in coords.items() if pts
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
self._learned_pref = learned_pref
|
||||
self._learned_home = learned_home
|
||||
self._last_refreshed = datetime.utcnow()
|
||||
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"riders_with_learned_kitchens": len(learned_pref),
|
||||
"riders_with_learned_home": len(learned_home),
|
||||
"min_deliveries": min_n,
|
||||
"window_days": window,
|
||||
"refreshed_at": self._last_refreshed.isoformat(),
|
||||
}
|
||||
logger.info(
|
||||
f"[Affinity] learned kitchens for {len(learned_pref)} riders, "
|
||||
f"home for {len(learned_home)} (min_deliveries={min_n})"
|
||||
)
|
||||
return summary
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
if self._last_refreshed is None:
|
||||
try:
|
||||
self.refresh()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Affinity] lazy refresh failed: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_preferred_kitchens(self) -> Dict[int, List[str]]:
|
||||
"""Curated config ∪ learned (union — never drops a curated owner)."""
|
||||
from app.config.dynamic_config import get_config
|
||||
if not bool(get_config().get("rider_affinity_enabled", True)):
|
||||
return {rid: list(v) for rid, v in RIDER_PREFERRED_KITCHENS.items()}
|
||||
self._ensure_loaded()
|
||||
merged: Dict[int, List[str]] = {rid: list(v) for rid, v in RIDER_PREFERRED_KITCHENS.items()}
|
||||
with self._lock:
|
||||
learned = {rid: list(v) for rid, v in self._learned_pref.items()}
|
||||
for rid, kitchens in learned.items():
|
||||
base = merged.setdefault(rid, [])
|
||||
base_lower = {b.lower() for b in base}
|
||||
for k in kitchens:
|
||||
if k.lower() not in base_lower:
|
||||
base.append(k)
|
||||
return merged
|
||||
|
||||
def get_home_locations(self) -> Dict[int, Tuple[float, float]]:
|
||||
"""Curated home primary; learned drop centroid only for riders absent from config."""
|
||||
from app.config.dynamic_config import get_config
|
||||
if not bool(get_config().get("rider_affinity_enabled", True)):
|
||||
return dict(RIDER_HOME_LOCATIONS)
|
||||
self._ensure_loaded()
|
||||
merged: Dict[int, Tuple[float, float]] = dict(RIDER_HOME_LOCATIONS)
|
||||
with self._lock:
|
||||
learned = dict(self._learned_home)
|
||||
for rid, home in learned.items():
|
||||
if rid not in merged or merged.get(rid) in (None, (0.0, 0.0)):
|
||||
merged[rid] = home
|
||||
return merged
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Learned-vs-config diff for the admin endpoint."""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
learned_pref = {rid: list(v) for rid, v in self._learned_pref.items()}
|
||||
learned_home = dict(self._learned_home)
|
||||
config_riders = set(RIDER_PREFERRED_KITCHENS)
|
||||
new_pref_riders = sorted(set(learned_pref) - config_riders)
|
||||
return {
|
||||
"last_refreshed": self._last_refreshed.isoformat() if self._last_refreshed else None,
|
||||
"config_preferred_riders": sorted(config_riders),
|
||||
"learned_preferred": {str(k): v for k, v in sorted(learned_pref.items())},
|
||||
"riders_newly_learned_not_in_config": new_pref_riders,
|
||||
"learned_home_count": len(learned_home),
|
||||
}
|
||||
|
||||
|
||||
_affinity: Optional[RiderAffinityService] = None
|
||||
_affinity_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_rider_affinity() -> RiderAffinityService:
|
||||
global _affinity
|
||||
with _affinity_lock:
|
||||
if _affinity is None:
|
||||
_affinity = RiderAffinityService()
|
||||
return _affinity
|
||||
Reference in New Issue
Block a user