haixunMaster/lib/ai/filter-placement.ts

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