From 5b952a00a34c1e455a3c98d104bf305964c47cf1 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 18:37:13 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20add=20Artist=20Intelligence=20Pack?= =?UTF-8?q?=20=E2=80=94=20Spotify=20+=20MusicFlamingo=20AI=20+=20Perplexit?= =?UTF-8?q?y=20=E2=86=92=20marketing=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New domain: lib/artistIntel/ - generateArtistIntelPack: orchestrates 3 parallel data sources into a complete pack - getArtistSpotifyData: fetches artist profile + top tracks with 30-sec preview URLs - getArtistMusicAnalysis: runs 4 MusicFlamingo NVIDIA presets (catalog_metadata, audience_profile, playlist_pitch, mood_tags) in parallel on a Spotify preview URL - getArtistWebContext: Perplexity web research for recent press and streaming news - buildArtistMarketingCopy: AI synthesis → playlist pitch email, social captions, press release opener, key talking points - validateArtistIntelBody: Zod input validation - generateArtistIntelPackHandler: route handler with validateAuthContext New API endpoint: POST /api/artists/intel (artist_name → complete intelligence pack) New MCP tool: generate_artist_intel_pack — available in the AI agent chat UI 10 unit tests, all green. No lint errors on new files. Co-Authored-By: Claude Sonnet 4.6 --- app/api/artists/intel/route.ts | 44 ++++ .../__tests__/generateArtistIntelPack.test.ts | 218 ++++++++++++++++++ lib/artistIntel/buildArtistMarketingCopy.ts | 83 +++++++ lib/artistIntel/generateArtistIntelPack.ts | 92 ++++++++ .../generateArtistIntelPackHandler.ts | 54 +++++ lib/artistIntel/getArtistMusicAnalysis.ts | 60 +++++ lib/artistIntel/getArtistSpotifyData.ts | 69 ++++++ lib/artistIntel/getArtistWebContext.ts | 34 +++ lib/artistIntel/validateArtistIntelBody.ts | 38 +++ lib/mcp/tools/artistIntel/index.ts | 11 + .../registerGenerateArtistIntelPackTool.ts | 73 ++++++ lib/mcp/tools/index.ts | 2 + 12 files changed, 778 insertions(+) create mode 100644 app/api/artists/intel/route.ts create mode 100644 lib/artistIntel/__tests__/generateArtistIntelPack.test.ts create mode 100644 lib/artistIntel/buildArtistMarketingCopy.ts create mode 100644 lib/artistIntel/generateArtistIntelPack.ts create mode 100644 lib/artistIntel/generateArtistIntelPackHandler.ts create mode 100644 lib/artistIntel/getArtistMusicAnalysis.ts create mode 100644 lib/artistIntel/getArtistSpotifyData.ts create mode 100644 lib/artistIntel/getArtistWebContext.ts create mode 100644 lib/artistIntel/validateArtistIntelBody.ts create mode 100644 lib/mcp/tools/artistIntel/index.ts create mode 100644 lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts diff --git a/app/api/artists/intel/route.ts b/app/api/artists/intel/route.ts new file mode 100644 index 00000000..36f0060d --- /dev/null +++ b/app/api/artists/intel/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateArtistIntelPackHandler } from "@/lib/artistIntel/generateArtistIntelPackHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/artists/intel + * + * Generates a complete Artist Intelligence Pack for any artist. + * + * Combines three data sources in parallel: + * 1. Spotify — Artist profile (name, genres, followers, popularity) + top tracks with + * 30-second audio preview URLs. + * 2. MusicFlamingo (NVIDIA 8B) — AI audio analysis of the artist's top track via the + * Spotify preview URL: genre/BPM/key/mood (catalog_metadata), target audience + * demographics (audience_profile), playlist pitch targets (playlist_pitch), and + * mood/vibe tags (mood_tags). + * 3. Perplexity — Real-time web research: recent press, streaming news, and trending + * moments for the artist. + * + * An AI marketing strategist then synthesizes all three sources into a ready-to-use + * marketing pack: playlist pitch email, Instagram/TikTok/Twitter captions, press + * release opener, and key talking points. + * + * Request body: + * - artist_name (required): The artist name to analyze (e.g. "Taylor Swift", "Bad Bunny"). + * + * @param request - The request object containing a JSON body. + * @returns A NextResponse with the complete intelligence pack (200) or an error. + */ +export async function POST(request: NextRequest) { + return generateArtistIntelPackHandler(request); +} diff --git a/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts new file mode 100644 index 00000000..6e494a36 --- /dev/null +++ b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack"; + +import { getArtistSpotifyData } from "@/lib/artistIntel/getArtistSpotifyData"; +import { getArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; +import { getArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; +import { buildArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketingCopy"; + +vi.mock("@/lib/artistIntel/getArtistSpotifyData", () => ({ + getArtistSpotifyData: vi.fn(), +})); +vi.mock("@/lib/artistIntel/getArtistMusicAnalysis", () => ({ + getArtistMusicAnalysis: vi.fn(), +})); +vi.mock("@/lib/artistIntel/getArtistWebContext", () => ({ + getArtistWebContext: vi.fn(), +})); +vi.mock("@/lib/artistIntel/buildArtistMarketingCopy", () => ({ + buildArtistMarketingCopy: vi.fn(), +})); + +const mockSpotifyData = { + artist: { + id: "spotify-123", + name: "Test Artist", + genres: ["pop", "indie"], + followers: { total: 1_000_000 }, + popularity: 75, + images: [{ url: "https://example.com/image.jpg", height: 640, width: 640 }], + }, + topTracks: [ + { + id: "track-123", + name: "Top Hit", + preview_url: "https://p.scdn.co/preview.mp3", + popularity: 80, + album: { name: "Great Album", images: [] }, + }, + ], + previewUrl: "https://p.scdn.co/preview.mp3", +}; + +const mockMusicAnalysis = { + catalog_metadata: { genre: "pop", bpm: 120 }, + audience_profile: { age_range: "18-34" }, + playlist_pitch: { playlists: ["Today's Top Hits"] }, + mood_tags: { moods: ["energetic", "uplifting"] }, +}; + +const mockWebContext = { + results: [{ title: "Test", url: "https://example.com", snippet: "Artist news" }], + summary: "Artist news summary", +}; + +const mockMarketingCopy = { + playlist_pitch_email: "Dear curator...", + instagram_caption: "New music out now! #pop", + tiktok_caption: "You need to hear this! #music", + twitter_post: "New drop! 🎵", + press_release_opener: "Test Artist releases their latest work...", + key_talking_points: ["1M Spotify followers", "Rising indie-pop artist"], +}; + +describe("generateArtistIntelPack", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("returns a complete intelligence pack when all services succeed", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(mockSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(mockMusicAnalysis); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + expect(result.pack.artist.name).toBe("Test Artist"); + expect(result.pack.artist.spotify_id).toBe("spotify-123"); + expect(result.pack.artist.followers).toBe(1_000_000); + expect(result.pack.artist.genres).toEqual(["pop", "indie"]); + expect(result.pack.artist.popularity).toBe(75); + expect(result.pack.artist.image_url).toBe("https://example.com/image.jpg"); + }); + + it("returns top track data correctly", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(mockSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(mockMusicAnalysis); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + expect(result.pack.top_track?.name).toBe("Top Hit"); + expect(result.pack.top_track?.preview_url).toBe("https://p.scdn.co/preview.mp3"); + expect(result.pack.top_track?.album_name).toBe("Great Album"); + }); + + it("returns music analysis and web context", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(mockSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(mockMusicAnalysis); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + expect(result.pack.music_analysis).toEqual(mockMusicAnalysis); + expect(result.pack.web_context).toEqual(mockWebContext); + expect(result.pack.marketing_pack).toEqual(mockMarketingCopy); + }); + + it("calls music analysis with the preview URL", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(mockSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(mockMusicAnalysis); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + await generateArtistIntelPack("Test Artist"); + + expect(getArtistMusicAnalysis).toHaveBeenCalledWith("https://p.scdn.co/preview.mp3"); + expect(getArtistWebContext).toHaveBeenCalledWith("Test Artist"); + }); + + it("skips music analysis when no preview URL is available", async () => { + const noPreviewData = { ...mockSpotifyData, previewUrl: null }; + vi.mocked(getArtistSpotifyData).mockResolvedValue(noPreviewData); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + expect(getArtistMusicAnalysis).not.toHaveBeenCalled(); + if (result.type === "success") { + expect(result.pack.music_analysis).toBeNull(); + } + }); + + it("succeeds even when music analysis returns null", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(mockSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(null); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + if (result.type === "success") { + expect(result.pack.music_analysis).toBeNull(); + } + }); + + it("succeeds even when web context returns null", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(mockSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(mockMusicAnalysis); + vi.mocked(getArtistWebContext).mockResolvedValue(null); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + if (result.type === "success") { + expect(result.pack.web_context).toBeNull(); + } + }); + + it("returns elapsed_seconds in the pack", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(mockSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(mockMusicAnalysis); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + if (result.type === "success") { + expect(typeof result.pack.elapsed_seconds).toBe("number"); + expect(result.pack.elapsed_seconds).toBeGreaterThanOrEqual(0); + } + }); + + it("returns null top_track when artist has no tracks", async () => { + const noTracksData = { ...mockSpotifyData, topTracks: [], previewUrl: null }; + vi.mocked(getArtistSpotifyData).mockResolvedValue(noTracksData); + vi.mocked(getArtistWebContext).mockResolvedValue(mockWebContext); + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(mockMarketingCopy); + + const result = await generateArtistIntelPack("Test Artist"); + + expect(result.type).toBe("success"); + if (result.type === "success") { + expect(result.pack.top_track).toBeNull(); + } + }); + }); + + describe("error cases", () => { + it("returns error when artist not found on Spotify", async () => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(null); + + const result = await generateArtistIntelPack("Totally Unknown Artist XYZ999"); + + expect(result.type).toBe("error"); + if (result.type === "error") { + expect(result.error).toContain("Totally Unknown Artist XYZ999"); + } + }); + }); +}); diff --git a/lib/artistIntel/buildArtistMarketingCopy.ts b/lib/artistIntel/buildArtistMarketingCopy.ts new file mode 100644 index 00000000..380d239a --- /dev/null +++ b/lib/artistIntel/buildArtistMarketingCopy.ts @@ -0,0 +1,83 @@ +import generateText from "@/lib/ai/generateText"; +import type { ArtistSpotifyData } from "@/lib/artistIntel/getArtistSpotifyData"; +import type { ArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; +import type { ArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; + +export interface ArtistMarketingCopy { + playlist_pitch_email: string; + instagram_caption: string; + tiktok_caption: string; + twitter_post: string; + press_release_opener: string; + key_talking_points: string[]; +} + +const SYSTEM_PROMPT = + "You are an elite music marketing strategist with 20 years of experience at major labels. " + + "You create compelling, genre-authentic marketing copy that resonates with target audiences. " + + "Always respond with valid JSON matching the exact schema requested."; + +/** + * Uses AI to synthesize Spotify metadata, MusicFlamingo analysis, and web research + * into ready-to-use marketing copy across multiple channels. + * + * @param spotifyData - Artist Spotify metadata and top tracks. + * @param musicAnalysis - MusicFlamingo AI audio analysis results. + * @param webContext - Recent web research about the artist. + * @returns Ready-to-use marketing copy (email, social captions, press release). + */ +export async function buildArtistMarketingCopy( + spotifyData: ArtistSpotifyData, + musicAnalysis: ArtistMusicAnalysis | null, + webContext: ArtistWebContext | null, +): Promise { + const { artist, topTracks } = spotifyData; + const topTrack = topTracks[0]; + + const prompt = `Generate marketing copy for ${artist.name}. + +Spotify Stats: +- Followers: ${artist.followers.total.toLocaleString()} +- Popularity Score: ${artist.popularity}/100 +- Genres: ${artist.genres.slice(0, 3).join(", ") || "not specified"} +- Top Track: "${topTrack?.name || "unknown"}" from "${topTrack?.album?.name || "unknown"}" + +AI Music Analysis (NVIDIA MusicFlamingo scan of their audio): +${musicAnalysis ? JSON.stringify(musicAnalysis, null, 2) : "Not available"} + +Recent Web Context: +${webContext?.summary || "Not available"} + +Generate a JSON response with these EXACT fields: +{ + "playlist_pitch_email": "A compelling 150-word pitch email to Spotify playlist curators. Include artist name, genre, track name, follower count, and why it fits their playlists. Professional but passionate tone.", + "instagram_caption": "An engaging Instagram caption (under 150 chars) with 5 relevant hashtags. Genre-authentic voice.", + "tiktok_caption": "A TikTok caption (under 100 chars) with 3 trending hashtags that would drive saves.", + "twitter_post": "A punchy tweet (under 280 chars) for a new release announcement. Include 2 hashtags.", + "press_release_opener": "A compelling 3-sentence press release opening paragraph. Professional journalism style.", + "key_talking_points": ["3-5 bullet points that make this artist unique and compelling to pitch to press, playlists, and sync supervisors"] +} + +Respond ONLY with the JSON object. No markdown, no extra text.`; + + try { + const result = await generateText({ system: SYSTEM_PROMPT, prompt }); + const parsed = JSON.parse(result.text) as ArtistMarketingCopy; + return parsed; + } catch (error) { + console.error("Failed to generate marketing copy:", error); + const genre = artist.genres[0]?.replace(/\s+/g, "") || "music"; + return { + playlist_pitch_email: `Dear Curator,\n\nI'd like to introduce you to ${artist.name}, a ${artist.genres[0] || "rising"} artist with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score. Their track "${topTrack?.name || "latest release"}" would be a perfect fit for your playlist.\n\nBest,\nThe Team`, + instagram_caption: `New music from ${artist.name} is here 🎵 #newmusic #${genre} #streaming`, + tiktok_caption: `You need to hear ${artist.name} 🔥 #newmusic #viral #${genre}`, + twitter_post: `New music alert: ${artist.name} just dropped something special 🎵 #newmusic #${genre}`, + press_release_opener: `${artist.name} releases their highly anticipated new work, continuing to build momentum in the ${artist.genres[0] || "music"} space. With ${artist.followers.total.toLocaleString()} Spotify followers and a growing global audience, the artist delivers another standout offering. The new release showcases their signature sound and artistic evolution.`, + key_talking_points: [ + `${artist.followers.total.toLocaleString()} Spotify followers`, + `Popularity score: ${artist.popularity}/100`, + `Genre: ${artist.genres.slice(0, 2).join(", ") || "emerging"}`, + ], + }; + } +} diff --git a/lib/artistIntel/generateArtistIntelPack.ts b/lib/artistIntel/generateArtistIntelPack.ts new file mode 100644 index 00000000..fe1cd6af --- /dev/null +++ b/lib/artistIntel/generateArtistIntelPack.ts @@ -0,0 +1,92 @@ +import type { ArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketingCopy"; +import type { ArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; +import type { ArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; +import { buildArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketingCopy"; +import { getArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; +import { getArtistSpotifyData } from "@/lib/artistIntel/getArtistSpotifyData"; +import { getArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; + +export interface ArtistIntelPack { + artist: { + name: string; + spotify_id: string; + genres: string[]; + followers: number; + popularity: number; + image_url: string | null; + }; + top_track: { + name: string; + spotify_id: string; + preview_url: string | null; + album_name: string; + popularity: number; + } | null; + music_analysis: ArtistMusicAnalysis | null; + web_context: ArtistWebContext | null; + marketing_pack: ArtistMarketingCopy; + elapsed_seconds: number; +} + +export type ArtistIntelPackResult = + | { type: "success"; pack: ArtistIntelPack } + | { type: "error"; error: string }; + +/** + * Generates a complete Artist Intelligence Pack by orchestrating: + * 1. Spotify: Artist profile + top tracks (including 30-second preview URLs). + * 2. MusicFlamingo (NVIDIA 8B): AI audio analysis via Spotify preview URL — genre, BPM, + * key, mood, audience profile, and playlist pitch targets. + * 3. Perplexity: Real-time web research on the artist. + * 4. AI Synthesis: Actionable marketing copy (pitch email, social captions, press release). + * + * Steps 2 and 3 run in parallel to minimize latency. + * + * @param artistName - The artist name to analyze. + * @returns A complete intelligence pack or an error. + */ +export async function generateArtistIntelPack(artistName: string): Promise { + const startTime = Date.now(); + + const spotifyData = await getArtistSpotifyData(artistName); + if (!spotifyData) { + return { type: "error", error: `Artist "${artistName}" not found on Spotify` }; + } + + const [musicAnalysis, webContext] = await Promise.all([ + spotifyData.previewUrl ? getArtistMusicAnalysis(spotifyData.previewUrl) : Promise.resolve(null), + getArtistWebContext(artistName), + ]); + + const marketingCopy = await buildArtistMarketingCopy(spotifyData, musicAnalysis, webContext); + + const { artist, topTracks } = spotifyData; + const topTrack = topTracks[0] ?? null; + const elapsed_seconds = Math.round(((Date.now() - startTime) / 1000) * 100) / 100; + + const pack: ArtistIntelPack = { + artist: { + name: artist.name, + spotify_id: artist.id, + genres: artist.genres, + followers: artist.followers.total, + popularity: artist.popularity, + image_url: artist.images?.[0]?.url ?? null, + }, + top_track: topTrack + ? { + name: topTrack.name, + spotify_id: topTrack.id, + preview_url: topTrack.preview_url, + album_name: topTrack.album.name, + popularity: topTrack.popularity, + } + : null, + music_analysis: musicAnalysis, + web_context: webContext, + marketing_pack: marketingCopy, + elapsed_seconds, + }; + + return { type: "success", pack }; +} diff --git a/lib/artistIntel/generateArtistIntelPackHandler.ts b/lib/artistIntel/generateArtistIntelPackHandler.ts new file mode 100644 index 00000000..96584b81 --- /dev/null +++ b/lib/artistIntel/generateArtistIntelPackHandler.ts @@ -0,0 +1,54 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack"; +import { validateArtistIntelBody } from "@/lib/artistIntel/validateArtistIntelBody"; + +/** + * Handler for POST /api/artists/intel. + * + * Generates a complete Artist Intelligence Pack by combining: + * - Spotify metadata and top tracks + * - MusicFlamingo NVIDIA AI audio analysis (genre, BPM, mood, audience profile) + * - Perplexity web research + * - AI-synthesized marketing copy (pitch email, social captions, press release) + * + * @param request - The incoming request with a JSON body containing artist_name. + * @returns A NextResponse with the complete intelligence pack or an error. + */ +export async function generateArtistIntelPackHandler(request: NextRequest): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Request body must be valid JSON" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const validated = validateArtistIntelBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const result = await generateArtistIntelPack(validated.artist_name); + + if (result.type === "error") { + return NextResponse.json( + { status: "error", error: result.error }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { status: "success", ...result.pack }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/artistIntel/getArtistMusicAnalysis.ts b/lib/artistIntel/getArtistMusicAnalysis.ts new file mode 100644 index 00000000..758577c6 --- /dev/null +++ b/lib/artistIntel/getArtistMusicAnalysis.ts @@ -0,0 +1,60 @@ +import { processAnalyzeMusicRequest } from "@/lib/flamingo/processAnalyzeMusicRequest"; + +export interface ArtistMusicAnalysis { + catalog_metadata: unknown; + audience_profile: unknown; + playlist_pitch: unknown; + mood_tags: unknown; +} + +const ANALYSIS_PRESETS = [ + "catalog_metadata", + "audience_profile", + "playlist_pitch", + "mood_tags", +] as const; + +type AnalysisPreset = (typeof ANALYSIS_PRESETS)[number]; + +/** + * Runs 4 MusicFlamingo presets in parallel on a Spotify 30-second preview URL. + * Analyzes genre, BPM, key, mood, audience profile, playlist targets, and vibe tags. + * + * @param previewUrl - Spotify 30-second preview URL (public MP3). + * @returns Parallel analysis results, or null if all presets fail. + */ +export async function getArtistMusicAnalysis( + previewUrl: string, +): Promise { + const results = await Promise.allSettled( + ANALYSIS_PRESETS.map(preset => + processAnalyzeMusicRequest({ + preset, + audio_url: previewUrl, + max_new_tokens: 512, + temperature: 1.0, + top_p: 1.0, + do_sample: false, + }), + ), + ); + + const analysis: Record = { + catalog_metadata: null, + audience_profile: null, + playlist_pitch: null, + mood_tags: null, + }; + + let anySuccess = false; + results.forEach((result, i) => { + const preset = ANALYSIS_PRESETS[i]; + if (result.status === "fulfilled" && result.value.type === "success") { + const value = result.value as { type: "success"; response: unknown }; + analysis[preset] = value.response; + anySuccess = true; + } + }); + + return anySuccess ? (analysis as ArtistMusicAnalysis) : null; +} diff --git a/lib/artistIntel/getArtistSpotifyData.ts b/lib/artistIntel/getArtistSpotifyData.ts new file mode 100644 index 00000000..8cfd6fa4 --- /dev/null +++ b/lib/artistIntel/getArtistSpotifyData.ts @@ -0,0 +1,69 @@ +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getSearch from "@/lib/spotify/getSearch"; +import { getArtistTopTracks } from "@/lib/spotify/getArtistTopTracks"; + +interface SpotifyArtist { + id: string; + name: string; + genres: string[]; + followers: { total: number }; + popularity: number; + images: Array<{ url: string; height: number; width: number }>; +} + +interface SpotifyTrack { + id: string; + name: string; + preview_url: string | null; + popularity: number; + album: { + name: string; + images: Array<{ url: string; height: number; width: number }>; + }; +} + +export interface ArtistSpotifyData { + artist: SpotifyArtist; + topTracks: SpotifyTrack[]; + previewUrl: string | null; +} + +/** + * Fetches Spotify artist data and top tracks, including 30-second preview URLs. + * + * @param artistName - The artist name to search for. + * @returns Artist data with top tracks and a preview URL, or null if not found. + */ +export async function getArtistSpotifyData(artistName: string): Promise { + const tokenResult = await generateAccessToken(); + if (tokenResult.error || !tokenResult.access_token) { + console.error("Failed to get Spotify token:", tokenResult.error); + return null; + } + + const accessToken = tokenResult.access_token; + + const searchResult = await getSearch({ q: artistName, type: "artist", limit: 1, accessToken }); + if (searchResult.error || !searchResult.data) { + console.error("Spotify artist search failed:", searchResult.error); + return null; + } + + const searchData = searchResult.data as { artists?: { items: SpotifyArtist[] } }; + const artist = searchData.artists?.items?.[0]; + if (!artist) { + return null; + } + + const topTracksResult = await getArtistTopTracks({ id: artist.id, market: "US", accessToken }); + if (topTracksResult.error || !topTracksResult.data) { + console.error("Failed to get top tracks:", topTracksResult.error); + return { artist, topTracks: [], previewUrl: null }; + } + + const tracksData = topTracksResult.data as { tracks: SpotifyTrack[] }; + const topTracks = tracksData.tracks || []; + const previewUrl = topTracks.find(t => t.preview_url)?.preview_url ?? null; + + return { artist, topTracks, previewUrl }; +} diff --git a/lib/artistIntel/getArtistWebContext.ts b/lib/artistIntel/getArtistWebContext.ts new file mode 100644 index 00000000..eac9a1b5 --- /dev/null +++ b/lib/artistIntel/getArtistWebContext.ts @@ -0,0 +1,34 @@ +import { searchPerplexity, type SearchResult } from "@/lib/perplexity/searchPerplexity"; + +export interface ArtistWebContext { + results: SearchResult[]; + summary: string; +} + +/** + * Fetches recent web context about an artist using Perplexity search. + * + * @param artistName - The artist name to research. + * @returns Web search results with a summary, or null on failure. + */ +export async function getArtistWebContext(artistName: string): Promise { + try { + const response = await searchPerplexity({ + query: `${artistName} music artist 2025 new release streaming stats news`, + max_results: 5, + }); + + const summary = response.results + .slice(0, 3) + .map(r => r.snippet) + .join(" | "); + + return { + results: response.results, + summary, + }; + } catch (error) { + console.error("Perplexity search failed:", error); + return null; + } +} diff --git a/lib/artistIntel/validateArtistIntelBody.ts b/lib/artistIntel/validateArtistIntelBody.ts new file mode 100644 index 00000000..f7e2118e --- /dev/null +++ b/lib/artistIntel/validateArtistIntelBody.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const artistIntelBodySchema = z.object({ + artist_name: z + .string({ message: "artist_name is required" }) + .min(1, "artist_name cannot be empty"), +}); + +export type ArtistIntelBody = z.infer; + +/** + * Validates the request body for POST /api/artists/intel. + * + * @param body - The raw request body (parsed JSON). + * @returns A NextResponse with an error if validation fails, or the validated body if it passes. + */ +export function validateArtistIntelBody(body: unknown): NextResponse | ArtistIntelBody { + const result = artistIntelBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/mcp/tools/artistIntel/index.ts b/lib/mcp/tools/artistIntel/index.ts new file mode 100644 index 00000000..73e44652 --- /dev/null +++ b/lib/mcp/tools/artistIntel/index.ts @@ -0,0 +1,11 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerGenerateArtistIntelPackTool } from "./registerGenerateArtistIntelPackTool"; + +/** + * Registers all artist intelligence MCP tools on the server. + * + * @param server - The MCP server instance. + */ +export function registerAllArtistIntelTools(server: McpServer): void { + registerGenerateArtistIntelPackTool(server); +} diff --git a/lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts b/lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts new file mode 100644 index 00000000..740b335c --- /dev/null +++ b/lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts @@ -0,0 +1,73 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; +import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { generateArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack"; + +const toolSchema = z.object({ + artist_name: z + .string() + .min(1) + .describe("The artist name to analyze (e.g. 'Taylor Swift', 'Bad Bunny', 'Olivia Rodrigo')."), +}); + +/** + * Registers the generate_artist_intel_pack MCP tool on the server. + * + * This tool generates a complete Artist Intelligence Pack by combining: + * - Spotify metadata (genres, followers, popularity, top tracks) + * - MusicFlamingo NVIDIA AI (8B params) — analyzes the artist's actual audio via a + * Spotify 30-second preview URL. Returns genre/BPM/key/mood, audience demographics, + * playlist pitch targets, and mood/vibe tags. + * - Perplexity web research — recent press, streaming news, trends. + * - AI synthesis — ready-to-use playlist pitch email, social captions, and press release. + * + * @param server - The MCP server instance to register the tool on. + */ +export function registerGenerateArtistIntelPackTool(server: McpServer): void { + server.registerTool( + "generate_artist_intel_pack", + { + description: + "Generate a complete Artist Intelligence Pack for any artist. " + + "Combines Spotify metadata, MusicFlamingo NVIDIA AI audio analysis (analyzes the artist's actual music — " + + "genre, BPM, key, mood, audience demographics, playlist pitch targets), and Perplexity web research into " + + "a comprehensive marketing intelligence report. " + + "Returns: artist profile, top track, music DNA from AI audio scan, web context, and a ready-to-use " + + "marketing pack (playlist pitch email, Instagram/TikTok/Twitter captions, press release opener, " + + "key talking points). " + + "Use this when you need to deeply research an artist or prepare marketing materials for them.", + inputSchema: toolSchema, + }, + async ( + args: { artist_name: string }, + extra: RequestHandlerExtra, + ) => { + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: undefined, + }); + + if (error) { + return getToolResultError(error); + } + + if (!accountId) { + return getToolResultError("Authentication required."); + } + + const result = await generateArtistIntelPack(args.artist_name); + + if (result.type === "error") { + return getToolResultError(result.error); + } + + return getToolResultSuccess(result.pack); + }, + ); +} diff --git a/lib/mcp/tools/index.ts b/lib/mcp/tools/index.ts index e95da17f..466067d7 100644 --- a/lib/mcp/tools/index.ts +++ b/lib/mcp/tools/index.ts @@ -18,6 +18,7 @@ import { registerAllYouTubeTools } from "./youtube"; import { registerTranscribeTools } from "./transcribe"; import { registerSendEmailTool } from "./registerSendEmailTool"; import { registerAllArtistTools } from "./artists"; +import { registerAllArtistIntelTools } from "./artistIntel"; import { registerAllChatsTools } from "./chats"; import { registerAllPulseTools } from "./pulse"; import { registerAllSandboxTools } from "./sandbox"; @@ -33,6 +34,7 @@ import { registerAllSandboxTools } from "./sandbox"; */ export const registerAllTools = (server: McpServer): void => { registerAllArtistTools(server); + registerAllArtistIntelTools(server); registerAllArtistSocialsTools(server); registerAllCatalogTools(server); registerAllChatsTools(server); From 57cf7142b5f4eeb6dfd28e2a106a188621c3c773 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 18:39:59 +0000 Subject: [PATCH 2/6] agent: @U0AJM7X8FBR implement the wildest craziest most WOW demoable feature. M Co-Authored-By: Claude Sonnet 4.6 --- app/api/accounts/[id]/route.ts | 1 + app/api/admins/privy/route.ts | 5 +++++ app/api/coding-agent/[platform]/route.ts | 2 ++ app/api/songs/analyze/presets/route.ts | 1 + app/api/transcribe/route.ts | 4 ++++ .../emails/__tests__/validateGetAdminEmailsQuery.test.ts | 4 ++++ lib/admins/privy/countNewAccounts.ts | 3 +++ lib/admins/privy/fetchPrivyLogins.ts | 4 ++++ lib/admins/privy/getCutoffMs.ts | 2 ++ lib/admins/privy/getLatestVerifiedAt.ts | 2 ++ lib/admins/privy/toMs.ts | 2 ++ lib/ai/getModel.ts | 1 + lib/ai/isEmbedModel.ts | 2 ++ lib/artists/__tests__/createArtistPostHandler.test.ts | 5 +++++ lib/artists/__tests__/validateCreateArtistBody.test.ts | 5 +++++ lib/auth/__tests__/validateAuthContext.test.ts | 4 ++++ lib/catalog/formatCatalogSongsAsCSV.ts | 2 ++ lib/catalog/getCatalogDataAsCSV.ts | 2 ++ lib/catalog/getCatalogSongs.ts | 7 +++++++ lib/catalog/getCatalogs.ts | 4 ++++ lib/chat/__tests__/integration/chatEndToEnd.test.ts | 5 +++++ lib/chat/toolChains/getPrepareStepResult.ts | 2 ++ lib/chats/processCompactChatRequest.ts | 3 +++ lib/coding-agent/__tests__/handleGitHubWebhook.test.ts | 6 ++++++ .../__tests__/onMergeTestToMainAction.test.ts | 3 +++ lib/coding-agent/encodeGitHubThreadId.ts | 2 ++ lib/coding-agent/handleMergeSuccess.ts | 2 ++ lib/coding-agent/parseMergeActionId.ts | 2 ++ lib/coding-agent/parseMergeTestToMainActionId.ts | 2 ++ lib/composio/getCallbackUrl.ts | 1 + lib/content/__tests__/validateCreateContentBody.test.ts | 4 ++++ lib/content/contentTemplates.ts | 4 ++++ lib/content/createContentHandler.ts | 2 ++ lib/content/getArtistContentReadiness.ts | 5 +++++ lib/content/getArtistFileTree.ts | 3 +++ lib/content/getArtistRootPrefix.ts | 5 +++++ lib/content/getContentValidateHandler.ts | 2 ++ lib/content/isCompletedRun.ts | 4 ++++ lib/content/persistCreateContentRunVideo.ts | 2 ++ lib/content/validateCreateContentBody.ts | 2 ++ lib/content/validateGetContentEstimateQuery.ts | 2 ++ lib/content/validateGetContentValidateQuery.ts | 2 ++ lib/credits/getCreditUsage.ts | 1 + lib/credits/handleChatCredits.ts | 4 ++++ lib/emails/processAndSendEmail.ts | 2 ++ lib/evals/callChatFunctions.ts | 1 + lib/evals/callChatFunctionsWithResult.ts | 2 ++ lib/evals/createToolsCalledScorer.ts | 3 +++ lib/evals/extractTextFromResult.ts | 2 ++ lib/evals/extractTextResultFromSteps.ts | 2 ++ lib/evals/getCatalogSongsCountExpected.ts | 3 +++ lib/evals/getSpotifyFollowersExpected.ts | 4 ++++ lib/evals/scorers/CatalogAvailability.ts | 5 +++++ lib/evals/scorers/QuestionAnswered.ts | 5 +++++ lib/evals/scorers/ToolsCalled.ts | 8 ++++++++ lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts | 3 +++ lib/flamingo/getFlamingoPresetsHandler.ts | 1 + lib/github/expandSubmoduleEntries.ts | 6 ++++++ lib/github/getRepoGitModules.ts | 3 +++ lib/github/resolveSubmodulePath.ts | 2 ++ lib/mcp/resolveAccountId.ts | 2 ++ lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts | 4 ++++ .../__tests__/createNotificationHandler.test.ts | 4 ++++ .../__tests__/validateCreateNotificationBody.test.ts | 5 +++++ lib/prompts/getSystemPrompt.ts | 1 + lib/spotify/getSpotifyFollowers.ts | 1 + lib/supabase/account_artist_ids/getAccountArtistIds.ts | 4 +++- .../account_workspace_ids/getAccountWorkspaceIds.ts | 2 +- lib/supabase/files/createFileRecord.ts | 2 ++ lib/supabase/song_artists/insertSongArtists.ts | 2 ++ lib/supabase/storage/uploadFileByKey.ts | 6 ++++++ lib/tasks/__tests__/getTaskRunHandler.test.ts | 3 +++ lib/tasks/__tests__/validateGetTaskRunQuery.test.ts | 2 ++ lib/transcribe/processAudioTranscription.ts | 6 ++++++ lib/transcribe/saveAudioToFiles.ts | 4 ++++ lib/transcribe/saveTranscriptToFiles.ts | 4 ++++ lib/transcribe/types.ts | 2 ++ lib/trigger/triggerCreateContent.ts | 2 ++ 78 files changed, 238 insertions(+), 2 deletions(-) diff --git a/app/api/accounts/[id]/route.ts b/app/api/accounts/[id]/route.ts index 3ed40db1..42b388bf 100644 --- a/app/api/accounts/[id]/route.ts +++ b/app/api/accounts/[id]/route.ts @@ -23,6 +23,7 @@ export async function OPTIONS() { * - id (required): The unique identifier of the account (UUID) * * @param request - The request object + * @param params.params * @param params - Route params containing the account ID * @returns A NextResponse with account data */ diff --git a/app/api/admins/privy/route.ts b/app/api/admins/privy/route.ts index 073bac60..d22ec616 100644 --- a/app/api/admins/privy/route.ts +++ b/app/api/admins/privy/route.ts @@ -8,11 +8,16 @@ import { getPrivyLoginsHandler } from "@/lib/admins/privy/getPrivyLoginsHandler" * Returns Privy login statistics for the requested time period. * Supports daily (last 24h), weekly (last 7 days), and monthly (last 30 days) periods. * Requires admin authentication. + * + * @param request */ export async function GET(request: NextRequest): Promise { return getPrivyLoginsHandler(request); } +/** + * + */ export async function OPTIONS(): Promise { return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); } diff --git a/app/api/coding-agent/[platform]/route.ts b/app/api/coding-agent/[platform]/route.ts index a51a2104..6fa942ac 100644 --- a/app/api/coding-agent/[platform]/route.ts +++ b/app/api/coding-agent/[platform]/route.ts @@ -10,6 +10,7 @@ import "@/lib/coding-agent/handlers/registerHandlers"; * Handles webhook verification handshakes (e.g. WhatsApp hub.challenge). * * @param request - The incoming verification request + * @param params.params * @param params - Route params containing the platform name */ export async function GET( @@ -34,6 +35,7 @@ export async function GET( * Handles Slack and WhatsApp webhooks via dynamic [platform] segment. * * @param request - The incoming webhook request + * @param params.params * @param params - Route params containing the platform name */ export async function POST( diff --git a/app/api/songs/analyze/presets/route.ts b/app/api/songs/analyze/presets/route.ts index 8baccd38..b809394c 100644 --- a/app/api/songs/analyze/presets/route.ts +++ b/app/api/songs/analyze/presets/route.ts @@ -28,6 +28,7 @@ export async function OPTIONS() { * - status: "success" * - presets: Array of { name, label, description, requiresAudio, responseFormat } * + * @param request * @returns A NextResponse with the list of available presets */ export async function GET(request: NextRequest): Promise { diff --git a/app/api/transcribe/route.ts b/app/api/transcribe/route.ts index 28cf4261..0896806b 100644 --- a/app/api/transcribe/route.ts +++ b/app/api/transcribe/route.ts @@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { processAudioTranscription } from "@/lib/transcribe/processAudioTranscription"; import { formatTranscriptionError } from "@/lib/transcribe/types"; +/** + * + * @param req + */ export async function POST(req: NextRequest) { try { const body = await req.json(); diff --git a/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts b/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts index 90e1a3d0..7531a477 100644 --- a/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts +++ b/lib/admins/emails/__tests__/validateGetAdminEmailsQuery.test.ts @@ -12,6 +12,10 @@ vi.mock("@/lib/admins/validateAdminAuth", () => ({ validateAdminAuth: vi.fn(), })); +/** + * + * @param url + */ function createMockRequest(url: string): NextRequest { return { url, diff --git a/lib/admins/privy/countNewAccounts.ts b/lib/admins/privy/countNewAccounts.ts index 012ced53..1d34a14a 100644 --- a/lib/admins/privy/countNewAccounts.ts +++ b/lib/admins/privy/countNewAccounts.ts @@ -5,6 +5,9 @@ import { getCutoffMs } from "./getCutoffMs"; /** * Counts how many users in the list were created within the cutoff period. + * + * @param users + * @param period */ export function countNewAccounts(users: User[], period: PrivyLoginsPeriod): number { const cutoffMs = getCutoffMs(period); diff --git a/lib/admins/privy/fetchPrivyLogins.ts b/lib/admins/privy/fetchPrivyLogins.ts index ae4d4dd0..35ac556c 100644 --- a/lib/admins/privy/fetchPrivyLogins.ts +++ b/lib/admins/privy/fetchPrivyLogins.ts @@ -20,6 +20,10 @@ export type FetchPrivyLoginsResult = { totalPrivyUsers: number; }; +/** + * + * @param period + */ export async function fetchPrivyLogins(period: PrivyLoginsPeriod): Promise { const isAll = period === "all"; const cutoffMs = getCutoffMs(period); diff --git a/lib/admins/privy/getCutoffMs.ts b/lib/admins/privy/getCutoffMs.ts index 8b80ec6a..4de0fa32 100644 --- a/lib/admins/privy/getCutoffMs.ts +++ b/lib/admins/privy/getCutoffMs.ts @@ -5,6 +5,8 @@ import { PERIOD_DAYS } from "./periodDays"; * Returns the cutoff timestamp in milliseconds for a given period. * Uses midnight UTC calendar day boundaries to match Privy dashboard behavior. * Returns 0 for "all" (no cutoff). + * + * @param period */ export function getCutoffMs(period: PrivyLoginsPeriod): number { if (period === "all") return 0; diff --git a/lib/admins/privy/getLatestVerifiedAt.ts b/lib/admins/privy/getLatestVerifiedAt.ts index 465ea876..c7f7ba9b 100644 --- a/lib/admins/privy/getLatestVerifiedAt.ts +++ b/lib/admins/privy/getLatestVerifiedAt.ts @@ -4,6 +4,8 @@ import type { User } from "@privy-io/node"; /** * Returns the most recent latest_verified_at (in ms) across all linked_accounts for a Privy user. * Returns null if no linked account has a latest_verified_at. + * + * @param user */ export function getLatestVerifiedAt(user: User): number | null { const linkedAccounts = user.linked_accounts; diff --git a/lib/admins/privy/toMs.ts b/lib/admins/privy/toMs.ts index 472ff9eb..2daad687 100644 --- a/lib/admins/privy/toMs.ts +++ b/lib/admins/privy/toMs.ts @@ -1,6 +1,8 @@ /** * Normalizes a Privy timestamp to milliseconds. * Privy docs say milliseconds but examples show seconds (10 digits). + * + * @param timestamp */ export function toMs(timestamp: number): number { return timestamp > 1e12 ? timestamp : timestamp * 1000; diff --git a/lib/ai/getModel.ts b/lib/ai/getModel.ts index edf4d425..99ca9c2f 100644 --- a/lib/ai/getModel.ts +++ b/lib/ai/getModel.ts @@ -3,6 +3,7 @@ import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; /** * Returns a specific model by its ID from the list of available models. + * * @param modelId - The ID of the model to find * @returns The matching model or undefined if not found */ diff --git a/lib/ai/isEmbedModel.ts b/lib/ai/isEmbedModel.ts index 7c5fbbfb..4901f1e8 100644 --- a/lib/ai/isEmbedModel.ts +++ b/lib/ai/isEmbedModel.ts @@ -3,6 +3,8 @@ import { GatewayLanguageModelEntry } from "@ai-sdk/gateway"; /** * Determines if a model is an embedding model (not suitable for chat). * Embed models typically have 0 output pricing since they only produce embeddings. + * + * @param m */ export const isEmbedModel = (m: GatewayLanguageModelEntry): boolean => { const pricing = m.pricing; diff --git a/lib/artists/__tests__/createArtistPostHandler.test.ts b/lib/artists/__tests__/createArtistPostHandler.test.ts index e63d244d..dd72b2e1 100644 --- a/lib/artists/__tests__/createArtistPostHandler.test.ts +++ b/lib/artists/__tests__/createArtistPostHandler.test.ts @@ -14,6 +14,11 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); +/** + * + * @param body + * @param headers + */ function createRequest(body: unknown, headers: Record = {}): NextRequest { const defaultHeaders: Record = { "Content-Type": "application/json", diff --git a/lib/artists/__tests__/validateCreateArtistBody.test.ts b/lib/artists/__tests__/validateCreateArtistBody.test.ts index 4de5562b..d12fe1ba 100644 --- a/lib/artists/__tests__/validateCreateArtistBody.test.ts +++ b/lib/artists/__tests__/validateCreateArtistBody.test.ts @@ -9,6 +9,11 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), })); +/** + * + * @param body + * @param headers + */ function createRequest(body: unknown, headers: Record = {}): NextRequest { const defaultHeaders: Record = { "Content-Type": "application/json" }; return new NextRequest("http://localhost/api/artists", { diff --git a/lib/auth/__tests__/validateAuthContext.test.ts b/lib/auth/__tests__/validateAuthContext.test.ts index 31dda345..c4769178 100644 --- a/lib/auth/__tests__/validateAuthContext.test.ts +++ b/lib/auth/__tests__/validateAuthContext.test.ts @@ -33,6 +33,10 @@ const mockGetAuthenticatedAccountId = vi.mocked(getAuthenticatedAccountId); const mockValidateOrganizationAccess = vi.mocked(validateOrganizationAccess); const mockCanAccessAccount = vi.mocked(canAccessAccount); +/** + * + * @param headers + */ function createMockRequest(headers: Record = {}): Request { return { headers: { diff --git a/lib/catalog/formatCatalogSongsAsCSV.ts b/lib/catalog/formatCatalogSongsAsCSV.ts index 5115eece..29cc443c 100644 --- a/lib/catalog/formatCatalogSongsAsCSV.ts +++ b/lib/catalog/formatCatalogSongsAsCSV.ts @@ -2,6 +2,8 @@ import { CatalogSong } from "./getCatalogSongs"; /** * Formats catalog songs into the CSV-like format expected by the scorer + * + * @param songs */ export function formatCatalogSongsAsCSV(songs: CatalogSong[]): string { const csvLines = songs.map(song => { diff --git a/lib/catalog/getCatalogDataAsCSV.ts b/lib/catalog/getCatalogDataAsCSV.ts index ea529c37..4a86fc0e 100644 --- a/lib/catalog/getCatalogDataAsCSV.ts +++ b/lib/catalog/getCatalogDataAsCSV.ts @@ -3,6 +3,8 @@ import { formatCatalogSongsAsCSV } from "./formatCatalogSongsAsCSV"; /** * Gets all catalog songs and formats them as CSV for the scorer + * + * @param catalogId */ export async function getCatalogDataAsCSV(catalogId: string): Promise { const allSongs: CatalogSong[] = []; diff --git a/lib/catalog/getCatalogSongs.ts b/lib/catalog/getCatalogSongs.ts index c58c33be..d7b5ca62 100644 --- a/lib/catalog/getCatalogSongs.ts +++ b/lib/catalog/getCatalogSongs.ts @@ -25,6 +25,13 @@ export interface CatalogSongsResponse { error?: string; } +/** + * + * @param catalogId + * @param pageSize + * @param page + * @param artistName + */ export async function getCatalogSongs( catalogId: string, pageSize: number = 100, diff --git a/lib/catalog/getCatalogs.ts b/lib/catalog/getCatalogs.ts index 9533183b..4ac8a842 100644 --- a/lib/catalog/getCatalogs.ts +++ b/lib/catalog/getCatalogs.ts @@ -8,6 +8,10 @@ export interface CatalogsResponse { error?: string; } +/** + * + * @param accountId + */ export async function getCatalogs(accountId: string): Promise { try { const response = await fetch( diff --git a/lib/chat/__tests__/integration/chatEndToEnd.test.ts b/lib/chat/__tests__/integration/chatEndToEnd.test.ts index 02e758be..f08d5bcb 100644 --- a/lib/chat/__tests__/integration/chatEndToEnd.test.ts +++ b/lib/chat/__tests__/integration/chatEndToEnd.test.ts @@ -170,6 +170,11 @@ const mockDeductCredits = vi.mocked(deductCredits); const mockGenerateChatTitle = vi.mocked(generateChatTitle); // Helper to create mock NextRequest +/** + * + * @param body + * @param headers + */ function createMockRequest(body: unknown, headers: Record = {}): Request { return { json: () => Promise.resolve(body), diff --git a/lib/chat/toolChains/getPrepareStepResult.ts b/lib/chat/toolChains/getPrepareStepResult.ts index 02dd8e71..c011c078 100644 --- a/lib/chat/toolChains/getPrepareStepResult.ts +++ b/lib/chat/toolChains/getPrepareStepResult.ts @@ -12,6 +12,8 @@ type PrepareStepOptions = { /** * Returns the next tool to run based on timeline progression through tool chains. * Uses toolCallsContent to track exact execution order and position in sequence. + * + * @param options */ const getPrepareStepResult = (options: PrepareStepOptions): PrepareStepResult | undefined => { const { steps } = options; diff --git a/lib/chats/processCompactChatRequest.ts b/lib/chats/processCompactChatRequest.ts index a1699c93..c98c2e97 100644 --- a/lib/chats/processCompactChatRequest.ts +++ b/lib/chats/processCompactChatRequest.ts @@ -17,6 +17,9 @@ interface ProcessCompactChatRequestParams { * Verifies the chat exists and the user has access before compacting. * * @param params - The parameters for processing the chat compaction. + * @param params.chatId + * @param params.prompt + * @param params.accountId * @returns The result of the compaction attempt. */ export async function processCompactChatRequest({ diff --git a/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts b/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts index 5e059f4e..194a7170 100644 --- a/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts +++ b/lib/coding-agent/__tests__/handleGitHubWebhook.test.ts @@ -45,6 +45,12 @@ const BASE_PAYLOAD = { }, }; +/** + * + * @param body + * @param event + * @param signature + */ function makeRequest(body: unknown, event = "issue_comment", signature = "valid") { return { text: () => Promise.resolve(JSON.stringify(body)), diff --git a/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts b/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts index 8af470e1..f173d6ce 100644 --- a/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts +++ b/lib/coding-agent/__tests__/onMergeTestToMainAction.test.ts @@ -12,6 +12,9 @@ beforeEach(() => { process.env.GITHUB_TOKEN = "ghp_test"; }); +/** + * + */ function createMockBot() { return { onAction: vi.fn() } as any; } diff --git a/lib/coding-agent/encodeGitHubThreadId.ts b/lib/coding-agent/encodeGitHubThreadId.ts index 1cfff2fe..f4797e43 100644 --- a/lib/coding-agent/encodeGitHubThreadId.ts +++ b/lib/coding-agent/encodeGitHubThreadId.ts @@ -6,6 +6,8 @@ import type { GitHubThreadId } from "@chat-adapter/github"; * * - PR-level: `github:{owner}/{repo}:{prNumber}` * - Review comment: `github:{owner}/{repo}:{prNumber}:rc:{reviewCommentId}` + * + * @param thread */ export function encodeGitHubThreadId(thread: GitHubThreadId): string { const { owner, repo, prNumber, reviewCommentId } = thread; diff --git a/lib/coding-agent/handleMergeSuccess.ts b/lib/coding-agent/handleMergeSuccess.ts index f026f48d..c241923b 100644 --- a/lib/coding-agent/handleMergeSuccess.ts +++ b/lib/coding-agent/handleMergeSuccess.ts @@ -7,6 +7,8 @@ import type { CodingAgentThreadState } from "./types"; * Handles post-merge cleanup after all PRs merged successfully. * Deletes the shared PR state keys for all repos and persists the latest * snapshot via upsertAccountSnapshot. + * + * @param state */ export async function handleMergeSuccess(state: CodingAgentThreadState): Promise { try { diff --git a/lib/coding-agent/parseMergeActionId.ts b/lib/coding-agent/parseMergeActionId.ts index 5118249e..25fd3eeb 100644 --- a/lib/coding-agent/parseMergeActionId.ts +++ b/lib/coding-agent/parseMergeActionId.ts @@ -1,6 +1,8 @@ /** * Parses a merge action ID like "merge_pr:recoupable/api#42" * into { repo, number } or null if the format doesn't match. + * + * @param actionId */ export function parseMergeActionId(actionId: string) { const match = actionId.match(/^merge_pr:(.+)#(\d+)$/); diff --git a/lib/coding-agent/parseMergeTestToMainActionId.ts b/lib/coding-agent/parseMergeTestToMainActionId.ts index 1228615f..14133eac 100644 --- a/lib/coding-agent/parseMergeTestToMainActionId.ts +++ b/lib/coding-agent/parseMergeTestToMainActionId.ts @@ -1,6 +1,8 @@ /** * Parses a merge_test_to_main action ID like "merge_test_to_main:recoupable/api" * into the repo string, or null if the format doesn't match. + * + * @param actionId */ export function parseMergeTestToMainActionId(actionId: string): string | null { const prefix = "merge_test_to_main:"; diff --git a/lib/composio/getCallbackUrl.ts b/lib/composio/getCallbackUrl.ts index 570c9251..8c83505a 100644 --- a/lib/composio/getCallbackUrl.ts +++ b/lib/composio/getCallbackUrl.ts @@ -19,6 +19,7 @@ interface CallbackOptions { * * @param options.destination - Where to redirect: "chat" or "connectors" * @param options.roomId - For chat destination, the room ID to return to + * @param options * @returns Full callback URL with success indicator */ export function getCallbackUrl(options: CallbackOptions): string { diff --git a/lib/content/__tests__/validateCreateContentBody.test.ts b/lib/content/__tests__/validateCreateContentBody.test.ts index 1a71d5ae..31b1c461 100644 --- a/lib/content/__tests__/validateCreateContentBody.test.ts +++ b/lib/content/__tests__/validateCreateContentBody.test.ts @@ -20,6 +20,10 @@ vi.mock("@/lib/content/resolveArtistSlug", () => ({ resolveArtistSlug: vi.fn().mockResolvedValue("gatsby-grace"), })); +/** + * + * @param body + */ function createRequest(body: unknown): NextRequest { return new NextRequest("http://localhost/api/content/create", { method: "POST", diff --git a/lib/content/contentTemplates.ts b/lib/content/contentTemplates.ts index 7d0653e3..66da0d33 100644 --- a/lib/content/contentTemplates.ts +++ b/lib/content/contentTemplates.ts @@ -25,6 +25,10 @@ export const CONTENT_TEMPLATES: ContentTemplate[] = [ /** Derived from the first entry in CONTENT_TEMPLATES to avoid string duplication. */ export const DEFAULT_CONTENT_TEMPLATE = CONTENT_TEMPLATES[0].name; +/** + * + * @param template + */ export function isSupportedContentTemplate(template: string): boolean { return CONTENT_TEMPLATES.some(item => item.name === template); } diff --git a/lib/content/createContentHandler.ts b/lib/content/createContentHandler.ts index b0e845f3..5459cbb8 100644 --- a/lib/content/createContentHandler.ts +++ b/lib/content/createContentHandler.ts @@ -9,6 +9,8 @@ import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectA /** * Handler for POST /api/content/create. * Always returns runIds array (KISS — one response shape for single and batch). + * + * @param request */ export async function createContentHandler(request: NextRequest): Promise { const validated = await validateCreateContentBody(request); diff --git a/lib/content/getArtistContentReadiness.ts b/lib/content/getArtistContentReadiness.ts index a902ce0f..9238598e 100644 --- a/lib/content/getArtistContentReadiness.ts +++ b/lib/content/getArtistContentReadiness.ts @@ -22,6 +22,11 @@ export interface ArtistContentReadiness { /** * Checks whether an artist has the expected files for content creation. * Searches the main repo and org submodule repos. + * + * @param root0 + * @param root0.accountId + * @param root0.artistAccountId + * @param root0.artistSlug */ export async function getArtistContentReadiness({ accountId, diff --git a/lib/content/getArtistFileTree.ts b/lib/content/getArtistFileTree.ts index 908855a0..b5392b52 100644 --- a/lib/content/getArtistFileTree.ts +++ b/lib/content/getArtistFileTree.ts @@ -4,6 +4,9 @@ import { getOrgRepoUrls } from "@/lib/github/getOrgRepoUrls"; /** * Gets the file tree that contains the artist, checking the main repo * first, then falling back to org submodule repos. + * + * @param githubRepo + * @param artistSlug */ export async function getArtistFileTree( githubRepo: string, diff --git a/lib/content/getArtistRootPrefix.ts b/lib/content/getArtistRootPrefix.ts index 5a777abe..bf81d48a 100644 --- a/lib/content/getArtistRootPrefix.ts +++ b/lib/content/getArtistRootPrefix.ts @@ -1,3 +1,8 @@ +/** + * + * @param paths + * @param artistSlug + */ export function getArtistRootPrefix(paths: string[], artistSlug: string): string { const preferredPrefix = `artists/${artistSlug}/`; if (paths.some(path => path.startsWith(preferredPrefix))) { diff --git a/lib/content/getContentValidateHandler.ts b/lib/content/getContentValidateHandler.ts index e0c758b8..81cd0ce8 100644 --- a/lib/content/getContentValidateHandler.ts +++ b/lib/content/getContentValidateHandler.ts @@ -8,6 +8,8 @@ import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadine * Handler for GET /api/content/validate. * NOTE: Phase 1 returns structural readiness scaffolding. Deep filesystem checks * are performed in the background task before spend-heavy steps. + * + * @param request */ export async function getContentValidateHandler(request: NextRequest): Promise { const validated = await validateGetContentValidateQuery(request); diff --git a/lib/content/isCompletedRun.ts b/lib/content/isCompletedRun.ts index 855ea068..951d20b2 100644 --- a/lib/content/isCompletedRun.ts +++ b/lib/content/isCompletedRun.ts @@ -5,6 +5,10 @@ export type TriggerRunLike = { output?: unknown; }; +/** + * + * @param run + */ export function isCompletedRun(run: TriggerRunLike): boolean { return run.status === "COMPLETED"; } diff --git a/lib/content/persistCreateContentRunVideo.ts b/lib/content/persistCreateContentRunVideo.ts index 25a77eed..69bac792 100644 --- a/lib/content/persistCreateContentRunVideo.ts +++ b/lib/content/persistCreateContentRunVideo.ts @@ -27,6 +27,8 @@ type CreateContentOutput = { * and returns the run with normalized output. * * This keeps Supabase writes in API only. + * + * @param run */ export async function persistCreateContentRunVideo(run: T): Promise { if (run.taskIdentifier !== CREATE_CONTENT_TASK_ID || !isCompletedRun(run)) { diff --git a/lib/content/validateCreateContentBody.ts b/lib/content/validateCreateContentBody.ts index 47e3d8fa..2c780e75 100644 --- a/lib/content/validateCreateContentBody.ts +++ b/lib/content/validateCreateContentBody.ts @@ -40,6 +40,8 @@ export type ValidatedCreateContentBody = { /** * Validates auth and request body for POST /api/content/create. + * + * @param request */ export async function validateCreateContentBody( request: NextRequest, diff --git a/lib/content/validateGetContentEstimateQuery.ts b/lib/content/validateGetContentEstimateQuery.ts index 5828e7cc..97af7468 100644 --- a/lib/content/validateGetContentEstimateQuery.ts +++ b/lib/content/validateGetContentEstimateQuery.ts @@ -15,6 +15,8 @@ export type ValidatedGetContentEstimateQuery = z.infer { diff --git a/lib/evals/callChatFunctionsWithResult.ts b/lib/evals/callChatFunctionsWithResult.ts index a792248b..b80fcb58 100644 --- a/lib/evals/callChatFunctionsWithResult.ts +++ b/lib/evals/callChatFunctionsWithResult.ts @@ -8,6 +8,8 @@ import { ChatRequestBody } from "@/lib/chat/validateChatRequest"; * * Note: result.toolCalls only contains calls from the LAST step. When using multi-step * tool chains, we need to collect toolCalls from result.steps to capture all tool usage. + * + * @param input */ export async function callChatFunctionsWithResult(input: string) { const messages: UIMessage[] = [ diff --git a/lib/evals/createToolsCalledScorer.ts b/lib/evals/createToolsCalledScorer.ts index 1d838ee3..8a9ac7e7 100644 --- a/lib/evals/createToolsCalledScorer.ts +++ b/lib/evals/createToolsCalledScorer.ts @@ -3,6 +3,9 @@ import { ToolsCalled } from "./scorers/ToolsCalled"; /** * Creates a scorer that checks if required tools were called. * Handles extracting output text and toolCalls from the task result. + * + * @param requiredTools + * @param penalizedTools */ export const createToolsCalledScorer = (requiredTools: string[], penalizedTools: string[] = []) => { return async (args: { output: unknown; expected?: string; input: string }) => { diff --git a/lib/evals/extractTextFromResult.ts b/lib/evals/extractTextFromResult.ts index fac24cf6..dc67f3ab 100644 --- a/lib/evals/extractTextFromResult.ts +++ b/lib/evals/extractTextFromResult.ts @@ -3,6 +3,8 @@ import { extractTextResultFromSteps } from "./extractTextResultFromSteps"; /** * Extract text from a GenerateTextResult + * + * @param result */ export function extractTextFromResult(result: Awaited>): string { // Handle multi-step responses (when maxSteps > 1) diff --git a/lib/evals/extractTextResultFromSteps.ts b/lib/evals/extractTextResultFromSteps.ts index 44c0ae0d..16881677 100644 --- a/lib/evals/extractTextResultFromSteps.ts +++ b/lib/evals/extractTextResultFromSteps.ts @@ -4,6 +4,8 @@ import type { TextPart } from "ai"; /** * Extract text from multi-step GenerateTextResult * Handles responses where maxSteps > 1 + * + * @param result */ export function extractTextResultFromSteps( result: Awaited>, diff --git a/lib/evals/getCatalogSongsCountExpected.ts b/lib/evals/getCatalogSongsCountExpected.ts index 6f04e59c..d94383ef 100644 --- a/lib/evals/getCatalogSongsCountExpected.ts +++ b/lib/evals/getCatalogSongsCountExpected.ts @@ -2,6 +2,9 @@ import { getCatalogs } from "@/lib/catalog/getCatalogs"; import { getCatalogSongs } from "@/lib/catalog/getCatalogSongs"; import { EVAL_ACCOUNT_ID } from "@/lib/consts"; +/** + * + */ async function getCatalogSongsCountExpected() { try { const catalogsData = await getCatalogs(EVAL_ACCOUNT_ID); diff --git a/lib/evals/getSpotifyFollowersExpected.ts b/lib/evals/getSpotifyFollowersExpected.ts index ef96e248..f5221937 100644 --- a/lib/evals/getSpotifyFollowersExpected.ts +++ b/lib/evals/getSpotifyFollowersExpected.ts @@ -1,5 +1,9 @@ import { getSpotifyFollowers } from "@/lib/spotify/getSpotifyFollowers"; +/** + * + * @param artist + */ async function getSpotifyFollowersExpected(artist: string) { try { const followerCount = await getSpotifyFollowers(artist); diff --git a/lib/evals/scorers/CatalogAvailability.ts b/lib/evals/scorers/CatalogAvailability.ts index f4829ea4..8cf292d9 100644 --- a/lib/evals/scorers/CatalogAvailability.ts +++ b/lib/evals/scorers/CatalogAvailability.ts @@ -5,6 +5,11 @@ import { z } from "zod"; /** * Custom scorer that uses AI to check if recommended songs are actually in the catalog + * + * @param root0 + * @param root0.output + * @param root0.expected + * @param root0.input */ export const CatalogAvailability = async ({ output, diff --git a/lib/evals/scorers/QuestionAnswered.ts b/lib/evals/scorers/QuestionAnswered.ts index abe0222c..a7bafd1d 100644 --- a/lib/evals/scorers/QuestionAnswered.ts +++ b/lib/evals/scorers/QuestionAnswered.ts @@ -5,6 +5,11 @@ import { z } from "zod"; /** * Custom scorer that checks if the AI actually answered the customer's question * with a specific answer, or if it deflected/explained why it couldn't answer + * + * @param root0 + * @param root0.output + * @param root0.expected + * @param root0.input */ export const QuestionAnswered = async ({ output, diff --git a/lib/evals/scorers/ToolsCalled.ts b/lib/evals/scorers/ToolsCalled.ts index 2d901ec3..6a451100 100644 --- a/lib/evals/scorers/ToolsCalled.ts +++ b/lib/evals/scorers/ToolsCalled.ts @@ -1,5 +1,13 @@ /** * Generic scorer that checks if specific tools were called + * + * @param root0 + * @param root0.output + * @param root0.expected + * @param root0.input + * @param root0.toolCalls + * @param root0.requiredTools + * @param root0.penalizedTools */ export const ToolsCalled = async ({ toolCalls, diff --git a/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts b/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts index 19109b2d..1c30d8fc 100644 --- a/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts +++ b/lib/flamingo/__tests__/getFlamingoPresetsHandler.test.ts @@ -17,6 +17,9 @@ vi.mock("../presets", () => ({ getPresetSummaries: vi.fn(), })); +/** + * + */ function createMockRequest(): NextRequest { return { headers: new Headers({ "x-api-key": "test-key" }), diff --git a/lib/flamingo/getFlamingoPresetsHandler.ts b/lib/flamingo/getFlamingoPresetsHandler.ts index e35b5899..f33d491d 100644 --- a/lib/flamingo/getFlamingoPresetsHandler.ts +++ b/lib/flamingo/getFlamingoPresetsHandler.ts @@ -10,6 +10,7 @@ import { validateAuthContext } from "@/lib/auth/validateAuthContext"; * Returns a list of all available analysis presets. * Requires authentication via x-api-key header or Authorization bearer token. * + * @param request * @returns A NextResponse with the list of available presets. */ export async function getFlamingoPresetsHandler(request: NextRequest): Promise { diff --git a/lib/github/expandSubmoduleEntries.ts b/lib/github/expandSubmoduleEntries.ts index 9531bee1..3082c63b 100644 --- a/lib/github/expandSubmoduleEntries.ts +++ b/lib/github/expandSubmoduleEntries.ts @@ -11,9 +11,15 @@ interface SubmoduleRef { * Resolves submodule URLs from .gitmodules, fetches each submodule's tree, * and merges the results into the regular entries with correct path prefixes. * + * @param regularEntries.regularEntries * @param regularEntries - Non-submodule file tree entries * @param submoduleEntries - Submodule references (type "commit" from GitHub Trees API) * @param repo - Repository context for fetching .gitmodules + * @param regularEntries.submoduleEntries + * @param regularEntries.repo + * @param regularEntries.repo.owner + * @param regularEntries.repo.repo + * @param regularEntries.repo.branch * @returns Combined file tree entries with submodules expanded as directories */ export async function expandSubmoduleEntries({ diff --git a/lib/github/getRepoGitModules.ts b/lib/github/getRepoGitModules.ts index caa0304e..8913a6ae 100644 --- a/lib/github/getRepoGitModules.ts +++ b/lib/github/getRepoGitModules.ts @@ -4,9 +4,12 @@ import { parseGitModules, type SubmoduleEntry } from "./parseGitModules"; * Fetches and parses .gitmodules from a GitHub repository. * Uses the GitHub Contents API (works for both public and private repos). * + * @param owner.owner * @param owner - The GitHub repository owner * @param repo - The GitHub repository name * @param branch - The branch to fetch from + * @param owner.repo + * @param owner.branch * @returns Array of submodule entries, or null if .gitmodules doesn't exist or fetch fails */ export async function getRepoGitModules({ diff --git a/lib/github/resolveSubmodulePath.ts b/lib/github/resolveSubmodulePath.ts index 7c3f60ed..029f1b1d 100644 --- a/lib/github/resolveSubmodulePath.ts +++ b/lib/github/resolveSubmodulePath.ts @@ -6,8 +6,10 @@ import { getRepoGitModules } from "./getRepoGitModules"; * If the path falls within a submodule, returns the submodule's repo URL * and the relative path within it. Otherwise returns the original values. * + * @param githubRepo.githubRepo * @param githubRepo - The parent GitHub repository URL * @param path - The file path to resolve + * @param githubRepo.path * @returns The resolved repo URL and path */ export async function resolveSubmodulePath({ diff --git a/lib/mcp/resolveAccountId.ts b/lib/mcp/resolveAccountId.ts index 03d1d0d8..456fe4c6 100644 --- a/lib/mcp/resolveAccountId.ts +++ b/lib/mcp/resolveAccountId.ts @@ -16,6 +16,8 @@ export interface ResolveAccountIdResult { * Validates access when an org API key attempts to use an account_id override. * * @param params - The auth info and optional account_id override. + * @param params.authInfo + * @param params.accountIdOverride * @returns The resolved accountId or an error message. */ export async function resolveAccountId({ diff --git a/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts b/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts index 4942fdfb..d8a64f79 100644 --- a/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts +++ b/lib/mcp/tools/transcribe/registerTranscribeAudioTool.ts @@ -15,6 +15,10 @@ const transcribeAudioSchema = z.object({ type TranscribeAudioArgs = z.infer; +/** + * + * @param server + */ export function registerTranscribeAudioTool(server: McpServer): void { server.registerTool( "transcribe_audio", diff --git a/lib/notifications/__tests__/createNotificationHandler.test.ts b/lib/notifications/__tests__/createNotificationHandler.test.ts index ca7fb677..60b6e5ba 100644 --- a/lib/notifications/__tests__/createNotificationHandler.test.ts +++ b/lib/notifications/__tests__/createNotificationHandler.test.ts @@ -26,6 +26,10 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(async (req: Request) => req.json()), })); +/** + * + * @param body + */ function createRequest(body: unknown): NextRequest { return new NextRequest("https://recoup-api.vercel.app/api/notifications", { method: "POST", diff --git a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts index 10390b15..645ccedc 100644 --- a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts +++ b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts @@ -16,6 +16,11 @@ vi.mock("@/lib/networking/safeParseJson", () => ({ safeParseJson: vi.fn(async (req: Request) => req.json()), })); +/** + * + * @param body + * @param headers + */ function createRequest(body: unknown, headers: Record = {}): NextRequest { const defaultHeaders: Record = { "Content-Type": "application/json" }; return new NextRequest("http://localhost/api/notifications", { diff --git a/lib/prompts/getSystemPrompt.ts b/lib/prompts/getSystemPrompt.ts index 54964670..5077609a 100644 --- a/lib/prompts/getSystemPrompt.ts +++ b/lib/prompts/getSystemPrompt.ts @@ -13,6 +13,7 @@ import { AccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetail * @param params.artistInstruction - The artist instruction * @param params.conversationName - The name of the conversation * @param params.accountWithDetails - The account with details + * @param params.orgId * @returns The system prompt */ export function getSystemPrompt({ diff --git a/lib/spotify/getSpotifyFollowers.ts b/lib/spotify/getSpotifyFollowers.ts index 235de41e..acd1c3be 100644 --- a/lib/spotify/getSpotifyFollowers.ts +++ b/lib/spotify/getSpotifyFollowers.ts @@ -37,6 +37,7 @@ interface SpotifySearchResponse { /** * Get Spotify follower count for an artist + * * @param artistName - The name of the artist to search for * @returns Promise - The follower count of the first matching artist */ diff --git a/lib/supabase/account_artist_ids/getAccountArtistIds.ts b/lib/supabase/account_artist_ids/getAccountArtistIds.ts index e4e6b809..42b550d0 100644 --- a/lib/supabase/account_artist_ids/getAccountArtistIds.ts +++ b/lib/supabase/account_artist_ids/getAccountArtistIds.ts @@ -8,7 +8,9 @@ export type AccountArtistRow = ArtistQueryRow & { artist_id: string; pinned: boo * Get all artists for an array of artist IDs or account IDs, with full info. * Returns raw data - formatting should be done by caller. * - * @param params Object with artistIds or accountIds array + * @param params - Object with artistIds or accountIds array + * @param params.artistIds + * @param params.accountIds * @returns Array of raw artist rows from database */ export async function getAccountArtistIds(params: { diff --git a/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts b/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts index ae121fdd..4ca7ad8e 100644 --- a/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts +++ b/lib/supabase/account_workspace_ids/getAccountWorkspaceIds.ts @@ -10,7 +10,7 @@ export type AccountWorkspaceRow = Omit & { * Get all workspaces for an account, with full info. * Returns raw data - formatting should be done by caller. * - * @param accountId The owner's account ID + * @param accountId - The owner's account ID * @returns Array of raw workspace rows from database */ export async function getAccountWorkspaceIds(accountId: string): Promise { diff --git a/lib/supabase/files/createFileRecord.ts b/lib/supabase/files/createFileRecord.ts index 6f836f3c..3182de11 100644 --- a/lib/supabase/files/createFileRecord.ts +++ b/lib/supabase/files/createFileRecord.ts @@ -25,6 +25,8 @@ export interface CreateFileRecordParams { /** * Create a file record in the database + * + * @param params */ export async function createFileRecord(params: CreateFileRecordParams): Promise { const { diff --git a/lib/supabase/song_artists/insertSongArtists.ts b/lib/supabase/song_artists/insertSongArtists.ts index b81879e3..69878d6d 100644 --- a/lib/supabase/song_artists/insertSongArtists.ts +++ b/lib/supabase/song_artists/insertSongArtists.ts @@ -5,6 +5,8 @@ export type SongArtistInsert = TablesInsert<"song_artists">; /** * Inserts song-artist relationships, skipping duplicates. + * + * @param songArtists */ export async function insertSongArtists(songArtists: SongArtistInsert[]): Promise { const records = songArtists.filter( diff --git a/lib/supabase/storage/uploadFileByKey.ts b/lib/supabase/storage/uploadFileByKey.ts index ba146fa3..ae149173 100644 --- a/lib/supabase/storage/uploadFileByKey.ts +++ b/lib/supabase/storage/uploadFileByKey.ts @@ -3,6 +3,12 @@ import { SUPABASE_STORAGE_BUCKET } from "@/lib/const"; /** * Upload file to Supabase storage by key + * + * @param key + * @param file + * @param options + * @param options.contentType + * @param options.upsert */ export async function uploadFileByKey( key: string, diff --git a/lib/tasks/__tests__/getTaskRunHandler.test.ts b/lib/tasks/__tests__/getTaskRunHandler.test.ts index 270b3d15..6f764de6 100644 --- a/lib/tasks/__tests__/getTaskRunHandler.test.ts +++ b/lib/tasks/__tests__/getTaskRunHandler.test.ts @@ -23,6 +23,9 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); +/** + * + */ function createMockRequest(): NextRequest { return { url: "http://localhost:3000/api/tasks/runs", diff --git a/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts b/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts index f7126175..77d410da 100644 --- a/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts +++ b/lib/tasks/__tests__/validateGetTaskRunQuery.test.ts @@ -24,6 +24,8 @@ vi.mock("@/lib/admins/checkIsAdmin", () => ({ /** * Creates a mock NextRequest with the given URL. + * + * @param url */ function createMockRequest(url: string): NextRequest { return { diff --git a/lib/transcribe/processAudioTranscription.ts b/lib/transcribe/processAudioTranscription.ts index 351eee34..0e05905a 100644 --- a/lib/transcribe/processAudioTranscription.ts +++ b/lib/transcribe/processAudioTranscription.ts @@ -7,6 +7,8 @@ import { ProcessTranscriptionParams, ProcessTranscriptionResult } from "./types" /** * Fetches audio from URL, transcribes it with OpenAI Whisper, and saves both * the original audio and transcript markdown to the customer's files. + * + * @param params */ export async function processAudioTranscription( params: ProcessTranscriptionParams, @@ -64,6 +66,10 @@ export async function processAudioTranscription( }; } +/** + * + * @param contentType + */ function getExtensionFromContentType(contentType: string): string { if (contentType.includes("wav")) return "wav"; if (contentType.includes("m4a") || contentType.includes("mp4")) return "m4a"; diff --git a/lib/transcribe/saveAudioToFiles.ts b/lib/transcribe/saveAudioToFiles.ts index 12bda1ef..2124e512 100644 --- a/lib/transcribe/saveAudioToFiles.ts +++ b/lib/transcribe/saveAudioToFiles.ts @@ -2,6 +2,10 @@ import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; import { SaveAudioParams, FileRecord } from "./types"; +/** + * + * @param params + */ export async function saveAudioToFiles(params: SaveAudioParams): Promise { const { audioBlob, diff --git a/lib/transcribe/saveTranscriptToFiles.ts b/lib/transcribe/saveTranscriptToFiles.ts index 627feb6d..fa7518c5 100644 --- a/lib/transcribe/saveTranscriptToFiles.ts +++ b/lib/transcribe/saveTranscriptToFiles.ts @@ -2,6 +2,10 @@ import { uploadFileByKey } from "@/lib/supabase/storage/uploadFileByKey"; import { createFileRecord } from "@/lib/supabase/files/createFileRecord"; import { SaveTranscriptParams, FileRecord } from "./types"; +/** + * + * @param params + */ export async function saveTranscriptToFiles(params: SaveTranscriptParams): Promise { const { markdown, ownerAccountId, artistAccountId, title = "Transcription" } = params; diff --git a/lib/transcribe/types.ts b/lib/transcribe/types.ts index 91c0ac10..916e699c 100644 --- a/lib/transcribe/types.ts +++ b/lib/transcribe/types.ts @@ -56,6 +56,8 @@ export interface ProcessTranscriptionResult { /** * Formats transcription errors into user-friendly messages. * Centralizes error message logic to avoid duplication. + * + * @param error */ export function formatTranscriptionError(error: unknown): { message: string; status: number } { const rawMessage = error instanceof Error ? error.message : "Transcription failed"; diff --git a/lib/trigger/triggerCreateContent.ts b/lib/trigger/triggerCreateContent.ts index 494f7063..e8732539 100644 --- a/lib/trigger/triggerCreateContent.ts +++ b/lib/trigger/triggerCreateContent.ts @@ -16,6 +16,8 @@ export interface TriggerCreateContentPayload { /** * Triggers the create-content task in Trigger.dev. + * + * @param payload */ export async function triggerCreateContent(payload: TriggerCreateContentPayload) { const handle = await tasks.trigger(CREATE_CONTENT_TASK_ID, payload); From ee555137fc86ec619b4715099363a3a7b50770fc Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 20:24:01 +0000 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20improve=20Artist=20Intel=20Pack=20o?= =?UTF-8?q?utput=20design=20=E2=80=94=20typed=20analysis,=20richer=20web?= =?UTF-8?q?=20context,=20markdown=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add proper TypeScript types for MusicFlamingo analysis responses (CatalogMetadata, AudienceProfile, MoodTagsResult) replacing `unknown` - Switch getArtistWebContext from Perplexity search snippets to chatWithPerplexity (sonar-pro) for richer narrative summaries with citations - Add formatArtistIntelPackAsMarkdown: formats the full pack as a structured markdown report with sections for artist profile, music DNA, web context, and marketing pack - Add formatted_report field to ArtistIntelPack returned by the API - Update generate_artist_intel_pack MCP tool to return formatted markdown directly (instead of raw JSON) so the chat UI renders it beautifully - 15 new formatter tests; 1529 total passing Co-Authored-By: Claude Sonnet 4.6 --- .../formatArtistIntelPackAsMarkdown.test.ts | 178 ++++++++++++++++++ .../__tests__/generateArtistIntelPack.test.ts | 2 +- .../formatArtistIntelPackAsMarkdown.ts | 157 +++++++++++++++ lib/artistIntel/generateArtistIntelPack.ts | 9 +- lib/artistIntel/getArtistMusicAnalysis.ts | 62 +++++- lib/artistIntel/getArtistWebContext.ts | 35 ++-- .../registerGenerateArtistIntelPackTool.ts | 4 +- 7 files changed, 420 insertions(+), 27 deletions(-) create mode 100644 lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts create mode 100644 lib/artistIntel/formatArtistIntelPackAsMarkdown.ts diff --git a/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts b/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts new file mode 100644 index 00000000..128dc510 --- /dev/null +++ b/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from "vitest"; +import { formatArtistIntelPackAsMarkdown } from "@/lib/artistIntel/formatArtistIntelPackAsMarkdown"; +import type { ArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack"; + +const basePack: ArtistIntelPack = { + artist: { + name: "Test Artist", + spotify_id: "spotify-123", + genres: ["pop", "indie pop"], + followers: 1_500_000, + popularity: 78, + image_url: "https://example.com/image.jpg", + }, + top_track: { + name: "Biggest Hit", + spotify_id: "track-456", + preview_url: "https://p.scdn.co/preview.mp3", + album_name: "Great Album", + popularity: 85, + }, + music_analysis: { + catalog_metadata: { + genre: "pop", + subgenres: ["indie pop", "bedroom pop"], + mood: ["dreamy", "melancholic"], + tempo_bpm: 120, + key: "C major", + time_signature: "4/4", + instruments: ["guitar", "synth", "drums"], + vocal_type: "female", + vocal_style: "breathy, conversational", + production_style: "bedroom pop", + energy_level: 6, + danceability: 7, + lyrical_themes: ["love", "nostalgia"], + similar_artists: ["Clairo", "Soccer Mommy"], + description: "A dreamy indie pop track with lush production.", + }, + audience_profile: { + age_range: "18-28", + gender_skew: "female-leaning", + lifestyle_tags: ["journal-keeper", "coffee shop regular"], + listening_contexts: ["late-night alone", "rainy day studying", "road trips"], + platforms: ["Spotify", "TikTok", "Apple Music"], + playlist_types: ["indie chill", "bedroom pop essentials"], + comparable_fanbases: ["Clairo fans", "Lorde fans"], + marketing_hook: "For the dreamers who feel everything too deeply.", + }, + playlist_pitch: + "SONG SUMMARY: A dreamy indie pop track.\n\nWHY IT FITS: Perfect for late-night playlists.", + mood_tags: { + tags: ["dreamy", "late-night", "heartbreak", "chill"], + primary_mood: "melancholic", + }, + }, + web_context: { + summary: + "Test Artist is a rising indie pop singer-songwriter who released their debut album in 2024 to critical acclaim.", + citations: ["https://pitchfork.com/test", "https://nme.com/test"], + }, + marketing_pack: { + playlist_pitch_email: + "Dear Curator,\n\nTest Artist is perfect for your playlist.\n\nBest,\nThe Team", + instagram_caption: "New music from Test Artist is here 🎵 #indiepop #newmusic", + tiktok_caption: "You need to hear Test Artist 🔥 #viral #indiepop", + twitter_post: "New music alert: Test Artist just dropped something special 🎵", + press_release_opener: + "Test Artist releases their highly anticipated debut. With 1.5M Spotify followers, they deliver a standout offering.", + key_talking_points: [ + "1.5M Spotify followers", + "78/100 popularity score", + "Rising indie pop artist", + ], + }, + formatted_report: "", + elapsed_seconds: 28.5, +}; + +describe("formatArtistIntelPackAsMarkdown", () => { + it("includes the artist name in the heading", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("# Artist Intelligence Pack: Test Artist"); + }); + + it("includes elapsed time in the header", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("28.5s"); + }); + + it("includes follower count and popularity", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("1,500,000"); + expect(result).toContain("78/100"); + }); + + it("includes genre information", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("pop"); + expect(result).toContain("indie pop"); + }); + + it("includes top track name and album", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("Biggest Hit"); + expect(result).toContain("Great Album"); + }); + + it("includes music DNA section when analysis is available", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("## Music DNA"); + expect(result).toContain("**BPM:** 120"); + expect(result).toContain("C major"); + expect(result).toContain("6/10"); + }); + + it("includes audience profile", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("18-28"); + expect(result).toContain("female-leaning"); + expect(result).toContain("For the dreamers who feel everything too deeply."); + }); + + it("includes mood tags", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("dreamy"); + expect(result).toContain("melancholic"); + }); + + it("includes web context and citations", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("## Recent Web Context"); + expect(result).toContain("rising indie pop singer-songwriter"); + expect(result).toContain("pitchfork.com"); + }); + + it("includes marketing pack sections", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("### Playlist Pitch Email"); + expect(result).toContain("### Social Media Captions"); + expect(result).toContain("**Instagram:**"); + expect(result).toContain("**TikTok:**"); + expect(result).toContain("**Twitter/X:**"); + expect(result).toContain("### Press Release Opener"); + expect(result).toContain("### Key Talking Points"); + }); + + it("includes key talking points as bullet list", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("- 1.5M Spotify followers"); + expect(result).toContain("- 78/100 popularity score"); + }); + + it("omits music DNA section when analysis is null", () => { + const pack = { ...basePack, music_analysis: null }; + const result = formatArtistIntelPackAsMarkdown(pack); + expect(result).not.toContain("## Music DNA"); + }); + + it("omits web context section when web_context is null", () => { + const pack = { ...basePack, web_context: null }; + const result = formatArtistIntelPackAsMarkdown(pack); + expect(result).not.toContain("## Recent Web Context"); + }); + + it("handles missing top track gracefully", () => { + const pack = { ...basePack, top_track: null }; + const result = formatArtistIntelPackAsMarkdown(pack); + expect(result).toContain("# Artist Intelligence Pack"); + expect(result).not.toContain("Biggest Hit"); + }); + + it("omits citations line when citations array is empty", () => { + const pack = { ...basePack, web_context: { summary: "Some context", citations: [] } }; + const result = formatArtistIntelPackAsMarkdown(pack); + expect(result).toContain("Some context"); + expect(result).not.toContain("*Sources:"); + }); +}); diff --git a/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts index 6e494a36..03ccd05d 100644 --- a/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts +++ b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts @@ -48,8 +48,8 @@ const mockMusicAnalysis = { }; const mockWebContext = { - results: [{ title: "Test", url: "https://example.com", snippet: "Artist news" }], summary: "Artist news summary", + citations: ["https://example.com"], }; const mockMarketingCopy = { diff --git a/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts b/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts new file mode 100644 index 00000000..d2c93796 --- /dev/null +++ b/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts @@ -0,0 +1,157 @@ +import type { ArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack"; + +/** + * Formats an Artist Intelligence Pack as a rich markdown document. + * + * Produces a human-readable report with sections for artist profile, + * music DNA (MusicFlamingo AI analysis), web context (Perplexity), and + * the full marketing pack (pitch email, social captions, press release). + * + * @param pack - The complete artist intelligence pack. + * @returns A formatted markdown string ready to display in chat or export. + */ +export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { + const { artist, top_track, music_analysis, web_context, marketing_pack, elapsed_seconds } = pack; + + const lines: string[] = []; + + lines.push(`# Artist Intelligence Pack: ${artist.name}`); + lines.push(``); + lines.push(`> Generated in ${elapsed_seconds}s · Spotify + MusicFlamingo AI + Perplexity`); + lines.push(``); + lines.push(`---`); + lines.push(``); + + // Artist profile + lines.push(`## Artist Profile`); + lines.push(``); + lines.push(`- **Followers:** ${artist.followers.toLocaleString()}`); + lines.push(`- **Popularity:** ${artist.popularity}/100`); + if (artist.genres.length > 0) { + lines.push(`- **Genres:** ${artist.genres.slice(0, 4).join(", ")}`); + } + if (top_track) { + lines.push( + `- **Top Track:** "${top_track.name}" — *${top_track.album_name}* (popularity: ${top_track.popularity}/100)`, + ); + if (top_track.preview_url) { + lines.push(`- **Preview:** ${top_track.preview_url}`); + } + } + lines.push(``); + lines.push(`---`); + lines.push(``); + + // Music DNA + if (music_analysis) { + lines.push(`## Music DNA (NVIDIA MusicFlamingo AI)`); + lines.push(``); + + const meta = music_analysis.catalog_metadata; + if (meta) { + const genreLine = meta.subgenres?.length + ? `**Genre:** ${meta.genre} *(${meta.subgenres.join(", ")})*` + : `**Genre:** ${meta.genre}`; + lines.push(genreLine); + lines.push( + `**BPM:** ${meta.tempo_bpm} | **Key:** ${meta.key} | **Time:** ${meta.time_signature}`, + ); + lines.push(`**Energy:** ${meta.energy_level}/10 | **Danceability:** ${meta.danceability}/10`); + if (meta.mood?.length) lines.push(`**Mood:** ${meta.mood.join(", ")}`); + if (meta.instruments?.length) lines.push(`**Instruments:** ${meta.instruments.join(", ")}`); + if (meta.vocal_style) lines.push(`**Vocal Style:** ${meta.vocal_style}`); + if (meta.production_style) lines.push(`**Production:** ${meta.production_style}`); + if (meta.similar_artists?.length) + lines.push(`**Sounds Like:** ${meta.similar_artists.join(", ")}`); + if (meta.description) lines.push(`**Description:** ${meta.description}`); + lines.push(``); + } + + const audience = music_analysis.audience_profile; + if (audience) { + lines.push( + `**Target Audience:** ${audience.age_range}${audience.gender_skew !== "neutral" ? `, ${audience.gender_skew}` : ""}`, + ); + if (audience.listening_contexts?.length) { + lines.push( + `**Listening Contexts:** ${audience.listening_contexts.slice(0, 3).join(" · ")}`, + ); + } + if (audience.platforms?.length) { + lines.push(`**Key Platforms:** ${audience.platforms.join(", ")}`); + } + if (audience.comparable_fanbases?.length) { + lines.push(`**Comparable Fanbases:** ${audience.comparable_fanbases.join(", ")}`); + } + if (audience.marketing_hook) { + lines.push(`**Marketing Hook:** *"${audience.marketing_hook}"*`); + } + lines.push(``); + } + + const moods = music_analysis.mood_tags; + if (moods?.tags?.length) { + lines.push(`**Vibe Tags:** ${moods.tags.join(" · ")}`); + if (moods.primary_mood) lines.push(`**Primary Mood:** ${moods.primary_mood}`); + lines.push(``); + } + + if (music_analysis.playlist_pitch) { + lines.push(`**Playlist Pitch (AI-generated from audio):**`); + lines.push(``); + lines.push(music_analysis.playlist_pitch); + lines.push(``); + } + + lines.push(`---`); + lines.push(``); + } + + // Web context + if (web_context?.summary) { + lines.push(`## Recent Web Context`); + lines.push(``); + lines.push(web_context.summary); + lines.push(``); + if (web_context.citations?.length) { + lines.push(`*Sources: ${web_context.citations.slice(0, 3).join(", ")}*`); + lines.push(``); + } + lines.push(`---`); + lines.push(``); + } + + // Marketing pack + lines.push(`## Marketing Pack`); + lines.push(``); + + lines.push(`### Playlist Pitch Email`); + lines.push(``); + lines.push(marketing_pack.playlist_pitch_email); + lines.push(``); + + lines.push(`### Social Media Captions`); + lines.push(``); + lines.push(`**Instagram:** ${marketing_pack.instagram_caption}`); + lines.push(``); + lines.push(`**TikTok:** ${marketing_pack.tiktok_caption}`); + lines.push(``); + lines.push(`**Twitter/X:** ${marketing_pack.twitter_post}`); + lines.push(``); + + lines.push(`### Press Release Opener`); + lines.push(``); + lines.push(marketing_pack.press_release_opener); + lines.push(``); + + if (marketing_pack.key_talking_points?.length) { + lines.push(`### Key Talking Points`); + lines.push(``); + for (const point of marketing_pack.key_talking_points) { + lines.push(`- ${point}`); + } + lines.push(``); + } + + return lines.join("\n").trim(); +} diff --git a/lib/artistIntel/generateArtistIntelPack.ts b/lib/artistIntel/generateArtistIntelPack.ts index fe1cd6af..74f39202 100644 --- a/lib/artistIntel/generateArtistIntelPack.ts +++ b/lib/artistIntel/generateArtistIntelPack.ts @@ -2,6 +2,7 @@ import type { ArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketing import type { ArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; import type { ArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; import { buildArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketingCopy"; +import { formatArtistIntelPackAsMarkdown } from "@/lib/artistIntel/formatArtistIntelPackAsMarkdown"; import { getArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; import { getArtistSpotifyData } from "@/lib/artistIntel/getArtistSpotifyData"; import { getArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; @@ -25,6 +26,7 @@ export interface ArtistIntelPack { music_analysis: ArtistMusicAnalysis | null; web_context: ArtistWebContext | null; marketing_pack: ArtistMarketingCopy; + formatted_report: string; elapsed_seconds: number; } @@ -64,7 +66,7 @@ export async function generateArtistIntelPack(artistName: string): Promise = { + const analysis: ArtistMusicAnalysis = { catalog_metadata: null, audience_profile: null, playlist_pitch: null, @@ -48,13 +86,21 @@ export async function getArtistMusicAnalysis( let anySuccess = false; results.forEach((result, i) => { - const preset = ANALYSIS_PRESETS[i]; + const preset = ANALYSIS_PRESETS[i] as AnalysisPreset; if (result.status === "fulfilled" && result.value.type === "success") { const value = result.value as { type: "success"; response: unknown }; - analysis[preset] = value.response; + if (preset === "catalog_metadata") { + analysis.catalog_metadata = value.response as CatalogMetadata; + } else if (preset === "audience_profile") { + analysis.audience_profile = value.response as AudienceProfile; + } else if (preset === "playlist_pitch") { + analysis.playlist_pitch = value.response as string; + } else if (preset === "mood_tags") { + analysis.mood_tags = value.response as MoodTagsResult; + } anySuccess = true; } }); - return anySuccess ? (analysis as ArtistMusicAnalysis) : null; + return anySuccess ? analysis : null; } diff --git a/lib/artistIntel/getArtistWebContext.ts b/lib/artistIntel/getArtistWebContext.ts index eac9a1b5..c899c806 100644 --- a/lib/artistIntel/getArtistWebContext.ts +++ b/lib/artistIntel/getArtistWebContext.ts @@ -1,34 +1,39 @@ -import { searchPerplexity, type SearchResult } from "@/lib/perplexity/searchPerplexity"; +import { chatWithPerplexity } from "@/lib/perplexity/chatWithPerplexity"; export interface ArtistWebContext { - results: SearchResult[]; summary: string; + citations: string[]; } /** - * Fetches recent web context about an artist using Perplexity search. + * Fetches rich narrative web context about an artist using Perplexity chat (sonar-pro). + * Returns a researched summary with citations rather than raw search snippets. * * @param artistName - The artist name to research. - * @returns Web search results with a summary, or null on failure. + * @returns A narrative summary with citations, or null on failure. */ export async function getArtistWebContext(artistName: string): Promise { try { - const response = await searchPerplexity({ - query: `${artistName} music artist 2025 new release streaming stats news`, - max_results: 5, - }); + const result = await chatWithPerplexity([ + { + role: "user", + content: `Research the music artist "${artistName}". Provide a concise but rich overview covering: +1. Who they are and their current career stage +2. Recent releases, streaming milestones, or news (2024-2025) +3. Their core sound and genre positioning +4. Notable collaborations, press coverage, or cultural moments +5. Why they matter right now in the music industry - const summary = response.results - .slice(0, 3) - .map(r => r.snippet) - .join(" | "); +Be specific and factual. Focus on current/recent information. Keep the response under 200 words.`, + }, + ]); return { - results: response.results, - summary, + summary: result.content, + citations: result.citations, }; } catch (error) { - console.error("Perplexity search failed:", error); + console.error("Perplexity chat failed:", error); return null; } } diff --git a/lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts b/lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts index 740b335c..c9dd57eb 100644 --- a/lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts +++ b/lib/mcp/tools/artistIntel/registerGenerateArtistIntelPackTool.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; +import { getCallToolResult } from "@/lib/mcp/getCallToolResult"; import { generateArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack"; const toolSchema = z.object({ @@ -67,7 +67,7 @@ export function registerGenerateArtistIntelPackTool(server: McpServer): void { return getToolResultError(result.error); } - return getToolResultSuccess(result.pack); + return getCallToolResult(result.pack.formatted_report); }, ); } From a65399a14d0aa9b98c6dd332f5dfe841433a542f Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 21:35:41 +0000 Subject: [PATCH 4/6] feat: upgrade Artist Intel Pack with industry-grade outputs for artists & labels Replace generic social media captions as the primary output with high-value music industry materials: artist one-sheet, A&R memo, sync licensing brief, named Spotify editorial playlist targets, and brand partnership pitch. Social captions remain but are moved to a secondary 'Outreach & Social' section. The Industry Pack section now leads with the outputs that drive actual revenue and career decisions for artists, managers, and label A&R teams. Co-Authored-By: Claude Sonnet 4.6 --- .../formatArtistIntelPackAsMarkdown.test.ts | 67 ++++++++++++++++- .../__tests__/generateArtistIntelPack.test.ts | 6 ++ lib/artistIntel/buildArtistMarketingCopy.ts | 72 +++++++++++++------ .../formatArtistIntelPackAsMarkdown.ts | 69 ++++++++++++++---- 4 files changed, 179 insertions(+), 35 deletions(-) diff --git a/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts b/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts index 128dc510..57ec25ed 100644 --- a/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts +++ b/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts @@ -59,6 +59,24 @@ const basePack: ArtistIntelPack = { citations: ["https://pitchfork.com/test", "https://nme.com/test"], }, marketing_pack: { + artist_one_sheet: + "Test Artist — indie pop artist with 1.5M Spotify followers.\n\nBio: Rising indie pop singer-songwriter from LA. Debut album 2024 to critical acclaim.\n\nKey Stats: 1.5M Spotify followers · 78/100 popularity · indie pop, bedroom pop\n\nFor Booking & Licensing: agent@recoupable.com", + ar_memo: + "ARTIST: Test Artist\nGENRE: indie pop\nCOMPS: Clairo (bedroom pop), Soccer Mommy (indie)\n\nMOMENTUM: 1.5M followers, 78/100 popularity. Debut album received critical acclaim.\n\nRECOMMENDATION: Development deal conversation. Strong sync and streaming upside.", + sync_brief: + "ARTIST: Test Artist\nTRACK: Biggest Hit\nMOOD: dreamy, melancholic, 120 BPM\n\nSync use cases:\n- Opening montage of a Netflix coming-of-age drama\n- Luxury skincare campaign targeting 20-35 year olds\n- Coffee shop lifestyle brand social content\n- End credits of an indie film\n\nContact: agent@recoupable.com", + spotify_playlist_targets: [ + "New Music Friday", + "Pollen", + "bedroom pop", + "Lorem", + "Fresh Finds", + "sad indie", + "Late Night Feelings", + "indie pop", + ], + brand_partnership_pitch: + "Glossier — bedroom pop aesthetic and female-leaning demo matches their indie-cool beauty positioning. Activation: campaign soundtrack + artist feature.\n\nNike — dreamy indie pop for their mindfulness/yoga vertical. Activation: branded playlist curation.\n\nAesop — understated aesthetic and emotional depth aligns with their luxury positioning. Activation: in-store and digital ambient content.", playlist_pitch_email: "Dear Curator,\n\nTest Artist is perfect for your playlist.\n\nBest,\nThe Team", instagram_caption: "New music from Test Artist is here 🎵 #indiepop #newmusic", @@ -128,13 +146,55 @@ describe("formatArtistIntelPackAsMarkdown", () => { it("includes web context and citations", () => { const result = formatArtistIntelPackAsMarkdown(basePack); - expect(result).toContain("## Recent Web Context"); + expect(result).toContain("## Recent News & Press"); expect(result).toContain("rising indie pop singer-songwriter"); expect(result).toContain("pitchfork.com"); }); - it("includes marketing pack sections", () => { + it("includes industry pack sections", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("## Industry Pack"); + expect(result).toContain("### Artist One-Sheet"); + expect(result).toContain("### A&R Memo"); + expect(result).toContain("### Sync & Licensing Brief"); + expect(result).toContain("### Spotify Editorial Playlist Targets"); + expect(result).toContain("### Brand Partnership Pitch"); + }); + + it("includes artist one-sheet content", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("agent@recoupable.com"); + expect(result).toContain("1.5M Spotify followers"); + }); + + it("includes A&R memo with comps", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("Clairo"); + expect(result).toContain("Development deal"); + }); + + it("includes sync brief with use cases", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("Netflix"); + expect(result).toContain("Sync & Licensing Brief"); + }); + + it("includes spotify playlist targets as bullet list", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("- New Music Friday"); + expect(result).toContain("- Pollen"); + expect(result).toContain("- bedroom pop"); + }); + + it("includes brand partnership pitch", () => { + const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("Glossier"); + expect(result).toContain("Brand Partnership Pitch"); + }); + + it("includes outreach and social section", () => { const result = formatArtistIntelPackAsMarkdown(basePack); + expect(result).toContain("## Outreach & Social"); expect(result).toContain("### Playlist Pitch Email"); expect(result).toContain("### Social Media Captions"); expect(result).toContain("**Instagram:**"); @@ -166,7 +226,8 @@ describe("formatArtistIntelPackAsMarkdown", () => { const pack = { ...basePack, top_track: null }; const result = formatArtistIntelPackAsMarkdown(pack); expect(result).toContain("# Artist Intelligence Pack"); - expect(result).not.toContain("Biggest Hit"); + // The top track section line (with popularity) should not appear + expect(result).not.toContain("*Great Album* (popularity:"); }); it("omits citations line when citations array is empty", () => { diff --git a/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts index 03ccd05d..08f9e4b8 100644 --- a/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts +++ b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts @@ -53,6 +53,12 @@ const mockWebContext = { }; const mockMarketingCopy = { + artist_one_sheet: "Test Artist — rising indie pop act with 1M followers...", + ar_memo: "ARTIST: Test Artist\nGENRE: pop\nRECOMMENDATION: Monitor for 90 days.", + sync_brief: "Track fits: Netflix drama, lifestyle brand campaign, yoga app.", + spotify_playlist_targets: ["New Music Friday", "Pollen", "Fresh Finds"], + brand_partnership_pitch: + "Nike — high-energy BPM aligns with Run Club. Glossier — indie aesthetic.", playlist_pitch_email: "Dear curator...", instagram_caption: "New music out now! #pop", tiktok_caption: "You need to hear this! #music", diff --git a/lib/artistIntel/buildArtistMarketingCopy.ts b/lib/artistIntel/buildArtistMarketingCopy.ts index 380d239a..e1d9ea69 100644 --- a/lib/artistIntel/buildArtistMarketingCopy.ts +++ b/lib/artistIntel/buildArtistMarketingCopy.ts @@ -4,27 +4,35 @@ import type { ArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalys import type { ArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; export interface ArtistMarketingCopy { + // Industry-grade outputs (highest value for artists & labels) + artist_one_sheet: string; + ar_memo: string; + sync_brief: string; + spotify_playlist_targets: string[]; + brand_partnership_pitch: string; + // Outreach copy playlist_pitch_email: string; + press_release_opener: string; + key_talking_points: string[]; + // Social media instagram_caption: string; tiktok_caption: string; twitter_post: string; - press_release_opener: string; - key_talking_points: string[]; } const SYSTEM_PROMPT = - "You are an elite music marketing strategist with 20 years of experience at major labels. " + - "You create compelling, genre-authentic marketing copy that resonates with target audiences. " + + "You are a senior music industry executive with 20 years spanning A&R, sync licensing, brand partnerships, and artist management at major labels. " + + "You write razor-sharp, industry-authentic copy that gets results — not fluff. " + "Always respond with valid JSON matching the exact schema requested."; /** * Uses AI to synthesize Spotify metadata, MusicFlamingo analysis, and web research - * into ready-to-use marketing copy across multiple channels. + * into industry-grade marketing and business development materials for artists and labels. * * @param spotifyData - Artist Spotify metadata and top tracks. * @param musicAnalysis - MusicFlamingo AI audio analysis results. * @param webContext - Recent web research about the artist. - * @returns Ready-to-use marketing copy (email, social captions, press release). + * @returns Ready-to-use industry copy (one-sheet, A&R memo, sync brief, playlist targets, brand pitch, social). */ export async function buildArtistMarketingCopy( spotifyData: ArtistSpotifyData, @@ -34,7 +42,7 @@ export async function buildArtistMarketingCopy( const { artist, topTracks } = spotifyData; const topTrack = topTracks[0]; - const prompt = `Generate marketing copy for ${artist.name}. + const prompt = `Generate industry-grade materials for ${artist.name}. Spotify Stats: - Followers: ${artist.followers.total.toLocaleString()} @@ -42,20 +50,36 @@ Spotify Stats: - Genres: ${artist.genres.slice(0, 3).join(", ") || "not specified"} - Top Track: "${topTrack?.name || "unknown"}" from "${topTrack?.album?.name || "unknown"}" -AI Music Analysis (NVIDIA MusicFlamingo scan of their audio): +AI Music Analysis (NVIDIA MusicFlamingo audio scan): ${musicAnalysis ? JSON.stringify(musicAnalysis, null, 2) : "Not available"} Recent Web Context: ${webContext?.summary || "Not available"} Generate a JSON response with these EXACT fields: + { - "playlist_pitch_email": "A compelling 150-word pitch email to Spotify playlist curators. Include artist name, genre, track name, follower count, and why it fits their playlists. Professional but passionate tone.", - "instagram_caption": "An engaging Instagram caption (under 150 chars) with 5 relevant hashtags. Genre-authentic voice.", - "tiktok_caption": "A TikTok caption (under 100 chars) with 3 trending hashtags that would drive saves.", - "twitter_post": "A punchy tweet (under 280 chars) for a new release announcement. Include 2 hashtags.", + "artist_one_sheet": "A polished 200-word artist one-sheet in the industry standard format. Include: artist name and tagline, 2-3 sentence bio with career highlights, genre/sound description, key stats (followers, popularity), top track and album, 3 press/pitch quotes or talking points, and a 'For Booking & Licensing' closing line. Professional but energetic tone.", + + "ar_memo": "A 150-word A&R discovery memo written as if for an internal label meeting. Include: artist name, genre/positioning, 2-3 comparable artists with market context (e.g. 'sounds like X but with Y's audience'), current momentum indicators (Spotify stats, recent press), commercial potential, recommended next steps (signing, development deal, joint venture). Be direct and analytical — this is for executives.", + + "sync_brief": "A 150-word sync licensing brief for music supervisors. Include: artist/track name, mood and sonic description, 3-5 specific sync use cases (e.g. 'Opening montage of a Netflix coming-of-age drama', 'Luxury car commercial targeting 30-45 year olds', 'Yoga app background loop'), tempo/BPM if known, comparable synced tracks. Make supervisors visualize the placement immediately.", + + "spotify_playlist_targets": ["8-10 specific named Spotify editorial playlists this artist should pitch to, based on genre and mood. Use real playlist names like 'New Music Friday', 'Pollen', 'Hot Country', 'Beast Mode', 'Peaceful Piano', 'Lorem', 'mint', 'Alternative Hip-Hop', etc. Each entry should be just the playlist name, nothing else."], + + "brand_partnership_pitch": "A 150-word brand partnership pitch covering 3-4 specific brand categories that align with this artist's sound, audience, and aesthetic (e.g. 'Nike — high-energy BPM and youth demographic aligns with their Run Club campaign', 'Glossier — bedroom pop aesthetic matches their indie-cool beauty positioning'). For each brand, name the brand, explain the alignment, and suggest a specific activation type (concert series, campaign soundtrack, social collab).", + + "playlist_pitch_email": "A compelling 150-word pitch email to an independent Spotify playlist curator. Include artist name, genre, track name, follower count, why it fits their playlists, and a clear ask. Professional but passionate tone.", + "press_release_opener": "A compelling 3-sentence press release opening paragraph. Professional journalism style.", - "key_talking_points": ["3-5 bullet points that make this artist unique and compelling to pitch to press, playlists, and sync supervisors"] + + "key_talking_points": ["5 punchy bullet points that make this artist unique — for press, playlist, sync, and A&R pitches. Each point should be one crisp sentence."], + + "instagram_caption": "An engaging Instagram caption (under 150 chars) with 5 relevant hashtags. Genre-authentic voice.", + + "tiktok_caption": "A TikTok caption (under 100 chars) with 3 trending hashtags.", + + "twitter_post": "A tweet (under 280 chars) for a new release announcement with 2 hashtags." } Respond ONLY with the JSON object. No markdown, no extra text.`; @@ -67,17 +91,25 @@ Respond ONLY with the JSON object. No markdown, no extra text.`; } catch (error) { console.error("Failed to generate marketing copy:", error); const genre = artist.genres[0]?.replace(/\s+/g, "") || "music"; + const genreLabel = artist.genres[0] || "emerging"; return { - playlist_pitch_email: `Dear Curator,\n\nI'd like to introduce you to ${artist.name}, a ${artist.genres[0] || "rising"} artist with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score. Their track "${topTrack?.name || "latest release"}" would be a perfect fit for your playlist.\n\nBest,\nThe Team`, + artist_one_sheet: `${artist.name}\n\n${artist.name} is a ${genreLabel} artist with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score. Their track "${topTrack?.name || "latest release"}" showcases a signature sound that resonates with a growing global audience.\n\nKey Stats: ${artist.followers.total.toLocaleString()} Spotify followers · ${artist.popularity}/100 popularity · ${artist.genres.slice(0, 2).join(", ") || "emerging"}\n\nFor Booking & Licensing: agent@recoupable.com`, + ar_memo: `ARTIST: ${artist.name}\nGENRE: ${genreLabel}\nSTATS: ${artist.followers.total.toLocaleString()} Spotify followers, ${artist.popularity}/100 popularity\nTOP TRACK: "${topTrack?.name || "unknown"}"\n\nMOMENTUM: Growing audience in the ${genreLabel} space with consistent engagement metrics. The ${artist.popularity}/100 popularity score indicates strong algorithmic traction.\n\nRECOMMENDATION: Monitor for 90 days. Consider development conversation.`, + sync_brief: `ARTIST: ${artist.name}\nGENRE: ${genreLabel}\nTRACK: "${topTrack?.name || "latest release"}"\n\nSync use cases:\n- Background score for lifestyle/documentary content\n- Retail or app advertising with ${genreLabel} aesthetic\n- Social media brand campaigns targeting youth audiences\n\nContact: agent@recoupable.com`, + spotify_playlist_targets: ["New Music Friday", "Lorem", "Fresh Finds", "Radar", "Pollen"], + brand_partnership_pitch: `${artist.name}'s ${genreLabel} sound and ${artist.followers.total.toLocaleString()}-follower audience opens strong brand alignment opportunities. Youth lifestyle brands (apparel, footwear, streaming platforms) match their demographic. Consider activations around new release cycles for maximum cultural relevance.`, + playlist_pitch_email: `Dear Curator,\n\nI'd like to introduce you to ${artist.name}, a ${genreLabel} artist with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score. Their track "${topTrack?.name || "latest release"}" would be a perfect fit for your playlist.\n\nBest,\nThe Team`, + press_release_opener: `${artist.name} releases their highly anticipated new work, continuing to build momentum in the ${genreLabel} space. With ${artist.followers.total.toLocaleString()} Spotify followers and a growing global audience, the artist delivers another standout offering. The new release showcases their signature sound and artistic evolution.`, + key_talking_points: [ + `${artist.followers.total.toLocaleString()} Spotify followers with strong engagement`, + `${artist.popularity}/100 Spotify popularity score — top-tier algorithmic traction`, + `Genre: ${artist.genres.slice(0, 2).join(", ") || "emerging"} — growing market segment`, + `Top track "${topTrack?.name || "latest release"}" demonstrates commercial appeal`, + `Positioned for playlist and sync opportunities across multiple formats`, + ], instagram_caption: `New music from ${artist.name} is here 🎵 #newmusic #${genre} #streaming`, tiktok_caption: `You need to hear ${artist.name} 🔥 #newmusic #viral #${genre}`, twitter_post: `New music alert: ${artist.name} just dropped something special 🎵 #newmusic #${genre}`, - press_release_opener: `${artist.name} releases their highly anticipated new work, continuing to build momentum in the ${artist.genres[0] || "music"} space. With ${artist.followers.total.toLocaleString()} Spotify followers and a growing global audience, the artist delivers another standout offering. The new release showcases their signature sound and artistic evolution.`, - key_talking_points: [ - `${artist.followers.total.toLocaleString()} Spotify followers`, - `Popularity score: ${artist.popularity}/100`, - `Genre: ${artist.genres.slice(0, 2).join(", ") || "emerging"}`, - ], }; } } diff --git a/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts b/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts index d2c93796..b66f7a56 100644 --- a/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts +++ b/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts @@ -5,7 +5,8 @@ import type { ArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack" * * Produces a human-readable report with sections for artist profile, * music DNA (MusicFlamingo AI analysis), web context (Perplexity), and - * the full marketing pack (pitch email, social captions, press release). + * the full industry pack (one-sheet, A&R memo, sync brief, playlist targets, + * brand partnership pitch, social copy). * * @param pack - The complete artist intelligence pack. * @returns A formatted markdown string ready to display in chat or export. @@ -109,7 +110,7 @@ export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { // Web context if (web_context?.summary) { - lines.push(`## Recent Web Context`); + lines.push(`## Recent News & Press`); lines.push(``); lines.push(web_context.summary); lines.push(``); @@ -121,22 +122,57 @@ export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { lines.push(``); } - // Marketing pack - lines.push(`## Marketing Pack`); + // Industry Pack — highest-value outputs for artists & labels + lines.push(`## Industry Pack`); lines.push(``); - lines.push(`### Playlist Pitch Email`); - lines.push(``); - lines.push(marketing_pack.playlist_pitch_email); - lines.push(``); + if (marketing_pack.artist_one_sheet) { + lines.push(`### Artist One-Sheet`); + lines.push(``); + lines.push(marketing_pack.artist_one_sheet); + lines.push(``); + } - lines.push(`### Social Media Captions`); + if (marketing_pack.ar_memo) { + lines.push(`### A&R Memo`); + lines.push(``); + lines.push(marketing_pack.ar_memo); + lines.push(``); + } + + if (marketing_pack.sync_brief) { + lines.push(`### Sync & Licensing Brief`); + lines.push(``); + lines.push(marketing_pack.sync_brief); + lines.push(``); + } + + if (marketing_pack.spotify_playlist_targets?.length) { + lines.push(`### Spotify Editorial Playlist Targets`); + lines.push(``); + for (const playlist of marketing_pack.spotify_playlist_targets) { + lines.push(`- ${playlist}`); + } + lines.push(``); + } + + if (marketing_pack.brand_partnership_pitch) { + lines.push(`### Brand Partnership Pitch`); + lines.push(``); + lines.push(marketing_pack.brand_partnership_pitch); + lines.push(``); + } + + lines.push(`---`); lines.push(``); - lines.push(`**Instagram:** ${marketing_pack.instagram_caption}`); + + // Outreach & Social (secondary section) + lines.push(`## Outreach & Social`); lines.push(``); - lines.push(`**TikTok:** ${marketing_pack.tiktok_caption}`); + + lines.push(`### Playlist Pitch Email`); lines.push(``); - lines.push(`**Twitter/X:** ${marketing_pack.twitter_post}`); + lines.push(marketing_pack.playlist_pitch_email); lines.push(``); lines.push(`### Press Release Opener`); @@ -153,5 +189,14 @@ export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { lines.push(``); } + lines.push(`### Social Media Captions`); + lines.push(``); + lines.push(`**Instagram:** ${marketing_pack.instagram_caption}`); + lines.push(``); + lines.push(`**TikTok:** ${marketing_pack.tiktok_caption}`); + lines.push(``); + lines.push(`**Twitter/X:** ${marketing_pack.twitter_post}`); + lines.push(``); + return lines.join("\n").trim(); } From 52ee8059a6ab640d3a030daad4160e72391d15f8 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 21:57:43 +0000 Subject: [PATCH 5/6] feat: elevate Artist Intel Pack with real data intelligence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add peer benchmarking via Spotify related-artists API — actual follower counts and popularity scores for 5 related artists, with percentile rankings showing exactly where the target artist sits among their competitive set - Add algorithmic opportunity scores (0-100) computed from real MusicFlamingo audio data and Spotify metrics — no AI inference, pure signal: Sync Score (BPM, energy, mood diversity, production), Playlist Score (danceability, energy, algorithmic momentum), A&R Score (popularity-to-follower efficiency, peer gap analysis), Brand Score (lifestyle tags, demographic specificity) - Add catalog depth analysis across all 10 top tracks: consistency score, hit-concentration %, catalog type classification (consistent / hit-driven / emerging) - Ground all AI marketing copy in real competitor data — the prompt now receives actual peer follower counts so 'comparable artist' references cite real numbers not hallucinations - Upgrade markdown report with: Opportunity Scores dashboard table with ASCII bars, Peer Benchmarking table with gap-to-peers column and YOU marker, Catalog Analysis with per-track popularity bars and actionable catalog type callout Co-Authored-By: Claude Sonnet 4.6 --- lib/artistIntel/analyzeCatalogDepth.ts | 83 ++++++ lib/artistIntel/buildArtistMarketingCopy.ts | 143 +++++++-- .../computeArtistOpportunityScores.ts | 274 ++++++++++++++++++ .../formatArtistIntelPackAsMarkdown.ts | 206 +++++++++++-- lib/artistIntel/generateArtistIntelPack.ts | 56 +++- lib/artistIntel/getRelatedArtistsData.ts | 78 +++++ lib/spotify/getRelatedArtists.ts | 27 ++ 7 files changed, 818 insertions(+), 49 deletions(-) create mode 100644 lib/artistIntel/analyzeCatalogDepth.ts create mode 100644 lib/artistIntel/computeArtistOpportunityScores.ts create mode 100644 lib/artistIntel/getRelatedArtistsData.ts create mode 100644 lib/spotify/getRelatedArtists.ts diff --git a/lib/artistIntel/analyzeCatalogDepth.ts b/lib/artistIntel/analyzeCatalogDepth.ts new file mode 100644 index 00000000..3daf47c4 --- /dev/null +++ b/lib/artistIntel/analyzeCatalogDepth.ts @@ -0,0 +1,83 @@ +interface Track { + name: string; + popularity: number; +} + +export interface CatalogDepth { + track_count: number; + avg_popularity: number; + top_track_popularity: number; + /** Std deviation of track popularities — low = consistent catalog, high = hit-driven */ + popularity_std_dev: number; + /** 0–100: how consistent the catalog is. 100 = every track equally popular */ + consistency_score: number; + /** % of total streams concentrated in the top track (proxy) */ + top_track_concentration_pct: number; + /** "consistent" | "hit_driven" | "emerging" */ + catalog_type: "consistent" | "hit_driven" | "emerging"; + catalog_type_label: string; + /** Tracks ranked by popularity */ + ranked_tracks: Array<{ name: string; popularity: number }>; +} + +/** + * Analyses catalog depth from Spotify top-track data. + * Computes consistency scores and catalog type without any AI inference — + * all metrics are derived from actual Spotify popularity numbers. + * + * @param topTracks - Up to 10 Spotify top tracks for the artist. + * @returns Catalog depth metrics, or null if no tracks. + */ +export function analyzeCatalogDepth(topTracks: Track[]): CatalogDepth | null { + if (!topTracks || topTracks.length === 0) return null; + + const popularities = topTracks.map(t => t.popularity); + const track_count = topTracks.length; + const avg_popularity = Math.round(popularities.reduce((s, p) => s + p, 0) / track_count); + const top_track_popularity = Math.max(...popularities); + + // Standard deviation + const variance = + popularities.reduce((sum, p) => sum + Math.pow(p - avg_popularity, 2), 0) / track_count; + const popularity_std_dev = Math.round(Math.sqrt(variance) * 10) / 10; + + // Consistency score: invert std dev normalised to 0-100 + // std dev of 0 = 100 (perfect consistency), std dev of 30+ = 0 + const consistency_score = Math.max(0, Math.round(100 - (popularity_std_dev / 30) * 100)); + + // Top-track concentration: what share of the "popularity budget" the top track commands + const total_popularity = popularities.reduce((s, p) => s + p, 0); + const top_track_concentration_pct = + total_popularity > 0 ? Math.round((top_track_popularity / total_popularity) * 100) : 0; + + // Catalog type classification + let catalog_type: CatalogDepth["catalog_type"]; + let catalog_type_label: string; + + if (avg_popularity < 30) { + catalog_type = "emerging"; + catalog_type_label = "Emerging — building catalog traction"; + } else if (consistency_score >= 60 && top_track_concentration_pct < 25) { + catalog_type = "consistent"; + catalog_type_label = "Consistent — multiple tracks with similar momentum"; + } else { + catalog_type = "hit_driven"; + catalog_type_label = "Hit-Driven — breakout track anchoring the catalog"; + } + + const ranked_tracks = [...topTracks] + .sort((a, b) => b.popularity - a.popularity) + .map(t => ({ name: t.name, popularity: t.popularity })); + + return { + track_count, + avg_popularity, + top_track_popularity, + popularity_std_dev, + consistency_score, + top_track_concentration_pct, + catalog_type, + catalog_type_label, + ranked_tracks, + }; +} diff --git a/lib/artistIntel/buildArtistMarketingCopy.ts b/lib/artistIntel/buildArtistMarketingCopy.ts index e1d9ea69..283a1ab2 100644 --- a/lib/artistIntel/buildArtistMarketingCopy.ts +++ b/lib/artistIntel/buildArtistMarketingCopy.ts @@ -2,6 +2,9 @@ import generateText from "@/lib/ai/generateText"; import type { ArtistSpotifyData } from "@/lib/artistIntel/getArtistSpotifyData"; import type { ArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; import type { ArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; +import type { PeerBenchmark } from "@/lib/artistIntel/getRelatedArtistsData"; +import type { ArtistOpportunityScores } from "@/lib/artistIntel/computeArtistOpportunityScores"; +import type { CatalogDepth } from "@/lib/artistIntel/analyzeCatalogDepth"; export interface ArtistMarketingCopy { // Industry-grade outputs (highest value for artists & labels) @@ -23,57 +26,144 @@ export interface ArtistMarketingCopy { const SYSTEM_PROMPT = "You are a senior music industry executive with 20 years spanning A&R, sync licensing, brand partnerships, and artist management at major labels. " + "You write razor-sharp, industry-authentic copy that gets results — not fluff. " + + "CRITICAL: You are given real Spotify data for comparable artists. You MUST use these actual numbers in your copy — never invent statistics. " + "Always respond with valid JSON matching the exact schema requested."; /** - * Uses AI to synthesize Spotify metadata, MusicFlamingo analysis, and web research - * into industry-grade marketing and business development materials for artists and labels. + * Formats peer benchmarking data into a context string for the AI prompt. + * + * @param peerBenchmark - Real Spotify data for related artists. + * @returns A formatted context string with actual follower counts and percentile rankings. + */ +function buildPeerContext(peerBenchmark: PeerBenchmark | null): string { + if (!peerBenchmark || peerBenchmark.peers.length === 0) return "No peer data available."; + + const rows = peerBenchmark.peers + .map( + p => + ` - ${p.name}: ${p.followers.toLocaleString()} followers, ${p.popularity}/100 popularity`, + ) + .join("\n"); + + return `Real Spotify peer data (DO NOT invent different numbers): +${rows} + +Peer benchmarks: +- Artist is at the ${peerBenchmark.follower_percentile}th percentile for followers among their peer set +- Artist is at the ${peerBenchmark.popularity_percentile}th percentile for popularity among their peer set +- Peer median followers: ${peerBenchmark.median_followers.toLocaleString()} +- Peer median popularity: ${peerBenchmark.median_popularity}/100 +${peerBenchmark.top_peer ? `- Top peer ceiling: ${peerBenchmark.top_peer.name} with ${peerBenchmark.top_peer.followers.toLocaleString()} followers` : ""}`; +} + +/** + * Formats algorithmically computed opportunity scores into a context string for the AI prompt. + * + * @param scores - Computed opportunity scores for sync, playlist, A&R, and brand. + * @returns A formatted context string with scores and rationale. + */ +function buildOpportunityContext(scores: ArtistOpportunityScores | null): string { + if (!scores) return ""; + return `Algorithmically computed opportunity scores (from real audio + audience data): +- Sync Score: ${scores.sync.score}/100 (${scores.sync.rating}) — ${scores.sync.rationale} +- Playlist Score: ${scores.playlist.score}/100 (${scores.playlist.rating}) — ${scores.playlist.rationale} +- A&R Score: ${scores.ar.score}/100 (${scores.ar.rating}) — ${scores.ar.rationale} +- Brand Score: ${scores.brand.score}/100 (${scores.brand.rating}) — ${scores.brand.rationale} +- Overall Score: ${scores.overall}/100`; +} + +/** + * Formats catalog depth metrics into a context string for the AI prompt. + * + * @param catalogDepth - Catalog consistency and hit-concentration metrics. + * @returns A formatted context string with catalog analysis data. + */ +function buildCatalogContext(catalogDepth: CatalogDepth | null): string { + if (!catalogDepth) return ""; + return `Catalog depth analysis (from actual Spotify track data): +- ${catalogDepth.track_count} tracks analysed +- Average track popularity: ${catalogDepth.avg_popularity}/100 +- Catalog type: ${catalogDepth.catalog_type_label} +- Consistency score: ${catalogDepth.consistency_score}/100 +- Top track concentration: ${catalogDepth.top_track_concentration_pct}% of total popularity +- Top 3 tracks: ${catalogDepth.ranked_tracks + .slice(0, 3) + .map(t => `"${t.name}" (${t.popularity})`) + .join(", ")}`; +} + +/** + * Uses AI to synthesize Spotify metadata, MusicFlamingo analysis, web research, + * real peer benchmarks, and algorithmic opportunity scores into industry-grade + * marketing and business development materials. + * + * Key improvement: All "comparable artist" references are grounded in real Spotify data + * fetched from the Spotify related-artists API — not hallucinated by the AI. * * @param spotifyData - Artist Spotify metadata and top tracks. * @param musicAnalysis - MusicFlamingo AI audio analysis results. * @param webContext - Recent web research about the artist. - * @returns Ready-to-use industry copy (one-sheet, A&R memo, sync brief, playlist targets, brand pitch, social). + * @param peerBenchmark - Real Spotify data for related artists (follower counts, popularity). + * @param opportunityScores - Algorithmically computed opportunity scores. + * @param catalogDepth - Catalog consistency metrics from top track analysis. + * @returns Ready-to-use industry copy grounded in real data. */ export async function buildArtistMarketingCopy( spotifyData: ArtistSpotifyData, musicAnalysis: ArtistMusicAnalysis | null, webContext: ArtistWebContext | null, + peerBenchmark: PeerBenchmark | null, + opportunityScores: ArtistOpportunityScores | null, + catalogDepth: CatalogDepth | null, ): Promise { const { artist, topTracks } = spotifyData; const topTrack = topTracks[0]; const prompt = `Generate industry-grade materials for ${artist.name}. -Spotify Stats: +SPOTIFY STATS (real data — use these exact numbers): - Followers: ${artist.followers.total.toLocaleString()} - Popularity Score: ${artist.popularity}/100 - Genres: ${artist.genres.slice(0, 3).join(", ") || "not specified"} - Top Track: "${topTrack?.name || "unknown"}" from "${topTrack?.album?.name || "unknown"}" -AI Music Analysis (NVIDIA MusicFlamingo audio scan): +AI MUSIC ANALYSIS (NVIDIA MusicFlamingo audio scan): ${musicAnalysis ? JSON.stringify(musicAnalysis, null, 2) : "Not available"} -Recent Web Context: +RECENT WEB CONTEXT: ${webContext?.summary || "Not available"} +${buildPeerContext(peerBenchmark)} + +${buildOpportunityContext(opportunityScores)} + +${buildCatalogContext(catalogDepth)} + +INSTRUCTIONS: +- Use the real peer follower/popularity numbers above when making comparisons — never invent statistics +- Tailor the A&R memo to reflect the actual opportunity scores and peer gap +- For sync: use the sync score rationale to identify specific placement contexts +- For brand partnerships: use the actual audience lifestyle tags and demographics from the music analysis +- Reference catalog type ("${catalogDepth?.catalog_type_label || "unknown"}") in A&R recommendation + Generate a JSON response with these EXACT fields: { - "artist_one_sheet": "A polished 200-word artist one-sheet in the industry standard format. Include: artist name and tagline, 2-3 sentence bio with career highlights, genre/sound description, key stats (followers, popularity), top track and album, 3 press/pitch quotes or talking points, and a 'For Booking & Licensing' closing line. Professional but energetic tone.", + "artist_one_sheet": "A polished 200-word artist one-sheet in the industry standard format. Include: artist name and tagline, 2-3 sentence bio with career highlights, genre/sound description, key stats (real followers and popularity score), top track and album, 3 press/pitch quotes or talking points, and a 'For Booking & Licensing' closing line. Reference at least one real peer artist for positioning context. Professional but energetic tone.", - "ar_memo": "A 150-word A&R discovery memo written as if for an internal label meeting. Include: artist name, genre/positioning, 2-3 comparable artists with market context (e.g. 'sounds like X but with Y's audience'), current momentum indicators (Spotify stats, recent press), commercial potential, recommended next steps (signing, development deal, joint venture). Be direct and analytical — this is for executives.", + "ar_memo": "A 150-word A&R discovery memo for an internal label meeting. Use the REAL peer data: cite actual follower counts from the peer set, state the artist's percentile ranking among peers, and identify the specific gap to the top peer. Reference the A&R opportunity score and what's driving it. Include: artist name, genre/positioning, real comparable artists with their actual stats, current momentum indicators, commercial potential based on catalog type, recommended next steps. Be direct and analytical.", - "sync_brief": "A 150-word sync licensing brief for music supervisors. Include: artist/track name, mood and sonic description, 3-5 specific sync use cases (e.g. 'Opening montage of a Netflix coming-of-age drama', 'Luxury car commercial targeting 30-45 year olds', 'Yoga app background loop'), tempo/BPM if known, comparable synced tracks. Make supervisors visualize the placement immediately.", + "sync_brief": "A 150-word sync licensing brief for music supervisors. Use the sync score (${opportunityScores?.sync.score ?? "N/A"}/100) to explain why this artist is or isn't sync-ready. Include: artist/track name, mood and sonic description derived from the MusicFlamingo analysis, 3-5 specific sync use cases (e.g. 'Opening montage of a Netflix coming-of-age drama'), tempo/BPM from audio analysis if available, comparable synced tracks. Make supervisors visualize the placement immediately.", - "spotify_playlist_targets": ["8-10 specific named Spotify editorial playlists this artist should pitch to, based on genre and mood. Use real playlist names like 'New Music Friday', 'Pollen', 'Hot Country', 'Beast Mode', 'Peaceful Piano', 'Lorem', 'mint', 'Alternative Hip-Hop', etc. Each entry should be just the playlist name, nothing else."], + "spotify_playlist_targets": ["8-10 specific named Spotify editorial playlists this artist should pitch to, based on genre, energy level, danceability, and mood from the MusicFlamingo data. Use real playlist names like 'New Music Friday', 'Pollen', 'Hot Country', 'Beast Mode', 'Peaceful Piano', 'Lorem', 'mint', 'Alternative Hip-Hop', etc. Each entry should be just the playlist name."], - "brand_partnership_pitch": "A 150-word brand partnership pitch covering 3-4 specific brand categories that align with this artist's sound, audience, and aesthetic (e.g. 'Nike — high-energy BPM and youth demographic aligns with their Run Club campaign', 'Glossier — bedroom pop aesthetic matches their indie-cool beauty positioning'). For each brand, name the brand, explain the alignment, and suggest a specific activation type (concert series, campaign soundtrack, social collab).", + "brand_partnership_pitch": "A 150-word brand partnership pitch for 3-4 specific brands. Base brand alignment on the actual audience lifestyle tags and demographics from the MusicFlamingo audience_profile. For each brand: name the brand, explain the alignment using real demographic data, suggest a specific activation type. Do not invent demographics — only use what's in the audience data.", - "playlist_pitch_email": "A compelling 150-word pitch email to an independent Spotify playlist curator. Include artist name, genre, track name, follower count, why it fits their playlists, and a clear ask. Professional but passionate tone.", + "playlist_pitch_email": "A compelling 150-word pitch email to an independent Spotify playlist curator. Include real stats (followers, popularity), genre, track name, why it fits their playlists, and a clear ask. Professional but passionate tone.", - "press_release_opener": "A compelling 3-sentence press release opening paragraph. Professional journalism style.", + "press_release_opener": "A compelling 3-sentence press release opening paragraph. Reference real momentum metrics (follower count, popularity score). Professional journalism style.", - "key_talking_points": ["5 punchy bullet points that make this artist unique — for press, playlist, sync, and A&R pitches. Each point should be one crisp sentence."], + "key_talking_points": ["5 punchy bullet points that make this artist unique — for press, playlist, sync, and A&R pitches. At least 2 points must reference real data (peer percentile, opportunity scores, catalog type, or actual follower milestones). Each point should be one crisp sentence."], "instagram_caption": "An engaging Instagram caption (under 150 chars) with 5 relevant hashtags. Genre-authentic voice.", @@ -92,20 +182,27 @@ Respond ONLY with the JSON object. No markdown, no extra text.`; console.error("Failed to generate marketing copy:", error); const genre = artist.genres[0]?.replace(/\s+/g, "") || "music"; const genreLabel = artist.genres[0] || "emerging"; + const peerLine = peerBenchmark?.top_peer + ? ` Comparable to ${peerBenchmark.top_peer.name} (${peerBenchmark.top_peer.followers.toLocaleString()} followers).` + : ""; return { - artist_one_sheet: `${artist.name}\n\n${artist.name} is a ${genreLabel} artist with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score. Their track "${topTrack?.name || "latest release"}" showcases a signature sound that resonates with a growing global audience.\n\nKey Stats: ${artist.followers.total.toLocaleString()} Spotify followers · ${artist.popularity}/100 popularity · ${artist.genres.slice(0, 2).join(", ") || "emerging"}\n\nFor Booking & Licensing: agent@recoupable.com`, - ar_memo: `ARTIST: ${artist.name}\nGENRE: ${genreLabel}\nSTATS: ${artist.followers.total.toLocaleString()} Spotify followers, ${artist.popularity}/100 popularity\nTOP TRACK: "${topTrack?.name || "unknown"}"\n\nMOMENTUM: Growing audience in the ${genreLabel} space with consistent engagement metrics. The ${artist.popularity}/100 popularity score indicates strong algorithmic traction.\n\nRECOMMENDATION: Monitor for 90 days. Consider development conversation.`, - sync_brief: `ARTIST: ${artist.name}\nGENRE: ${genreLabel}\nTRACK: "${topTrack?.name || "latest release"}"\n\nSync use cases:\n- Background score for lifestyle/documentary content\n- Retail or app advertising with ${genreLabel} aesthetic\n- Social media brand campaigns targeting youth audiences\n\nContact: agent@recoupable.com`, + artist_one_sheet: `${artist.name}\n\n${artist.name} is a ${genreLabel} artist with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score.${peerLine} Their track "${topTrack?.name || "latest release"}" showcases a signature sound that resonates with a growing global audience.\n\nFor Booking & Licensing: agent@recoupable.com`, + ar_memo: `ARTIST: ${artist.name}\nGENRE: ${genreLabel}\nSTATS: ${artist.followers.total.toLocaleString()} Spotify followers, ${artist.popularity}/100 popularity${peerBenchmark ? ` (${peerBenchmark.follower_percentile}th percentile among peers)` : ""}\nTOP TRACK: "${topTrack?.name || "unknown"}"\n\nMOMENTUM: Growing audience in the ${genreLabel} space.${peerBenchmark?.top_peer ? ` Gap to top peer ${peerBenchmark.top_peer.name}: ${((peerBenchmark.top_peer.followers - artist.followers.total) / 1000).toFixed(0)}K followers.` : ""}\n\nRECOMMENDATION: Monitor for 90 days. Consider development conversation.`, + sync_brief: `ARTIST: ${artist.name}\nGENRE: ${genreLabel}\nTRACK: "${topTrack?.name || "latest release"}"\nSYNC SCORE: ${opportunityScores?.sync.score ?? "N/A"}/100\n\nSync use cases:\n- Background score for lifestyle/documentary content\n- Retail or app advertising with ${genreLabel} aesthetic\n- Social media brand campaigns\n\nContact: agent@recoupable.com`, spotify_playlist_targets: ["New Music Friday", "Lorem", "Fresh Finds", "Radar", "Pollen"], - brand_partnership_pitch: `${artist.name}'s ${genreLabel} sound and ${artist.followers.total.toLocaleString()}-follower audience opens strong brand alignment opportunities. Youth lifestyle brands (apparel, footwear, streaming platforms) match their demographic. Consider activations around new release cycles for maximum cultural relevance.`, + brand_partnership_pitch: `${artist.name}'s ${genreLabel} sound and ${artist.followers.total.toLocaleString()}-follower audience opens strong brand alignment opportunities. Brand score: ${opportunityScores?.brand.score ?? "N/A"}/100.`, playlist_pitch_email: `Dear Curator,\n\nI'd like to introduce you to ${artist.name}, a ${genreLabel} artist with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score. Their track "${topTrack?.name || "latest release"}" would be a perfect fit for your playlist.\n\nBest,\nThe Team`, - press_release_opener: `${artist.name} releases their highly anticipated new work, continuing to build momentum in the ${genreLabel} space. With ${artist.followers.total.toLocaleString()} Spotify followers and a growing global audience, the artist delivers another standout offering. The new release showcases their signature sound and artistic evolution.`, + press_release_opener: `${artist.name} releases their highly anticipated new work, building momentum with ${artist.followers.total.toLocaleString()} Spotify followers and a ${artist.popularity}/100 popularity score. The new release demonstrates ${catalogDepth ? `their ${catalogDepth.catalog_type_label.toLowerCase()} approach to catalog-building` : "their growing global audience"}.`, key_talking_points: [ - `${artist.followers.total.toLocaleString()} Spotify followers with strong engagement`, - `${artist.popularity}/100 Spotify popularity score — top-tier algorithmic traction`, + `${artist.followers.total.toLocaleString()} Spotify followers with ${artist.popularity}/100 popularity score`, + peerBenchmark + ? `Ranked at the ${peerBenchmark.follower_percentile}th percentile among their peer set on Spotify` + : `${artist.popularity}/100 Spotify popularity — strong algorithmic traction`, `Genre: ${artist.genres.slice(0, 2).join(", ") || "emerging"} — growing market segment`, - `Top track "${topTrack?.name || "latest release"}" demonstrates commercial appeal`, - `Positioned for playlist and sync opportunities across multiple formats`, + catalogDepth + ? `${catalogDepth.catalog_type_label} — ${catalogDepth.track_count} tracks averaging ${catalogDepth.avg_popularity}/100 popularity` + : `Top track "${topTrack?.name || "latest release"}" demonstrates commercial appeal`, + `Overall opportunity score: ${opportunityScores?.overall ?? "N/A"}/100 across sync, playlist, A&R, and brand`, ], instagram_caption: `New music from ${artist.name} is here 🎵 #newmusic #${genre} #streaming`, tiktok_caption: `You need to hear ${artist.name} 🔥 #newmusic #viral #${genre}`, diff --git a/lib/artistIntel/computeArtistOpportunityScores.ts b/lib/artistIntel/computeArtistOpportunityScores.ts new file mode 100644 index 00000000..8f58f13b --- /dev/null +++ b/lib/artistIntel/computeArtistOpportunityScores.ts @@ -0,0 +1,274 @@ +import type { ArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; +import type { PeerBenchmark } from "@/lib/artistIntel/getRelatedArtistsData"; + +export interface OpportunityScore { + score: number; // 0–100 + rating: "weak" | "moderate" | "strong" | "exceptional"; + rationale: string; +} + +export interface ArtistOpportunityScores { + /** Sync licensing readiness — based on audio characteristics from MusicFlamingo */ + sync: OpportunityScore; + /** Editorial playlist placement likelihood — based on energy, danceability, genre signals */ + playlist: OpportunityScore; + /** A&R acquisition priority — based on trajectory, peer gap, and popularity-to-followers ratio */ + ar: OpportunityScore; + /** Brand partnership potential — based on audience demographics and listening context */ + brand: OpportunityScore; + /** Overall weighted opportunity score */ + overall: number; +} + +/** + * Maps a numeric score to a qualitative rating label. + * + * @param score - A numeric score from 0 to 100. + * @returns A qualitative rating label. + */ +function rating(score: number): OpportunityScore["rating"] { + if (score >= 80) return "exceptional"; + if (score >= 60) return "strong"; + if (score >= 40) return "moderate"; + return "weak"; +} + +/** + * Computes four opportunity scores from real MusicFlamingo and Spotify data. + * All scores are algorithmic — no AI inference, purely computed from observed metrics. + * + * @param musicAnalysis - MusicFlamingo audio analysis results. + * @param followers - Artist's Spotify follower count. + * @param popularity - Artist's Spotify popularity score (0–100). + * @param peerBenchmark - Peer comparison data from Spotify related artists. + * @returns Four domain-specific opportunity scores plus an overall score. + */ +export function computeArtistOpportunityScores( + musicAnalysis: ArtistMusicAnalysis | null, + followers: number, + popularity: number, + peerBenchmark: PeerBenchmark | null, +): ArtistOpportunityScores { + // ── Sync Score ────────────────────────────────────────────────────────────── + // High sync value = moderate energy (not too extreme), rich mood diversity, + // clean/polished production, instrumental potential + let syncScore = 50; + const syncRationale: string[] = []; + + const meta = musicAnalysis?.catalog_metadata; + if (meta) { + // Moderate energy (60–75 BPM or energy 4–7) is most sync-placeable + const bpm = meta.tempo_bpm ?? 0; + if (bpm >= 70 && bpm <= 130) { + syncScore += 10; + syncRationale.push(`BPM ${bpm} is squarely in the sweet spot for sync placements`); + } else if (bpm > 0) { + syncScore -= 5; + syncRationale.push(`BPM ${bpm} is outside typical sync range (70–130 BPM)`); + } + + const energy = meta.energy_level ?? 0; + if (energy >= 3 && energy <= 8) { + syncScore += 10; + syncRationale.push(`Energy level ${energy}/10 is versatile for TV/film placements`); + } + + if (meta.mood && meta.mood.length >= 3) { + syncScore += 8; + syncRationale.push( + `${meta.mood.length} distinct moods signals tonal versatility for editorial`, + ); + } + + const cleanProduction = ["polished", "produced", "studio", "professional"].some(w => + meta.production_style?.toLowerCase().includes(w), + ); + if (cleanProduction) { + syncScore += 7; + syncRationale.push("Polished production style meets broadcast quality standards"); + } + + if (meta.instruments && meta.instruments.length >= 4) { + syncScore += 5; + syncRationale.push( + `Rich instrumentation (${meta.instruments.slice(0, 3).join(", ")}) opens multiple sync contexts`, + ); + } + } else { + syncRationale.push("Audio analysis unavailable — score based on Spotify popularity proxy"); + syncScore = Math.round(30 + popularity * 0.4); + } + + syncScore = Math.min(100, Math.max(0, syncScore)); + + // ── Playlist Score ─────────────────────────────────────────────────────────── + // High playlist value = high danceability, strong energy, genre alignment with + // editorial playlists, mood tags that match common playlist contexts + let playlistScore = 40; + const playlistRationale: string[] = []; + + if (meta) { + const danceability = meta.danceability ?? 0; + playlistScore += Math.round(danceability * 5); // 0–50 points + if (danceability >= 7) { + playlistRationale.push( + `Danceability ${danceability}/10 is strong for workout and party playlists`, + ); + } else if (danceability >= 5) { + playlistRationale.push(`Danceability ${danceability}/10 fits lifestyle and mood playlists`); + } + + const energy2 = meta.energy_level ?? 0; + if (energy2 >= 7) { + playlistScore += 10; + playlistRationale.push( + "High energy tracks perform well on New Music Friday and workout edits", + ); + } else if (energy2 >= 4) { + playlistScore += 5; + playlistRationale.push("Moderate energy suits focus, chill, and indie discovery playlists"); + } + + // Popularity as a proxy for Spotify algorithm favour + playlistScore += Math.round(popularity * 0.15); + playlistRationale.push(`Spotify popularity ${popularity}/100 signals algorithmic momentum`); + } else { + playlistScore = Math.round(35 + popularity * 0.5); + playlistRationale.push("Score computed from Spotify popularity in absence of audio analysis"); + } + + playlistScore = Math.min(100, Math.max(0, playlistScore)); + + // ── A&R Score ──────────────────────────────────────────────────────────────── + // High A&R priority = artist punching above their weight (high popularity relative + // to followers = algorithmic pull without mass following = acquisition opportunity), + // meaningful gap below peer ceiling = upside potential + let arScore = 40; + const arRationale: string[] = []; + + // Popularity-to-followers efficiency: artists with high popularity but fewer followers + // than their peers are "undervalued" — the classic A&R discovery signal + const followerEfficiency = followers > 0 ? popularity / Math.log10(followers + 1) : 0; + + if (followerEfficiency > 12) { + arScore += 25; + arRationale.push( + `High popularity-to-follower ratio (${followerEfficiency.toFixed(1)}) — strong algorithmic pull without mass audience`, + ); + } else if (followerEfficiency > 8) { + arScore += 15; + arRationale.push( + `Solid popularity-to-follower ratio (${followerEfficiency.toFixed(1)}) indicates organic traction`, + ); + } else { + arRationale.push( + `Popularity-to-follower ratio (${followerEfficiency.toFixed(1)}) suggests established/saturated positioning`, + ); + } + + if (peerBenchmark) { + const followerGap = peerBenchmark.top_peer ? peerBenchmark.top_peer.followers - followers : 0; + + if (followers < peerBenchmark.median_followers) { + arScore += 20; + arRationale.push( + `${((peerBenchmark.median_followers - followers) / 1000).toFixed(0)}K below peer median — significant upside runway`, + ); + } else { + arRationale.push("At or above peer median — focus on next tier of growth"); + } + + if (followerGap > 0) { + arRationale.push( + `${(followerGap / 1000).toFixed(0)}K gap to top peer (${peerBenchmark.top_peer?.name}) shows potential ceiling`, + ); + } + + arScore += Math.round(peerBenchmark.popularity_percentile * 0.15); + } else { + arScore += Math.round(popularity * 0.3); + arRationale.push("Peer data unavailable — score based on raw popularity"); + } + + arScore = Math.min(100, Math.max(0, arScore)); + + // ── Brand Score ────────────────────────────────────────────────────────────── + // High brand value = specific demographic data, identifiable lifestyle tags, + // multi-platform presence, clear marketing hook + let brandScore = 40; + const brandRationale: string[] = []; + + const audience = musicAnalysis?.audience_profile; + if (audience) { + if (audience.lifestyle_tags && audience.lifestyle_tags.length >= 3) { + brandScore += 15; + brandRationale.push( + `${audience.lifestyle_tags.length} lifestyle tags (${audience.lifestyle_tags.slice(0, 2).join(", ")}) signal strong brand alignment surface area`, + ); + } + + if (audience.platforms && audience.platforms.length >= 3) { + brandScore += 10; + brandRationale.push( + `Multi-platform presence (${audience.platforms.join(", ")}) maximises brand campaign reach`, + ); + } + + if (audience.marketing_hook) { + brandScore += 10; + brandRationale.push(`Clear marketing hook: "${audience.marketing_hook}"`); + } + + // Specific age ranges are more valuable to brands than vague "18-35" + const ageRange = audience.age_range ?? ""; + const isSpecificDemo = !ageRange.includes("18-35") && ageRange.length > 0; + if (isSpecificDemo) { + brandScore += 10; + brandRationale.push( + `Tight demographic (${ageRange}) is more valuable to brand media buyers than broad targeting`, + ); + } + + if (audience.comparable_fanbases && audience.comparable_fanbases.length >= 2) { + brandScore += 5; + brandRationale.push( + `Comparable fanbases (${audience.comparable_fanbases.slice(0, 2).join(", ")}) enable look-alike campaign targeting`, + ); + } + } else { + brandScore = Math.round(35 + popularity * 0.35); + brandRationale.push("Score based on Spotify popularity proxy (audience data unavailable)"); + } + + brandScore = Math.min(100, Math.max(0, brandScore)); + + // ── Overall ────────────────────────────────────────────────────────────────── + // Weighted: A&R (30%), Playlist (25%), Sync (25%), Brand (20%) + const overall = Math.round( + arScore * 0.3 + playlistScore * 0.25 + syncScore * 0.25 + brandScore * 0.2, + ); + + return { + sync: { + score: syncScore, + rating: rating(syncScore), + rationale: syncRationale.join(". ") || "Based on available audio metadata.", + }, + playlist: { + score: playlistScore, + rating: rating(playlistScore), + rationale: playlistRationale.join(". ") || "Based on available audio metadata.", + }, + ar: { + score: arScore, + rating: rating(arScore), + rationale: arRationale.join(". ") || "Based on Spotify metrics.", + }, + brand: { + score: brandScore, + rating: rating(brandScore), + rationale: brandRationale.join(". ") || "Based on available audience data.", + }, + overall, + }; +} diff --git a/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts b/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts index b66f7a56..6c52d842 100644 --- a/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts +++ b/lib/artistIntel/formatArtistIntelPackAsMarkdown.ts @@ -1,49 +1,219 @@ import type { ArtistIntelPack } from "@/lib/artistIntel/generateArtistIntelPack"; +/** + * Renders a 10-character ASCII progress bar for a 0–100 score. + * + * @param score - A numeric score from 0 to 100. + * @returns A 10-character bar using block/light characters. + */ +function scoreBar(score: number): string { + const filled = Math.round(score / 10); + return "█".repeat(filled) + "░".repeat(10 - filled); +} + +/** + * Returns an emoji that represents the qualitative opportunity rating. + * + * @param rating - One of: "exceptional", "strong", "moderate", "weak". + * @returns An emoji character for inline display. + */ +function ratingEmoji(rating: string): string { + switch (rating) { + case "exceptional": + return "🔥"; + case "strong": + return "✅"; + case "moderate": + return "⚡"; + default: + return "○"; + } +} + /** * Formats an Artist Intelligence Pack as a rich markdown document. * - * Produces a human-readable report with sections for artist profile, - * music DNA (MusicFlamingo AI analysis), web context (Perplexity), and - * the full industry pack (one-sheet, A&R memo, sync brief, playlist targets, - * brand partnership pitch, social copy). + * Sections: + * 1. Artist Profile (Spotify stats) + * 2. Opportunity Scores Dashboard (algorithmically computed — new) + * 3. Peer Benchmarking (real Spotify data for related artists — new) + * 4. Catalog Analysis (hit-driven vs. consistent — new) + * 5. Music DNA (MusicFlamingo AI) + * 6. Recent News & Press (Perplexity) + * 7. Industry Pack (AI copy grounded in real data) + * 8. Outreach & Social * * @param pack - The complete artist intelligence pack. * @returns A formatted markdown string ready to display in chat or export. */ export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { - const { artist, top_track, music_analysis, web_context, marketing_pack, elapsed_seconds } = pack; + const { + artist, + top_track, + music_analysis, + web_context, + peer_benchmark, + opportunity_scores, + catalog_depth, + marketing_pack, + elapsed_seconds, + } = pack; const lines: string[] = []; lines.push(`# Artist Intelligence Pack: ${artist.name}`); lines.push(``); - lines.push(`> Generated in ${elapsed_seconds}s · Spotify + MusicFlamingo AI + Perplexity`); + lines.push( + `> Generated in ${elapsed_seconds}s · Spotify + MusicFlamingo AI + Perplexity + Peer Intelligence`, + ); lines.push(``); lines.push(`---`); lines.push(``); - // Artist profile + // ── 1. Artist Profile ──────────────────────────────────────────────────────── lines.push(`## Artist Profile`); lines.push(``); - lines.push(`- **Followers:** ${artist.followers.toLocaleString()}`); - lines.push(`- **Popularity:** ${artist.popularity}/100`); + lines.push(`| Metric | Value |`); + lines.push(`|--------|-------|`); + lines.push(`| Followers | ${artist.followers.toLocaleString()} |`); + lines.push(`| Spotify Popularity | ${artist.popularity}/100 |`); if (artist.genres.length > 0) { - lines.push(`- **Genres:** ${artist.genres.slice(0, 4).join(", ")}`); + lines.push(`| Genres | ${artist.genres.slice(0, 4).join(", ")} |`); } if (top_track) { - lines.push( - `- **Top Track:** "${top_track.name}" — *${top_track.album_name}* (popularity: ${top_track.popularity}/100)`, - ); + lines.push(`| Top Track | "${top_track.name}" — *${top_track.album_name}* |`); + lines.push(`| Track Popularity | ${top_track.popularity}/100 |`); if (top_track.preview_url) { - lines.push(`- **Preview:** ${top_track.preview_url}`); + lines.push(`| Preview | ${top_track.preview_url} |`); } } lines.push(``); lines.push(`---`); lines.push(``); - // Music DNA + // ── 2. Opportunity Scores Dashboard ───────────────────────────────────────── + lines.push(`## Opportunity Scores`); + lines.push(``); + lines.push( + `> Algorithmically computed from real audio data and Spotify metrics — not AI-generated text.`, + ); + lines.push(``); + lines.push(`**Overall Score: ${opportunity_scores.overall}/100**`); + lines.push(``); + lines.push(`| Category | Score | Rating | Signal |`); + lines.push(`|----------|-------|--------|--------|`); + lines.push( + `| 🎬 Sync & Licensing | ${opportunity_scores.sync.score}/100 | ${ratingEmoji(opportunity_scores.sync.rating)} ${opportunity_scores.sync.rating} | ${scoreBar(opportunity_scores.sync.score)} |`, + ); + lines.push( + `| 🎵 Playlist Placement | ${opportunity_scores.playlist.score}/100 | ${ratingEmoji(opportunity_scores.playlist.rating)} ${opportunity_scores.playlist.rating} | ${scoreBar(opportunity_scores.playlist.score)} |`, + ); + lines.push( + `| 🎤 A&R Priority | ${opportunity_scores.ar.score}/100 | ${ratingEmoji(opportunity_scores.ar.rating)} ${opportunity_scores.ar.rating} | ${scoreBar(opportunity_scores.ar.score)} |`, + ); + lines.push( + `| 🤝 Brand Partnership | ${opportunity_scores.brand.score}/100 | ${ratingEmoji(opportunity_scores.brand.rating)} ${opportunity_scores.brand.rating} | ${scoreBar(opportunity_scores.brand.score)} |`, + ); + lines.push(``); + + lines.push(`**What's driving these scores:**`); + lines.push(``); + lines.push(`- **Sync:** ${opportunity_scores.sync.rationale}`); + lines.push(`- **Playlist:** ${opportunity_scores.playlist.rationale}`); + lines.push(`- **A&R:** ${opportunity_scores.ar.rationale}`); + lines.push(`- **Brand:** ${opportunity_scores.brand.rationale}`); + lines.push(``); + lines.push(`---`); + lines.push(``); + + // ── 3. Peer Benchmarking ───────────────────────────────────────────────────── + if (peer_benchmark && peer_benchmark.peers.length > 0) { + lines.push(`## Peer Benchmarking`); + lines.push(``); + lines.push( + `> Real Spotify data for related artists — actual follower counts, not AI estimates.`, + ); + lines.push(``); + lines.push( + `**${artist.name}** ranks at the **${peer_benchmark.follower_percentile}th percentile** for followers and **${peer_benchmark.popularity_percentile}th percentile** for popularity among their peer set.`, + ); + lines.push(``); + + lines.push(`| Artist | Followers | Popularity |`); + lines.push(`|--------|-----------|------------|`); + // Insert target artist in the table for easy comparison + lines.push( + `| **${artist.name} ← YOU** | **${artist.followers.toLocaleString()}** | **${artist.popularity}/100** |`, + ); + for (const peer of peer_benchmark.peers) { + const gap = peer.followers - artist.followers; + const gapStr = gap > 0 ? `+${(gap / 1000).toFixed(0)}K` : `${(gap / 1000).toFixed(0)}K`; + lines.push( + `| ${peer.name} | ${peer.followers.toLocaleString()} (${gapStr}) | ${peer.popularity}/100 |`, + ); + } + lines.push(``); + + lines.push( + `**Peer medians:** ${peer_benchmark.median_followers.toLocaleString()} followers · ${peer_benchmark.median_popularity}/100 popularity`, + ); + if (peer_benchmark.top_peer) { + const gapToTop = peer_benchmark.top_peer.followers - artist.followers; + if (gapToTop > 0) { + lines.push( + `**Growth ceiling:** ${peer_benchmark.top_peer.name} is the top peer at ${peer_benchmark.top_peer.followers.toLocaleString()} followers — ${(gapToTop / 1000).toFixed(0)}K gap to close.`, + ); + } + } + lines.push(``); + lines.push(`---`); + lines.push(``); + } + + // ── 4. Catalog Analysis ────────────────────────────────────────────────────── + if (catalog_depth) { + lines.push(`## Catalog Analysis`); + lines.push(``); + + lines.push(`**${catalog_depth.catalog_type_label}**`); + lines.push(``); + lines.push(`| Metric | Value |`); + lines.push(`|--------|-------|`); + lines.push(`| Tracks Analysed | ${catalog_depth.track_count} |`); + lines.push(`| Avg Track Popularity | ${catalog_depth.avg_popularity}/100 |`); + lines.push(`| Consistency Score | ${catalog_depth.consistency_score}/100 |`); + lines.push( + `| Top Track Concentration | ${catalog_depth.top_track_concentration_pct}% of total popularity |`, + ); + lines.push(``); + + lines.push(`**Track Popularity Ranking:**`); + lines.push(``); + for (const track of catalog_depth.ranked_tracks) { + const bar = scoreBar(track.popularity); + lines.push(`- "${track.name}" ${bar} ${track.popularity}/100`); + } + lines.push(``); + + if (catalog_depth.catalog_type === "hit_driven") { + lines.push( + `> ⚡ **Hit-Driven Catalog:** The top track drives a disproportionate share of streams. A&R and sync pitches should lead with that single while building catalog breadth.`, + ); + } else if (catalog_depth.catalog_type === "consistent") { + lines.push( + `> ✅ **Consistent Catalog:** Multiple tracks perform at similar levels — ideal for playlist placement campaigns and licensing packages.`, + ); + } else { + lines.push( + `> 🌱 **Emerging Catalog:** Still building traction. Focus on breaking the first standout track before broader pitching.`, + ); + } + lines.push(``); + lines.push(`---`); + lines.push(``); + } + + // ── 5. Music DNA ───────────────────────────────────────────────────────────── if (music_analysis) { lines.push(`## Music DNA (NVIDIA MusicFlamingo AI)`); lines.push(``); @@ -108,7 +278,7 @@ export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { lines.push(``); } - // Web context + // ── 6. Web Context ─────────────────────────────────────────────────────────── if (web_context?.summary) { lines.push(`## Recent News & Press`); lines.push(``); @@ -122,7 +292,7 @@ export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { lines.push(``); } - // Industry Pack — highest-value outputs for artists & labels + // ── 7. Industry Pack ───────────────────────────────────────────────────────── lines.push(`## Industry Pack`); lines.push(``); @@ -166,7 +336,7 @@ export function formatArtistIntelPackAsMarkdown(pack: ArtistIntelPack): string { lines.push(`---`); lines.push(``); - // Outreach & Social (secondary section) + // ── 8. Outreach & Social ───────────────────────────────────────────────────── lines.push(`## Outreach & Social`); lines.push(``); diff --git a/lib/artistIntel/generateArtistIntelPack.ts b/lib/artistIntel/generateArtistIntelPack.ts index 74f39202..548a4f72 100644 --- a/lib/artistIntel/generateArtistIntelPack.ts +++ b/lib/artistIntel/generateArtistIntelPack.ts @@ -1,11 +1,17 @@ import type { ArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketingCopy"; +import type { CatalogDepth } from "@/lib/artistIntel/analyzeCatalogDepth"; import type { ArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; import type { ArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; +import type { PeerBenchmark } from "@/lib/artistIntel/getRelatedArtistsData"; +import type { ArtistOpportunityScores } from "@/lib/artistIntel/computeArtistOpportunityScores"; import { buildArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketingCopy"; +import { analyzeCatalogDepth } from "@/lib/artistIntel/analyzeCatalogDepth"; import { formatArtistIntelPackAsMarkdown } from "@/lib/artistIntel/formatArtistIntelPackAsMarkdown"; import { getArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; import { getArtistSpotifyData } from "@/lib/artistIntel/getArtistSpotifyData"; import { getArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; +import { getRelatedArtistsData } from "@/lib/artistIntel/getRelatedArtistsData"; +import { computeArtistOpportunityScores } from "@/lib/artistIntel/computeArtistOpportunityScores"; export interface ArtistIntelPack { artist: { @@ -25,6 +31,12 @@ export interface ArtistIntelPack { } | null; music_analysis: ArtistMusicAnalysis | null; web_context: ArtistWebContext | null; + /** Real Spotify data for related artists — grounds all "comparable artist" references */ + peer_benchmark: PeerBenchmark | null; + /** Algorithmically computed from MusicFlamingo + Spotify data — no AI inference */ + opportunity_scores: ArtistOpportunityScores; + /** Catalog consistency analysis across all top tracks */ + catalog_depth: CatalogDepth | null; marketing_pack: ArtistMarketingCopy; formatted_report: string; elapsed_seconds: number; @@ -37,12 +49,14 @@ export type ArtistIntelPackResult = /** * Generates a complete Artist Intelligence Pack by orchestrating: * 1. Spotify: Artist profile + top tracks (including 30-second preview URLs). - * 2. MusicFlamingo (NVIDIA 8B): AI audio analysis via Spotify preview URL — genre, BPM, - * key, mood, audience profile, and playlist pitch targets. + * 2. MusicFlamingo (NVIDIA 8B): AI audio analysis — genre, BPM, key, mood, audience profile, playlist pitch. * 3. Perplexity: Real-time web research on the artist. - * 4. AI Synthesis: Actionable marketing copy (pitch email, social captions, press release). + * 4. Related Artists: Real Spotify data for peer benchmarking (actual follower counts, not hallucinated comps). + * 5. Opportunity Scores: Algorithmically computed from real data — Sync, Playlist, A&R, Brand (0–100). + * 6. Catalog Depth: Hit-driven vs. consistent catalog analysis across all top tracks. + * 7. AI Synthesis: Marketing copy grounded in real competitor data and scores. * - * Steps 2 and 3 run in parallel to minimize latency. + * Steps 2, 3, and 4 run in parallel to minimize latency. * * @param artistName - The artist name to analyze. * @returns A complete intelligence pack or an error. @@ -55,18 +69,41 @@ export async function generateArtistIntelPack(artistName: string): Promise ({ name: t.name, popularity: t.popularity })), + ); + + // AI synthesis is last — it receives all real data to ground its outputs + const marketingCopy = await buildArtistMarketingCopy( + spotifyData, + musicAnalysis, + webContext, + peerBenchmark, + opportunityScores, + catalogDepth, + ); - const { artist, topTracks } = spotifyData; const topTrack = topTracks[0] ?? null; const elapsed_seconds = Math.round(((Date.now() - startTime) / 1000) * 100) / 100; - const packWithoutReport = { + const packWithoutReport: Omit = { artist: { name: artist.name, spotify_id: artist.id, @@ -86,6 +123,9 @@ export async function generateArtistIntelPack(artistName: string): Promise { + const tokenResult = await generateAccessToken(); + if (tokenResult.error || !tokenResult.access_token) return null; + + const related = await getRelatedArtists(artistId, tokenResult.access_token); + if (!related || related.length === 0) return null; + + // Take top 5 most-followed peers for a focused comparison + const peers: PeerArtist[] = related + .slice(0, 10) + .sort((a, b) => b.followers.total - a.followers.total) + .slice(0, 5) + .map(a => ({ + name: a.name, + spotify_id: a.id, + followers: a.followers.total, + popularity: a.popularity, + genres: a.genres.slice(0, 3), + })); + + const followerCounts = peers.map(p => p.followers).sort((a, b) => a - b); + const popularityCounts = peers.map(p => p.popularity).sort((a, b) => a - b); + + const median = (arr: number[]) => { + const mid = Math.floor(arr.length / 2); + return arr.length % 2 !== 0 ? arr[mid] : Math.round((arr[mid - 1] + arr[mid]) / 2); + }; + + const percentile = (arr: number[], value: number) => { + const below = arr.filter(v => v < value).length; + return Math.round((below / arr.length) * 100); + }; + + return { + peers, + follower_percentile: percentile(followerCounts, targetFollowers), + popularity_percentile: percentile(popularityCounts, targetPopularity), + median_followers: median(followerCounts), + median_popularity: median(popularityCounts), + top_peer: peers[0] ?? null, + }; +} diff --git a/lib/spotify/getRelatedArtists.ts b/lib/spotify/getRelatedArtists.ts new file mode 100644 index 00000000..8b34b3d4 --- /dev/null +++ b/lib/spotify/getRelatedArtists.ts @@ -0,0 +1,27 @@ +import { SpotifyArtist } from "@/types/spotify.types"; + +/** + * Fetches related artists for a given Spotify artist ID. + * + * @param artistId - The Spotify artist ID. + * @param accessToken - A valid Spotify access token. + * @returns An array of related SpotifyArtist objects, or null on failure. + */ +export async function getRelatedArtists( + artistId: string, + accessToken: string, +): Promise { + try { + const response = await fetch(`https://api.spotify.com/v1/artists/${artistId}/related-artists`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + if (!response.ok) return null; + const data = (await response.json()) as { artists: SpotifyArtist[] }; + return data.artists ?? null; + } catch (error) { + console.error("Failed to fetch related artists:", error); + return null; + } +} From 919ea620e5e00445302516ec8f3a37606d34e499 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Sat, 21 Mar 2026 22:45:26 +0000 Subject: [PATCH 6/6] fix: correct pre-market artist A&R rationale + add Gatsby Grace test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - computeArtistOpportunityScores: fix misleading "established/saturated" A&R rationale for artists with <1000 followers AND <10 popularity. These are pre-market acts (e.g. Gatsby Grace: 2 followers, 0 popularity), not saturated ones. Now correctly says "Pre-market artist — highest early- discovery upside" and awards a +10 discovery bonus. - Add computeArtistOpportunityScores.test.ts with 6 tests covering pre-market detection, high-follower low-popularity case, music analysis paths, and overall score weighting. - generateArtistIntelPack.test.ts: add Gatsby Grace scenario (8 tests) using real Spotify data (Spotify ID: 7ljukJB2Ctl0T4vCoYfb2x, 2 followers, 0 popularity, no genres). Mock now also covers getRelatedArtistsData. - formatArtistIntelPackAsMarkdown.test.ts: add missing peer_benchmark, opportunity_scores, and catalog_depth fields to basePack — these were added in a prior upgrade but the test mock was never updated (21 tests now pass). Co-Authored-By: Claude Sonnet 4.6 --- .../computeArtistOpportunityScores.test.ts | 134 ++++++++++++++ .../formatArtistIntelPackAsMarkdown.test.ts | 67 +++++++ .../__tests__/generateArtistIntelPack.test.ts | 167 ++++++++++++++++++ .../computeArtistOpportunityScores.ts | 8 +- 4 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 lib/artistIntel/__tests__/computeArtistOpportunityScores.test.ts diff --git a/lib/artistIntel/__tests__/computeArtistOpportunityScores.test.ts b/lib/artistIntel/__tests__/computeArtistOpportunityScores.test.ts new file mode 100644 index 00000000..4c7efe22 --- /dev/null +++ b/lib/artistIntel/__tests__/computeArtistOpportunityScores.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { computeArtistOpportunityScores } from "@/lib/artistIntel/computeArtistOpportunityScores"; + +describe("computeArtistOpportunityScores", () => { + describe("pre-market artist (very low followers + low popularity)", () => { + it("detects a pre-market artist like Gatsby Grace (2 followers, 0 popularity)", () => { + const scores = computeArtistOpportunityScores( + null, // no music analysis (no preview URL) + 2, // followers — real Gatsby Grace Spotify data + 0, // popularity — real Gatsby Grace Spotify data + null, // no peer benchmark (Spotify has no related artists for pre-market acts) + ); + + // All scores should be in the weak-to-moderate range — early stage, not established + expect(scores.sync.score).toBe(30); // 30 + 0*0.4 = 30 + expect(scores.sync.rating).toBe("weak"); + expect(scores.sync.rationale).toContain("Audio analysis unavailable"); + + expect(scores.playlist.score).toBe(35); // 35 + 0*0.5 = 35 + expect(scores.playlist.rating).toBe("weak"); + + // A&R rationale should say "pre-market" — NOT "established/saturated" + expect(scores.ar.rationale).toContain("Pre-market artist"); + expect(scores.ar.rationale).not.toContain("established"); + expect(scores.ar.rationale).not.toContain("saturated"); + expect(scores.ar.score).toBeGreaterThan(40); // gets a 10-pt early-discovery bonus + + expect(scores.brand.score).toBe(35); // 35 + 0*0.35 = 35 + expect(scores.brand.rating).toBe("weak"); + + // Overall should reflect early-stage reality + expect(scores.overall).toBeGreaterThan(0); + expect(scores.overall).toBeLessThan(50); + }); + + it("uses growing/maturing rationale for artist with many followers but low popularity", () => { + const scores = computeArtistOpportunityScores( + null, + 5_000_000, // high followers + 5, // but very low popularity — not getting algorithm play + null, + ); + + // Should NOT trigger pre-market — this is an established artist with low traction + expect(scores.ar.rationale).not.toContain("Pre-market artist"); + expect(scores.ar.rationale).toContain("growing or maturing"); + }); + + it("gives higher efficiency bonus when popularity is high relative to small audience", () => { + // Artist with 500 followers but 60 popularity = punching way above weight + const scores = computeArtistOpportunityScores(null, 500, 60, null); + + // followerEfficiency = 60 / log10(501) ≈ 60 / 2.7 ≈ 22.2 → "High" bonus (+25) + expect(scores.ar.score).toBeGreaterThan(60); + expect(scores.ar.rationale).toContain("High popularity-to-follower ratio"); + }); + }); + + describe("with music analysis", () => { + const fullMusicAnalysis = { + catalog_metadata: { + genre: "indie pop", + subgenres: ["bedroom pop"], + tempo_bpm: 110, + key: "C major", + time_signature: "4/4", + energy_level: 6, + danceability: 7, + mood: ["uplifting", "nostalgic", "dreamy"], + instruments: ["guitar", "piano", "synth", "drums"], + vocal_style: "breathy", + production_style: "polished, studio-quality", + similar_artists: ["Clairo", "Phoebe Bridgers"], + description: "Dreamy indie pop with bedroom vibes", + }, + audience_profile: { + age_range: "18-24", + gender_skew: "female", + lifestyle_tags: ["coffee shops", "college campus", "late nights", "aesthetics"], + listening_contexts: ["studying", "commuting", "late night"], + platforms: ["Spotify", "Apple Music", "TikTok"], + comparable_fanbases: ["Clairo fans", "Phoebe Bridgers fans"], + marketing_hook: "The soundtrack to your 3am thoughts", + }, + playlist_pitch: "Perfect for late-night indie playlists and bedroom pop discovery", + mood_tags: { + tags: ["dreamy", "nostalgic", "cozy"], + primary_mood: "contemplative", + }, + }; + + it("computes sync score from BPM and energy data", () => { + const scores = computeArtistOpportunityScores(fullMusicAnalysis, 10_000, 45, null); + + // BPM 110 (in 70-130 range): +10 + // Energy 6 (in 3-8 range): +10 + // 3 moods: +8 + // polished production: +7 + // 4 instruments: +5 + // base: 50 + // total: 90 → capped at 100 → "exceptional" + expect(scores.sync.score).toBeGreaterThan(60); + expect(scores.sync.rationale).toContain("BPM"); + }); + + it("computes playlist score from danceability", () => { + const scores = computeArtistOpportunityScores(fullMusicAnalysis, 10_000, 45, null); + + // danceability 7: 7*5 = 35 points + // energy 6: +5 for moderate + // popularity 45 * 0.15 = +7 + // base: 40 + // total: 87 → "exceptional" + expect(scores.playlist.score).toBeGreaterThan(70); + expect(scores.playlist.rationale).toContain("Danceability"); + }); + }); + + describe("overall score weighting", () => { + it("computes overall as weighted average of four scores", () => { + const scores = computeArtistOpportunityScores(null, 100_000, 50, null); + + // expected overall = Math.round(ar*0.3 + playlist*0.25 + sync*0.25 + brand*0.2) + const expectedOverall = Math.round( + scores.ar.score * 0.3 + + scores.playlist.score * 0.25 + + scores.sync.score * 0.25 + + scores.brand.score * 0.2, + ); + + expect(scores.overall).toBe(expectedOverall); + }); + }); +}); diff --git a/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts b/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts index 57ec25ed..096c00d2 100644 --- a/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts +++ b/lib/artistIntel/__tests__/formatArtistIntelPackAsMarkdown.test.ts @@ -90,6 +90,73 @@ const basePack: ArtistIntelPack = { "Rising indie pop artist", ], }, + peer_benchmark: { + peers: [ + { + name: "Clairo", + spotify_id: "clairo-id", + followers: 2_500_000, + popularity: 82, + genres: ["indie pop"], + }, + { + name: "Soccer Mommy", + spotify_id: "soccermommy-id", + followers: 1_800_000, + popularity: 74, + genres: ["indie"], + }, + ], + follower_percentile: 45, + popularity_percentile: 52, + median_followers: 2_000_000, + median_popularity: 78, + top_peer: { + name: "Clairo", + spotify_id: "clairo-id", + followers: 2_500_000, + popularity: 82, + genres: ["indie pop"], + }, + }, + opportunity_scores: { + sync: { + score: 75, + rating: "strong", + rationale: "BPM 120 is squarely in the sweet spot for sync placements", + }, + playlist: { + score: 82, + rating: "exceptional", + rationale: "Danceability 7/10 is strong for workout and party playlists", + }, + ar: { + score: 68, + rating: "strong", + rationale: "Solid popularity-to-follower ratio indicates organic traction", + }, + brand: { + score: 71, + rating: "strong", + rationale: "4 lifestyle tags signal strong brand alignment surface area", + }, + overall: 74, + }, + catalog_depth: { + track_count: 8, + avg_popularity: 62, + top_track_popularity: 85, + popularity_std_dev: 12.3, + consistency_score: 59, + top_track_concentration_pct: 21, + catalog_type: "hit_driven", + catalog_type_label: "Hit-Driven — breakout track anchoring the catalog", + ranked_tracks: [ + { name: "Biggest Hit", popularity: 85 }, + { name: "Second Track", popularity: 71 }, + { name: "Third Track", popularity: 58 }, + ], + }, formatted_report: "", elapsed_seconds: 28.5, }; diff --git a/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts index 08f9e4b8..1bf94856 100644 --- a/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts +++ b/lib/artistIntel/__tests__/generateArtistIntelPack.test.ts @@ -5,6 +5,7 @@ import { getArtistSpotifyData } from "@/lib/artistIntel/getArtistSpotifyData"; import { getArtistMusicAnalysis } from "@/lib/artistIntel/getArtistMusicAnalysis"; import { getArtistWebContext } from "@/lib/artistIntel/getArtistWebContext"; import { buildArtistMarketingCopy } from "@/lib/artistIntel/buildArtistMarketingCopy"; +import { getRelatedArtistsData } from "@/lib/artistIntel/getRelatedArtistsData"; vi.mock("@/lib/artistIntel/getArtistSpotifyData", () => ({ getArtistSpotifyData: vi.fn(), @@ -18,6 +19,9 @@ vi.mock("@/lib/artistIntel/getArtistWebContext", () => ({ vi.mock("@/lib/artistIntel/buildArtistMarketingCopy", () => ({ buildArtistMarketingCopy: vi.fn(), })); +vi.mock("@/lib/artistIntel/getRelatedArtistsData", () => ({ + getRelatedArtistsData: vi.fn(), +})); const mockSpotifyData = { artist: { @@ -221,4 +225,167 @@ describe("generateArtistIntelPack", () => { } }); }); + + describe("pre-market artist — Gatsby Grace edge case", () => { + // Real Spotify data for Gatsby Grace (Spotify ID: 7ljukJB2Ctl0T4vCoYfb2x) + // 2 followers, 0 popularity, no genres — tests the pre-market edge case + const gatsbyGraceSpotifyData = { + artist: { + id: "7ljukJB2Ctl0T4vCoYfb2x", + name: "Gatsby Grace", + genres: [], + followers: { total: 2 }, + popularity: 0, + images: [ + { + url: "https://i.scdn.co/image/ab6761610000e5eb5fd8fa1d768bbc789e903a3f", + height: 640, + width: 640, + }, + ], + }, + topTracks: [ + { + id: "track-gg-001", + name: "Stay", + preview_url: null, // no preview for very new artists + popularity: 0, + album: { name: "Stay - Single", images: [] }, + }, + { + id: "track-gg-002", + name: "Running", + preview_url: null, + popularity: 0, + album: { name: "Running - Single", images: [] }, + }, + ], + previewUrl: null, // no preview URL — triggers fallback scores + }; + + const gatsbyGraceMarketingCopy = { + artist_one_sheet: "Gatsby Grace — an emerging independent artist building early traction.", + ar_memo: "ARTIST: Gatsby Grace\nGENRE: not specified\nRECOMMENDATION: Early watch list.", + sync_brief: "ARTIST: Gatsby Grace\nSYNC SCORE: 30/100\nEarly stage — monitor catalog growth.", + spotify_playlist_targets: ["Fresh Finds", "New Music Friday", "Lorem"], + brand_partnership_pitch: "Limited brand data at this stage. Monitor for 6 months.", + playlist_pitch_email: "Dear Curator, introducing Gatsby Grace...", + press_release_opener: "Gatsby Grace is an emerging artist building their catalog.", + key_talking_points: ["Pre-market independent artist", "2 Spotify followers"], + instagram_caption: "New music from Gatsby Grace 🎵 #newmusic #indie", + tiktok_caption: "You need to hear Gatsby Grace 🔥 #newmusic", + twitter_post: "New drop from Gatsby Grace 🎵 #newmusic", + }; + + beforeEach(() => { + vi.mocked(getArtistSpotifyData).mockResolvedValue(gatsbyGraceSpotifyData); + vi.mocked(getArtistMusicAnalysis).mockResolvedValue(null); // no preview URL + vi.mocked(getArtistWebContext).mockResolvedValue(null); // no web presence yet + vi.mocked(getRelatedArtistsData).mockResolvedValue(null); // no related artists yet + vi.mocked(buildArtistMarketingCopy).mockResolvedValue(gatsbyGraceMarketingCopy); + }); + + it("succeeds for a pre-market artist with 0 popularity", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + }); + + it("correctly maps Gatsby Grace Spotify profile", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + expect(result.pack.artist.name).toBe("Gatsby Grace"); + expect(result.pack.artist.spotify_id).toBe("7ljukJB2Ctl0T4vCoYfb2x"); + expect(result.pack.artist.followers).toBe(2); + expect(result.pack.artist.popularity).toBe(0); + expect(result.pack.artist.genres).toEqual([]); + expect(result.pack.artist.image_url).toBe( + "https://i.scdn.co/image/ab6761610000e5eb5fd8fa1d768bbc789e903a3f", + ); + }); + + it("returns top track even when track popularity is 0", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + expect(result.pack.top_track?.name).toBe("Stay"); + expect(result.pack.top_track?.popularity).toBe(0); + expect(result.pack.top_track?.preview_url).toBeNull(); + }); + + it("skips music analysis when no preview URL (no audio to analyze)", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + expect(getArtistMusicAnalysis).not.toHaveBeenCalled(); + if (result.type === "success") { + expect(result.pack.music_analysis).toBeNull(); + } + }); + + it("computes fallback opportunity scores for pre-market artist", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + const scores = result.pack.opportunity_scores; + + // Sync: fallback = 30 + 0*0.4 = 30 + expect(scores.sync.score).toBe(30); + expect(scores.sync.rating).toBe("weak"); + + // Playlist: fallback = 35 + 0*0.5 = 35 + expect(scores.playlist.score).toBe(35); + expect(scores.playlist.rating).toBe("weak"); + + // A&R: pre-market bonus (+10) → 40 + 10 = 50, then +0 (no peer data) = 50 + expect(scores.ar.rationale).toContain("Pre-market artist"); + expect(scores.ar.rationale).not.toContain("established"); + expect(scores.ar.score).toBeGreaterThan(40); // gets pre-market discovery bonus + + // Brand: fallback = 35 + 0*0.35 = 35 + expect(scores.brand.score).toBe(35); + expect(scores.brand.rating).toBe("weak"); + }); + + it("catalogs depth shows emerging type with 0 popularity tracks", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + // avg_popularity = 0 → "emerging" catalog type + expect(result.pack.catalog_depth?.catalog_type).toBe("emerging"); + expect(result.pack.catalog_depth?.avg_popularity).toBe(0); + expect(result.pack.catalog_depth?.track_count).toBe(2); + }); + + it("returns a formatted_report markdown string", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + expect(typeof result.pack.formatted_report).toBe("string"); + expect(result.pack.formatted_report).toContain("Gatsby Grace"); + expect(result.pack.formatted_report).toContain("Opportunity Scores"); + expect(result.pack.formatted_report).toContain("Emerging Catalog"); + }); + + it("returns null web_context and null peer_benchmark for brand-new artist", async () => { + const result = await generateArtistIntelPack("Gatsby-Grace"); + + expect(result.type).toBe("success"); + if (result.type !== "success") return; + + expect(result.pack.web_context).toBeNull(); + expect(result.pack.peer_benchmark).toBeNull(); + }); + }); }); diff --git a/lib/artistIntel/computeArtistOpportunityScores.ts b/lib/artistIntel/computeArtistOpportunityScores.ts index 8f58f13b..adf53cf3 100644 --- a/lib/artistIntel/computeArtistOpportunityScores.ts +++ b/lib/artistIntel/computeArtistOpportunityScores.ts @@ -160,9 +160,15 @@ export function computeArtistOpportunityScores( arRationale.push( `Solid popularity-to-follower ratio (${followerEfficiency.toFixed(1)}) indicates organic traction`, ); + } else if (followers < 1000 && popularity < 10) { + // Pre-market artist: very low followers AND very low popularity = early discovery opportunity + arScore += 10; + arRationale.push( + `Pre-market artist (${followers} followers, ${popularity}/100 popularity) — no Spotify traction yet, highest early-discovery upside`, + ); } else { arRationale.push( - `Popularity-to-follower ratio (${followerEfficiency.toFixed(1)}) suggests established/saturated positioning`, + `Popularity-to-follower ratio (${followerEfficiency.toFixed(1)}) suggests a growing or maturing audience positioning`, ); }