haixunMaster/lib/ai/fact-check.ts

175 lines
5.4 KiB
TypeScript
Raw Permalink Normal View History

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