/**
* Fact-Check Verification Pipeline
*
* Uses Claude API with tool calling to verify claims against
* parliamentary data sources.
*/
import Anthropic from '@anthropic-ai/sdk';
import type {
FactCheck,
FactCheckCitation,
FactCheckVerdict,
VerificationMode,
} from './types';
import { hashClaimText } from './types';
import { getPrompt, DEFAULT_PROMPTS } from '@/lib/prompts/promptConfig';
// Timeouts for different modes
const TIMEOUT_FAST = 4000; // 4 seconds
const TIMEOUT_DEEP = 15000; // 15 seconds
// Default model for verification
const VERIFICATION_MODEL = 'claude-sonnet-4-20250514';
/**
* Tool definitions for the verification agent
*/
const VERIFICATION_TOOLS: Anthropic.Tool[] = [
{
name: 'search_hansard',
description:
'Search House of Commons debates (Hansard) for specific quotes, topics, or MP statements. Returns matching speeches with dates, speakers, and context.',
input_schema: {
type: 'object' as const,
properties: {
query: {
type: 'string',
description: 'Search query - keywords, phrases, or exact quotes',
},
mpName: {
type: 'string',
description: 'Filter by MP name (optional)',
},
startDate: {
type: 'string',
description: 'Start date in YYYY-MM-DD format (optional)',
},
endDate: {
type: 'string',
description: 'End date in YYYY-MM-DD format (optional)',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 10)',
},
},
required: ['query'],
},
},
{
name: 'search_votes',
description:
'Search parliamentary votes to verify how MPs voted on specific bills or motions. Returns vote results and individual MP positions.',
input_schema: {
type: 'object' as const,
properties: {
billNumber: {
type: 'string',
description: 'Bill number (e.g., "C-47") to find votes on',
},
mpName: {
type: 'string',
description: 'MP name to check voting record',
},
startDate: {
type: 'string',
description: 'Start date in YYYY-MM-DD format (optional)',
},
endDate: {
type: 'string',
description: 'End date in YYYY-MM-DD format (optional)',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 10)',
},
},
required: [],
},
},
{
name: 'search_bills',
description:
'Search for bills by number, title, or keywords. Returns bill details including status, sponsor, and votes.',
input_schema: {
type: 'object' as const,
properties: {
query: {
type: 'string',
description: 'Bill number (e.g., "C-47") or keywords to search',
},
status: {
type: 'string',
description: 'Filter by status (e.g., "Royal Assent", "First Reading")',
},
session: {
type: 'string',
description: 'Parliamentary session (e.g., "44-1")',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 10)',
},
},
required: ['query'],
},
},
{
name: 'search_contracts',
description:
'Search federal government contracts by vendor, department, or amount. Use to verify spending claims.',
input_schema: {
type: 'object' as const,
properties: {
vendorName: {
type: 'string',
description: 'Vendor/company name to search',
},
department: {
type: 'string',
description: 'Government department',
},
year: {
type: 'number',
description: 'Contract year',
},
minAmount: {
type: 'number',
description: 'Minimum contract amount',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 10)',
},
},
required: [],
},
},
{
name: 'search_grants',
description:
'Search federal grants and contributions by recipient, program, or amount.',
input_schema: {
type: 'object' as const,
properties: {
recipientName: {
type: 'string',
description: 'Grant recipient name',
},
programName: {
type: 'string',
description: 'Grant program name',
},
department: {
type: 'string',
description: 'Issuing department',
},
year: {
type: 'number',
description: 'Grant year',
},
limit: {
type: 'number',
description: 'Maximum results to return (default: 10)',
},
},
required: [],
},
},
{
name: 'get_mp_info',
description:
'Get detailed information about an MP including party, riding, cabinet position, and recent activity.',
input_schema: {
type: 'object' as const,
properties: {
mpName: {
type: 'string',
description: 'MP name or ID to look up',
},
},
required: ['mpName'],
},
},
];
/**
* Get the system prompt for fact-checking verification
* Loaded dynamically from database (with fallback to default)
*/
async function getVerificationSystemPrompt(): Promise<string> {
return getPrompt('factcheck_system');
}
/**
* Execute a verification tool call via GraphQL
*/
async function executeVerificationTool(
toolName: string,
input: Record<string, unknown>,
graphqlEndpoint: string
): Promise<unknown> {
// Build GraphQL query based on tool
let query: string;
let variables: Record<string, unknown>;
switch (toolName) {
case 'search_hansard':
query = `
query SearchHansard($query: String!, $limit: Int) {
searchHansard(query: $query, limit: $limit) {
id
who_en
content_en
h1_en
h2_en
partOf {
date
document_type
}
madeBy {
id
name
party
}
}
}
`;
variables = {
query: input.query,
limit: input.limit || 10,
};
break;
case 'search_votes':
query = `
query SearchVotes($mpId: ID, $limit: Int) {
mps(where: { name_CONTAINS: "${input.mpName || ''}" }, options: { limit: 1 }) {
id
name
party
votedConnection(first: ${input.limit || 10}) {
edges {
properties {
position
}
node {
id
number
date
result
description
bill_number
}
}
}
}
}
`;
variables = {};
break;
case 'search_bills':
query = `
query SearchBills($searchTerm: String, $status: String, $session: String, $limit: Int) {
searchBills(searchTerm: $searchTerm, status: $status, session: $session, limit: $limit) {
number
session
title
status
bill_type
introduced_date
sponsor {
name
party
}
}
}
`;
variables = {
searchTerm: input.query,
status: input.status,
session: input.session,
limit: input.limit || 10,
};
break;
case 'search_contracts':
query = `
query SearchContracts($vendorName: String, $department: String, $year: Int, $minAmount: Float, $limit: Int) {
contracts(
where: {
vendor_CONTAINS: $vendorName
department_CONTAINS: $department
year: $year
amount_GTE: $minAmount
}
options: { limit: $limit, sort: [{ amount: DESC }] }
) {
vendor
amount
department
date
description
}
}
`;
variables = {
vendorName: input.vendorName,
department: input.department,
year: input.year,
minAmount: input.minAmount,
limit: input.limit || 10,
};
break;
case 'search_grants':
query = `
query SearchGrants($recipientName: String, $programName: String, $department: String, $year: Int, $limit: Int) {
grants(
where: {
recipient_CONTAINS: $recipientName
program_name_CONTAINS: $programName
department_CONTAINS: $department
year: $year
}
options: { limit: $limit, sort: [{ amount: DESC }] }
) {
recipient
amount
program_name
department
date
}
}
`;
variables = {
recipientName: input.recipientName,
programName: input.programName,
department: input.department,
year: input.year,
limit: input.limit || 10,
};
break;
case 'get_mp_info':
query = `
query GetMP($name: String!) {
mps(where: { name_CONTAINS: $name }, options: { limit: 1 }) {
id
name
party
riding
current
cabinet_position
elected_date
}
}
`;
variables = { name: input.mpName };
break;
default:
throw new Error(`Unknown tool: ${toolName}`);
}
// Execute GraphQL query with API key authentication
const apiKey = process.env.NEXT_PUBLIC_GRAPHQL_API_KEY;
const response = await fetch(graphqlEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { 'X-API-Key': apiKey } : {}),
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`GraphQL request failed: ${response.statusText}`);
}
const result = await response.json();
return result.data;
}
/**
* Parse the verification result from Claude's response
*/
function parseVerificationResult(content: string): {
verdict: FactCheckVerdict;
confidence: number;
rationale: string;
rationale_short: string;
citations: FactCheckCitation[];
} {
// Try to extract JSON from the response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No JSON found in response');
}
const parsed = JSON.parse(jsonMatch[0]);
// Validate verdict
const validVerdicts: FactCheckVerdict[] = [
'TRUE',
'FALSE',
'MISLEADING',
'NEEDS_CONTEXT',
'UNVERIFIABLE',
];
if (!validVerdicts.includes(parsed.verdict)) {
throw new Error(`Invalid verdict: ${parsed.verdict}`);
}
return {
verdict: parsed.verdict,
confidence: Math.max(0, Math.min(1, parsed.confidence || 0.5)),
rationale: parsed.rationale || 'No rationale provided',
rationale_short: parsed.rationale_short || parsed.rationale?.substring(0, 100) || '',
citations: Array.isArray(parsed.citations) ? parsed.citations : [],
};
}
/**
* Verify a claim using Claude API with tool calling
*/
export async function verifyClaim(
claimText: string,
mode: VerificationMode = 'fast',
graphqlEndpoint: string
): Promise<Omit<FactCheck, 'id' | 'created_at' | 'source_statement_id'>> {
const startTime = Date.now();
const timeout = mode === 'fast' ? TIMEOUT_FAST : TIMEOUT_DEEP;
// Fetch system prompt dynamically (allows tuning without redeployment)
const systemPrompt = await getVerificationSystemPrompt();
// Initialize Anthropic client
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
// Generate claim hash
const claimHash = await hashClaimText(claimText);
// Initial message to Claude
const messages: Anthropic.MessageParam[] = [
{
role: 'user',
content: `Please fact-check the following claim about Canadian politics:
"${claimText}"
Use the available tools to gather evidence, then provide your verdict in JSON format.`,
},
];
// Run conversation with tool use
let response = await anthropic.messages.create({
model: VERIFICATION_MODEL,
max_tokens: 2048,
system: systemPrompt,
tools: VERIFICATION_TOOLS,
messages,
});
// Handle tool use loop
while (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter(
(block): block is Anthropic.ToolUseBlock => block.type === 'tool_use'
);
// Execute all tool calls
const toolResults: Anthropic.ToolResultBlockParam[] = await Promise.all(
toolUseBlocks.map(async (toolUse) => {
try {
const result = await executeVerificationTool(
toolUse.name,
toolUse.input as Record<string, unknown>,
graphqlEndpoint
);
return {
type: 'tool_result' as const,
tool_use_id: toolUse.id,
content: JSON.stringify(result, null, 2),
};
} catch (error) {
return {
type: 'tool_result' as const,
tool_use_id: toolUse.id,
content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
is_error: true,
};
}
})
);
// Add assistant response and tool results to messages
messages.push({
role: 'assistant',
content: response.content,
});
messages.push({
role: 'user',
content: toolResults,
});
// Continue conversation
response = await anthropic.messages.create({
model: VERIFICATION_MODEL,
max_tokens: 2048,
system: systemPrompt,
tools: VERIFICATION_TOOLS,
messages,
});
}
// Extract final text response
const textBlocks = response.content.filter(
(block): block is Anthropic.TextBlock => block.type === 'text'
);
const finalResponse = textBlocks.map((b) => b.text).join('\n');
// Parse the result
const result = parseVerificationResult(finalResponse);
const processingTime = Date.now() - startTime;
return {
claim_text: claimText,
claim_text_hash: claimHash,
verdict: result.verdict,
confidence: result.confidence,
rationale: result.rationale,
rationale_short: result.rationale_short,
citations: result.citations,
model_used: VERIFICATION_MODEL,
processing_time_ms: processingTime,
verification_mode: mode,
checked_at: new Date().toISOString(),
};
} catch (error) {
clearTimeout(timeoutId);
// Handle timeout
if (error instanceof Error && error.name === 'AbortError') {
const claimHash = await hashClaimText(claimText);
return {
claim_text: claimText,
claim_text_hash: claimHash,
verdict: 'UNVERIFIABLE',
confidence: 0,
rationale: `Verification timed out after ${timeout}ms. The claim could not be verified within the time limit.`,
rationale_short: 'Verification timed out',
citations: [],
model_used: VERIFICATION_MODEL,
processing_time_ms: timeout,
verification_mode: mode,
checked_at: new Date().toISOString(),
};
}
// Handle other errors
const claimHash = await hashClaimText(claimText);
return {
claim_text: claimText,
claim_text_hash: claimHash,
verdict: 'UNVERIFIABLE',
confidence: 0,
rationale: `Verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
rationale_short: 'Verification failed',
citations: [],
model_used: VERIFICATION_MODEL,
processing_time_ms: Date.now() - startTime,
verification_mode: mode,
checked_at: new Date().toISOString(),
};
} finally {
clearTimeout(timeoutId);
}
}
/**
* Generate a unique ID for a new fact-check
*/
export function generateFactCheckId(): string {
// Generate a UUID-like ID without external dependencies
const timestamp = Date.now().toString(36);
const randomPart = Math.random().toString(36).substring(2, 15);
const randomPart2 = Math.random().toString(36).substring(2, 15);
return `fc-${timestamp}-${randomPart}${randomPart2}`;
}