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", [])], )