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; +}