141 lines
5.4 KiB
TypeScript
141 lines
5.4 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 { 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<QualityAssessment[]> {
|
||
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);
|
||
}
|