open-multi-agent/examples/cookbook/meeting-summarizer.ts

285 lines
9.6 KiB
TypeScript

/**
* Meeting Summarizer (Parallel Post-Processing)
*
* Demonstrates:
* - Fan-out of three specialized agents on the same meeting transcript
* - Structured output (Zod schemas) for action items and sentiment
* - Parallel timing check: wall time vs sum of per-agent durations
* - Aggregator merges into a single Markdown report
*
* Run:
* npx tsx examples/patterns/meeting-summarizer.ts
*
* Prerequisites:
* ANTHROPIC_API_KEY env var must be set.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { z } from 'zod'
import { Agent, AgentPool, ToolRegistry, ToolExecutor, registerBuiltInTools } from '../../src/index.js'
import type { AgentConfig, AgentRunResult } from '../../src/types.js'
// ---------------------------------------------------------------------------
// Load the transcript fixture
// ---------------------------------------------------------------------------
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const TRANSCRIPT = readFileSync(
path.join(__dirname, '../fixtures/meeting-transcript.txt'),
'utf-8',
)
// ---------------------------------------------------------------------------
// Zod schemas for structured agents
// ---------------------------------------------------------------------------
const ActionItemList = z.object({
items: z.array(
z.object({
task: z.string().describe('The action to be taken'),
owner: z.string().describe('Name of the person responsible'),
due_date: z.string().optional().describe('ISO date or human-readable due date if mentioned'),
}),
),
})
type ActionItemList = z.infer<typeof ActionItemList>
const SentimentReport = z.object({
participants: z.array(
z.object({
participant: z.string().describe('Name as it appears in the transcript'),
tone: z.enum(['positive', 'neutral', 'negative', 'mixed']),
evidence: z.string().describe('Direct quote or brief paraphrase supporting the tone'),
}),
),
})
type SentimentReport = z.infer<typeof SentimentReport>
// ---------------------------------------------------------------------------
// Agent configs
// ---------------------------------------------------------------------------
const summaryConfig: AgentConfig = {
name: 'summary',
model: 'claude-sonnet-4-6',
systemPrompt: `You are a meeting note-taker. Given a transcript, produce a
three-paragraph summary:
1. What was discussed (the agenda).
2. Decisions made.
3. Notable context or risk the team should remember.
Plain prose. No bullet points. 200-300 words total.`,
maxTurns: 1,
temperature: 0.3,
}
const actionItemsConfig: AgentConfig = {
name: 'action-items',
model: 'claude-sonnet-4-6',
systemPrompt: `You extract action items from meeting transcripts. An action
item is a concrete task with a clear owner. Skip vague intentions ("we should
think about X"). Include due dates only when the speaker named one explicitly.
Return JSON matching the schema.`,
maxTurns: 1,
temperature: 0.1,
outputSchema: ActionItemList,
}
const sentimentConfig: AgentConfig = {
name: 'sentiment',
model: 'claude-sonnet-4-6',
systemPrompt: `You analyze the tone of each participant in a meeting. For
every named speaker, classify their overall tone as positive, neutral,
negative, or mixed, and include one short quote or paraphrase as evidence.
Return JSON matching the schema.`,
maxTurns: 1,
temperature: 0.2,
outputSchema: SentimentReport,
}
const aggregatorConfig: AgentConfig = {
name: 'aggregator',
model: 'claude-sonnet-4-6',
systemPrompt: `You are a report writer. You receive three pre-computed
analyses of the same meeting: a summary, an action-item list, and a sentiment
report. Your job is to merge them into a single Markdown report.
Output structure — use exactly these four H2 headings, in order:
## Summary
## Action Items
## Sentiment
## Next Steps
Under "Action Items" render a Markdown table with columns: Task, Owner, Due.
Under "Sentiment" render one bullet per participant.
Under "Next Steps" synthesize 3-5 concrete follow-ups based on the other
sections. Do not invent action items that are not grounded in the other data.`,
maxTurns: 1,
temperature: 0.3,
}
// ---------------------------------------------------------------------------
// Build agents
// ---------------------------------------------------------------------------
function buildAgent(config: AgentConfig): Agent {
const registry = new ToolRegistry()
registerBuiltInTools(registry)
const executor = new ToolExecutor(registry)
return new Agent(config, registry, executor)
}
const summary = buildAgent(summaryConfig)
const actionItems = buildAgent(actionItemsConfig)
const sentiment = buildAgent(sentimentConfig)
const aggregator = buildAgent(aggregatorConfig)
const pool = new AgentPool(3) // three specialists can run concurrently
pool.add(summary)
pool.add(actionItems)
pool.add(sentiment)
pool.add(aggregator)
console.log('Meeting Summarizer (Parallel Post-Processing)')
console.log('='.repeat(60))
console.log(`\nTranscript: ${TRANSCRIPT.split('\n')[0]}`)
console.log(`Length: ${TRANSCRIPT.split(/\s+/).length} words\n`)
// ---------------------------------------------------------------------------
// Step 1: Parallel fan-out with per-agent timing
// ---------------------------------------------------------------------------
console.log('[Step 1] Running 3 agents in parallel...\n')
const specialists = ['summary', 'action-items', 'sentiment'] as const
// Kick off all three concurrently and record each one's own wall duration.
// Sum-of-per-agent beats a separate serial pass: half the LLM cost, and the
// sum is the work parallelism saved.
const parallelStart = performance.now()
const timed = await Promise.all(
specialists.map(async (name) => {
const t = performance.now()
const result = await pool.run(name, TRANSCRIPT)
return { name, result, durationMs: performance.now() - t }
}),
)
const parallelElapsed = performance.now() - parallelStart
const byName = new Map<string, AgentRunResult>()
const serialSum = timed.reduce((acc, r) => {
byName.set(r.name, r.result)
return acc + r.durationMs
}, 0)
for (const { name, result, durationMs } of timed) {
const status = result.success ? 'OK' : 'FAILED'
console.log(
` ${name.padEnd(14)} [${status}] — ${Math.round(durationMs)}ms, ${result.tokenUsage.output_tokens} out tokens`,
)
}
console.log()
for (const { name, result } of timed) {
if (!result.success) {
console.error(`Specialist '${name}' failed: ${result.output}`)
process.exit(1)
}
}
const actionData = byName.get('action-items')!.structured as ActionItemList | undefined
const sentimentData = byName.get('sentiment')!.structured as SentimentReport | undefined
if (!actionData || !sentimentData) {
console.error('Structured output missing: action-items or sentiment failed schema validation')
process.exit(1)
}
// ---------------------------------------------------------------------------
// Step 2: Parallelism assertion
// ---------------------------------------------------------------------------
console.log('[Step 2] Parallelism check')
console.log(` Parallel wall time: ${Math.round(parallelElapsed)}ms`)
console.log(` Serial sum (per-agent): ${Math.round(serialSum)}ms`)
console.log(` Speedup: ${(serialSum / parallelElapsed).toFixed(2)}x\n`)
if (parallelElapsed >= serialSum * 0.7) {
console.error(
`ASSERTION FAILED: parallel wall time (${Math.round(parallelElapsed)}ms) is not ` +
`less than 70% of serial sum (${Math.round(serialSum)}ms). Expected substantial ` +
`speedup from fan-out.`,
)
process.exit(1)
}
// ---------------------------------------------------------------------------
// Step 3: Aggregate into Markdown report
// ---------------------------------------------------------------------------
console.log('[Step 3] Aggregating into Markdown report...\n')
const aggregatorPrompt = `Merge the three analyses below into a single Markdown report.
--- SUMMARY (prose) ---
${byName.get('summary')!.output}
--- ACTION ITEMS (JSON) ---
${JSON.stringify(actionData, null, 2)}
--- SENTIMENT (JSON) ---
${JSON.stringify(sentimentData, null, 2)}
Produce the Markdown report per the system instructions.`
const reportResult = await pool.run('aggregator', aggregatorPrompt)
if (!reportResult.success) {
console.error('Aggregator failed:', reportResult.output)
process.exit(1)
}
// ---------------------------------------------------------------------------
// Final output
// ---------------------------------------------------------------------------
console.log('='.repeat(60))
console.log('MEETING REPORT')
console.log('='.repeat(60))
console.log()
console.log(reportResult.output)
console.log()
console.log('-'.repeat(60))
// ---------------------------------------------------------------------------
// Token usage summary
// ---------------------------------------------------------------------------
console.log('\nToken Usage Summary:')
console.log('-'.repeat(60))
let totalInput = 0
let totalOutput = 0
for (const { name, result } of timed) {
totalInput += result.tokenUsage.input_tokens
totalOutput += result.tokenUsage.output_tokens
console.log(
` ${name.padEnd(14)} — input: ${result.tokenUsage.input_tokens}, output: ${result.tokenUsage.output_tokens}`,
)
}
totalInput += reportResult.tokenUsage.input_tokens
totalOutput += reportResult.tokenUsage.output_tokens
console.log(
` ${'aggregator'.padEnd(14)} — input: ${reportResult.tokenUsage.input_tokens}, output: ${reportResult.tokenUsage.output_tokens}`,
)
console.log('-'.repeat(60))
console.log(` ${'TOTAL'.padEnd(14)} — input: ${totalInput}, output: ${totalOutput}`)
console.log('\nDone.')