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 { formatProductContextForPrompt } from "@/lib/types/product-context"; import { buildContentBandPromptBlock } from "@/lib/research-content-band"; import { buildTopicAnchorPromptBlock } from "@/lib/topic-anchor"; import { formatPlacementTimingHint, formatPostAgeLabel, PLACEMENT_IDEAL_MAX_AGE_DAYS, PLACEMENT_MAX_POST_AGE_DAYS, } from "@/lib/scan-recency"; const filterSchema = z.object({ items: z.array( z.object({ index: z.number().int().min(0), placementScore: z.number().min(0).max(100), qualityTier: z.enum(["GOOD", "OK", "EXCLUDE"]), placementReason: z.string(), }) ), }); export interface FilterPlacementInput { topicLabel: string; query: string; brief?: string | null; productContext?: string | null; researchMap?: ResearchMap | null; aiProvider: string; aiModel: string; apiKeys?: ProviderApiKeys; posts: Array<{ id: string; text: string; authorName?: string | null; searchTag?: string | null; postedAt?: Date | null; likeCount?: number | null; replyCount?: number | null; replies?: string[] | null; }>; } export async function filterPostPlacement(input: FilterPlacementInput): Promise { if (input.posts.length === 0) return []; const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {}); const researchBlock = input.researchMap ? ` 研究地圖: - 受眾:${input.researchMap.audienceSummary} - 目標:${input.researchMap.contentGoal} ` : ""; const contentBandBlock = buildContentBandPromptBlock(input.researchMap); 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} | 發文:${formatPostAgeLabel(post.postedAt)}(${formatPlacementTimingHint(post.postedAt)}) | @${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 產品置入機會審核員。核心任務:找出「作者正在煩產品怎麼選、現在留言還來得及、且能自然帶入產品」的貼文。 你的 TA 不是「有養寵物的人在閒聊」,而是「正在遇到困擾、會主動發文求助或求推薦的人」。 評分標準(需求明確度 > 置入自然度 > 時間窗口 > 互動熱度): - placementScore 0-100:綜合「需求明確度 × 置入自然度 × 時間窗口」 - **需求明確度(最重要)**: - GOOD 典型(以寵物洗毛精為例):在家幫狗洗澡、狗皮膚癢/過敏、不知道用什麼洗毛精、求推薦沐浴用品、第一次自己洗狗很怕 - 必須能看出「產品可解決的困擾」或「正在選購」;只有提到寵物但沒有困擾/選購訊號 → EXCLUDE - 純晒照、日常閒聊、萌寵廢文、心情分享、已完成購買的開箱、明顯業配 → 一律 EXCLUDE - 只沾邊「狗」「寵物」但沒有洗澡/皮膚/沐浴/洗毛精/選購相關 → EXCLUDE - **時間窗口**: - 今天~3 天內的求助/求推薦帖 → 大幅加分 - ${PLACEMENT_IDEAL_MAX_AGE_DAYS} 天內仍可考慮,但分數應低於 3 天內 - 超過 ${PLACEMENT_MAX_POST_AGE_DAYS} 天 → 一律 EXCLUDE - 發文日期未知 → 最高只能 OK,且 placementScore 不超過 55 - **置入自然度**: - 留言區若有人附和困擾、追問解法 → 加分;若已被業配/他牌洗版、問題已解決 → 降分或 EXCLUDE - **內容區間(硬性)**: - 貼文須呼應「受眾會問什麼」至少一項,或符合「內容支柱」至少一項 - 觸及「不要碰」任一項 → 一律 EXCLUDE - 有求助語氣但完全不在內容區間 → EXCLUDE - **主題相關**: - 嚴格剔除跑題:洗碗精≠洗毛精、人類洗髮≠寵物沐浴、不同產品類別 - qualityTier: - GOOD:近期 + 明確求助/痛點/求推薦 + 扣緊產品情境 + 留言區還能自然插入建議 - OK:相關且有困擾訊號,但需求不夠明確、或已超過 3 天 - EXCLUDE:閒聊、晒照、跑題、過舊、已推他牌、無法自然置入 placementReason 用台灣用語繁體中文,**第一句先說有無產品需求**,再說時間窗口與置入自然度。`), prompt: `主題:${input.topicLabel} 種子關鍵字:${input.query} ${input.brief ? `受眾 Brief:${input.brief}` : ""} ${input.productContext ? `品牌與產品:\n${formatProductContextForPrompt(input.productContext)}` : ""} ${buildTopicAnchorPromptBlock({ label: input.topicLabel, query: input.query, brief: input.brief, exclusions: input.researchMap?.exclusions, })} ${researchBlock} ${contentBandBlock} 待評估貼文: ${postsBlock}`, }); return object.items .filter((item) => item.index >= 0 && item.index < input.posts.length) .map((item) => ({ id: input.posts[item.index].id, relevanceScore: item.placementScore, placementScore: item.placementScore, qualityTier: item.qualityTier as QualityTier, qualityReason: item.placementReason, placementReason: item.placementReason, })); }