fractafrag/services/api/app/routers/desires.py
John Lightner 5936ab167e feat(M001): Desire Economy
Completed slices:
- S01: Desire Embedding & Clustering
- S02: Fulfillment Flow & Frontend

Branch: milestone/M001
2026-03-25 02:22:50 -05:00

133 lines
4.7 KiB
Python

"""Desires & Bounties router."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from app.database import get_db
from app.models import User, Desire, Shader
from app.schemas import DesireCreate, DesirePublic
from app.middleware.auth import get_current_user, require_tier
router = APIRouter()
@router.get("", response_model=list[DesirePublic])
async def list_desires(
status_filter: str | None = Query(None, alias="status"),
min_heat: float = Query(0, ge=0),
limit: int = Query(20, ge=1, le=50),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
query = select(Desire).where(Desire.heat_score >= min_heat)
if status_filter:
query = query.where(Desire.status == status_filter)
else:
query = query.where(Desire.status == "open")
query = query.order_by(Desire.heat_score.desc()).limit(limit).offset(offset)
result = await db.execute(query)
desires = list(result.scalars().all())
# Batch-annotate cluster_count to avoid N+1 queries
desire_ids = [d.id for d in desires]
if desire_ids:
cluster_query = text("""
SELECT dc1.desire_id, COUNT(dc2.desire_id) as cluster_count
FROM desire_clusters dc1
JOIN desire_clusters dc2 ON dc1.cluster_id = dc2.cluster_id
WHERE dc1.desire_id = ANY(:desire_ids)
GROUP BY dc1.desire_id
""")
cluster_result = await db.execute(cluster_query, {"desire_ids": desire_ids})
cluster_counts = {row[0]: row[1] for row in cluster_result}
for d in desires:
d.cluster_count = cluster_counts.get(d.id, 0)
return desires
@router.get("/{desire_id}", response_model=DesirePublic)
async def get_desire(desire_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Desire).where(Desire.id == desire_id))
desire = result.scalar_one_or_none()
if not desire:
raise HTTPException(status_code=404, detail="Desire not found")
# Annotate cluster_count for single desire
cluster_query = text("""
SELECT COUNT(dc2.desire_id) as cluster_count
FROM desire_clusters dc1
JOIN desire_clusters dc2 ON dc1.cluster_id = dc2.cluster_id
WHERE dc1.desire_id = :desire_id
""")
cluster_result = await db.execute(cluster_query, {"desire_id": desire_id})
row = cluster_result.first()
desire.cluster_count = row[0] if row else 0
return desire
@router.post("", response_model=DesirePublic, status_code=status.HTTP_201_CREATED)
async def create_desire(
body: DesireCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_tier("pro", "studio")),
):
desire = Desire(
author_id=user.id,
prompt_text=body.prompt_text,
style_hints=body.style_hints,
)
db.add(desire)
await db.flush()
# Fire-and-forget: enqueue embedding + clustering worker task
from app.worker import process_desire
process_desire.delay(str(desire.id))
return desire
@router.post("/{desire_id}/fulfill", status_code=status.HTTP_200_OK)
async def fulfill_desire(
desire_id: UUID,
shader_id: UUID = Query(..., description="Shader that fulfills this desire"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark a desire as fulfilled by a shader. (Track G)"""
desire = (await db.execute(select(Desire).where(Desire.id == desire_id))).scalar_one_or_none()
if not desire:
raise HTTPException(status_code=404, detail="Desire not found")
if desire.status != "open":
raise HTTPException(status_code=400, detail="Desire is not open")
# Validate shader exists and is published
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 != "published":
raise HTTPException(status_code=400, detail="Shader must be published to fulfill a desire")
from datetime import datetime, timezone
desire.status = "fulfilled"
desire.fulfilled_by_shader = shader_id
desire.fulfilled_at = datetime.now(timezone.utc)
return {"status": "fulfilled", "desire_id": desire_id, "shader_id": shader_id}
@router.post("/{desire_id}/tip")
async def tip_desire(
desire_id: UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_tier("pro", "studio")),
):
"""Add a tip to a bounty. (Track H — stub)"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Bounty tipping coming in M4"
)