110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
import { z } from "zod";
|
||
import type { ProviderApiKeys } from "./keys";
|
||
import { generateStructuredObject } from "./generate-structured";
|
||
import { getModel } from "./provider";
|
||
|
||
const filterSchema = z.object({
|
||
items: z.array(
|
||
z.object({
|
||
id: z.string(),
|
||
relevant: z.boolean(),
|
||
score: z.number().min(0).max(1),
|
||
reason: z.string(),
|
||
})
|
||
),
|
||
});
|
||
|
||
export interface DiscoverFilterItem {
|
||
id: string;
|
||
text: string;
|
||
username?: string;
|
||
source: string;
|
||
tags?: string[];
|
||
}
|
||
|
||
export interface DiscoverFilterResult {
|
||
relevant: boolean;
|
||
score: number;
|
||
reason: string;
|
||
}
|
||
|
||
export async function filterDiscoverItemsWithAi(input: {
|
||
label: string;
|
||
query: string;
|
||
brief?: string | null;
|
||
exclusions?: string[];
|
||
pillars?: string[];
|
||
requiredConcepts?: string[];
|
||
items: DiscoverFilterItem[];
|
||
aiProvider: string;
|
||
aiModel: string;
|
||
apiKeys?: ProviderApiKeys;
|
||
}): Promise<Map<string, DiscoverFilterResult>> {
|
||
const fallback = new Map<string, DiscoverFilterResult>();
|
||
for (const item of input.items) {
|
||
fallback.set(item.id, { relevant: true, score: 0.55, reason: "規則通過(AI 未審核)" });
|
||
}
|
||
|
||
if (input.items.length === 0) return fallback;
|
||
|
||
try {
|
||
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
|
||
const listBlock = input.items
|
||
.map((item) => {
|
||
const tags = item.tags?.length ? `\n標籤:${item.tags.join("、")}` : "";
|
||
const user = item.username ? `\n帳號:@${item.username}` : "";
|
||
return `[${item.id}](來源:${item.source})${user}${tags}\n內容:${item.text.slice(0, 280)}`;
|
||
})
|
||
.join("\n\n");
|
||
|
||
const result = await generateStructuredObject({
|
||
model,
|
||
provider: input.aiProvider,
|
||
modelId: input.aiModel,
|
||
schema: filterSchema,
|
||
system: `你是 Threads 主題研究助理。任務:判斷搜尋結果是否與「指定主題」真正相關,用於找相似創作者帳號。
|
||
|
||
審核要嚴格、扣緊主題:
|
||
- 複合主題要同時符合核心概念,不能只命中其中一個字
|
||
- 易混淆要剔除:例如「寵物洗毛精」vs「洗碗精」、「寵物」vs「人類掉髮/洗髮」
|
||
- 太寬泛的泛用詞(只有「寵物」「洗毛精」其中一項而無法確認是目標情境)→ relevant=false
|
||
- 明顯不同產品類別、不同受眾、不同痛點 → relevant=false
|
||
- 只有稍微相關但無法當相似帳號參考 → score ≤ 0.4
|
||
|
||
每筆都要輸出 id、relevant、score(0-1)、reason(繁體中文一句話)。`,
|
||
prompt: `【主題】${input.label}
|
||
【核心查詢】${input.query}
|
||
${input.brief ? `【受眾簡述】${input.brief}` : ""}
|
||
${input.pillars?.length ? `【內容支柱】${input.pillars.join("、")}` : ""}
|
||
${input.requiredConcepts?.length ? `【必備概念(需同時符合)】${input.requiredConcepts.join(" + ")}` : ""}
|
||
${input.exclusions?.length ? `【明確排除】${input.exclusions.join("、")}` : ""}
|
||
|
||
請審核以下 ${input.items.length} 筆搜尋結果:
|
||
|
||
${listBlock}`,
|
||
jsonPromptSuffix:
|
||
'\n\n只回傳 JSON:{"items":[{"id":"...","relevant":true,"score":0.8,"reason":"..."}]}',
|
||
});
|
||
|
||
const mapped = new Map<string, DiscoverFilterResult>();
|
||
for (const row of result.items) {
|
||
mapped.set(row.id, {
|
||
relevant: row.relevant,
|
||
score: row.score,
|
||
reason: row.reason.slice(0, 120),
|
||
});
|
||
}
|
||
|
||
for (const item of input.items) {
|
||
if (!mapped.has(item.id)) {
|
||
mapped.set(item.id, { relevant: false, score: 0, reason: "AI 未回傳審核結果" });
|
||
}
|
||
}
|
||
|
||
return mapped;
|
||
} catch (error) {
|
||
console.warn("[filter-discover-relevance] AI filter failed, using rules only:", error);
|
||
return fallback;
|
||
}
|
||
}
|