import { z } from "zod"; import type { ProviderApiKeys } from "./keys"; import { withAgentSystem } from "./agent"; import { generateStructuredObject } from "./generate-structured"; import { getModel } from "./provider"; import type { QualityAssessment, QualityTier, ResearchMap } from "@/lib/types/research"; import { buildTopicAnchorPromptBlock } from "@/lib/topic-anchor"; const filterSchema = z.object({ items: z.array( z.object({ index: z.number().int().min(0), relevanceScore: z.number().min(0).max(100), qualityTier: z.enum(["GOOD", "OK", "EXCLUDE"]), qualityReason: z.string(), }) ), }); export interface FilterQualityInput { topicLabel: string; query: string; brief?: string | null; researchMap?: ResearchMap | null; aiProvider: string; aiModel: string; apiKeys?: ProviderApiKeys; posts: Array<{ id: string; text: string; authorName?: string | null; searchTag?: string | null; likeCount?: number | null; replyCount?: number | null; replies?: string[] | null; }>; } function normalizeEngagement(likeCount?: number | null, replyCount?: number | null): number { const likes = likeCount ?? 0; const replies = replyCount ?? 0; const raw = likes * 1.0 + replies * 2.0; return Math.min(100, Math.log10(raw + 1) * 25); } export function computeCombinedScore(relevanceScore: number, engagementNorm: number): number { return relevanceScore * 0.7 + engagementNorm * 0.3; } export function computePlacementCombinedScore( placementScore: number, engagementNorm: number, replyCount?: number | null, recencyScore?: number | null ): number { const replySignal = Math.min(100, (replyCount ?? 0) * 6); const recency = recencyScore ?? 35; return placementScore * 0.5 + recency * 0.28 + engagementNorm * 0.12 + replySignal * 0.1; } export async function filterPostQuality(input: FilterQualityInput): Promise { if (input.posts.length === 0) return []; const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {}); const similarAccounts = (input.researchMap?.similarAccounts ?? []) .slice(0, 8) .map((a) => `@${a.username}`) .join("、"); const researchBlock = input.researchMap ? ` 研究地圖: - 受眾:${input.researchMap.audienceSummary} - 目標:${input.researchMap.contentGoal} - 想了解的問題:${input.researchMap.questions.join("、")} - 內容支柱:${input.researchMap.pillars.join("、")} - 排除:${input.researchMap.exclusions.join("、")} ${similarAccounts ? `- 相似帳號(同領域標竿,其貼文通常更值得參考):${similarAccounts}` : ""} ` : ""; const postsBlock = input.posts .map((post, i) => { const replyLines = (post.replies ?? []).filter(Boolean).slice(0, 2); const replyBlock = replyLines.length > 0 ? `\n熱門留言(觀眾真實反應):${replyLines.map((r) => `「${r.slice(0, 60)}」`).join(" ")}` : ""; return `[${i}] 標籤:${post.searchTag ?? input.query} | @${post.authorName ?? "匿名"} | ${post.likeCount ?? 0}讚 ${post.replyCount ?? 0}留言 ${post.text.slice(0, 280)}${replyBlock}`; }) .join("\n\n"); const object = await generateStructuredObject({ model, provider: input.aiProvider, modelId: input.aiModel, schema: filterSchema, system: withAgentSystem(`你是 Threads 內容品質審核員。評估每篇貼文是否值得改寫成「新型小語」——觀眾想看的、盡量正確、有故事感的一兩句話。 評分標準: - relevanceScore 0-100:與主題、受眾需求的相關程度(嚴格扣緊種子關鍵字,略偏題即降分) - 語錄型、故事感、情緒共鳴、一兩句說中人心 → 提高 relevanceScore - qualityTier: - GOOD:有資訊或故事、結構清楚、值得參考改寫成新型小語 - OK:角度可參考但內容薄弱、略偏題、或太長不像小語 - EXCLUDE:業配、偏方、純宣洩無觀點、無關、低質量 - 若有提供「熱門留言」,代表觀眾真實反應:留言越能呼應、討論越熱烈、共鳴越強 → 適度提高 relevanceScore;留言冷淡、跑題或反感 → 降低分數。沒有提供留言時,僅依貼文內容與讚數判斷即可,不要因為缺留言而扣分。 - 帳號只是來源,不是相關性的保證。searchTag 為 @帳號時仍須逐篇審核;個人抒發、生活閒聊、家庭日常、政治或其他主題,即使作者是相似帳號也一律 EXCLUDE。 - 來自 @帳號的貼文,只有內容本身明確命中主題與受眾問題才能給 GOOD/OK;不得因作者是網紅或相似帳號而加分。 請為每篇貼文(用 index 對應)給出評分。qualityReason 用台灣用語繁體中文。`), prompt: `主題:${input.topicLabel} 種子關鍵字:${input.query} ${input.brief ? `Brief:${input.brief}` : ""} ${buildTopicAnchorPromptBlock({ label: input.topicLabel, query: input.query, brief: input.brief, exclusions: input.researchMap?.exclusions, })} ${researchBlock} 待評估貼文: ${postsBlock}`, }); return object.items .filter((item) => item.index >= 0 && item.index < input.posts.length) .map((item) => ({ id: input.posts[item.index].id, relevanceScore: item.relevanceScore, qualityTier: item.qualityTier as QualityTier, qualityReason: item.qualityReason, })); } export function engagementNormForPost(likeCount?: number | null, replyCount?: number | null): number { return normalizeEngagement(likeCount, replyCount); }