haixunMaster/lib/ai/filter-quality.ts

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