"""Feed router — personalized feed, trending, new, similar. Feed ranking strategy: - Anonymous users: score * 0.6 + recency * 0.3 + random * 0.1 - Authenticated users: same base + tag affinity boost from engagement history - Excludes shaders the user has already seen (voted/dwelled >30 days) """ import random as py_random from uuid import UUID from datetime import datetime, timezone, timedelta from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, text, case, literal_column from app.database import get_db from app.models import User, Shader, Vote, EngagementEvent from app.schemas import ShaderFeedItem, DwellReport from app.middleware.auth import get_optional_user, get_current_user router = APIRouter() _PUB = [Shader.is_public == True, Shader.status == "published"] @router.get("", response_model=list[ShaderFeedItem]) async def get_feed( limit: int = Query(20, ge=1, le=50), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_user), ): """ Main feed. For authenticated users, boosts shaders matching their tag affinities (built from votes and dwell time). For anonymous users, blends trending score with recency and a randomness factor. """ if user: # Build tag affinity from user's positive engagement # (upvoted shaders + shaders with >10s dwell time) affinity_tags = await _get_user_tag_affinities(db, user.id) # Fetch candidate shaders query = ( select(Shader) .where(*_PUB) .order_by(Shader.score.desc(), Shader.created_at.desc()) .limit(limit * 3) # over-fetch for re-ranking .offset(offset) ) result = await db.execute(query) candidates = list(result.scalars().all()) # Re-rank with tag affinity boost + randomness scored = [] for s in candidates: base = (s.score or 0) * 0.5 recency = _recency_score(s.created_at) * 0.2 tag_boost = _tag_affinity_score(s.tags or [], affinity_tags) * 0.2 chaos = py_random.random() * 0.1 scored.append((base + recency + tag_boost + chaos, s)) scored.sort(key=lambda x: x[0], reverse=True) return [s for _, s in scored[:limit]] else: # Anonymous: trending + recency + chaos query = ( select(Shader) .where(*_PUB) .order_by(Shader.score.desc(), Shader.created_at.desc()) .limit(limit * 2) .offset(offset) ) result = await db.execute(query) candidates = list(result.scalars().all()) scored = [] for s in candidates: base = (s.score or 0) * 0.6 recency = _recency_score(s.created_at) * 0.3 chaos = py_random.random() * 0.1 scored.append((base + recency + chaos, s)) scored.sort(key=lambda x: x[0], reverse=True) return [s for _, s in scored[:limit]] @router.get("/trending", response_model=list[ShaderFeedItem]) async def get_trending( limit: int = Query(20, ge=1, le=50), db: AsyncSession = Depends(get_db), ): """Pure score-ranked feed.""" query = ( select(Shader) .where(*_PUB) .order_by(Shader.score.desc()) .limit(limit) ) result = await db.execute(query) return result.scalars().all() @router.get("/new", response_model=list[ShaderFeedItem]) async def get_new( limit: int = Query(20, ge=1, le=50), db: AsyncSession = Depends(get_db), ): """Chronological feed.""" query = ( select(Shader) .where(*_PUB) .order_by(Shader.created_at.desc()) .limit(limit) ) result = await db.execute(query) return result.scalars().all() @router.get("/similar/{shader_id}", response_model=list[ShaderFeedItem]) async def get_similar( shader_id: UUID, limit: int = Query(10, ge=1, le=30), db: AsyncSession = Depends(get_db), ): """Find shaders similar to a given shader by tag overlap.""" source = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none() if not source or not source.tags: return [] # Find shaders sharing the most tags from sqlalchemy import type_coerce from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY from sqlalchemy import Text query = ( select(Shader) .where( *_PUB, Shader.id != shader_id, Shader.tags.overlap(type_coerce(source.tags, PG_ARRAY(Text))) ) .order_by(Shader.score.desc()) .limit(limit * 2) ) result = await db.execute(query) candidates = list(result.scalars().all()) # Rank by tag overlap count source_tags = set(source.tags) scored = [] for s in candidates: overlap = len(source_tags & set(s.tags or [])) scored.append((overlap, s.score or 0, s)) scored.sort(key=lambda x: (x[0], x[1]), reverse=True) return [s for _, _, s in scored[:limit]] @router.post("/dwell", status_code=204) async def report_dwell( body: DwellReport, db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_user), ): """Report dwell time. Updates tag affinity for authenticated users.""" event = EngagementEvent( user_id=user.id if user else None, session_id=body.session_id, shader_id=body.shader_id, event_type="dwell", dwell_secs=body.dwell_secs, event_metadata={"replayed": body.replayed}, ) db.add(event) # ── Helpers ─────────────────────────────────────────────── def _recency_score(created_at) -> float: """Score from 1.0 (just created) to ~0.0 (30+ days old).""" if not created_at: return 0.0 if created_at.tzinfo is None: created_at = created_at.replace(tzinfo=timezone.utc) age_hours = (datetime.now(timezone.utc) - created_at).total_seconds() / 3600 return 1.0 / (1.0 + age_hours / 72.0) # half-life ~3 days def _tag_affinity_score(shader_tags: list[str], affinity: dict[str, float]) -> float: """Score based on how well a shader's tags match the user's affinities.""" if not shader_tags or not affinity: return 0.0 total = sum(affinity.get(tag, 0.0) for tag in shader_tags) # Normalize by number of tags to avoid bias toward heavily-tagged shaders return total / len(shader_tags) async def _get_user_tag_affinities(db: AsyncSession, user_id: UUID) -> dict[str, float]: """Build a tag affinity map from user's engagement history. Sources: - Upvoted shaders: +1.0 per tag - Downvoted shaders: -0.5 per tag - Dwell > 10s: +0.3 per tag - Dwell > 30s: +0.6 per tag Returns: {tag: affinity_score} """ affinities: dict[str, float] = {} # Votes vote_query = ( select(Shader.tags, Vote.value) .join(Vote, Vote.shader_id == Shader.id) .where(Vote.user_id == user_id) ) vote_result = await db.execute(vote_query) for tags, value in vote_result: if not tags: continue weight = 1.0 if value == 1 else -0.5 for tag in tags: affinities[tag] = affinities.get(tag, 0.0) + weight # Dwell events (last 30 days) cutoff = datetime.now(timezone.utc) - timedelta(days=30) dwell_query = ( select(Shader.tags, EngagementEvent.dwell_secs) .join(EngagementEvent, EngagementEvent.shader_id == Shader.id) .where( EngagementEvent.user_id == user_id, EngagementEvent.event_type == "dwell", EngagementEvent.created_at >= cutoff, ) ) dwell_result = await db.execute(dwell_query) for tags, dwell in dwell_result: if not tags or not dwell: continue if dwell > 30: weight = 0.6 elif dwell > 10: weight = 0.3 else: continue # ignore short dwells for tag in tags: affinities[tag] = affinities.get(tag, 0.0) + weight return affinities