fractafrag/services/api/app/routers/shaders.py
John Lightner dc27435ca1 M2 complete: Recommendation engine + similar shaders + tag affinities
Feed ranking (anonymous users):
- score * 0.6 + recency * 0.3 + random * 0.1
- Recency uses 72-hour half-life decay
- 10% randomness prevents filter bubbles

Feed ranking (authenticated users):
- score * 0.5 + recency * 0.2 + tag_affinity * 0.2 + random * 0.1
- Tag affinity built from engagement history:
  - Upvoted shader tags: +1.0 per tag
  - Downvoted: -0.5 per tag
  - Dwell >10s: +0.3, >30s: +0.6
- Over-fetches 3x candidates, re-ranks with affinity, returns top N

Similar shaders endpoint:
- GET /api/v1/feed/similar/{shader_id}
- Finds shaders with overlapping tags
- Ranks by tag overlap count, breaks ties by score
- MCP tool: get_similar_shaders

Fix: PostgreSQL text[] && varchar[] type mismatch
- Used type_coerce() instead of cast() for ARRAY overlap operator
- Affects both shaders search-by-tags and similar-by-tags queries
2026-03-24 23:25:45 -05:00

416 lines
15 KiB
Python

"""Shaders router — CRUD, versioning, drafts, fork, search."""
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.models import User, Shader, ShaderVersion
from app.schemas import ShaderCreate, ShaderUpdate, ShaderPublic, ShaderVersionPublic
from app.middleware.auth import get_current_user, get_optional_user
from app.services.glsl_validator import validate_glsl
router = APIRouter()
# ── Public list / search ──────────────────────────────────
@router.get("", response_model=list[ShaderPublic])
async def list_shaders(
q: str | None = Query(None, description="Search query"),
tags: list[str] | None = Query(None, description="Filter by tags"),
shader_type: str | None = Query(None, description="Filter by type: 2d, 3d, audio-reactive"),
sort: str = Query("trending", description="Sort: trending, new, top"),
is_system: bool | None = Query(None, description="Filter to system/platform shaders"),
limit: int = Query(20, ge=1, le=50),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
query = select(Shader).where(
Shader.is_public == True,
Shader.status == "published",
)
if q:
query = query.where(Shader.title.ilike(f"%{q}%"))
if tags:
from sqlalchemy import type_coerce, Text
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY
query = query.where(Shader.tags.overlap(type_coerce(tags, PG_ARRAY(Text))))
if shader_type:
query = query.where(Shader.shader_type == shader_type)
if is_system is not None:
query = query.where(Shader.is_system == is_system)
if sort == "new":
query = query.order_by(Shader.created_at.desc())
elif sort == "top":
query = query.order_by(Shader.score.desc())
else:
query = query.order_by(Shader.score.desc(), Shader.created_at.desc())
query = query.limit(limit).offset(offset)
result = await db.execute(query)
return result.scalars().all()
# ── My shaders (workspace) ───────────────────────────────
@router.get("/mine", response_model=list[ShaderPublic])
async def my_shaders(
status_filter: str | None = Query(None, alias="status", description="draft, published, archived"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""List the authenticated user's shaders — drafts, published, archived."""
query = select(Shader).where(Shader.author_id == user.id)
if status_filter:
query = query.where(Shader.status == status_filter)
query = query.order_by(Shader.updated_at.desc()).limit(limit).offset(offset)
result = await db.execute(query)
return result.scalars().all()
# ── Single shader ─────────────────────────────────────────
@router.get("/{shader_id}", response_model=ShaderPublic)
async def get_shader(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
shader = result.scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
# Drafts are only visible to their author
if shader.status == "draft" and (not user or user.id != shader.author_id):
raise HTTPException(status_code=404, detail="Shader not found")
if not shader.is_public and (not user or user.id != shader.author_id):
raise HTTPException(status_code=404, detail="Shader not found")
shader.view_count += 1
return shader
# ── Version history ───────────────────────────────────────
@router.get("/{shader_id}/versions", response_model=list[ShaderVersionPublic])
async def list_versions(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User | None = Depends(get_optional_user),
):
"""Get the version history of a shader."""
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")
if shader.status == "draft" and (not user or user.id != shader.author_id):
raise HTTPException(status_code=404, detail="Shader not found")
result = await db.execute(
select(ShaderVersion)
.where(ShaderVersion.shader_id == shader_id)
.order_by(ShaderVersion.version_number.desc())
)
return result.scalars().all()
@router.get("/{shader_id}/versions/{version_number}", response_model=ShaderVersionPublic)
async def get_version(
shader_id: UUID,
version_number: int,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ShaderVersion).where(
ShaderVersion.shader_id == shader_id,
ShaderVersion.version_number == version_number,
)
)
version = result.scalar_one_or_none()
if not version:
raise HTTPException(status_code=404, detail="Version not found")
return version
# ── Create shader (draft or published) ───────────────────
@router.post("", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
async def create_shader(
body: ShaderCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
# Rate limit published shaders for free tier (drafts are unlimited)
if body.status == "published" and user.subscription_tier == "free":
month_start = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
count_result = await db.execute(
select(func.count()).select_from(Shader).where(
Shader.author_id == user.id,
Shader.status == "published",
Shader.created_at >= month_start,
)
)
monthly_count = count_result.scalar()
if monthly_count >= 5:
raise HTTPException(status_code=429, detail="Free tier: 5 published shaders/month. Upgrade to Pro for unlimited.")
# Validate GLSL
validation = validate_glsl(body.glsl_code, body.shader_type)
if not validation.valid:
raise HTTPException(status_code=422, detail={
"message": "GLSL validation failed",
"errors": validation.errors,
"warnings": validation.warnings,
})
shader = Shader(
author_id=user.id,
title=body.title,
description=body.description,
glsl_code=body.glsl_code,
tags=body.tags,
shader_type=body.shader_type,
is_public=body.is_public if body.status == "published" else False,
status=body.status,
style_metadata=body.style_metadata,
render_status="ready" if body.status == "draft" else "pending",
current_version=1,
)
db.add(shader)
await db.flush()
# Create version 1 snapshot
v1 = ShaderVersion(
shader_id=shader.id,
version_number=1,
glsl_code=body.glsl_code,
title=body.title,
description=body.description,
tags=body.tags,
style_metadata=body.style_metadata,
change_note="Initial version",
)
db.add(v1)
# Enqueue render for published shaders
if body.status == "published":
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(shader.id)])
except Exception:
shader.render_status = "ready"
# Link to desire if fulfilling
if body.fulfills_desire_id:
from app.models import Desire
desire = (await db.execute(
select(Desire).where(Desire.id == body.fulfills_desire_id, Desire.status == "open")
)).scalar_one_or_none()
if desire:
desire.status = "fulfilled"
desire.fulfilled_by_shader = shader.id
desire.fulfilled_at = datetime.now(timezone.utc)
return shader
# ── Update shader (creates new version) ──────────────────
@router.put("/{shader_id}", response_model=ShaderPublic)
async def update_shader(
shader_id: UUID,
body: ShaderUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
shader = result.scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner")
updates = body.model_dump(exclude_unset=True)
change_note = updates.pop("change_note", None)
code_changed = "glsl_code" in updates
# Re-validate GLSL if code changed
if code_changed:
validation = validate_glsl(updates["glsl_code"], shader.shader_type)
if not validation.valid:
raise HTTPException(status_code=422, detail={
"message": "GLSL validation failed",
"errors": validation.errors,
"warnings": validation.warnings,
})
# Apply updates
for field, value in updates.items():
setattr(shader, field, value)
# Create a new version snapshot if code or metadata changed
if code_changed or "title" in updates or "description" in updates or "tags" in updates:
shader.current_version += 1
new_version = ShaderVersion(
shader_id=shader.id,
version_number=shader.current_version,
glsl_code=shader.glsl_code,
title=shader.title,
description=shader.description,
tags=shader.tags,
style_metadata=shader.style_metadata,
change_note=change_note,
)
db.add(new_version)
# Re-render if code changed and shader is published
if code_changed and shader.status == "published":
shader.render_status = "pending"
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(shader.id)])
except Exception:
shader.render_status = "ready"
# If publishing a draft, ensure it's public and queue render
if "status" in updates and updates["status"] == "published" and shader.render_status != "ready":
shader.is_public = True
shader.render_status = "pending"
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(shader.id)])
except Exception:
shader.render_status = "ready"
shader.updated_at = datetime.now(timezone.utc)
return shader
# ── Delete ────────────────────────────────────────────────
@router.delete("/{shader_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_shader(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
shader = result.scalar_one_or_none()
if not shader:
raise HTTPException(status_code=404, detail="Shader not found")
if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner")
await db.delete(shader)
# ── Fork ──────────────────────────────────────────────────
@router.post("/{shader_id}/fork", response_model=ShaderPublic, status_code=status.HTTP_201_CREATED)
async def fork_shader(
shader_id: UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
result = await db.execute(select(Shader).where(Shader.id == shader_id))
original = result.scalar_one_or_none()
if not original:
raise HTTPException(status_code=404, detail="Shader not found")
if not original.is_public and original.status != "published":
raise HTTPException(status_code=404, detail="Shader not found")
forked = Shader(
author_id=user.id,
title=f"Fork of {original.title}",
description=f"Forked from {original.title}",
glsl_code=original.glsl_code,
tags=original.tags,
shader_type=original.shader_type,
forked_from=original.id,
style_metadata=original.style_metadata,
status="draft", # Forks start as drafts
is_public=False,
render_status="ready",
current_version=1,
)
db.add(forked)
await db.flush()
v1 = ShaderVersion(
shader_id=forked.id,
version_number=1,
glsl_code=original.glsl_code,
title=forked.title,
description=forked.description,
tags=original.tags,
style_metadata=original.style_metadata,
change_note=f"Forked from {original.title}",
)
db.add(v1)
return forked
# ── Restore a version ────────────────────────────────────
@router.post("/{shader_id}/versions/{version_number}/restore", response_model=ShaderPublic)
async def restore_version(
shader_id: UUID,
version_number: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Restore a shader to a previous version (creates a new version snapshot)."""
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")
if shader.author_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="Not the shader owner")
version = (await db.execute(
select(ShaderVersion).where(
ShaderVersion.shader_id == shader_id,
ShaderVersion.version_number == version_number,
)
)).scalar_one_or_none()
if not version:
raise HTTPException(status_code=404, detail="Version not found")
# Apply version data to shader
shader.glsl_code = version.glsl_code
shader.title = version.title
shader.description = version.description
shader.tags = version.tags
shader.style_metadata = version.style_metadata
shader.current_version += 1
shader.updated_at = datetime.now(timezone.utc)
# Create a new version snapshot for the restore
restore_v = ShaderVersion(
shader_id=shader.id,
version_number=shader.current_version,
glsl_code=version.glsl_code,
title=version.title,
description=version.description,
tags=version.tags,
style_metadata=version.style_metadata,
change_note=f"Restored from version {version_number}",
)
db.add(restore_v)
# Re-render if published
if shader.status == "published":
shader.render_status = "pending"
from app.worker import celery_app
try:
celery_app.send_task("render_shader", args=[str(shader.id)])
except Exception:
shader.render_status = "ready"
return shader