From fac739fba18b29a5e51daf2021a4596d8e5467a0 Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Tue, 31 Mar 2026 14:33:54 +0000 Subject: [PATCH] feat: extract artist name from Slack prompt for dynamic artist selection When a user mentions an artist name in their Slack message (e.g. "generate a video for Mac Miller"), the content agent now extracts that name via AI, looks up the matching artist in the account's roster, and uses them for content creation. Falls back to Gatsby Grace when no artist name is provided or no match is found. - Add artistName field to contentPromptFlagsSchema for AI extraction - Add resolveArtistFromName() to match artist name against account roster - Update registerOnNewMention to use dynamic artist lookup with fallback - 12 new tests covering artist name extraction and resolution Co-Authored-By: Paperclip --- .../__tests__/parseContentPrompt.test.ts | 31 ++++++ .../__tests__/registerOnNewMention.test.ts | 94 +++++++++++++++++++ .../__tests__/resolveArtistFromName.test.ts | 80 ++++++++++++++++ .../content/createContentPromptAgent.ts | 6 ++ .../content/handlers/registerOnNewMention.ts | 21 ++++- lib/agents/content/resolveArtistFromName.ts | 40 ++++++++ 6 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 lib/agents/content/__tests__/resolveArtistFromName.test.ts create mode 100644 lib/agents/content/resolveArtistFromName.ts diff --git a/lib/agents/content/__tests__/parseContentPrompt.test.ts b/lib/agents/content/__tests__/parseContentPrompt.test.ts index 7c9a741e..a6c035fd 100644 --- a/lib/agents/content/__tests__/parseContentPrompt.test.ts +++ b/lib/agents/content/__tests__/parseContentPrompt.test.ts @@ -188,4 +188,35 @@ describe("parseContentPrompt", () => { expect(result.songs).toBeUndefined(); }); + + it("extracts artistName from prompt when an artist is mentioned", async () => { + const flags: ContentPromptFlags = { + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + artistName: "Mac Miller", + }; + mockGenerate.mockResolvedValue({ output: flags }); + + const result = await parseContentPrompt("generate a video for Mac Miller"); + + expect(result.artistName).toBe("Mac Miller"); + }); + + it("returns undefined artistName when no artist is mentioned", async () => { + const flags: ContentPromptFlags = { + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }; + mockGenerate.mockResolvedValue({ output: flags }); + + const result = await parseContentPrompt("make me a video"); + + expect(result.artistName).toBeUndefined(); + }); }); diff --git a/lib/agents/content/__tests__/registerOnNewMention.test.ts b/lib/agents/content/__tests__/registerOnNewMention.test.ts index 9f1a147c..6e2eb40a 100644 --- a/lib/agents/content/__tests__/registerOnNewMention.test.ts +++ b/lib/agents/content/__tests__/registerOnNewMention.test.ts @@ -29,11 +29,16 @@ vi.mock("../parseContentPrompt", () => ({ parseContentPrompt: vi.fn(), })); +vi.mock("../resolveArtistFromName", () => ({ + resolveArtistFromName: vi.fn(), +})); + const { triggerCreateContent } = await import("@/lib/trigger/triggerCreateContent"); const { triggerPollContentRun } = await import("@/lib/trigger/triggerPollContentRun"); const { resolveArtistSlug } = await import("@/lib/content/resolveArtistSlug"); const { getArtistContentReadiness } = await import("@/lib/content/getArtistContentReadiness"); const { parseContentPrompt } = await import("../parseContentPrompt"); +const { resolveArtistFromName } = await import("../resolveArtistFromName"); /** * Creates a mock content agent bot for testing. @@ -300,4 +305,93 @@ describe("registerOnNewMention", () => { expect(ackMessage).toContain("Songs:"); expect(ackMessage).toContain("hiccups"); }); + + it("uses resolved artist when artistName is provided in prompt", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + artistName: "Mac Miller", + }); + vi.mocked(resolveArtistFromName).mockResolvedValue("bbb-artist-id"); + vi.mocked(resolveArtistSlug).mockResolvedValue("mac-miller"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("generate a video for Mac Miller"); + await bot.getHandler()!(thread, message); + + expect(resolveArtistFromName).toHaveBeenCalledWith( + "Mac Miller", + "fb678396-a68f-4294-ae50-b8cacf9ce77b", + ); + expect(resolveArtistSlug).toHaveBeenCalledWith("bbb-artist-id"); + expect(triggerCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ artistSlug: "mac-miller" }), + ); + }); + + it("falls back to default artist when artistName is not provided", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + }); + vi.mocked(resolveArtistSlug).mockResolvedValue("gatsby-grace"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("make me a video"); + await bot.getHandler()!(thread, message); + + expect(resolveArtistFromName).not.toHaveBeenCalled(); + expect(resolveArtistSlug).toHaveBeenCalledWith("1873859c-dd37-4e9a-9bac-80d3558527a9"); + }); + + it("falls back to default artist when resolveArtistFromName returns null", async () => { + const bot = createMockBot(); + registerOnNewMention(bot as never); + + vi.mocked(parseContentPrompt).mockResolvedValue({ + lipsync: false, + batch: 1, + captionLength: "short", + upscale: false, + template: "artist-caption-bedroom", + artistName: "Unknown Artist", + }); + vi.mocked(resolveArtistFromName).mockResolvedValue(null); + vi.mocked(resolveArtistSlug).mockResolvedValue("gatsby-grace"); + vi.mocked(getArtistContentReadiness).mockResolvedValue({ + githubRepo: "https://github.com/test/repo", + } as never); + vi.mocked(triggerCreateContent).mockResolvedValue({ id: "run-1" }); + vi.mocked(triggerPollContentRun).mockResolvedValue(undefined as never); + + const thread = createMockThread(); + const message = createMockMessage("generate a video for Unknown Artist"); + await bot.getHandler()!(thread, message); + + expect(resolveArtistSlug).toHaveBeenCalledWith("1873859c-dd37-4e9a-9bac-80d3558527a9"); + const ackMessage = thread.post.mock.calls[0][0] as string; + expect(ackMessage).toContain("*gatsby-grace*"); + }); }); diff --git a/lib/agents/content/__tests__/resolveArtistFromName.test.ts b/lib/agents/content/__tests__/resolveArtistFromName.test.ts new file mode 100644 index 00000000..ba82bc84 --- /dev/null +++ b/lib/agents/content/__tests__/resolveArtistFromName.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveArtistFromName } from "../resolveArtistFromName"; + +vi.mock("@/lib/supabase/account_artist_ids/getAccountArtistIds", () => ({ + getAccountArtistIds: vi.fn(), +})); + +const { getAccountArtistIds } = await import( + "@/lib/supabase/account_artist_ids/getAccountArtistIds" +); + +/** + * + * @param name + * @param artistId + */ +function createArtistRow(name: string, artistId: string) { + return { + artist_id: artistId, + pinned: false, + artist_info: { name, id: artistId }, + }; +} + +describe("resolveArtistFromName", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the matching artist_id for an exact name match (case-insensitive)", async () => { + vi.mocked(getAccountArtistIds).mockResolvedValue([ + createArtistRow("Gatsby Grace", "aaa"), + createArtistRow("Mac Miller", "bbb"), + ] as never); + + const result = await resolveArtistFromName("mac miller", "account-1"); + expect(result).toBe("bbb"); + }); + + it("returns the closest match when the name is a partial match", async () => { + vi.mocked(getAccountArtistIds).mockResolvedValue([ + createArtistRow("Gatsby Grace", "aaa"), + createArtistRow("Mac Miller", "bbb"), + ] as never); + + const result = await resolveArtistFromName("Mac", "account-1"); + expect(result).toBe("bbb"); + }); + + it("returns null when no artists match", async () => { + vi.mocked(getAccountArtistIds).mockResolvedValue([ + createArtistRow("Gatsby Grace", "aaa"), + ] as never); + + const result = await resolveArtistFromName("Unknown Artist", "account-1"); + expect(result).toBeNull(); + }); + + it("returns null when the account has no artists", async () => { + vi.mocked(getAccountArtistIds).mockResolvedValue([]); + + const result = await resolveArtistFromName("Mac Miller", "account-1"); + expect(result).toBeNull(); + }); + + it("returns null when artistName is empty", async () => { + const result = await resolveArtistFromName("", "account-1"); + expect(result).toBeNull(); + expect(getAccountArtistIds).not.toHaveBeenCalled(); + }); + + it("queries getAccountArtistIds with the correct accountId", async () => { + vi.mocked(getAccountArtistIds).mockResolvedValue([ + createArtistRow("Gatsby Grace", "aaa"), + ] as never); + + await resolveArtistFromName("Gatsby", "account-42"); + expect(getAccountArtistIds).toHaveBeenCalledWith({ accountIds: ["account-42"] }); + }); +}); diff --git a/lib/agents/content/createContentPromptAgent.ts b/lib/agents/content/createContentPromptAgent.ts index 52711707..f792d8d9 100644 --- a/lib/agents/content/createContentPromptAgent.ts +++ b/lib/agents/content/createContentPromptAgent.ts @@ -35,6 +35,12 @@ export const contentPromptFlagsSchema = z.object({ songs: songsSchema.describe( "Song names or slugs mentioned in the prompt. Extract from phrases like 'the hiccups song', 'use track X', 'for song Y'. Omit if no specific songs are mentioned.", ), + artistName: z + .string() + .optional() + .describe( + "The artist name mentioned in the prompt. Extract from phrases like 'for Mac Miller', 'generate a video for [artist]', 'make content for [artist]'. Omit if no specific artist is mentioned.", + ), }); export type ContentPromptFlags = z.infer; diff --git a/lib/agents/content/handlers/registerOnNewMention.ts b/lib/agents/content/handlers/registerOnNewMention.ts index d5e4b706..477a97d7 100644 --- a/lib/agents/content/handlers/registerOnNewMention.ts +++ b/lib/agents/content/handlers/registerOnNewMention.ts @@ -5,6 +5,10 @@ import { resolveArtistSlug } from "@/lib/content/resolveArtistSlug"; import { getArtistContentReadiness } from "@/lib/content/getArtistContentReadiness"; import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; import { parseContentPrompt } from "../parseContentPrompt"; +import { resolveArtistFromName } from "../resolveArtistFromName"; + +const DEFAULT_ACCOUNT_ID = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; +const DEFAULT_ARTIST_ACCOUNT_ID = "1873859c-dd37-4e9a-9bac-80d3558527a9"; /** * Registers the onNewMention handler on the content agent bot. @@ -17,13 +21,20 @@ import { parseContentPrompt } from "../parseContentPrompt"; export function registerOnNewMention(bot: ContentAgentBot) { bot.onNewMention(async (thread, message) => { try { - const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; - const artistAccountId = "1873859c-dd37-4e9a-9bac-80d3558527a9"; + const accountId = DEFAULT_ACCOUNT_ID; // Parse the user's natural-language prompt into structured flags - const { lipsync, batch, captionLength, upscale, template, songs } = await parseContentPrompt( - message.text, - ); + const { lipsync, batch, captionLength, upscale, template, songs, artistName } = + await parseContentPrompt(message.text); + + // Resolve artist account ID from name, or fall back to default + let artistAccountId = DEFAULT_ARTIST_ACCOUNT_ID; + if (artistName) { + const resolvedId = await resolveArtistFromName(artistName, accountId); + if (resolvedId) { + artistAccountId = resolvedId; + } + } // Resolve artist slug const artistSlug = await resolveArtistSlug(artistAccountId); diff --git a/lib/agents/content/resolveArtistFromName.ts b/lib/agents/content/resolveArtistFromName.ts new file mode 100644 index 00000000..6d080185 --- /dev/null +++ b/lib/agents/content/resolveArtistFromName.ts @@ -0,0 +1,40 @@ +import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccountArtistIds"; + +/** + * Resolves an artist name to an artist_account_id by searching + * the account's artist roster for the closest match. + * + * @param artistName - The artist name extracted from the prompt + * @param accountId - The workspace account ID to search within + * @returns The matching artist_id, or null if no match is found + */ +export async function resolveArtistFromName( + artistName: string, + accountId: string, +): Promise { + if (!artistName) return null; + + const artists = await getAccountArtistIds({ accountIds: [accountId] }); + if (!artists || artists.length === 0) return null; + + const query = artistName.toLowerCase(); + + // Try exact match first (case-insensitive) + const exactMatch = artists.find( + a => + (a as unknown as { artist_info: { name: string } }).artist_info?.name?.toLowerCase() === + query, + ); + if (exactMatch) return exactMatch.artist_id; + + // Try prefix/includes match + const partialMatch = artists.find(a => { + const name = ( + a as unknown as { artist_info: { name: string } } + ).artist_info?.name?.toLowerCase(); + return name?.includes(query) || query.includes(name ?? ""); + }); + if (partialMatch) return partialMatch.artist_id; + + return null; +}