Files
routesapi/app/services/routing/rider_affinity_service.py
2026-06-22 17:40:08 +05:30

172 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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