Compare commits

...
14 changed files with 1162 additions and 42 deletions
@@ -0,0 +1,89 @@
require 'agents'
class Captain::Assistant::BaseAssistantService
def initialize(text:)
@text = text
end
def execute
agent = build_agent
runner = Agents::Runner.with_agents(agent)
result = runner.run(@text, context: {})
return error_response(result.error) if result.respond_to?(:error) && result.error
process_result(result)
rescue StandardError => e
Rails.logger.error "[Captain V2] #{self.class.name} error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
error_response(e.message)
end
protected
def build_agent
Agents::Agent.new(
name: agent_name,
instructions: build_instructions,
model: agent_model,
response_schema: response_schema
)
end
def agent_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
end
def agent_name
raise NotImplementedError, "#{self.class} must implement agent_name"
end
def build_instructions
raise NotImplementedError, "#{self.class} must implement build_instructions"
end
def response_schema
raise NotImplementedError, "#{self.class} must implement response_schema"
end
def process_result(result)
output = result.output
return error_response(output[:error] || output['error']) if output.is_a?(Hash) && (output[:error] || output['error'])
build_success_response(output)
end
def build_success_response(output)
{
success: true,
result: extract_primary_field(output),
original_text: @text
}
end
def error_response(error_message)
{
success: false,
error: error_message,
original_text: @text
}
end
def extract_field(output, *field_names)
return output.to_s unless output.is_a?(Hash)
field_names.each do |field|
value = output[field.to_sym] || output[field.to_s]
return value if value
end
nil
end
def extract_primary_field(output)
output
end
end
@@ -0,0 +1,56 @@
class Captain::Assistant::ChangeToneService < Captain::Assistant::BaseAssistantService
SUPPORTED_TONES = %w[professional casual straightforward confident friendly].freeze
def initialize(text:, tone:)
super(text: text)
@tone = validate_tone(tone)
end
protected
def agent_name
'ToneChanger'
end
def build_instructions
context = { tone: @tone }
Captain::PromptRenderer.render('rewrite/tone', context.with_indifferent_access)
end
def response_schema
{
type: 'object',
properties: {
rewritten_text: {
type: 'string',
description: 'The rewritten text with the requested tone applied'
},
tone_applied: {
type: 'string',
description: 'The tone that was applied to the text'
}
},
required: %w[rewritten_text tone_applied],
additionalProperties: false
}
end
def build_success_response(output)
{
success: true,
rewritten_text: extract_field(output, 'rewritten_text'),
tone: @tone,
original_text: @text
}
end
private
def validate_tone(tone)
tone_str = tone.to_s.downcase
raise ArgumentError, "Unsupported tone: #{tone}. Supported tones: #{SUPPORTED_TONES.join(', ')}" unless SUPPORTED_TONES.include?(tone_str)
tone_str
end
end
@@ -0,0 +1,124 @@
class Captain::Assistant::ConversationSummaryService < Captain::Assistant::BaseAssistantService
TOKEN_LIMIT = 16_000
def initialize(conversation:)
@conversation = conversation
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 if message.content.blank?
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 = []
if (intent = extract_field(output, 'customer_intent')).present?
sections << "**Customer Intent**\n\n#{intent}"
end
if (summary = extract_field(output, 'conversation_summary')).present?
sections << "**Conversation Summary**\n\n#{summary}"
end
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 = 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,49 @@
class Captain::Assistant::FixGrammarService < Captain::Assistant::BaseAssistantService
protected
def agent_name
'GrammarFixer'
end
def build_instructions
Captain::PromptRenderer.render('rewrite/grammar', {})
end
def response_schema
{
type: 'object',
properties: {
corrected_text: {
type: 'string',
description: 'The text with corrected grammar, spelling, and punctuation'
},
corrections_made: {
type: 'array',
description: 'List of corrections that were made',
items: {
type: 'string'
}
}
},
required: ['corrected_text'],
additionalProperties: false
}
end
def build_success_response(output)
{
success: true,
corrected_text: extract_field(output, 'corrected_text'),
corrections_made: extract_corrections(output),
original_text: @text
}
end
private
def extract_corrections(output)
return [] unless output.is_a?(Hash)
output[:corrections_made] || output['corrections_made'] || []
end
end
-25
View File
@@ -1,25 +0,0 @@
require 'liquid'
class Captain::PromptRenderer
class << self
def render(template_name, context = {})
template = load_template(template_name)
liquid_template = Liquid::Template.parse(template)
liquid_template.render(stringify_keys(context))
end
private
def load_template(template_name)
template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
raise "Template not found: #{template_name}" unless File.exist?(template_path)
File.read(template_path)
end
def stringify_keys(hash)
hash.deep_stringify_keys
end
end
end
@@ -0,0 +1,9 @@
module Enterprise::Captain::PromptRenderer
private
def candidate_paths(template_name)
[
Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
] + super
end
end
+35
View File
@@ -0,0 +1,35 @@
require 'liquid'
class Captain::PromptRenderer
class << self
def render(template_name, context = {})
template = load_template(template_name)
liquid_template = Liquid::Template.parse(template)
liquid_template.render(stringify_keys(context))
end
private
def load_template(template_name)
candidate_paths(template_name).each do |template_path|
next unless File.exist?(template_path)
return File.read(template_path)
end
raise "Template not found: #{template_name}"
end
def candidate_paths(template_name)
[
Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid")
]
end
def stringify_keys(hash)
hash.deep_stringify_keys
end
end
end
Captain::PromptRenderer.singleton_class.prepend_mod_with('Captain::PromptRenderer')
@@ -0,0 +1,8 @@
You are an expert writing editor. Rewrite the provided text to correct grammar, spelling, and punctuation while fully preserving the original meaning and style.
Rules:
• Preserve tone, language, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
• Do not modify content inside code blocks or backticks.
• Do not add content or opinions. Keep length similar.
• Minimal edits if already correct.
• Do not alter legal, medical, or compliance meaning.
+17
View File
@@ -0,0 +1,17 @@
You are an expert writing editor. Rewrite the provided text according to the selected tone while keeping the original intent.
{% case tone %}
{% when "professional" %}Rewrite in a formal and precise tone.
{% when "casual" %}Rewrite in a relaxed and conversational tone.
{% when "straightforward" %}Rewrite concise and direct. Remove filler.
{% when "confident" %}Rewrite bold and assured.
{% when "friendly" %}Rewrite warm and positive.
{% else %}Rewrite to improve clarity and smoothness.
{% endcase %}
Rules:
• Preserve original language, meaning, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
• Do not modify content inside code blocks or backticks.
• Do not add content or opinions. Keep length similar.
• Minimal edits if already correct.
• Do not alter legal, medical, or compliance meaning.
+19
View File
@@ -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.
@@ -1,17 +1,24 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Captain::PromptRenderer do
let(:template_name) { 'test_template' }
let(:template_content) { 'Hello {{name}}, your balance is {{balance}}' }
let(:template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
let(:enterprise_template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
let(:oss_template_path) { Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid") }
let(:context) { { name: 'John', balance: 100 } }
before do
if defined?(Enterprise::Captain::PromptRenderer) &&
!Captain::PromptRenderer.singleton_class.ancestors.include?(Enterprise::Captain::PromptRenderer)
Captain::PromptRenderer.singleton_class.prepend(Enterprise::Captain::PromptRenderer)
end
allow(File).to receive(:exist?).and_return(false)
allow(File).to receive(:exist?).with(template_path).and_return(true)
allow(File).to receive(:read).with(template_path).and_return(template_content)
allow(File).to receive(:read)
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(false)
allow(File).to receive(:exist?).with(oss_template_path).and_return(true)
allow(File).to receive(:read).with(oss_template_path).and_return(template_content)
end
describe '.render' do
@@ -39,7 +46,7 @@ RSpec.describe Captain::PromptRenderer do
nested_template = 'User: {{user.name}}, Account: {{user.account.type}}'
nested_context = { user: { name: 'Alice', account: { type: 'premium' } } }
allow(File).to receive(:read).with(template_path).and_return(nested_template)
allow(File).to receive(:read).with(oss_template_path).and_return(nested_template)
result = described_class.render(template_name, nested_context)
@@ -48,7 +55,7 @@ RSpec.describe Captain::PromptRenderer do
it 'handles empty context' do
simple_template = 'Hello World'
allow(File).to receive(:read).with(template_path).and_return(simple_template)
allow(File).to receive(:read).with(oss_template_path).and_return(simple_template)
result = described_class.render(template_name, {})
@@ -65,30 +72,41 @@ RSpec.describe Captain::PromptRenderer do
expect(result).to eq('rendered')
expect(Liquid::Template).to have_received(:parse).with(template_content)
end
it 'prefers enterprise template when available' do
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
allow(File).to receive(:read).with(enterprise_template_path).and_return('Enterprise {{name}}')
result = described_class.render(template_name, context)
expect(result).to eq('Enterprise John')
expect(File).to have_received(:read).with(enterprise_template_path)
expect(File).not_to have_received(:read).with(oss_template_path)
end
end
describe '.load_template' do
it 'reads template file from correct path' do
it 'reads template file from OSS path when enterprise file absent' do
described_class.send(:load_template, template_name)
expect(File).to have_received(:read).with(template_path)
expect(File).to have_received(:read).with(oss_template_path)
end
it 'raises error when template does not exist' do
allow(File).to receive(:exist?).with(template_path).and_return(false)
it 'raises error when template does not exist in either location' do
allow(File).to receive(:exist?).with(oss_template_path).and_return(false)
expect { described_class.send(:load_template, template_name) }
.to raise_error("Template not found: #{template_name}")
end
it 'constructs correct template path' do
expected_path = Rails.root.join('enterprise/lib/captain/prompts/my_template.liquid')
allow(File).to receive(:exist?).with(expected_path).and_return(true)
allow(File).to receive(:read).with(expected_path).and_return('test content')
it 'checks enterprise path before OSS path' do
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
allow(File).to receive(:read).with(enterprise_template_path).and_return('enterprise')
described_class.send(:load_template, 'my_template')
described_class.send(:load_template, template_name)
expect(File).to have_received(:exist?).with(expected_path)
expect(File).to have_received(:exist?).with(enterprise_template_path)
expect(File).not_to have_received(:read).with(oss_template_path)
end
end
@@ -0,0 +1,250 @@
require 'rails_helper'
RSpec.describe Captain::Assistant::ChangeToneService do
let(:text) { 'Hey! Just checking if you got my email about the project' }
let(:tone) { 'professional' }
let(:service) { described_class.new(text: text, tone: tone) }
let(:agent) { instance_double(Agents::Agent) }
let(:runner) { instance_double(Agents::Runner) }
let(:result) do
instance_double(Agents::RunResult,
output: {
'rewritten_text' => 'Good afternoon. I am following up regarding my previous email about the project.',
'tone_applied' => 'professional'
},
error: nil)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
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('tone prompt')
end
describe '#initialize' do
it 'initializes with text and tone' do
expect(service.instance_variable_get(:@text)).to eq(text)
expect(service.instance_variable_get(:@tone)).to eq('professional')
end
it 'converts tone to lowercase' do
service = described_class.new(text: text, tone: 'PROFESSIONAL')
expect(service.instance_variable_get(:@tone)).to eq('professional')
end
context 'with invalid tone' do
it 'raises ArgumentError' do
expect do
described_class.new(text: text, tone: 'invalid_tone')
end.to raise_error(ArgumentError, /Unsupported tone: invalid_tone/)
end
it 'includes supported tones in error message' do
expect do
described_class.new(text: text, tone: 'invalid')
end.to raise_error(ArgumentError, /professional, casual, straightforward, confident, friendly/)
end
end
end
describe '#execute' do
context 'when successful' do
it 'renders the tone prompt with correct context' do
expect(Captain::PromptRenderer).to receive(:render).with(
'rewrite/tone',
hash_including(tone: 'professional')
)
service.execute
end
it 'builds agent with correct parameters' do
expect(Agents::Agent).to receive(:new).with(
name: 'ToneChanger',
instructions: 'tone prompt',
model: 'gpt-4o-mini',
response_schema: service.send(:response_schema)
)
service.execute
end
it 'returns success response with rewritten text' do
response = service.execute
expect(response[:success]).to be true
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
expect(response[:tone]).to eq('professional')
expect(response[:original_text]).to eq(text)
end
end
context 'with different tones' do
%w[professional casual straightforward confident friendly].each do |test_tone|
it "works with #{test_tone} tone" do
service = described_class.new(text: text, tone: test_tone)
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)
expect(Captain::PromptRenderer).to receive(:render).with(
'rewrite/tone',
hash_including(tone: test_tone)
)
service.execute
end
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')
expect(response[:original_text]).to eq(text)
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(/ChangeToneService 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 ToneChanger' do
expect(service.send(:agent_name)).to eq('ToneChanger')
end
end
describe '#build_instructions' do
it 'renders the tone template with tone context' do
expect(Captain::PromptRenderer).to receive(:render).with(
'rewrite/tone',
hash_including(tone: 'professional')
)
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 rewritten_text property' do
expect(schema[:properties][:rewritten_text]).to include(
type: 'string',
description: 'The rewritten text with the requested tone applied'
)
end
it 'includes tone_applied property' do
expect(schema[:properties][:tone_applied]).to include(
type: 'string',
description: 'The tone that was applied to the text'
)
end
it 'marks both fields as required' do
expect(schema[:required]).to match_array(%w[rewritten_text tone_applied])
end
it 'disallows additional properties' do
expect(schema[:additionalProperties]).to be false
end
end
describe '#build_success_response' do
let(:output) do
{
'rewritten_text' => 'Rewritten content',
'tone_applied' => 'professional'
}
end
it 'extracts rewritten_text from output' do
response = service.send(:build_success_response, output)
expect(response[:rewritten_text]).to eq('Rewritten content')
end
it 'includes the requested tone' do
response = service.send(:build_success_response, output)
expect(response[:tone]).to eq('professional')
end
it 'includes original text' do
response = service.send(:build_success_response, output)
expect(response[:original_text]).to eq(text)
end
it 'marks response as successful' do
response = service.send(:build_success_response, output)
expect(response[:success]).to be true
end
end
describe 'SUPPORTED_TONES constant' do
it 'includes all valid tones' do
expect(described_class::SUPPORTED_TONES).to match_array(
%w[professional casual straightforward confident friendly]
)
end
end
describe 'End-to-End Tests' do
context 'when making real API call (stubbed at HTTP level)' do
let(:openai_response) do
{
id: 'chatcmpl-123',
object: 'chat.completion',
created: 1_677_652_288,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: JSON.generate({
rewritten_text: 'Good afternoon. I am following up regarding my previous email about the project.',
tone_applied: 'professional'
})
},
finish_reason: 'stop'
}
]
}
end
before do
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
end
it 'successfully changes tone through entire stack' do
response = service.execute
expect(response[:success]).to be true
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
expect(response[:tone]).to eq('professional')
expect(response[:original_text]).to eq(text)
end
end
end
end
@@ -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
@@ -0,0 +1,249 @@
require 'rails_helper'
RSpec.describe Captain::Assistant::FixGrammarService do
let(:text) { 'their going too the store tommorow and there bringing they\'re friends' }
let(:service) { described_class.new(text: text) }
let(:agent) { instance_double(Agents::Agent) }
let(:runner) { instance_double(Agents::Runner) }
let(:result) do
instance_double(Agents::RunResult,
output: {
'corrected_text' => 'They\'re going to the store tomorrow and they\'re bringing their friends.',
'corrections_made' => [
'Changed "their" to "They\'re"',
'Changed "too" to "to"',
'Fixed spelling: "tommorow" to "tomorrow"',
'Changed "there" to "they\'re"',
'Changed "they\'re" to "their"'
]
},
error: nil)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
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('grammar prompt')
end
describe '#initialize' do
it 'initializes with text only' do
expect(service.instance_variable_get(:@text)).to eq(text)
end
end
describe '#execute' do
context 'when successful' do
it 'renders the grammar prompt' do
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
service.execute
end
it 'builds agent with correct parameters' do
expect(Agents::Agent).to receive(:new).with(
name: 'GrammarFixer',
instructions: 'grammar prompt',
model: 'gpt-4o-mini',
response_schema: service.send(:response_schema)
)
service.execute
end
it 'returns success response with corrected text' do
response = service.execute
expect(response[:success]).to be true
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
expect(response[:original_text]).to eq(text)
end
it 'includes corrections made' do
response = service.execute
expect(response[:corrections_made]).to be_an(Array)
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
end
end
context 'when output does not include corrections_made' do
let(:result) do
instance_double(Agents::RunResult,
output: {
'corrected_text' => 'Corrected text without corrections list'
},
error: nil)
end
it 'returns empty array for corrections_made' do
response = service.execute
expect(response[:success]).to be true
expect(response[:corrections_made]).to eq([])
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')
expect(response[:original_text]).to eq(text)
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(/FixGrammarService 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 GrammarFixer' do
expect(service.send(:agent_name)).to eq('GrammarFixer')
end
end
describe '#build_instructions' do
it 'renders the grammar template with empty context' do
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
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 corrected_text property' do
expect(schema[:properties][:corrected_text]).to include(
type: 'string',
description: 'The text with corrected grammar, spelling, and punctuation'
)
end
it 'includes corrections_made property' do
expect(schema[:properties][:corrections_made]).to include(
type: 'array',
description: 'List of corrections that were made'
)
end
it 'marks corrected_text as required' do
expect(schema[:required]).to include('corrected_text')
end
it 'disallows additional properties' do
expect(schema[:additionalProperties]).to be false
end
end
describe '#build_success_response' do
let(:output) do
{
'corrected_text' => 'Corrected content',
'corrections_made' => ['Fix 1', 'Fix 2']
}
end
it 'extracts corrected_text from output' do
response = service.send(:build_success_response, output)
expect(response[:corrected_text]).to eq('Corrected content')
end
it 'includes corrections made' do
response = service.send(:build_success_response, output)
expect(response[:corrections_made]).to eq(['Fix 1', 'Fix 2'])
end
it 'includes original text' do
response = service.send(:build_success_response, output)
expect(response[:original_text]).to eq(text)
end
it 'marks response as successful' do
response = service.send(:build_success_response, output)
expect(response[:success]).to be true
end
end
describe '#extract_corrections' do
it 'returns array when output has corrections_made as symbol key' do
output = { corrections_made: ['Fix 1'] }
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
end
it 'returns array when output has corrections_made as string key' do
output = { 'corrections_made' => ['Fix 1'] }
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
end
it 'returns empty array when output is not a hash' do
expect(service.send(:extract_corrections, 'string')).to eq([])
end
it 'returns empty array when corrections_made is missing' do
expect(service.send(:extract_corrections, {})).to eq([])
end
end
describe 'End-to-End Tests' do
context 'when making real API call (stubbed at HTTP level)' do
let(:openai_response) do
{
id: 'chatcmpl-456',
object: 'chat.completion',
created: 1_677_652_300,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: JSON.generate({
corrected_text: 'They\'re going to the store tomorrow and they\'re bringing their friends.',
corrections_made: [
'Changed "their" to "They\'re"',
'Changed "too" to "to"',
'Fixed spelling: "tommorow" to "tomorrow"'
]
})
},
finish_reason: 'stop'
}
]
}
end
before do
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
end
it 'successfully fixes grammar through entire stack' do
response = service.execute
expect(response[:success]).to be true
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
expect(response[:original_text]).to eq(text)
end
end
end
end