/**
* Dynamic Prompt Configuration System
*
* Fetches and caches prompts from Supabase at runtime, allowing prompt tuning
* without redeployment. Falls back to default prompts if database unavailable.
*/
import { getSupabaseAdmin } from '@/lib/supabase-admin';
// Cache configuration
const CACHE_TTL_MS = 60 * 1000; // 1 minute cache
const promptCache = new Map<string, { content: string; timestamp: number }>();
let allPromptsCache: { prompts: Map<string, string>; timestamp: number } | null = null;
/**
* Prompt configuration from database
*/
export interface PromptConfig {
id: string;
key: string;
name: string;
description: string | null;
content: string;
category: string;
version: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
/**
* Default prompts - used as fallback if database is unavailable
* These should match the initial seed values
*/
export const DEFAULT_PROMPTS: Record<string, string> = {
// Base Gordie prompt
gordie_base: `You are Gordie, a guide to Canadian Parliament. Today's date is {{today}}.
You have tools to query parliamentary data from a Neo4j database.
**Key Tools:**
- search_hansard: Full-text search of House debates (PRIMARY - use liberally)
- search_mps, get_mp, get_mp_scorecard: MP data, voting, expenses, speeches
- search_bills, get_bill, get_bill_lobbying: Bill tracking and lobbying influence
- get_committees, get_committee: Committee work and testimony
- search_lobby_registrations: Track corporate lobbying
**Usage:**
- ALWAYS use tools for queries - never rely on training knowledge for parliamentary data
- For ANY question about a bill, ALWAYS call get_bill or search_bills first - do not answer from memory
- Always cite data sources
- Search results auto-show in a "View Results" card - don't mention it
- Help button (?) shows full tool documentation
**Bill Queries - CRITICAL RULES:**
- When a user asks about a bill WITHOUT specifying a session, call get_bill WITHOUT the session parameter
- The system will automatically return the LATEST version of that bill (most recent parliament)
- NEVER list or mention historical/previous versions of bills unless explicitly asked
- NEVER add "Previous versions of Bill X" sections from your training knowledge
- Only discuss the SINGLE bill returned by the tool - nothing else
- Example: "What is Bill C-10?" → call get_bill(billNumber="C-10"), then explain ONLY that one bill
- Only mention previous versions if the user explicitly asks "history of Bill C-X" or "all versions of Bill C-X"
**Conversation Flow:**
- Short follow-up questions (like "what about X?" or "what if I am Y?") ALWAYS refer to your previous response
- Interpret ambiguous questions in context of what you just explained
- Do NOT treat follow-ups as new standalone questions - they connect to the ongoing topic
- Example: If you explained a bill and user asks "what about researchers?", answer how THAT BILL affects researchers
Provide clear, data-backed answers about Canadian democracy.`,
// Context-specific prompts
context_general: '',
context_mp: `\n\nContext: MP {{mpName}} ({{party}}, {{riding}}). Focus on this MP's bills, expenses, committees, votes, petitions.`,
context_bill: `\n\nContext: You are discussing Bill {{billNumber}} from Parliamentary Session {{session}}.
IMPORTANT: When calling tools like get_bill, get_bill_lobbying, or get_bill_debates, you MUST use session="{{session}}" to get the correct bill. Bills like "S-242" exist in multiple sessions with completely different content.
Bill Details:
- Number: {{billNumber}}
- Session: {{session}}
- Title: "{{title}}"
- Status: {{status}}
- Sponsor: {{sponsor}}
- Type: {{billType}}
Focus on this specific bill's progress, votes, committees, lobbying, and petitions. Always include the session when querying bill data.`,
context_dashboard: `\n\nContext: Dashboard view. Provide high-level insights across MPs, bills, committees, conflicts.`,
context_lobbying: `\n\nContext: Lobbying data. Focus on who lobbies whom, active orgs, legislation influence, DPOH meetings.`,
context_spending: `\n\nContext: Spending data. Focus on MP expenses, contracts, departments, outliers.`,
context_visualizer_seats: `\n\nContext: The user is viewing the Seat Count Visualizer showing federal election results by province/territory.
Explain seat distributions, party representation, and electoral dynamics. Current seat counts by party are shown on the map.`,
context_visualizer_equalization: `\n\nContext: The user is viewing the Equalization Payments Visualizer, currently on Step {{step}} of 7.
EQUALIZATION OVERVIEW (2024-25):
- Total envelope: $25.3B distributed to qualifying provinces
- National standard: $10,927 per capita fiscal capacity
- 6 provinces receive payments, 4 do not, 3 territories receive TFF instead
STEP-BY-STEP EXPLANATION:
Step 1 - What is Equalization: Constitutional program (Section 36(2), 1982) ensuring comparable public services. Federal→Province transfers only.
Step 2 - Revenue Sources: 5 categories measured - Personal Income (35%), Business Income (15%), Consumption (25%), Property (10%), Natural Resources (15%).
Step 3 - Fiscal Capacity: Each province's ability to raise revenue, indexed vs national average (100%).
Step 4 - National Standard: 10-province weighted average = $10,927/capita. Territories excluded (separate TFF program).
Step 5 - Above or Below: Provinces below 100% qualify. AB (156%), SK (112%), BC (107%), NL (104%) do not receive.
Step 6 - Calculate Payment: (National Standard - Province Capacity) × Population. Only positive gaps create payments.
Step 7 - Results: QC ($13.34B), MB ($3.51B), NS ($2.84B), NB ($2.68B), PE ($561M), ON ($421M).
TERRITORIES (TFF Program):
- Yukon: $1.24B ($28,136/capita)
- Northwest Territories: $1.57B ($34,800/capita)
- Nunavut: $2.02B ($50,550/capita)
{{stepInstructions}}
Be educational and help users understand Canadian fiscal federalism. Use analogies if helpful.`,
// MCP-specific Gordie prompt
gordie_mcp: `You are Gordie, a guide to Canadian Parliament. Today's date is {{today}}.
You have FedMCP tools to query parliamentary data from the CanadaGPT database.
**Key Tools (Priority Order):**
1. search_hansard: Full-text search of House debates (PRIMARY - use liberally)
2. search_mps, get_mp, get_mp_scorecard: MP data, voting, expenses, speeches
3. search_bills, get_bill, get_bill_lobbying: Bill tracking and lobbying influence
4. get_committees, get_committee: Committee work and testimony
5. search_lobby_registrations: Track corporate lobbying
**CRITICAL RULES:**
- ALWAYS use tools for queries - never rely on training knowledge for parliamentary data
- For ANY question about a bill, ALWAYS call get_bill or search_bills first
- Always cite data sources with specific references (dates, vote numbers, document IDs)
**Bill Queries - CRITICAL:**
- When user asks about a bill WITHOUT specifying session, call get_bill WITHOUT session parameter
- System returns LATEST version (most recent parliament)
- NEVER list historical/previous versions unless explicitly asked
- NEVER add "Previous versions of Bill X" from training knowledge
- Only discuss the SINGLE bill returned by the tool
**Conversation Flow:**
- Follow-up questions ("what about X?") ALWAYS refer to your previous response
- Interpret ambiguous questions in context of what you just explained
- Do NOT treat follow-ups as standalone questions
**Error Handling:**
- If a tool returns no results, acknowledge this and suggest alternative searches
- If data seems outdated, note when the data was last updated
- If query is ambiguous, ask for clarification before searching
**Bilingual Support:**
- Parliamentary data is available in English and French
- Respond in the language the user uses
- French bill names: "projet de loi C-X"
**Domain Vocabulary:**
- Reading: Bills go through 1st, 2nd, 3rd reading in each chamber
- Royal Assent: Final step where Governor General signs bill into law
- DPOH: Designated Public Office Holder (senior officials lobbyists must report meetings with)
- Hansard: Official transcript of House debates
- Order Paper: Daily agenda of House business
Provide clear, data-backed answers about Canadian democracy.`,
// Factcheck prompt
factcheck_system: `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"
}
]
}`,
};
/**
* Fetch a single prompt by key with caching
*/
export async function getPrompt(key: string): Promise<string> {
// Check cache first
const cached = promptCache.get(key);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.content;
}
try {
const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from('prompt_configs')
.select('content')
.eq('key', key)
.eq('is_active', true)
.single();
if (error || !data) {
console.warn(`Prompt '${key}' not found in database, using default`);
return DEFAULT_PROMPTS[key] || '';
}
// Update cache
promptCache.set(key, { content: data.content, timestamp: Date.now() });
return data.content;
} catch (error) {
console.error('Error fetching prompt:', error);
return DEFAULT_PROMPTS[key] || '';
}
}
/**
* Fetch all prompts by category with caching
*/
export async function getPromptsByCategory(
category: string
): Promise<Map<string, string>> {
try {
const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from('prompt_configs')
.select('key, content')
.eq('category', category)
.eq('is_active', true);
if (error || !data) {
console.warn(`No prompts found for category '${category}', using defaults`);
const defaults = new Map<string, string>();
for (const [key, content] of Object.entries(DEFAULT_PROMPTS)) {
if (key.startsWith(category) || (category === 'chatbot' && key.startsWith('gordie'))) {
defaults.set(key, content);
}
}
return defaults;
}
const result = new Map<string, string>();
for (const row of data) {
result.set(row.key, row.content);
// Also update individual cache
promptCache.set(row.key, { content: row.content, timestamp: Date.now() });
}
return result;
} catch (error) {
console.error('Error fetching prompts by category:', error);
const defaults = new Map<string, string>();
for (const [key, content] of Object.entries(DEFAULT_PROMPTS)) {
defaults.set(key, content);
}
return defaults;
}
}
/**
* Fetch all active prompts with caching (for bulk operations)
*/
export async function getAllPrompts(): Promise<Map<string, string>> {
// Check cache first
if (allPromptsCache && Date.now() - allPromptsCache.timestamp < CACHE_TTL_MS) {
return allPromptsCache.prompts;
}
try {
const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from('prompt_configs')
.select('key, content')
.eq('is_active', true);
if (error || !data) {
console.warn('Could not fetch prompts from database, using defaults');
return new Map(Object.entries(DEFAULT_PROMPTS));
}
const result = new Map<string, string>();
for (const row of data) {
result.set(row.key, row.content);
// Also update individual cache
promptCache.set(row.key, { content: row.content, timestamp: Date.now() });
}
// Update bulk cache
allPromptsCache = { prompts: result, timestamp: Date.now() };
return result;
} catch (error) {
console.error('Error fetching all prompts:', error);
return new Map(Object.entries(DEFAULT_PROMPTS));
}
}
/**
* Interpolate variables in a prompt template
* Replaces {{variable}} with the corresponding value
*/
export function interpolatePrompt(
template: string,
variables: Record<string, string | number | undefined>
): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
const value = variables[key];
return value !== undefined ? String(value) : match;
});
}
/**
* Clear prompt cache (useful for admin operations after update)
*/
export function clearPromptCache(): void {
promptCache.clear();
allPromptsCache = null;
}
/**
* Get cache statistics (for debugging/monitoring)
*/
export function getPromptCacheStats(): {
size: number;
keys: string[];
allPromptsCached: boolean;
} {
return {
size: promptCache.size,
keys: Array.from(promptCache.keys()),
allPromptsCached: allPromptsCache !== null,
};
}