Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions lib/ai/generateArray.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { generateObject } from "ai";
import { DEFAULT_MODEL } from "@/lib/const";
import { z } from "zod";

export interface GenerateArrayResult {
segmentName: string;
fans: string[];
}

const generateArray = async ({
system,
prompt,
}: {
system?: string;
prompt: string;
}): Promise<GenerateArrayResult[]> => {
const result = await generateObject({
model: DEFAULT_MODEL,
system,
prompt,
output: "array",
schema: z.object({
segmentName: z.string().describe("Segment name."),
fans: z
.array(z.string())
.describe(
"For each Segment Name return an array of fan_social_id included in the segment. Do not make these up. Only use the actual fan_social_id provided in the fan data prompt input.",
),
}),
});

return result.object;
};

export default generateArray;
4 changes: 2 additions & 2 deletions lib/artist/getArtistSegments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { selectArtistSegmentsCount } from "@/lib/supabase/artist_segments/selectArtistSegmentsCount";
import { selectArtistSegments } from "@/lib/supabase/artist_segments/selectArtistSegments";
import { selectArtistSegmentsWithDetails } from "@/lib/supabase/artist_segments/selectArtistSegmentsWithDetails";
import type { ArtistSegmentsQuery } from "@/lib/artist/validateArtistSegmentsQuery";
import { mapArtistSegments, type MappedArtistSegment } from "@/lib/artist/mapArtistSegments";

Expand Down Expand Up @@ -38,7 +38,7 @@ export const getArtistSegments = async ({
};
}

const data = await selectArtistSegments(artist_account_id, offset, limit);
const data = await selectArtistSegmentsWithDetails(artist_account_id, offset, limit);

if (!data) {
return {
Expand Down
2 changes: 1 addition & 1 deletion lib/artist/mapArtistSegments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SegmentQueryResult } from "@/lib/supabase/artist_segments/selectArtistSegments";
import type { SegmentQueryResult } from "@/lib/supabase/artist_segments/selectArtistSegmentsWithDetails";

export interface MappedArtistSegment {
id: string;
Expand Down
2 changes: 2 additions & 0 deletions lib/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerUpdateAccountInfoTool } from "./registerUpdateAccountInfoTool";
import { registerAllArtistSocialsTools } from "./artistSocials";
import { registerSearchWebTool } from "./registerSearchWebTool";
import { registerAllFileTools } from "./files";
import { registerCreateSegmentsTool } from "./registerCreateSegmentsTool";

/**
* Registers all MCP tools on the server.
Expand All @@ -29,4 +30,5 @@ export const registerAllTools = (server: McpServer): void => {
registerGetLocalTimeTool(server);
registerSearchWebTool(server);
registerUpdateAccountInfoTool(server);
registerCreateSegmentsTool(server);
};
55 changes: 55 additions & 0 deletions lib/mcp/tools/registerCreateSegmentsTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createSegments } from "@/lib/segments/createSegments";
import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess";
import { getToolResultError } from "@/lib/mcp/getToolResultError";

const createSegmentsSchema = z.object({
artist_account_id: z
.string()
.min(1, "Artist account ID is required")
.describe(
"The artist_account_id to create segments for. If not provided, check system prompt for the active artist_account_id.",
),
prompt: z
.string()
.min(1, "Prompt is required")
.describe(
"The prompt to use for generating segment names. This should be generated by the system if not provided.",
),
});

export type CreateSegmentsArgs = z.infer<typeof createSegmentsSchema>;

/**
* Registers the "create_segments" tool on the MCP server.
* Creates segments by analyzing fan data and generating segment names.
*
* @param server - The MCP server instance to register the tool on.
*/
export function registerCreateSegmentsTool(server: McpServer): void {
server.registerTool(
"create_segments",
{
description:
"Create segments by analyzing fan data and generating segment names. This tool fetches all fans for an artist, generates segment names based on the provided prompt, and saves the segments to the database. If required information is missing (social accounts or fan data), the tool will provide step-by-step instructions to gather the missing prerequisites using other available tools.",
inputSchema: createSegmentsSchema,
},
async (args: CreateSegmentsArgs) => {
try {
const result = await createSegments(args);

if (result.success) {
return getToolResultSuccess(result);
}

return getToolResultError(result.message || "Failed to create segments");
} catch (error) {
console.error("Error creating segments:", error);
return getToolResultError(
error instanceof Error ? error.message : "Failed to create segments",
);
}
},
);
}
16 changes: 16 additions & 0 deletions lib/segments/consts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const SEGMENT_FAN_SOCIAL_ID_PROMPT = `For each Segment Name return an array of fan_social_id included in the segment. Do not make these up. Only use the actual fan_social_id provided in the fan data prompt input.`;

export const SEGMENT_SYSTEM_PROMPT = `You are an expert music industry analyst specializing in fan segmentation.
Your task is to analyze fan data and generate meaningful segment names that would be useful for marketing and engagement strategies.

Guidelines for segment names:
- Keep names concise and descriptive (2-4 words)
- Focus on engagement patterns, demographics, or behavioral characteristics
- Use clear, actionable language that marketers can understand
- Avoid generic terms like "fans" or "followers"
- Consider factors like engagement frequency, recency, and intensity
- Generate 5-10 segment names that cover different aspects of the fan base

The segment names should help artists and managers understand their audience better for targeted marketing campaigns.

${SEGMENT_FAN_SOCIAL_ID_PROMPT}`;
30 changes: 30 additions & 0 deletions lib/segments/createSegmentResponses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Tables } from "@/types/database.types";
import type { GenerateArrayResult } from "./generateSegments";

interface CreateArtistSegmentsSuccessData {
supabase_segments: Tables<"segments">[];
supabase_artist_segments: Tables<"artist_segments">[];
segments: GenerateArrayResult[];
supabase_fan_segments: Tables<"fan_segments">[];
}

export const successResponse = (
message: string,
data: CreateArtistSegmentsSuccessData,
count: number,
) => ({
success: true,
status: "success",
message,
data,
count,
});

export const errorResponse = (message: string) => ({
success: false,
status: "error",
message,
data: [],
count: 0,
});

117 changes: 117 additions & 0 deletions lib/segments/createSegments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials";
import { selectSocialFans } from "@/lib/supabase/social_fans/selectSocialFans";
import { generateSegments } from "./generateSegments";
import { insertSegments } from "@/lib/supabase/segments/insertSegments";
import { deleteSegments } from "@/lib/supabase/segments/deleteSegments";
import { insertArtistSegments } from "@/lib/supabase/artist_segments/insertArtistSegments";
import { insertFanSegments } from "@/lib/supabase/fan_segments/insertFanSegments";
import { Tables } from "@/types/database.types";
import { successResponse, errorResponse } from "./createSegmentResponses";
import type { GenerateArrayResult } from "./generateSegments";
import { getFanSegmentsToInsert } from "./getFanSegmentsToInsert";
import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts";
import { CreateSegmentsArgs } from "../mcp/tools/registerCreateSegmentsTool";

export const createSegments = async ({ artist_account_id, prompt }: CreateSegmentsArgs) => {
try {
// Get artist info for better error messages
const accounts = await selectAccounts(artist_account_id);
const artistInfo = accounts[0];
const artistName = artistInfo?.name || "this artist";

// Step 1: Get all social IDs for the artist
const accountSocials = await selectAccountSocials(artist_account_id, 0, 10000);
const socialIds = (accountSocials || []).map((as: { social_id: string }) => as.social_id);

if (socialIds.length === 0) {
return {
...errorResponse("No social account found for this artist"),
feedback:
`No Instagram accounts found for ${artistName}. To automatically set up Instagram accounts, please follow these steps:\n` +
`1. Call 'search_web' to search for "${artistName} Instagram handle"\n` +
"2. Call 'update_artist_socials' with the discovered Instagram profile URL\n" +
"3. Call 'create_segments' again to retry segment creation\n" +
"Instagram is required for fan segmentation as it's the primary social platform configured for segments.",
};
}

// Step 2: Get all fans for the artist
const fans = await selectSocialFans({
social_ids: socialIds,
orderBy: "latest_engagement",
orderDirection: "desc",
});

if (fans.length === 0) {
return {
...errorResponse("No fans found for this artist"),
feedback:
`No social_fans records found for ${artistName}. Before creating segments, you need social_fans data. Follow these steps:\n` +
"1. Call 'scrape_instagram_profile' with the artist's Instagram handles to get posts\n" +
"2. Call 'scrape_instagram_comments' with Instagram post URLs to scrape comment data\n" +
"3. Wait for the scraping jobs to complete and process into social_fans records\n" +
"4. Call 'create_segments' again once social_fans records are populated\n" +
"Note: Scraping jobs may take several minutes to complete.",
};
}

// Step 3: Generate segment names using AI
const segments = await generateSegments({ fans, prompt });

if (segments.length === 0) {
return errorResponse("Failed to generate segment names");
}

// Step 4: Delete existing segments for the artist
await deleteSegments(artist_account_id);

// Step 5: Insert segments into the database
const segmentsToInsert = segments.map((segment: GenerateArrayResult) => ({
name: segment.segmentName,
updated_at: new Date().toISOString(),
}));

const insertedSegments = await insertSegments(segmentsToInsert);

// Step 6: Associate segments with the artist
const artistSegmentsToInsert = insertedSegments.map((segment: Tables<"segments">) => ({
artist_account_id,
segment_id: segment.id,
updated_at: new Date().toISOString(),
}));

const insertedArtistSegments = await insertArtistSegments(artistSegmentsToInsert);

// Step 7: Associate fans with the new segments
// Build a set of valid IDs from the fetched fan list
const validFanIds = new Set(fans.map(f => f.fan_social_id));

const fanSegmentsToInsert = getFanSegmentsToInsert(segments, insertedSegments).filter(fs => {
const ok = validFanIds.has(fs.fan_social_id);
if (!ok) console.warn(`Skipping unknown fan_social_id: ${fs.fan_social_id}`);
return ok;
});

if (fanSegmentsToInsert.length === 0) {
return errorResponse("No valid fan IDs matched any segment.");
}

const insertedFanSegments = await insertFanSegments(fanSegmentsToInsert);

return successResponse(
`Successfully created ${segments.length} segments for artist`,
{
supabase_segments: insertedSegments,
supabase_artist_segments: insertedArtistSegments,
supabase_fan_segments: insertedFanSegments,
segments,
},
segments.length,
);
} catch (error) {
console.error("Error creating artist segments:", error);
return errorResponse(
error instanceof Error ? error.message : "Failed to create artist segments",
);
}
};
33 changes: 33 additions & 0 deletions lib/segments/generateSegments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import generateArray from "@/lib/ai/generateArray";
import { SEGMENT_SYSTEM_PROMPT } from "./consts";
import getAnalysisPrompt from "./getAnalysisPrompt";
import type { SocialFanWithDetails } from "@/lib/supabase/social_fans/selectSocialFans";

export interface GenerateSegmentsParams {
fans: SocialFanWithDetails[];
prompt: string;
}

export interface GenerateArrayResult {
segmentName: string;
fans: string[];
}

export const generateSegments = async ({
fans,
prompt,
}: GenerateSegmentsParams): Promise<GenerateArrayResult[]> => {
try {
const analysisPrompt = getAnalysisPrompt({ fans, prompt });

const result = await generateArray({
system: SEGMENT_SYSTEM_PROMPT,
prompt: analysisPrompt,
});

return result;
} catch (error) {
console.error("Error generating segments:", error);
throw new Error("Failed to generate segments from fan data");
}
};
36 changes: 36 additions & 0 deletions lib/segments/getAnalysisPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { SEGMENT_FAN_SOCIAL_ID_PROMPT } from "./consts";
import type { GenerateSegmentsParams } from "./generateSegments";

const getAnalysisPrompt = ({ fans, prompt }: GenerateSegmentsParams) => {
const fanCount = fans.length;
const fanData = fans.map(fan => {
const obj = {
fan_social_id: fan.fan_social_id,
username: fan.fan_social.username,
bio: fan.fan_social.bio,
followerCount: fan.fan_social.followerCount,
followingCount: fan.fan_social.followingCount,
comment: fan.latest_engagement_comment?.comment || null,
};
// Remove keys with null values
return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== null));
});

const maxFans = 111;
const slicedFanData = fanData.slice(0, maxFans);

const fanDataString = JSON.stringify(slicedFanData, null, 2);
const analysisPrompt = `Analyze the following fan data and generate segment names based on the provided prompt.

Fan Data Summary:
- Total fans: ${fanCount}
- Fan data: ${fanDataString}

Artist's specific prompt: ${prompt}

Generate segment names that align with the artist's requirements and the fan data characteristics.
${SEGMENT_FAN_SOCIAL_ID_PROMPT}`;
return analysisPrompt;
};

export default getAnalysisPrompt;
27 changes: 27 additions & 0 deletions lib/segments/getFanSegmentsToInsert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { GenerateArrayResult } from "./generateSegments";
import { Tables } from "@/types/database.types";

/**
* Returns an array of fan-segment associations to insert, based on the AI-generated segments and the inserted segment records.
* Each fan is only associated with the segment(s) they are assigned to in the segments array.
*
* @param segments - The AI-generated segments.
* @param insertedSegments - The inserted segment records.
* @returns An array of fan-segment associations to insert.
*/
export function getFanSegmentsToInsert(
segments: GenerateArrayResult[],
insertedSegments: Tables<"segments">[],
) {
const segmentNameToId = new Map(insertedSegments.map(seg => [seg.name, seg.id]));

return segments.flatMap((segment: GenerateArrayResult) => {
const segmentId = segmentNameToId.get(segment.segmentName);
if (!segmentId || !segment.fans) return [];
return segment.fans.map((fan_social_id: string) => ({
fan_social_id,
segment_id: segmentId,
updated_at: new Date().toISOString(),
}));
});
}
Loading