feat: add political focus group demo (#12)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
146
examples/focus-group/README.md
Normal file
146
examples/focus-group/README.md
Normal 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.
|
||||
```
|
||||
196
examples/focus-group/agents.ts
Normal file
196
examples/focus-group/agents.ts
Normal 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
147
examples/focus-group/cli.ts
Executable 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);
|
||||
329
examples/focus-group/focus-group.ts
Normal file
329
examples/focus-group/focus-group.ts
Normal 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();
|
||||
}
|
||||
47
examples/focus-group/types.ts
Normal file
47
examples/focus-group/types.ts
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user