Initial commit

This commit is contained in:
2026-06-22 17:40:08 +05:30
commit c742ef0e53
308 changed files with 68519 additions and 0 deletions

View 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