rewardpilot-api / app /availability.py
sammy786's picture
Deploy current accuracy engine, rich content APIs, and verified offer gate
9acbdec
Raw History Blame Contribute Delete
7.1 kB
"""
RewardPilot - City & platform availability
===========================================
Answers two questions the channel comparison needs:
1. Which city is the user in? (explicit city > city named in the query >
nearest metro centroid from GPS coordinates)
2. Is a booking platform - and, when we know the chain, this specific
merchant - actually available in that city?
Curated seed, same philosophy as offers.py: structured and dated in spirit,
refreshed by the ingestion pipeline (connectors/) later. Coverage values are
illustrative until a live feed is wired.
Availability levels:
"available" - platform (and merchant, when known) confirmed in this city
"unknown" - no data to confirm; channel is kept but flagged unverified
"unavailable" - confirmed absent (platform not in city, or chain not listed
on this platform); channel is dropped from the comparison
Mirrored on-device in app/src/data/availability.ts.
"""
import math
from typing import Dict, Optional, Set, Union
# ---------------------------------------------------------------------------
# Cities: canonical name -> (lat, lng) centroid. Used for GPS fallback.
# ---------------------------------------------------------------------------
CITIES: Dict[str, tuple] = {
"mumbai": (19.0760, 72.8777),
"delhi": (28.6139, 77.2090),
"bengaluru": (12.9716, 77.5946),
"hyderabad": (17.3850, 78.4867),
"chennai": (13.0827, 80.2707),
"kolkata": (22.5726, 88.3639),
"pune": (18.5204, 73.8567),
"ahmedabad": (23.0225, 72.5714),
"jaipur": (26.9124, 75.7873),
"surat": (21.1702, 72.8311),
"lucknow": (26.8467, 80.9462),
"chandigarh": (30.7333, 76.7794),
"indore": (22.7196, 75.8577),
"kochi": (9.9312, 76.2673),
"goa": (15.4909, 73.8278),
}
# Common alternates / NCR satellites -> canonical city key.
CITY_SYNONYMS: Dict[str, str] = {
"bombay": "mumbai", "navi mumbai": "mumbai", "thane": "mumbai",
"new delhi": "delhi", "gurgaon": "delhi", "gurugram": "delhi",
"noida": "delhi", "ghaziabad": "delhi", "faridabad": "delhi",
"bangalore": "bengaluru",
"madras": "chennai",
"calcutta": "kolkata",
"cochin": "kochi",
"secunderabad": "hyderabad",
"panaji": "goa", "panjim": "goa",
}
def normalize_city(name: Optional[str]) -> Optional[str]:
"""Free-text city name -> canonical key, or None if we don't know it."""
t = (name or "").strip().lower()
if not t:
return None
if t in CITIES:
return t
return CITY_SYNONYMS.get(t)
def extract_city(text: str) -> Optional[str]:
"""Find a city mentioned inside a free-text query ("dinner ... in Mumbai")."""
t = (text or "").lower()
hits = [k for k in list(CITIES) + list(CITY_SYNONYMS) if k in t]
if not hits:
return None
pick = sorted(hits, key=len, reverse=True)[0]
return pick if pick in CITIES else CITY_SYNONYMS[pick]
def city_from_latlng(lat: float, lng: float, max_km: float = 60.0) -> Optional[str]:
"""Nearest metro centroid within max_km, else None."""
best, best_d = None, max_km
for city, (clat, clng) in CITIES.items():
p1, p2 = math.radians(lat), math.radians(clat)
dp, dl = math.radians(clat - lat), math.radians(clng - lng)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
d = 2 * 6371.0 * math.asin(min(1.0, math.sqrt(h)))
if d < best_d:
best, best_d = city, d
return best
# ---------------------------------------------------------------------------
# Platform coverage: (channel_key, kind) -> "*" (pan-India) or a city set.
# Direct/walk-in channels and travel OTAs are always available.
# ---------------------------------------------------------------------------
_ALL = "*"
PLATFORM_CITIES: Dict[tuple, Union[str, Set[str]]] = {
("dineout", "dining"): {
"mumbai", "delhi", "bengaluru", "hyderabad", "chennai", "kolkata",
"pune", "ahmedabad", "jaipur", "lucknow", "chandigarh", "indore",
},
("district", "dining"): {
"mumbai", "delhi", "bengaluru", "hyderabad", "chennai", "kolkata",
"pune", "ahmedabad",
},
("district", "movies"): {
"mumbai", "delhi", "bengaluru", "hyderabad", "chennai", "pune",
},
("bookmyshow", "movies"): _ALL,
# Grocery apps: BigBasket, Blinkit and Instamart cover every metro we track;
# Zepto's footprint is narrower (no Goa on their own city list). Coverage
# values are illustrative until a live feed is wired, same as the rest of
# this file - the honest failure is a hidden row, never an overclaimed one.
("bigbasket", "groceries"): _ALL,
("blinkit", "groceries"): _ALL,
("instamart", "groceries"): _ALL,
("jiomart", "groceries"): _ALL,
("zepto", "groceries"): {
"mumbai", "delhi", "bengaluru", "hyderabad", "chennai", "kolkata",
"pune", "ahmedabad", "jaipur", "surat", "lucknow", "chandigarh",
"indore", "kochi",
},
}
# ---------------------------------------------------------------------------
# Merchant-level listings (dining chains we track): brand_key -> platform ->
# "*" or city set. A wholly empty map explicitly means counter-service/no
# dine-in. A missing key in a non-empty legacy map is UNKNOWN: new platforms
# must not be called absent merely because this seed predates them.
# ---------------------------------------------------------------------------
MERCHANT_PLATFORMS: Dict[str, Dict[str, Union[str, Set[str]]]] = {
"copper_chimney": {
"dineout": {"mumbai", "delhi", "pune", "bengaluru"},
"district": {"mumbai", "pune", "bengaluru"},
},
"barbeque_nation": {"dineout": _ALL, "district": _ALL},
# counter/QSR chains: you pay at the till, not through a dine-in bill app
"starbucks": {},
"haldiram": {},
"dominos": {},
"pizza_hut": {},
"mcdonalds": {},
"kfc": {},
}
def channel_availability(
channel_key: str,
kind: str,
city: Optional[str],
brand_key: Optional[str] = None,
at_place: bool = False,
) -> str:
"""'available' | 'unknown' | 'unavailable' for one channel in one city.
TRUTHFULNESS RULE: 'available' is a claim the user reads as fact, so for a
SPECIFIC place (at_place=True, e.g. detected via Near me) it is returned
ONLY when we have merchant-level data saying this merchant is on this
platform. 'The platform operates in this city' is NOT the same fact as
'this restaurant is on the platform' and must never be displayed as such;
those cases return 'unknown', which the app renders as 'if listed'.
Generic category queries (no specific place) may use platform-city
coverage, because there the claim is about the platform, not a merchant.
"""
if channel_key == "direct":
return "available"
# Static city/merchant tables are discovery seeds, not fresh availability
# evidence. Only caller-supplied live overrides may claim available or
# unavailable; a seed miss must remain visible and explicitly unknown.
return "unknown"