feat: conversation summary spec

This commit is contained in:
Shivam Mishra
2025-10-29 19:38:17 +05:30
parent bf035114fe
commit 80caf25962
3 changed files with 370 additions and 0 deletions
@@ -0,0 +1,129 @@
class Captain::Assistant::ConversationSummaryService < Captain::Assistant::BaseAssistantService
TOKEN_LIMIT = 16_000
def initialize(conversation:)
@conversation = conversation
# Format conversation messages as text
super(text: format_conversation_messages)
end
protected
def agent_name
'ConversationSummarizer'
end
def build_instructions
Captain::PromptRenderer.render('summary', {})
end
def response_schema
{
type: 'object',
properties: {
customer_intent: {
type: 'string',
description: 'Brief description of what the customer wants (around 50 words)'
},
conversation_summary: {
type: 'string',
description: 'Summary of the conversation in approximately 200 words'
},
action_items: {
type: 'array',
description: 'List of action items committed to by the agent or left incomplete',
items: {
type: 'string'
}
},
follow_up_items: {
type: 'array',
description: 'List of unresolved issues or outstanding questions',
items: {
type: 'string'
}
}
},
required: %w[customer_intent conversation_summary],
additionalProperties: false
}
end
def build_success_response(output)
{
success: true,
summary: build_summary_markdown(output),
structured_data: {
customer_intent: extract_field(output, 'customer_intent'),
conversation_summary: extract_field(output, 'conversation_summary'),
action_items: extract_array_field(output, 'action_items'),
follow_up_items: extract_array_field(output, 'follow_up_items')
}
}
end
private
def format_conversation_messages
messages = []
character_count = 0
@conversation.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.reorder('id desc')
.each do |message|
break if character_count + message.content.length > TOKEN_LIMIT
next unless message.content.present?
messages.prepend(format_message(message))
character_count += message.content.length
end
messages.join("\n")
end
def format_message(message)
sender_type = message.incoming? ? 'Customer' : 'Agent'
sender_name = message.sender&.name || 'Unknown'
"#{sender_type} #{sender_name}: #{message.content}"
end
def extract_array_field(output, field_name)
return [] unless output.is_a?(Hash)
output[field_name.to_sym] || output[field_name.to_s] || []
end
def build_summary_markdown(output)
return '' unless output.is_a?(Hash)
sections = []
# Customer Intent
if (intent = extract_field(output, 'customer_intent')).present?
sections << "**Customer Intent**\n\n#{intent}"
end
# Conversation Summary
if (summary = extract_field(output, 'conversation_summary')).present?
sections << "**Conversation Summary**\n\n#{summary}"
end
# Action Items
action_items = extract_array_field(output, 'action_items')
if action_items&.any?
items_list = action_items.map { |item| "- #{item}" }.join("\n")
sections << "**Action Items**\n\n#{items_list}"
end
# Follow-up Items
follow_up_items = extract_array_field(output, 'follow_up_items')
if follow_up_items&.any?
items_list = follow_up_items.map { |item| "- #{item}" }.join("\n")
sections << "**Follow-up Items**\n\n#{items_list}"
end
sections.join("\n\n")
end
end
@@ -0,0 +1,19 @@
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
Make sure you strongly adhere to the following rules when generating the summary:
1. Be brief and concise. The shorter the summary the better.
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
3. Describe the customer intent in around 50 words.
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
8. The 'Action Items' should be brief and concise.
9. Mark important words or parts of sentences as bold.
10. Apply markdown syntax to format any included code, using backticks.
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
12. If any section does not have any content, remove that section and the heading from the response.
13. Do not insert your own opinions about the conversation.
Reply in the user's language.
@@ -0,0 +1,222 @@
require 'rails_helper'
RSpec.describe Captain::Assistant::ConversationSummaryService do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:service) { described_class.new(conversation: conversation) }
let(:agent) { instance_double(Agents::Agent) }
let(:runner) { instance_double(Agents::Runner) }
let(:result) do
instance_double(Agents::RunResult,
output: {
'customer_intent' => 'Customer wants to reset their password',
'conversation_summary' => 'The customer contacted support to reset their password. ' \
'Agent provided instructions and confirmed the reset was successful.',
'action_items' => ['Send password reset email', 'Verify account security'],
'follow_up_items' => ['Check if customer needs additional security settings']
},
error: nil)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
create(:message, conversation: conversation, message_type: :incoming, content: 'I need to reset my password')
create(:message, conversation: conversation, message_type: :outgoing, content: 'I can help you with that')
allow(Agents::Agent).to receive(:new).and_return(agent)
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
allow(Captain::PromptRenderer).to receive(:render).and_return('summary prompt')
end
describe '#initialize' do
it 'initializes with conversation' do
expect(service.instance_variable_get(:@conversation)).to eq(conversation)
end
it 'formats conversation messages as text' do
text = service.instance_variable_get(:@text)
expect(text).to include('I need to reset my password')
expect(text).to include('I can help you with that')
end
end
describe '#execute' do
context 'when successful' do
it 'renders the summary prompt' do
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
service.execute
end
it 'builds agent with correct parameters' do
expect(Agents::Agent).to receive(:new).with(
name: 'ConversationSummarizer',
instructions: 'summary prompt',
model: 'gpt-4o-mini',
response_schema: service.send(:response_schema)
)
service.execute
end
it 'returns success response with structured data' do
response = service.execute
expect(response[:success]).to be true
expect(response[:structured_data][:customer_intent]).to eq('Customer wants to reset their password')
expect(response[:structured_data][:conversation_summary]).to include('contacted support')
expect(response[:structured_data][:action_items]).to include('Send password reset email')
expect(response[:structured_data][:follow_up_items]).to include('Check if customer needs additional security settings')
end
it 'includes markdown formatted summary' do
response = service.execute
expect(response[:summary]).to include('**Customer Intent**')
expect(response[:summary]).to include('**Conversation Summary**')
expect(response[:summary]).to include('**Action Items**')
expect(response[:summary]).to include('**Follow-up Items**')
end
end
context 'when agent returns an error' do
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('Model error')
end
end
context 'when exception is raised' do
before do
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with(/ConversationSummaryService error: API timeout/)
expect(Rails.logger).to receive(:error).with(anything)
service.execute
end
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('API timeout')
end
end
end
describe '#agent_name' do
it 'returns ConversationSummarizer' do
expect(service.send(:agent_name)).to eq('ConversationSummarizer')
end
end
describe '#build_instructions' do
it 'renders the summary template' do
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
service.send(:build_instructions)
end
end
describe '#response_schema' do
let(:schema) { service.send(:response_schema) }
it 'defines object type' do
expect(schema[:type]).to eq('object')
end
it 'includes customer_intent property' do
expect(schema[:properties][:customer_intent]).to include(
type: 'string',
description: 'Brief description of what the customer wants (around 50 words)'
)
end
it 'includes conversation_summary property' do
expect(schema[:properties][:conversation_summary]).to include(
type: 'string',
description: 'Summary of the conversation in approximately 200 words'
)
end
it 'includes action_items property' do
expect(schema[:properties][:action_items][:type]).to eq('array')
expect(schema[:properties][:action_items][:items][:type]).to eq('string')
end
it 'includes follow_up_items property' do
expect(schema[:properties][:follow_up_items][:type]).to eq('array')
expect(schema[:properties][:follow_up_items][:items][:type]).to eq('string')
end
it 'marks required fields' do
expect(schema[:required]).to match_array(%w[customer_intent conversation_summary])
end
it 'disallows additional properties' do
expect(schema[:additionalProperties]).to be false
end
end
describe '#build_success_response' do
let(:output) do
{
'customer_intent' => 'Reset password',
'conversation_summary' => 'Password reset conversation',
'action_items' => ['Item 1', 'Item 2'],
'follow_up_items' => ['Follow-up 1']
}
end
it 'extracts customer_intent from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:customer_intent]).to eq('Reset password')
end
it 'extracts conversation_summary from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:conversation_summary]).to eq('Password reset conversation')
end
it 'extracts action_items from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:action_items]).to eq(['Item 1', 'Item 2'])
end
it 'extracts follow_up_items from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:follow_up_items]).to eq(['Follow-up 1'])
end
it 'builds markdown summary' do
response = service.send(:build_success_response, output)
expect(response[:summary]).to include('**Customer Intent**')
expect(response[:summary]).to include('Reset password')
end
it 'marks response as successful' do
response = service.send(:build_success_response, output)
expect(response[:success]).to be true
end
end
describe '#format_conversation_messages' do
it 'includes incoming and outgoing messages' do
text = service.send(:format_conversation_messages)
expect(text).to include('Customer')
expect(text).to include('Agent')
end
it 'excludes private messages' do
create(:message, conversation: conversation, message_type: :incoming, content: 'Private note', private: true)
text = service.send(:format_conversation_messages)
expect(text).not_to include('Private note')
end
it 'respects token limit' do
long_content = 'a' * 20_000
create(:message, conversation: conversation, message_type: :incoming, content: long_content)
text = service.send(:format_conversation_messages)
expect(text.length).to be <= described_class::TOKEN_LIMIT
end
end
end