Completed slices: - S01: Desire Embedding & Clustering - S02: Fulfillment Flow & Frontend Branch: milestone/M001
133 lines
4.7 KiB
Python
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"
|
|
)
|