Files
vynl/backend/app/api/endpoints/playlist_fix.py
root f2b8dadbf8 Add API cost tracking to admin dashboard
Track estimated Anthropic API costs per request across all Claude API
call sites (recommender, analyze, artist-dive, generate-playlist, crate,
rabbit-hole, playlist-fix, timeline, compatibility). Log token usage and
estimated cost to the app logger. Aggregate costs in admin stats endpoint
and display total/today costs and token usage in the admin dashboard.
2026-03-31 20:51:51 -05:00

142 lines
4.2 KiB
Python

import json
import logging
import anthropic
api_logger = logging.getLogger("app")
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.playlist import Playlist
from app.models.track import Track
from app.models.user import User
from app.services.recommender import build_taste_profile
router = APIRouter(prefix="/playlists", tags=["playlists"])
class PlaylistFixRequest(BaseModel):
count: int = 5
class OutlierTrack(BaseModel):
track_number: int
artist: str
title: str
reason: str
class ReplacementTrack(BaseModel):
title: str
artist: str
album: str | None = None
reason: str
class PlaylistFixResponse(BaseModel):
playlist_vibe: str
outliers: list[OutlierTrack]
replacements: list[ReplacementTrack]
@router.post("/{playlist_id}/fix", response_model=PlaylistFixResponse)
async def fix_playlist(
playlist_id: int,
data: PlaylistFixRequest = PlaylistFixRequest(),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Load playlist
result = await db.execute(
select(Playlist).where(Playlist.id == playlist_id, Playlist.user_id == user.id)
)
playlist = result.scalar_one_or_none()
if not playlist:
raise HTTPException(status_code=404, detail="Playlist not found")
# Load tracks
result = await db.execute(
select(Track).where(Track.playlist_id == playlist.id)
)
tracks = list(result.scalars().all())
if not tracks:
raise HTTPException(status_code=400, detail="Playlist has no tracks")
# Build taste profile
taste_profile = build_taste_profile(tracks)
# Build numbered track list
track_list = "\n".join(
f"{i + 1}. {t.artist} - {t.title}" for i, t in enumerate(tracks)
)
count = min(max(data.count, 1), 10)
prompt = f"""You are Vynl, a music playlist curator. Analyze this playlist and identify tracks that don't fit the overall vibe.
Playlist: {playlist.name}
Taste profile: {json.dumps(taste_profile, indent=2)}
Tracks:
{track_list}
Analyze the playlist and respond with a JSON object:
{{
"playlist_vibe": "A 1-2 sentence description of the overall playlist vibe/mood",
"outliers": [
{{
"track_number": 1,
"artist": "...",
"title": "...",
"reason": "Why this track doesn't fit the playlist vibe"
}}
],
"replacements": [
{{
"title": "...",
"artist": "...",
"album": "...",
"reason": "Why this fits better"
}}
]
}}
Identify up to {count} outlier tracks. For each outlier, suggest a replacement that fits the playlist vibe better. Focus on maintaining sonic cohesion — same energy, tempo range, and mood.
Return ONLY the JSON object."""
# Call Claude API
client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}],
)
# Track API cost (Haiku: $0.80/M input, $4/M output)
input_tokens = message.usage.input_tokens
output_tokens = message.usage.output_tokens
cost = (input_tokens * 0.80 / 1_000_000) + (output_tokens * 4 / 1_000_000)
api_logger.info(f"API_COST|model=claude-haiku|input={input_tokens}|output={output_tokens}|cost=${cost:.4f}|user={user.id}|endpoint=playlist_fix")
response_text = message.content[0].text.strip()
# Handle potential markdown code blocks
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
response_text = response_text.rsplit("```", 1)[0]
try:
fix_data = json.loads(response_text)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Failed to parse AI response")
return PlaylistFixResponse(
playlist_vibe=fix_data.get("playlist_vibe", ""),
outliers=[OutlierTrack(**o) for o in fix_data.get("outliers", [])],
replacements=[ReplacementTrack(**r) for r in fix_data.get("replacements", [])],
)