diff --git a/blueprints/beli/logic.py b/blueprints/beli/logic.py index bd301a8..aa283aa 100644 --- a/blueprints/beli/logic.py +++ b/blueprints/beli/logic.py @@ -9,6 +9,7 @@ from utils.beli_service import ( authenticate, extract_business, fetch_bookmarks, + fetch_following, fetch_user_rankings, is_token_expired, refresh_access_token, @@ -21,6 +22,7 @@ logger = logging.getLogger(__name__) MAX_PROFILES_PER_USER = 3 NIGHTLY_BATCH_SIZE = 3 NIGHTLY_WINDOW_SECONDS = 4 * 3600 # spread refreshes across a 4-hour window +FRIEND_SAMPLE_SIZE = 5 # friends to randomly refresh per profile per night async def _get_access_token(profile: BeliProfile) -> str: @@ -42,6 +44,73 @@ async def _get_access_token(profile: BeliProfile) -> str: return session["access_token"] +async def _refresh_friend_sample(profile: BeliProfile, access_token: str) -> int: + """Fetch rankings for a random sample of followed users and upsert them.""" + try: + following = await fetch_following(access_token, profile.beli_user_id) + except Exception as e: + logger.warning("Beli following fetch failed for profile %s: %s", profile.id, e) + return 0 + + if not following: + return 0 + + sample = random.sample(following, min(FRIEND_SAMPLE_SIZE, len(following))) + upserted = 0 + + for friend in sample: + friend_id = str(friend.get("id") or "") + friend_name = friend.get("full_name") or friend.get("username") or friend_id + if not friend_id: + continue + + for category in RANKING_CATEGORIES: + await asyncio.sleep(REQUEST_SPACING) + try: + results = await fetch_user_rankings(access_token, friend_id, category) + except Exception as e: + logger.warning( + "Beli friend ranking fetch failed (%s/%s): %s", + friend_id, + category, + e, + ) + continue + + for idx, item in enumerate(results): + biz = extract_business(item, rank_index=idx + 1) + if not biz: + continue + await BeliRestaurant.update_or_create( + profile=profile, + beli_business_id=biz["beli_business_id"], + status=BeliStatus.BEEN, + friend_beli_user_id=friend_id, + defaults={ + "friend_name": friend_name, + "name": biz["name"], + "city": biz["city"], + "neighborhood": biz["neighborhood"], + "cuisine": biz["cuisine"], + "price_range": biz["price_range"], + "rank_index": biz["rank_index"], + "score": biz["score"], + "notes": None, + "tags": biz["tags"], + }, + ) + upserted += 1 + + if upserted: + logger.info( + "Beli profile %s: %d friend restaurants upserted from %d friends", + profile.id, + upserted, + len(sample), + ) + return upserted + + async def refresh_profile(profile_id: str) -> dict: """Fetch the latest rankings and bookmarks for a profile and upsert them.""" profile = await BeliProfile.get_or_none(id=profile_id) @@ -112,6 +181,9 @@ async def refresh_profile(profile_id: str) -> dict: ) upserted += 1 + # Randomly refresh a sample of followed users' rankings + upserted += await _refresh_friend_sample(profile, access_token) + profile.last_refreshed_at = datetime.now(timezone.utc) profile.last_error = None await profile.save() diff --git a/blueprints/beli/models.py b/blueprints/beli/models.py index a2dbde6..069e5ac 100644 --- a/blueprints/beli/models.py +++ b/blueprints/beli/models.py @@ -35,6 +35,9 @@ class BeliRestaurant(Model): id = fields.UUIDField(primary_key=True, default=uuid.uuid4) profile = fields.ForeignKeyField("models.BeliProfile", related_name="restaurants") beli_business_id = fields.CharField(max_length=255) + # Empty string = profile owner's own ranking; UUID string = a followed user's ranking + friend_beli_user_id = fields.CharField(max_length=255, default="") + friend_name = fields.CharField(max_length=255, null=True) name = fields.CharField(max_length=500) city = fields.CharField(max_length=255, null=True) neighborhood = fields.CharField(max_length=255, null=True) @@ -50,4 +53,6 @@ class BeliRestaurant(Model): class Meta: table = "beli_restaurants" - unique_together = (("profile", "beli_business_id", "status"),) + unique_together = ( + ("profile", "beli_business_id", "status", "friend_beli_user_id"), + ) diff --git a/blueprints/conversation/agents.py b/blueprints/conversation/agents.py index ff40d52..84c27b9 100644 --- a/blueprints/conversation/agents.py +++ b/blueprints/conversation/agents.py @@ -1294,30 +1294,34 @@ async def beli_search_restaurants( city: str = "", cuisine: str = "", name: str = "", + include_friends: bool = True, ) -> str: """Search restaurant recommendations from Beli profiles. Use this tool when the user asks about: - Restaurant recommendations or suggestions - - Places they've been to - - Restaurants on their want-to-try list + - Places they've been to or want to try - Dining recommendations in a specific city or cuisine - - What restaurants they've rated or reviewed on Beli + - What restaurants their friends have rated on Beli Args: status: Filter by status — 'been' (ranked places) or 'want_to_try' (bookmarks). Leave blank for all. city: Filter by city name (partial match). Leave blank for all cities. cuisine: Filter by cuisine type (partial match). Leave blank for all cuisines. name: Filter by restaurant name (partial match). Leave blank for all. + include_friends: Include friends' rankings (default True). Set False for own lists only. Returns: - Formatted list of matching restaurants with ratings, cuisine, and location. + Formatted list of matching restaurants with ratings, cuisine, location, and attribution. """ from blueprints.beli.models import BeliRestaurant, BeliStatus try: qs = BeliRestaurant.all() + if not include_friends: + qs = qs.filter(friend_beli_user_id="") + if status: try: qs = qs.filter(status=BeliStatus(status)) @@ -1329,7 +1333,9 @@ async def beli_search_restaurants( if name: qs = qs.filter(name__icontains=name) - restaurants = await qs.order_by("rank_index", "name").all() + restaurants = await qs.order_by( + "friend_beli_user_id", "rank_index", "name" + ).all() if cuisine: cuisine_lower = cuisine.lower() @@ -1350,7 +1356,12 @@ async def beli_search_restaurants( cuisines = ", ".join(r.cuisine) if r.cuisine else "" price = f" ({r.price_range})" if r.price_range else "" status_label = r.status.value.replace("_", " ").title() - line = f"• {r.name}{price}{rank}{score} [{status_label}]" + attribution = ( + f" [via {r.friend_name or r.friend_beli_user_id}]" + if r.friend_beli_user_id + else "" + ) + line = f"• {r.name}{price}{rank}{score} [{status_label}]{attribution}" if location: line += f" — {location}" if cuisines: diff --git a/utils/beli_service.py b/utils/beli_service.py index d1b8929..c65bf3a 100644 --- a/utils/beli_service.py +++ b/utils/beli_service.py @@ -116,6 +116,22 @@ async def fetch_user_rankings( return resp.json().get("results") or [] +async def fetch_following(access_token: str, user_id: str) -> list[dict]: + """Fetch users that user_id follows. Returns list of user objects with id, full_name, username.""" + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{HOST_API}/api/following/{user_id}/", + headers=_base_headers(access_token), + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + # Endpoint returns either a paginated {results:[]} or bare list + if isinstance(data, list): + return data + return data.get("results") or [] + + async def fetch_bookmarks(access_token: str) -> list[dict]: """Fetch the authenticated user's bookmarked (want-to-try) places.