From 6684578701349b35bb7db11127beb4708b595f54 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 20:59:46 +0000 Subject: [PATCH] feat(discovery): trending sort heuristic over world_visits with decay weighting --- backend/app/routers/discovery.py | 16 +++- backend/app/services/trending.py | 34 ++++++++ backend/tests/test_discovery_trending.py | 103 +++++++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/trending.py create mode 100644 backend/tests/test_discovery_trending.py diff --git a/backend/app/routers/discovery.py b/backend/app/routers/discovery.py index 5ab16cc..7acf6ac 100644 --- a/backend/app/routers/discovery.py +++ b/backend/app/routers/discovery.py @@ -16,6 +16,8 @@ from app.database import get_db from app.dependencies import OptionalUser from app.models.world import World, WorldVisibility from app.models.world_visit import WorldVisit +from app.services.trending import get_trending_subquery + router = APIRouter() @@ -208,11 +210,23 @@ async def list_discovery_worlds( total = (await db.execute(count_query)).scalar_one() # Sort - if sort == "most_played": + if sort == "trending": + trending_subq = get_trending_subquery() + base_query = ( + base_query + .outerjoin(trending_subq, World.id == trending_subq.c.trending_world_id) + .order_by( + func.coalesce(trending_subq.c.trending_score, 0.0).desc(), + World.play_count.desc(), + World.created_at.desc(), + ) + ) + elif sort == "most_played": base_query = base_query.order_by(World.play_count.desc(), World.created_at.desc()) else: # default: newest base_query = base_query.order_by(World.created_at.desc()) + # Pagination & load owner query = ( base_query diff --git a/backend/app/services/trending.py b/backend/app/services/trending.py new file mode 100644 index 0000000..51feb90 --- /dev/null +++ b/backend/app/services/trending.py @@ -0,0 +1,34 @@ +"""Trending recommendation heuristic service. + +docs/planning/discovery.md (task 33706f401ad9). +""" + +from sqlalchemy import case, func, select, text +from app.models.world_visit import WorldVisit + + +def get_trending_subquery(days: int = 7): + """Build a subquery that computes a decay-weighted trending score per world_id. + + Decay weights: + - Visited within 24 hours: weight 3.0 + - Visited within 3 days: weight 2.0 + - Visited within 7 days: weight 1.0 + """ + trending_weight = case( + (WorldVisit.visited_at >= func.now() - text("interval '1 day'"), 3.0), + (WorldVisit.visited_at >= func.now() - text("interval '3 days'"), 2.0), + else_=1.0, + ) + + interval_str = f"interval '{days} days'" + subq = ( + select( + WorldVisit.world_id.label("trending_world_id"), + func.coalesce(func.sum(trending_weight), 0.0).label("trending_score"), + ) + .where(WorldVisit.visited_at >= func.now() - text(interval_str)) + .group_by(WorldVisit.world_id) + .subquery("trending_scores") + ) + return subq diff --git a/backend/tests/test_discovery_trending.py b/backend/tests/test_discovery_trending.py new file mode 100644 index 0000000..e88465b --- /dev/null +++ b/backend/tests/test_discovery_trending.py @@ -0,0 +1,103 @@ +"""API and heuristic tests for trending discovery sort. + +docs/planning/discovery.md (task 33706f401ad9). +""" + +from datetime import datetime, timedelta, timezone +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.config import settings +from app.main import app +from app.models.world_visit import WorldVisit + + +def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]: + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": f"{username}@example.com", + "password": "secret123", + "date_of_birth": "1990-01-01", + }, + ) + assert resp.status_code == 201, resp.text + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return me.json()["id"], headers + + +def _session_factory(): + engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) + return engine, async_sessionmaker(engine, expire_on_commit=False) + + +@pytest.mark.asyncio +async def test_trending_sort_heuristic_and_zero_visits(): + client = TestClient(app) + uid, headers = _register(client, "creator_trending_1") + + # Create 3 worlds + wA = client.post( + "/api/worlds/", + headers=headers, + json={"name": "Trending Hot World", "visibility": "public", "category": "Action"}, + ).json() + wB = client.post( + "/api/worlds/", + headers=headers, + json={"name": "Older Mild World", "visibility": "public", "category": "Action"}, + ).json() + wC = client.post( + "/api/worlds/", + headers=headers, + json={"name": "Zero Visit World", "visibility": "public", "category": "Action"}, + ).json() + + engine, sf = _session_factory() + now = datetime.now(timezone.utc) + try: + async with sf() as session: + # World A: 3 visits within last 2 hours (weight 3 each = score 9) + for i in range(3): + session.add( + WorldVisit( + world_id=wA["id"], + anonymous_token=f"hot_visitor_{i}", + visited_at=now - timedelta(hours=1, minutes=i), + ) + ) + # World B: 1 visit 5 days ago (weight 1 = score 1) + session.add( + WorldVisit( + world_id=wB["id"], + anonymous_token="old_visitor", + visited_at=now - timedelta(days=5), + ) + ) + # World C: zero visits + await session.commit() + finally: + await engine.dispose() + + # Query with sort=trending + resp = client.get("/api/discovery/worlds?sort=trending&category=Action") + assert resp.status_code == 200, resp.text + items = resp.json()["items"] + + # Verify order: wA should be first, wB second, wC third + ids = [item["id"] for item in items] + assert wA["id"] in ids + assert wB["id"] in ids + assert wC["id"] in ids + + idxA = ids.index(wA["id"]) + idxB = ids.index(wB["id"]) + idxC = ids.index(wC["id"]) + + assert idxA < idxB < idxC, f"Expected order A ({idxA}) < B ({idxB}) < C ({idxC})"