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:
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user