import { z } from "zod";
import { extractNicknames } from "../lib/llm.js";
import type { Post, RankedPost, RankPostsResult } from "../types.js";
const postSchema = z.object({
id: z.string(),
text: z.string(),
author: z.string(),
author_handle: z.string(),
url: z.string(),
created_at: z.string(),
likes: z.number(),
retweets: z.number(),
replies: z.number(),
has_media: z.boolean(),
media_urls: z.array(z.string()).optional(),
});
export const rankPostsSchema = z.object({
posts: z.array(postSchema).describe("Posts from fetch_posts"),
top_n: z.number().min(1).max(20).default(3).describe("Results per category (default: 3)"),
target: z.string().optional().describe("Target name for nickname extraction"),
});
export type RankPostsInput = z.infer<typeof rankPostsSchema>;
function calculateEngagementScore(post: Post): number {
// Engagement Score Formula from PRD:
// score = (likes * 1.0) + (retweets * 2.0) + (replies * 0.5)
return post.likes * 1.0 + post.retweets * 2.0 + post.replies * 0.5;
}
export async function rankPosts(input: RankPostsInput): Promise<RankPostsResult> {
const topN = input.top_n ?? 3;
const target = input.target ?? "";
// Calculate scores for all posts
const scoredPosts: RankedPost[] = input.posts.map((post) => ({
...post,
score: calculateEngagementScore(post),
}));
// Sort by score descending
scoredPosts.sort((a, b) => b.score - a.score);
// Separate text posts and media posts
const textPosts: RankedPost[] = [];
const mediaPosts: RankedPost[] = [];
for (const post of scoredPosts) {
if (post.has_media && post.media_urls && post.media_urls.length > 0) {
if (mediaPosts.length < topN) {
mediaPosts.push(post);
}
} else {
if (textPosts.length < topN) {
textPosts.push(post);
}
}
// Stop early if we have enough of both
if (textPosts.length >= topN && mediaPosts.length >= topN) {
break;
}
}
// Extract nicknames via LLM
const nicknames = target
? await extractNicknames(target, input.posts)
: [];
return {
text_posts: textPosts.map((p) => ({
text: p.text,
author: p.author,
author_handle: p.author_handle,
url: p.url,
likes: p.likes,
retweets: p.retweets,
replies: p.replies,
score: p.score,
id: p.id,
created_at: p.created_at,
has_media: p.has_media,
})),
media_posts: mediaPosts.map((p) => ({
text: p.text,
author: p.author,
author_handle: p.author_handle,
url: p.url,
likes: p.likes,
retweets: p.retweets,
replies: p.replies,
score: p.score,
media_urls: p.media_urls || [],
id: p.id,
created_at: p.created_at,
has_media: p.has_media,
})),
nicknames,
};
}
export const rankPostsTool = {
name: "rank_posts",
description:
"Rank fetched posts by engagement, separate text from media posts, and extract nicknames. Uses engagement formula: (likes * 1.0) + (retweets * 2.0) + (replies * 0.5). Retweets weighted highest because sharing is strong signal for humor.",
inputSchema: {
type: "object" as const,
properties: {
posts: {
type: "array",
description: "Posts from fetch_posts (can combine multiple fetches)",
items: {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
author: { type: "string" },
author_handle: { type: "string" },
url: { type: "string" },
created_at: { type: "string" },
likes: { type: "number" },
retweets: { type: "number" },
replies: { type: "number" },
has_media: { type: "boolean" },
media_urls: { type: "array", items: { type: "string" } },
},
required: [
"id",
"text",
"author",
"author_handle",
"url",
"created_at",
"likes",
"retweets",
"replies",
"has_media",
],
},
},
top_n: {
type: "number",
description: "Results per category (default: 3, max: 20)",
},
target: {
type: "string",
description: "Target name for nickname extraction (required for nicknames)",
},
},
required: ["posts"],
},
};