Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be68927a52 | ||
|
|
1fd77fdec2 | ||
|
|
992cf6fd4d | ||
|
|
b3ac1dcd03 | ||
|
|
80caf25962 | ||
|
|
bf035114fe | ||
|
|
990b58fe62 | ||
|
|
52271dc984 | ||
|
|
52d6f2a369 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+35
-17
@@ -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
|
||||
Reference in New Issue
Block a user