feat: add political focus group demo (#12)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Cameron
2026-01-30 11:42:20 -08:00
committed by GitHub
parent f245a2120b
commit 361954edae
5 changed files with 865 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
# Political Focus Group Simulator
Simulate a political focus group with persistent AI personas to test candidate messaging.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ FOCUS GROUP │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ CANDIDATE │ ──────────► Presents position │
│ │ │ ◄────────── Asks follow-up questions │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ VOTER 1 │ │ VOTER 2 │ ... (expandable) │
│ │ (Maria) │ │ (James) │ │
│ │ Independent │ │ Republican │ │
│ └──────────────┘ └──────────────┘ │
│ │ │ │
│ └────────┬─────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ ANALYST │ Observes and provides insights │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Quick Start
```bash
cd examples/focus-group
# Run on a specific topic
bun cli.ts "healthcare reform"
# Interactive mode (enter multiple topics)
bun cli.ts
# Check status
bun cli.ts --status
# Reset all agents
bun cli.ts --reset
```
## Flow
1. **Candidate presents** - Takes a topic and presents a specific position
2. **Voters react** - Each voter responds based on their persona
3. **Candidate follows up** - Asks a probing question based on reactions
4. **Voters respond** - Answer the follow-up question
5. **Analyst summarizes** - Provides insights and recommendations
## Default Personas
### Maria (Independent)
- 34 years old, Phoenix, AZ
- Nurse, mother of two
- Top issues: healthcare costs, education, immigration
- Weak party identification
### James (Republican)
- 58 years old, rural Ohio
- Former auto worker, small business owner
- Top issues: economy, manufacturing jobs, government spending
- Moderate party identification, skeptical of both parties
## Customizing Personas
Edit `agents.ts` and modify `SAMPLE_PERSONAS`:
```typescript
export const SAMPLE_PERSONAS: VoterPersona[] = [
{
name: 'Sarah',
age: 28,
location: 'Austin, TX',
party: 'Democrat',
leaningStrength: 'moderate',
topIssues: ['climate change', 'student debt', 'housing costs'],
background: 'Software engineer, renting, worried about buying a home.',
},
// Add more personas...
];
```
## Key Letta Features Demonstrated
1. **Agent Persistence** - All agents remember previous sessions
2. **Multi-Agent Coordination** - Candidate, voters, and analyst interact
3. **Persona Memory** - Each voter maintains their identity in memory blocks
4. **Streaming Responses** - See responses as they're generated
## Expanding to More Voters
The architecture supports any number of voters. To add more:
1. Add personas to `SAMPLE_PERSONAS` in `agents.ts`
2. The `FocusGroup` class automatically creates agents for each persona
3. Each new voter gets their own persistent agent
## Example Session
```
$ bun cli.ts "raising the minimum wage"
═══ CANDIDATE PRESENTS ═══
Candidate: I support raising the minimum wage to $15/hour over three years.
This gives businesses time to adjust while ensuring workers can afford basic
necessities. No one working full-time should live in poverty.
═══ VOTER REACTIONS ═══
Maria: That really resonates with me. As a nurse, I see patients who can't
afford medications because they're working two jobs just to pay rent. $15
feels like a starting point for dignity.
James: I'm torn. My employees deserve better pay, but I'm already struggling
with costs. Three years might help, but I worry about the businesses that
can't absorb it. What happens to them?
═══ FOLLOW-UP QUESTION ═══
Candidate: James, if there were tax credits or support for small businesses
during the transition, would that change how you feel about it?
═══ FOLLOW-UP RESPONSES ═══
Maria: That actually makes me more confident. If we're supporting workers
AND small businesses, that's the kind of balanced approach I can get behind.
James: Tax credits would help. I'm still skeptical, but at least you're
thinking about people like me. Most politicians forget we exist.
═══ ANALYST INSIGHTS ═══
Analyst: Key finding: the "three year transition" message softened James's
opposition. Maria is already supportive but responds to fairness framing.
James opened up when small business concerns were acknowledged directly -
quote: "at least you're thinking about people like me."
Recommendation: Lead with the transition timeline and pair minimum wage
with small business support. This creates permission for moderate
Republicans to consider the position.
```

View File

@@ -0,0 +1,196 @@
/**
* Focus Group Agents
*
* Creates and manages the three types of agents:
* 1. Candidate - presents positions, asks follow-ups
* 2. Voters - respond based on their persona
* 3. Analyst - provides focus group analysis
*/
import { createSession, resumeSession, type Session } from '../../src/index.js';
import { VoterPersona, CONFIG } from './types.js';
// ============================================================================
// CANDIDATE AGENT
// ============================================================================
const CANDIDATE_PROMPT = `You are a political candidate presenting your positions to a focus group.
Your role:
- Present clear, specific policy positions when asked
- Listen to voter feedback and ask thoughtful follow-up questions
- Stay focused on understanding voter concerns
- Be authentic but strategic
When presenting a position:
- Be specific about what you would do
- Explain the reasoning briefly
- Keep it to 2-3 sentences
When asking follow-ups:
- Probe deeper into concerns raised
- Ask about trade-offs they'd accept
- Keep questions focused and open-ended`;
export async function createCandidateAgent(): Promise<Session> {
return createSession({
model: CONFIG.model,
systemPrompt: CANDIDATE_PROMPT,
memory: [
{
label: 'positions',
value: '# My Positions\n\n(Positions I\'ve presented)',
description: 'Policy positions I have presented to focus groups',
},
{
label: 'voter-insights',
value: '# Voter Insights\n\n(What I\'ve learned about voter concerns)',
description: 'Insights gathered from voter feedback',
},
],
permissionMode: 'bypassPermissions',
});
}
export async function resumeCandidateAgent(agentId: string): Promise<Session> {
return resumeSession(agentId, {
model: CONFIG.model,
permissionMode: 'bypassPermissions',
});
}
// ============================================================================
// VOTER AGENT
// ============================================================================
function buildVoterPrompt(persona: VoterPersona): string {
const partyDesc = persona.leaningStrength === 'strong'
? `strongly identifies as ${persona.party}`
: persona.leaningStrength === 'moderate'
? `leans ${persona.party}`
: `weakly identifies as ${persona.party}`;
return `You are ${persona.name}, a ${persona.age}-year-old voter from ${persona.location}.
YOUR IDENTITY:
- You ${partyDesc}
- Your top issues: ${persona.topIssues.join(', ')}
- Background: ${persona.background}
YOUR ROLE IN THIS FOCUS GROUP:
- React authentically to political positions based on your persona
- Share how positions make you FEEL, not just what you think
- Be specific about what resonates or concerns you
- You can be persuaded but stay true to your core values
RESPONSE STYLE:
- Speak naturally, as yourself (first person)
- Keep responses to 2-4 sentences
- Show emotional reactions when appropriate
- Reference your personal situation when relevant`;
}
export async function createVoterAgent(persona: VoterPersona): Promise<Session> {
return createSession({
model: CONFIG.model,
systemPrompt: buildVoterPrompt(persona),
memory: [
{
label: 'my-identity',
value: `# Who I Am
Name: ${persona.name}
Age: ${persona.age}
Location: ${persona.location}
Party: ${persona.party} (${persona.leaningStrength})
Top Issues: ${persona.topIssues.join(', ')}
Background: ${persona.background}`,
description: 'My demographic information and political identity',
},
{
label: 'my-reactions',
value: '# My Reactions\n\n(How I\'ve felt about positions presented)',
description: 'My emotional reactions to political positions',
},
],
permissionMode: 'bypassPermissions',
});
}
export async function resumeVoterAgent(agentId: string): Promise<Session> {
return resumeSession(agentId, {
model: CONFIG.model,
permissionMode: 'bypassPermissions',
});
}
// ============================================================================
// ANALYST AGENT
// ============================================================================
const ANALYST_PROMPT = `You are a focus group analyst observing voter reactions to political messaging.
Your role:
- Observe voter responses and identify patterns
- Note which messages resonate and which fall flat
- Identify persuadable voters and potential wedge issues
- Provide actionable insights for the candidate
Analysis style:
- Be specific and cite voter quotes
- Identify emotional triggers
- Note differences between voter segments
- Keep analysis concise but substantive (4-6 sentences)
- End with 1-2 tactical recommendations`;
export async function createAnalystAgent(): Promise<Session> {
return createSession({
model: CONFIG.model,
systemPrompt: ANALYST_PROMPT,
memory: [
{
label: 'session-notes',
value: '# Focus Group Notes\n\n(Observations from sessions)',
description: 'Running notes on voter reactions and patterns',
},
{
label: 'recommendations',
value: '# Strategic Recommendations\n\n(Messaging recommendations)',
description: 'Tactical recommendations based on focus group insights',
},
],
permissionMode: 'bypassPermissions',
});
}
export async function resumeAnalystAgent(agentId: string): Promise<Session> {
return resumeSession(agentId, {
model: CONFIG.model,
permissionMode: 'bypassPermissions',
});
}
// ============================================================================
// SAMPLE PERSONAS
// ============================================================================
export const SAMPLE_PERSONAS: VoterPersona[] = [
{
name: 'Maria',
age: 34,
location: 'Phoenix, AZ',
party: 'Independent',
leaningStrength: 'weak',
topIssues: ['healthcare costs', 'education', 'immigration'],
background: 'Nurse and mother of two. Worried about affording childcare and her kids\' future.',
},
{
name: 'James',
age: 58,
location: 'Rural Ohio',
party: 'Republican',
leaningStrength: 'moderate',
topIssues: ['economy', 'manufacturing jobs', 'government spending'],
background: 'Former auto worker, now runs a small business. Skeptical of both parties.',
},
];

147
examples/focus-group/cli.ts Executable file
View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bun
/**
* Focus Group CLI
*
* Simulate a political focus group with AI personas.
*
* Usage:
* bun cli.ts "healthcare" # Run focus group on healthcare
* bun cli.ts # Interactive mode
* bun cli.ts --status # Show agent status
* bun cli.ts --reset # Reset all agents
*
* The focus group includes:
* - A candidate who presents positions
* - Voters with distinct personas who react
* - An analyst who provides insights
*
* Agents persist across sessions, so they remember previous discussions.
*/
import { parseArgs } from 'node:util';
import {
FocusGroup,
loadState,
resetFocusGroup,
showStatus,
} from './focus-group.js';
async function main() {
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
status: { type: 'boolean', default: false },
reset: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
allowPositionals: true,
});
if (values.help) {
printHelp();
return;
}
if (values.reset) {
await resetFocusGroup();
return;
}
if (values.status) {
await showStatus();
return;
}
// Load state and create focus group
const state = await loadState();
const focusGroup = new FocusGroup(state);
try {
await focusGroup.initialize();
if (positionals.length > 0) {
// Run on specified topic
const topic = positionals.join(' ');
await focusGroup.runRound(topic);
} else {
// Interactive mode
await interactiveMode(focusGroup);
}
} finally {
focusGroup.close();
}
}
async function interactiveMode(focusGroup: FocusGroup): Promise<void> {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = (prompt: string): Promise<string> => {
return new Promise((resolve) => {
rl.question(prompt, (answer) => resolve(answer));
});
};
console.log('\n📊 Focus Group - Interactive Mode');
console.log('Enter topics to test, or "quit" to exit.\n');
while (true) {
const topic = await ask('\x1b[1mTopic:\x1b[0m ');
if (topic.toLowerCase() === 'quit' || topic.toLowerCase() === 'exit') {
break;
}
if (!topic.trim()) continue;
await focusGroup.runRound(topic);
console.log('\n' + '─'.repeat(60) + '\n');
}
rl.close();
}
function printHelp() {
console.log(`
📊 Focus Group Simulator
Test political messaging against AI voter personas.
USAGE:
bun cli.ts [topic] Run focus group on a topic
bun cli.ts Interactive mode
bun cli.ts --status Show agent status
bun cli.ts --reset Reset all agents
bun cli.ts -h, --help Show this help
EXAMPLES:
bun cli.ts "healthcare reform"
bun cli.ts "immigration policy"
bun cli.ts "tax cuts vs government services"
HOW IT WORKS:
1. Candidate presents a position on the topic
2. Voters (Maria & James) react based on their personas
3. Candidate asks a follow-up question
4. Voters respond to the follow-up
5. Analyst provides insights and recommendations
VOTER PERSONAS:
Maria - 34yo Independent from Phoenix, AZ
Nurse, mom of two. Cares about healthcare, education.
James - 58yo moderate Republican from rural Ohio
Former auto worker, small business owner. Skeptical.
PERSISTENCE:
Agents remember previous sessions. Run multiple topics to see
how insights accumulate over time.
`);
}
main().catch(console.error);

View File

@@ -0,0 +1,329 @@
/**
* Focus Group Session
*
* Orchestrates the interaction between candidate, voters, and analyst.
*
* Flow:
* 1. Candidate presents a position
* 2. Each voter responds
* 3. Candidate asks a follow-up question
* 4. Each voter responds to the follow-up
* 5. Analyst provides summary and insights
*/
import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Session } from '../../src/index.js';
import {
FocusGroupState,
VoterPersona,
FocusGroupRound,
} from './types.js';
import {
createCandidateAgent,
resumeCandidateAgent,
createVoterAgent,
resumeVoterAgent,
createAnalystAgent,
resumeAnalystAgent,
SAMPLE_PERSONAS,
} from './agents.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STATE_FILE = join(__dirname, 'state.json');
// ANSI colors for terminal output
const C = {
reset: '\x1b[0m',
dim: '\x1b[2m',
candidate: '\x1b[36m', // cyan
voter1: '\x1b[33m', // yellow
voter2: '\x1b[35m', // magenta
analyst: '\x1b[32m', // green
header: '\x1b[1m', // bold
};
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
export async function loadState(): Promise<FocusGroupState> {
if (existsSync(STATE_FILE)) {
return JSON.parse(await readFile(STATE_FILE, 'utf-8'));
}
return {
candidateAgentId: null,
voterAgentIds: {},
analystAgentId: null,
sessionCount: 0,
};
}
export async function saveState(state: FocusGroupState): Promise<void> {
await writeFile(STATE_FILE, JSON.stringify(state, null, 2));
}
// ============================================================================
// AGENT COMMUNICATION
// ============================================================================
/**
* Send a message to an agent and collect the full response
*/
async function chat(session: Session, message: string): Promise<string> {
await session.send(message);
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') {
response += msg.content;
}
}
return response.trim();
}
/**
* Send a message and stream the response to console
*/
async function chatWithOutput(
session: Session,
message: string,
color: string,
label: string
): Promise<string> {
process.stdout.write(`\n${C.header}${label}:${C.reset} `);
await session.send(message);
let response = '';
for await (const msg of session.stream()) {
if (msg.type === 'assistant') {
response += msg.content;
process.stdout.write(`${color}${msg.content}${C.reset}`);
}
}
console.log(); // newline
return response.trim();
}
// ============================================================================
// FOCUS GROUP SESSION
// ============================================================================
export class FocusGroup {
private state: FocusGroupState;
private candidate: Session | null = null;
private voters: Map<string, Session> = new Map();
private analyst: Session | null = null;
private personas: VoterPersona[];
constructor(state: FocusGroupState, personas: VoterPersona[] = SAMPLE_PERSONAS) {
this.state = state;
this.personas = personas;
}
/**
* Initialize all agents (create new or resume existing)
*/
async initialize(): Promise<void> {
console.log(`${C.dim}Initializing focus group...${C.reset}\n`);
// Candidate
if (this.state.candidateAgentId) {
console.log(`${C.dim}Resuming candidate agent...${C.reset}`);
this.candidate = await resumeCandidateAgent(this.state.candidateAgentId);
} else {
console.log(`${C.dim}Creating candidate agent...${C.reset}`);
this.candidate = await createCandidateAgent();
}
// Voters
for (const persona of this.personas) {
const existingId = this.state.voterAgentIds[persona.name];
if (existingId) {
console.log(`${C.dim}Resuming voter: ${persona.name}...${C.reset}`);
this.voters.set(persona.name, await resumeVoterAgent(existingId));
} else {
console.log(`${C.dim}Creating voter: ${persona.name}...${C.reset}`);
this.voters.set(persona.name, await createVoterAgent(persona));
}
}
// Analyst
if (this.state.analystAgentId) {
console.log(`${C.dim}Resuming analyst agent...${C.reset}`);
this.analyst = await resumeAnalystAgent(this.state.analystAgentId);
} else {
console.log(`${C.dim}Creating analyst agent...${C.reset}`);
this.analyst = await createAnalystAgent();
}
console.log(`${C.dim}All agents ready.${C.reset}\n`);
}
/**
* Run a focus group round on a topic
*/
async runRound(topic: string): Promise<FocusGroupRound> {
const round: FocusGroupRound = {
position: '',
voterResponses: [],
};
// Step 1: Candidate presents position
console.log(`${C.header}═══ CANDIDATE PRESENTS ═══${C.reset}`);
round.position = await chatWithOutput(
this.candidate!,
`Present your position on: ${topic}\n\nBe specific about what you would do. Keep it to 2-3 sentences.`,
C.candidate,
'Candidate'
);
// Step 2: Voters respond
console.log(`\n${C.header}═══ VOTER REACTIONS ═══${C.reset}`);
const voterColors = [C.voter1, C.voter2];
let i = 0;
for (const [name, voter] of this.voters) {
const reaction = await chatWithOutput(
voter,
`A political candidate just said:\n\n"${round.position}"\n\nHow does this make you feel? React as yourself.`,
voterColors[i % voterColors.length],
name
);
round.voterResponses.push({ voterName: name, reaction });
i++;
}
// Step 3: Candidate asks follow-up
console.log(`\n${C.header}═══ FOLLOW-UP QUESTION ═══${C.reset}`);
const voterSummary = round.voterResponses
.map(r => `${r.voterName}: "${r.reaction}"`)
.join('\n\n');
round.followUpQuestion = await chatWithOutput(
this.candidate!,
`Here's how the voters reacted to your position:\n\n${voterSummary}\n\nAsk ONE follow-up question to dig deeper into their concerns.`,
C.candidate,
'Candidate'
);
// Step 4: Voters respond to follow-up
console.log(`\n${C.header}═══ FOLLOW-UP RESPONSES ═══${C.reset}`);
round.followUpResponses = [];
i = 0;
for (const [name, voter] of this.voters) {
const reaction = await chatWithOutput(
voter,
`The candidate asks: "${round.followUpQuestion}"\n\nAnswer honestly based on your perspective.`,
voterColors[i % voterColors.length],
name
);
round.followUpResponses.push({ voterName: name, reaction });
i++;
}
// Step 5: Analyst provides insights
console.log(`\n${C.header}═══ ANALYST INSIGHTS ═══${C.reset}`);
const fullTranscript = `
TOPIC: ${topic}
CANDIDATE'S POSITION:
"${round.position}"
INITIAL REACTIONS:
${round.voterResponses.map(r => `${r.voterName}: "${r.reaction}"`).join('\n')}
FOLLOW-UP QUESTION:
"${round.followUpQuestion}"
FOLLOW-UP RESPONSES:
${round.followUpResponses.map(r => `${r.voterName}: "${r.reaction}"`).join('\n')}
`;
round.analysis = await chatWithOutput(
this.analyst!,
`Analyze this focus group exchange:\n${fullTranscript}\n\nProvide insights on what worked, what didn't, and recommendations.`,
C.analyst,
'Analyst'
);
// Save agent IDs after first interaction
await this.saveAgentIds();
return round;
}
/**
* Save agent IDs to state
*/
private async saveAgentIds(): Promise<void> {
let needsSave = false;
if (!this.state.candidateAgentId && this.candidate?.agentId) {
this.state.candidateAgentId = this.candidate.agentId;
needsSave = true;
}
for (const [name, voter] of this.voters) {
if (!this.state.voterAgentIds[name] && voter.agentId) {
this.state.voterAgentIds[name] = voter.agentId;
needsSave = true;
}
}
if (!this.state.analystAgentId && this.analyst?.agentId) {
this.state.analystAgentId = this.analyst.agentId;
needsSave = true;
}
if (needsSave) {
this.state.sessionCount++;
await saveState(this.state);
console.log(`\n${C.dim}[Agents saved - session #${this.state.sessionCount}]${C.reset}`);
}
}
/**
* Close all sessions
*/
close(): void {
this.candidate?.close();
for (const voter of this.voters.values()) {
voter.close();
}
this.analyst?.close();
}
}
/**
* Reset focus group state
*/
export async function resetFocusGroup(): Promise<void> {
if (existsSync(STATE_FILE)) {
const { unlink } = await import('node:fs/promises');
await unlink(STATE_FILE);
}
console.log('Focus group reset. All agents forgotten.');
}
/**
* Show current status
*/
export async function showStatus(): Promise<void> {
const state = await loadState();
console.log('\n📊 Focus Group Status\n');
console.log(`Sessions: ${state.sessionCount}`);
console.log(`Candidate: ${state.candidateAgentId || '(not created)'}`);
console.log(`Analyst: ${state.analystAgentId || '(not created)'}`);
console.log(`Voters:`);
for (const [name, id] of Object.entries(state.voterAgentIds)) {
console.log(` - ${name}: ${id}`);
}
if (Object.keys(state.voterAgentIds).length === 0) {
console.log(' (none created)');
}
console.log();
}

View File

@@ -0,0 +1,47 @@
/**
* Focus Group Types
*
* This demo simulates a political focus group where:
* - A candidate presents positions to voters
* - Voters (with distinct personas) respond with their reactions
* - An analyst observes and provides insights
*/
// Voter persona definition - describes who the voter is
export interface VoterPersona {
name: string;
age: number;
location: string;
party: 'Democrat' | 'Republican' | 'Independent';
leaningStrength: 'strong' | 'moderate' | 'weak';
topIssues: string[]; // What they care about most
background: string; // Brief description
}
// State persisted to disk
export interface FocusGroupState {
candidateAgentId: string | null;
voterAgentIds: Record<string, string>; // persona name -> agent ID
analystAgentId: string | null;
sessionCount: number;
}
// A single exchange in the focus group
export interface FocusGroupRound {
position: string; // What the candidate presented
voterResponses: {
voterName: string;
reaction: string;
}[];
followUpQuestion?: string; // Candidate's follow-up
followUpResponses?: {
voterName: string;
reaction: string;
}[];
analysis?: string; // Analyst's summary
}
// Configuration
export const CONFIG = {
model: 'haiku', // Fast and cheap for demos
};