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,
|
|||
|
|
};
|
|||
|
|
}
|