138 lines
5.7 KiB
TypeScript
138 lines
5.7 KiB
TypeScript
|
|
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,
|
|||
|
|
}));
|
|||
|
|
}
|