175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
import { z } from "zod";
|
||
import { withAgentSystem } from "./agent";
|
||
import type { ProviderApiKeys } from "./keys";
|
||
import { generateStructuredObject } from "./generate-structured";
|
||
import { getModel } from "./provider";
|
||
import {
|
||
getProviderLabel,
|
||
isBraveSearchConfigured,
|
||
searchWebThorough,
|
||
type SearchProvider,
|
||
} from "@/lib/services/web-search";
|
||
|
||
const classifySchema = z.object({
|
||
isKnowledgeContent: z.boolean(),
|
||
reason: z.string().optional().default(""),
|
||
claims: z.array(z.string()).max(5).default([]),
|
||
});
|
||
|
||
const verdictSchema = z.object({
|
||
passed: z.boolean(),
|
||
summary: z.string(),
|
||
issues: z.array(z.string()),
|
||
verifiedPoints: z.array(z.string()),
|
||
sources: z.array(
|
||
z.object({
|
||
title: z.string(),
|
||
link: z.string(),
|
||
})
|
||
),
|
||
});
|
||
|
||
export interface FactCheckResult {
|
||
isKnowledgeContent: boolean;
|
||
passed: boolean;
|
||
summary: string;
|
||
issues: string[];
|
||
verifiedPoints: string[];
|
||
sources: Array<{ title: string; link: string }>;
|
||
searchProvider: SearchProvider | "none";
|
||
searchProviderLabel: string;
|
||
skipped?: boolean;
|
||
skipReason?: string;
|
||
}
|
||
|
||
export async function factCheckDraft(params: {
|
||
text: string;
|
||
angle?: string | null;
|
||
searchTag?: string | null;
|
||
topicLabel?: string | null;
|
||
aiProvider: string;
|
||
aiModel: string;
|
||
apiKeys?: ProviderApiKeys;
|
||
}): Promise<FactCheckResult> {
|
||
const model = getModel(params.aiProvider, params.aiModel, params.apiKeys ?? {});
|
||
|
||
const classified = await generateStructuredObject({
|
||
model,
|
||
provider: params.aiProvider,
|
||
modelId: params.aiModel,
|
||
schema: classifySchema,
|
||
system: withAgentSystem(
|
||
"判斷 Threads 貼文是否屬於「知識型」內容(含健康、醫療、營養、數據、科學、法規、步驟教學等可被查證的陳述)。"
|
||
),
|
||
prompt: `貼文:
|
||
${params.text}
|
||
|
||
${params.angle ? `角度:${params.angle}` : ""}
|
||
${params.searchTag ? `標籤:${params.searchTag}` : ""}
|
||
${params.topicLabel ? `主題:${params.topicLabel}` : ""}
|
||
|
||
若主要是語錄、心情、故事、觀點抒發且無具體可驗證事實 → isKnowledgeContent=false。
|
||
若是知識型,列出 1~5 個需要網路搜尋查證的關鍵陳述(claims)。`,
|
||
});
|
||
|
||
if (!classified.isKnowledgeContent) {
|
||
return {
|
||
isKnowledgeContent: false,
|
||
passed: true,
|
||
summary: classified.reason || "非知識型內容,無需查證即可發布。",
|
||
issues: [],
|
||
verifiedPoints: [],
|
||
sources: [],
|
||
searchProvider: "none",
|
||
searchProviderLabel: "略過",
|
||
skipped: true,
|
||
skipReason: classified.reason,
|
||
};
|
||
}
|
||
|
||
const claims = classified.claims.filter(Boolean).slice(0, 5);
|
||
if (claims.length === 0) {
|
||
return {
|
||
isKnowledgeContent: true,
|
||
passed: false,
|
||
summary: "判定為知識型內容,但無法提取可查證陳述,請改寫得更具體或人工確認後再發。",
|
||
issues: ["無法提取可查證的關鍵陳述"],
|
||
verifiedPoints: [],
|
||
sources: [],
|
||
searchProvider: "none",
|
||
searchProviderLabel: "無",
|
||
};
|
||
}
|
||
|
||
const searchBlocks: string[] = [];
|
||
let primaryProvider: SearchProvider = "brave";
|
||
let providerLabel = isBraveSearchConfigured() ? "Brave Search" : "未設定";
|
||
const allResults: Array<{ title: string; link: string; snippet: string }> = [];
|
||
|
||
for (const claim of claims) {
|
||
const query = `${claim} ${params.topicLabel ?? ""}`.trim();
|
||
const { results, provider, providerLabel: label } = await searchWebThorough(query, 5, {
|
||
patrolMode: false,
|
||
threadsOnly: false,
|
||
});
|
||
primaryProvider = provider;
|
||
providerLabel = label;
|
||
for (const r of results) {
|
||
allResults.push({ title: r.title, link: r.link, snippet: r.snippet });
|
||
searchBlocks.push(
|
||
`【查詢:${query}】(${getProviderLabel(r.provider)})\n- ${r.title}\n ${r.snippet}\n ${r.link}`
|
||
);
|
||
}
|
||
}
|
||
|
||
if (allResults.length === 0) {
|
||
return {
|
||
isKnowledgeContent: true,
|
||
passed: false,
|
||
summary:
|
||
"網路搜尋暫無結果,知識型內容暫不建議發布。請稍後重試或改寫成較好查證的表述。",
|
||
issues: ["查無可參考來源(請設定 BRAVE_SEARCH_API_KEY 或稍後重試)"],
|
||
verifiedPoints: [],
|
||
sources: [],
|
||
searchProvider: primaryProvider,
|
||
searchProviderLabel: providerLabel,
|
||
};
|
||
}
|
||
|
||
const verdict = await generateStructuredObject({
|
||
model,
|
||
provider: params.aiProvider,
|
||
modelId: params.aiModel,
|
||
schema: verdictSchema,
|
||
system: withAgentSystem(`你是事實查核編輯。知識型 Threads 貼文發布前,必須根據網路搜尋結果判斷是否可發。
|
||
|
||
規則:
|
||
- 只有搜尋結果能支持或合理支持的核心陳述才算通過
|
||
- 醫療健康:保守判斷,有疑慮就 passed=false
|
||
- issues 列出具體哪句話有問題、為什麼
|
||
- verifiedPoints 列出已獲搜尋結果支持的陳述
|
||
- sources 從搜尋結果選 2~5 個最相關連結
|
||
- 繁體中文台灣用語`),
|
||
prompt: `待發布貼文:
|
||
${params.text}
|
||
|
||
需查證陳述:
|
||
${claims.map((c, i) => `${i + 1}. ${c}`).join("\n")}
|
||
|
||
網路搜尋結果(來源:${providerLabel}):
|
||
${searchBlocks.join("\n\n")}
|
||
|
||
請判斷是否可發布。`,
|
||
});
|
||
|
||
return {
|
||
isKnowledgeContent: true,
|
||
passed: verdict.passed,
|
||
summary: verdict.summary,
|
||
issues: verdict.issues,
|
||
verifiedPoints: verdict.verifiedPoints,
|
||
sources: verdict.sources,
|
||
searchProvider: primaryProvider,
|
||
searchProviderLabel: providerLabel,
|
||
};
|
||
} |