haixunMaster/lib/ai/filter-quality.ts

141 lines
5.4 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
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);
}