"""Votes & engagement router with hot score calculation.""" import math from uuid import UUID from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.database import get_db from app.models import User, Shader, Vote, EngagementEvent from app.schemas import VoteCreate from app.middleware.auth import get_current_user, get_optional_user router = APIRouter() def hot_score(upvotes: int, downvotes: int, age_hours: float) -> float: """Wilson score lower bound with time decay. Balances confidence (more votes = more certain) with recency (newer shaders get a boost that decays over ~48 hours). """ n = upvotes + downvotes if n == 0: return 0.0 z = 1.96 # 95% confidence p = upvotes / n wilson = (p + z * z / (2 * n) - z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))) / (1 + z * z / n) decay = 1.0 / (1.0 + age_hours / 48.0) return wilson * decay async def recalculate_score(db: AsyncSession, shader: Shader): """Recalculate and update a shader's hot score based on current votes.""" up_result = await db.execute( select(func.count()).select_from(Vote).where(Vote.shader_id == shader.id, Vote.value == 1) ) down_result = await db.execute( select(func.count()).select_from(Vote).where(Vote.shader_id == shader.id, Vote.value == -1) ) upvotes = up_result.scalar() or 0 downvotes = down_result.scalar() or 0 age_hours = (datetime.now(timezone.utc) - shader.created_at.replace(tzinfo=timezone.utc)).total_seconds() / 3600 shader.score = hot_score(upvotes, downvotes, age_hours) @router.post("/shaders/{shader_id}/vote", status_code=status.HTTP_200_OK) async def vote_shader( shader_id: UUID, body: VoteCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") existing = (await db.execute( select(Vote).where(Vote.user_id == user.id, Vote.shader_id == shader_id) )).scalar_one_or_none() if existing: existing.value = body.value else: db.add(Vote(user_id=user.id, shader_id=shader_id, value=body.value)) await db.flush() await recalculate_score(db, shader) return {"status": "ok", "value": body.value, "new_score": round(shader.score, 4)} @router.delete("/shaders/{shader_id}/vote", status_code=status.HTTP_204_NO_CONTENT) async def remove_vote( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): shader = (await db.execute(select(Shader).where(Shader.id == shader_id))).scalar_one_or_none() if not shader: raise HTTPException(status_code=404, detail="Shader not found") existing = (await db.execute( select(Vote).where(Vote.user_id == user.id, Vote.shader_id == shader_id) )).scalar_one_or_none() if existing: await db.delete(existing) await db.flush() await recalculate_score(db, shader) @router.post("/shaders/{shader_id}/replay", status_code=status.HTTP_204_NO_CONTENT) async def report_replay( shader_id: UUID, db: AsyncSession = Depends(get_db), user: User | None = Depends(get_optional_user), ): event = EngagementEvent( user_id=user.id if user else None, shader_id=shader_id, event_type="replay", ) db.add(event)