haixunMaster/lib/ai/filter-placement.ts

138 lines
5.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<QualityAssessment[]> {
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,
}));
}