172 lines
6.9 KiB
Python
172 lines
6.9 KiB
Python
"""
|
||
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
|