""" 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