From 990b58fe629e64f29fb5743feb67c7aec4062c28 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 28 Oct 2025 16:51:45 +0530 Subject: [PATCH] test: rewrite services --- .../assistant/change_tone_service_spec.rb | 250 ++++++++++++++++++ .../assistant/fix_grammar_service_spec.rb | 249 +++++++++++++++++ 2 files changed, 499 insertions(+) create mode 100644 spec/enterprise/services/captain/assistant/change_tone_service_spec.rb create mode 100644 spec/enterprise/services/captain/assistant/fix_grammar_service_spec.rb diff --git a/spec/enterprise/services/captain/assistant/change_tone_service_spec.rb b/spec/enterprise/services/captain/assistant/change_tone_service_spec.rb new file mode 100644 index 000000000..6eb588e1e --- /dev/null +++ b/spec/enterprise/services/captain/assistant/change_tone_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/captain/assistant/fix_grammar_service_spec.rb b/spec/enterprise/services/captain/assistant/fix_grammar_service_spec.rb new file mode 100644 index 000000000..9d593a85f --- /dev/null +++ b/spec/enterprise/services/captain/assistant/fix_grammar_service_spec.rb @@ -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