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