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'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Captain::PromptRenderer do
|
RSpec.describe Captain::PromptRenderer do
|
||||||
let(:template_name) { 'test_template' }
|
let(:template_name) { 'test_template' }
|
||||||
let(:template_content) { 'Hello {{name}}, your balance is {{balance}}' }
|
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 } }
|
let(:context) { { name: 'John', balance: 100 } }
|
||||||
|
|
||||||
before do
|
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?).and_return(false)
|
||||||
allow(File).to receive(:exist?).with(template_path).and_return(true)
|
allow(File).to receive(:read)
|
||||||
allow(File).to receive(:read).with(template_path).and_return(template_content)
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
describe '.render' do
|
describe '.render' do
|
||||||
@@ -39,7 +46,7 @@ RSpec.describe Captain::PromptRenderer do
|
|||||||
nested_template = 'User: {{user.name}}, Account: {{user.account.type}}'
|
nested_template = 'User: {{user.name}}, Account: {{user.account.type}}'
|
||||||
nested_context = { user: { name: 'Alice', account: { type: 'premium' } } }
|
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)
|
result = described_class.render(template_name, nested_context)
|
||||||
|
|
||||||
@@ -48,7 +55,7 @@ RSpec.describe Captain::PromptRenderer do
|
|||||||
|
|
||||||
it 'handles empty context' do
|
it 'handles empty context' do
|
||||||
simple_template = 'Hello World'
|
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, {})
|
result = described_class.render(template_name, {})
|
||||||
|
|
||||||
@@ -65,30 +72,41 @@ RSpec.describe Captain::PromptRenderer do
|
|||||||
expect(result).to eq('rendered')
|
expect(result).to eq('rendered')
|
||||||
expect(Liquid::Template).to have_received(:parse).with(template_content)
|
expect(Liquid::Template).to have_received(:parse).with(template_content)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe '.load_template' do
|
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)
|
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
|
end
|
||||||
|
|
||||||
it 'raises error when template does not exist' do
|
it 'raises error when template does not exist in either location' do
|
||||||
allow(File).to receive(:exist?).with(template_path).and_return(false)
|
allow(File).to receive(:exist?).with(oss_template_path).and_return(false)
|
||||||
|
|
||||||
expect { described_class.send(:load_template, template_name) }
|
expect { described_class.send(:load_template, template_name) }
|
||||||
.to raise_error("Template not found: #{template_name}")
|
.to raise_error("Template not found: #{template_name}")
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'constructs correct template path' do
|
it 'checks enterprise path before OSS path' do
|
||||||
expected_path = Rails.root.join('enterprise/lib/captain/prompts/my_template.liquid')
|
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
|
||||||
allow(File).to receive(:exist?).with(expected_path).and_return(true)
|
allow(File).to receive(:read).with(enterprise_template_path).and_return('enterprise')
|
||||||
allow(File).to receive(:read).with(expected_path).and_return('test content')
|
|
||||||
|
|
||||||
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
|
||||||
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