Add random friend ranking refresh to Beli integration

Each nightly profile refresh now also picks FRIEND_SAMPLE_SIZE (5) random
followed users and fetches their rankings, building up a picture of friends'
lists over time. Rows are tagged with friend_beli_user_id/friend_name so the
agent tool can surface attribution ("via Alex").

Requires migration to add friend_beli_user_id/friend_name columns and update
unique_together on beli_restaurants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 20:52:14 -04:00
parent 7d1a89475a
commit b79229a5b0
4 changed files with 111 additions and 7 deletions
+72
View File
@@ -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()
+6 -1
View File
@@ -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"),
)
+17 -6
View File
@@ -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:
+16
View File
@@ -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.