fractafrag/services/api/app/routers/votes.py
John Lightner cf591424a1 M2: MCP server live + hot score ranking
MCP Server (8 tools):
- browse_shaders: search by title, tags, type, sort (trending/new/top)
- get_shader: full details + GLSL source by ID
- get_shader_versions: version history with change notes
- get_shader_version_code: GLSL code from any specific version
- submit_shader: create new shader (published or draft)
- update_shader: push revisions with change notes, auto-versions
- get_trending: top-scored shaders
- get_desire_queue: open community requests

MCP resource: fractafrag://platform-info with shader format guide

Auth: Internal service token (Bearer internal:mcp-service) allows MCP
server to write to the API as the system user. No user API keys needed
for the MCP→API internal path.

Transport: Streamable HTTP on port 3200 via FastMCP SDK.
Stateless mode with JSON responses.

Hot Score Ranking:
- Wilson score lower bound with 48-hour time decay
- Recalculated on every vote (up/down/remove)
- Feed sorts by score for trending view

Connection config for Claude Desktop:
{
  "mcpServers": {
    "fractafrag": {
      "url": "http://localhost:3200/mcp"
    }
  }
}
2026-03-24 22:56:03 -05:00

106 lines
3.5 KiB
Python

"""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)