/**
* 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';
// 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'],
},
},
];
/**
* System prompt for the verification agent
*/
const VERIFICATION_SYSTEM_PROMPT = `You are a fact-checker for Canadian political claims. Your job is to verify claims made about Canadian politics, MPs, legislation, and government spending using official parliamentary data.
CRITICAL INSTRUCTIONS:
1. Only make claims you can directly support with evidence from the tools
2. If you cannot find sufficient evidence, mark the claim as UNVERIFIABLE
3. Consider partial truths as MISLEADING, not FALSE
4. Context matters - claims that are technically true but misleading should be marked MISLEADING
5. Always cite specific sources (dates, documents, vote numbers) in your rationale
VERDICT OPTIONS:
- TRUE: The claim is accurate and supported by evidence
- FALSE: The claim is demonstrably incorrect
- MISLEADING: The claim contains some truth but is presented in a misleading way
- NEEDS_CONTEXT: The claim is incomplete without additional context
- UNVERIFIABLE: Cannot be verified with available data
After gathering evidence, respond with a JSON object in this exact format:
{
"verdict": "TRUE" | "FALSE" | "MISLEADING" | "NEEDS_CONTEXT" | "UNVERIFIABLE",
"confidence": 0.0-1.0,
"rationale": "Detailed explanation with evidence",
"rationale_short": "One sentence summary (~100 chars)",
"citations": [
{
"url": "internal url or reference",
"title": "source title",
"excerpt": "relevant quote or data",
"source_type": "hansard" | "vote" | "bill" | "contract" | "grant" | "mp"
}
]
}`;
/**
* 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;
// 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: VERIFICATION_SYSTEM_PROMPT,
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: VERIFICATION_SYSTEM_PROMPT,
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}`;
}