From e580fd8d21188d97ae2ed95b9e77f684f70e50c5 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Tue, 12 Aug 2025 13:46:21 +0530 Subject: [PATCH] fix the rspec --- .../documents/response_builder_job_spec.rb | 248 ++++++-- .../jobs/captain/response_builder_job_spec.rb | 191 ------ .../paginated_faq_generator_service_spec.rb | 546 ++++++++---------- 3 files changed, 470 insertions(+), 515 deletions(-) delete mode 100644 spec/enterprise/jobs/captain/response_builder_job_spec.rb diff --git a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb index c91a3a65a..6182fcbd1 100644 --- a/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/documents/response_builder_job_spec.rb @@ -1,47 +1,229 @@ require 'rails_helper' RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do - let(:assistant) { create(:captain_assistant) } - let(:document) { create(:captain_document, assistant: assistant) } - let(:faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) } - let(:faqs) do - [ - { 'question' => 'What is Ruby?', 'answer' => 'A programming language' }, - { 'question' => 'What is Rails?', 'answer' => 'A web framework' } - ] - end - - before do - allow(Captain::Llm::FaqGeneratorService).to receive(:new) - .with(document.content) - .and_return(faq_generator) - allow(faq_generator).to receive(:generate).and_return(faqs) - end + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:document) { create(:captain_document, assistant: assistant, account: account) } describe '#perform' do - context 'when processing a document' do - it 'deletes previous responses' do - existing_response = create(:captain_assistant_response, documentable: document) + before do + # Mock the InstallationConfig for services that need it + allow(InstallationConfig).to receive(:find_by!) + .with(name: 'CAPTAIN_OPEN_AI_API_KEY') + .and_return(instance_double(InstallationConfig, value: 'test-api-key')) + end - described_class.new.perform(document) - - expect { existing_response.reload }.to raise_error(ActiveRecord::RecordNotFound) + context 'when document requires pagination' do + before do + allow(document).to receive(:pdf_document?).and_return(true) + allow(document).to receive(:openai_file_id).and_return('file-123') + allow(document).to receive(:update!).and_return(true) + allow(document).to receive(:metadata).and_return({}) end - it 'creates new responses for each FAQ' do - expect do - described_class.new.perform(document) - end.to change(Captain::AssistantResponse, :count).by(2) + it 'uses paginated FAQ generator service' do + paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - responses = document.responses.reload - expect(responses.count).to eq(2) + expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new) + .with(document, hash_including(:pages_per_chunk, :max_pages)) + .and_return(paginated_service) - first_response = responses.first - expect(first_response.question).to eq('What is Ruby?') - expect(first_response.answer).to eq('A programming language') - expect(first_response.assistant).to eq(assistant) - expect(first_response.documentable).to eq(document) + expect(paginated_service).to receive(:generate).and_return([]) + expect(paginated_service).to receive(:total_pages_processed).and_return(10) + expect(paginated_service).to receive(:iterations_completed).and_return(1) + + described_class.perform_now(document) + end + + it 'accepts pagination options' do + options = { pages_per_chunk: 5, max_pages: 20 } + paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) + + expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new) + .with(document, pages_per_chunk: 5, max_pages: 20) + .and_return(paginated_service) + + expect(paginated_service).to receive(:generate).and_return([]) + expect(paginated_service).to receive(:total_pages_processed).and_return(20) + expect(paginated_service).to receive(:iterations_completed).and_return(4) + + described_class.perform_now(document, options) + end + + it 'stores metadata about paginated generation' do + paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) + + allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new) + .and_return(paginated_service) + allow(paginated_service).to receive(:generate).and_return([]) + allow(paginated_service).to receive(:total_pages_processed).and_return(15) + allow(paginated_service).to receive(:iterations_completed).and_return(2) + + expect(document).to receive(:update!).with( + metadata: hash_including( + 'faq_generation' => hash_including( + 'method' => 'paginated', + 'pages_processed' => 15, + 'iterations' => 2 + ) + ) + ) + + described_class.perform_now(document) + end + end + + context 'when document uses standard generation' do + before do + allow(document).to receive(:pdf_document?).and_return(false) + allow(document).to receive(:content).and_return('Document content') + end + + it 'uses standard FAQ generator service' do + standard_service = instance_double(Captain::Llm::FaqGeneratorService) + + expect(Captain::Llm::FaqGeneratorService).to receive(:new) + .with('Document content') + .and_return(standard_service) + + expect(standard_service).to receive(:generate).and_return([]) + + described_class.perform_now(document) + end + end + + context 'when FAQs are generated successfully' do + let(:faqs) do + [ + { 'question' => 'What is this?', 'answer' => 'This is a test' }, + { 'question' => 'How does it work?', 'answer' => 'It works well' } + ] + end + + before do + allow(document).to receive(:pdf_document?).and_return(false) + allow(document).to receive(:content).and_return('Document content') + + standard_service = instance_double(Captain::Llm::FaqGeneratorService) + allow(Captain::Llm::FaqGeneratorService).to receive(:new).and_return(standard_service) + allow(standard_service).to receive(:generate).and_return(faqs) + end + + it 'creates response records from FAQs' do + expect { described_class.perform_now(document) } + .to change { document.responses.count }.by(2) + end + + it 'creates responses with correct attributes' do + described_class.perform_now(document) + + response = document.responses.first + expect(response.question).to eq('What is this?') + expect(response.answer).to eq('This is a test') + expect(response.assistant).to eq(assistant) + expect(response.documentable).to eq(document) + end + + it 'removes previous responses before creating new ones' do + # Create existing responses + document.responses.create!( + question: 'Old question', + answer: 'Old answer', + assistant: assistant, + documentable: document + ) + + expect(document.responses.count).to eq(1) + + described_class.perform_now(document) + + expect(document.responses.count).to eq(2) + expect(document.responses.pluck(:question)).not_to include('Old question') + end + end + + context 'when FAQ generation fails' do + before do + allow(document).to receive(:pdf_document?).and_return(false) + allow(document).to receive(:content).and_return('Document content') + end + + it 'propagates the error' do + standard_service = instance_double(Captain::Llm::FaqGeneratorService) + allow(Captain::Llm::FaqGeneratorService).to receive(:new).and_return(standard_service) + allow(standard_service).to receive(:generate).and_raise(StandardError, 'Generation failed') + + expect { described_class.perform_now(document) } + .to raise_error(StandardError, 'Generation failed') + end + end + + context 'when creating response fails' do + let(:faqs) do + [ + { 'question' => nil, 'answer' => 'Invalid FAQ' } # Invalid due to nil question + ] + end + + before do + allow(document).to receive(:pdf_document?).and_return(false) + allow(document).to receive(:content).and_return('Document content') + + standard_service = instance_double(Captain::Llm::FaqGeneratorService) + allow(Captain::Llm::FaqGeneratorService).to receive(:new).and_return(standard_service) + allow(standard_service).to receive(:generate).and_return(faqs) + end + + it 'logs the error and continues' do + expect(Rails.logger).to receive(:error).with(/Error in creating response document/) + + expect { described_class.perform_now(document) } + .not_to raise_error end end end + + describe 'job queuing' do + it 'is enqueued in the low queue' do + expect do + described_class.perform_later(document) + end.to have_enqueued_job(described_class).on_queue('low') + end + + it 'can be scheduled for later execution' do + expect do + described_class.set(wait: 5.minutes).perform_later(document) + end.to have_enqueued_job(described_class).at(a_value_within(1.second).of(5.minutes.from_now)) + end + end + + describe '#should_use_pagination?' do + subject { described_class.new.send(:should_use_pagination?, document) } + + context 'when document is a PDF with openai_file_id' do + before do + allow(document).to receive(:pdf_document?).and_return(true) + allow(document).to receive(:openai_file_id).and_return('file-123') + end + + it { is_expected.to be true } + end + + context 'when document is a PDF without openai_file_id' do + before do + allow(document).to receive(:pdf_document?).and_return(true) + allow(document).to receive(:openai_file_id).and_return(nil) + end + + it { is_expected.to be false } + end + + context 'when document is not a PDF' do + before do + allow(document).to receive(:pdf_document?).and_return(false) + end + + it { is_expected.to be false } + end + end end diff --git a/spec/enterprise/jobs/captain/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/response_builder_job_spec.rb deleted file mode 100644 index 35cc838e5..000000000 --- a/spec/enterprise/jobs/captain/response_builder_job_spec.rb +++ /dev/null @@ -1,191 +0,0 @@ -require 'rails_helper' - -RSpec.describe Captain::ResponseBuilderJob, type: :job do - let(:account) { create(:account) } - let(:assistant) { create(:captain_assistant, account: account) } - let(:document) { create(:captain_document, assistant: assistant, account: account) } - let(:user) { create(:user, account: account) } - - describe '#perform' do - let(:job_params) do - { - account_id: account.id, - assistant_id: assistant.id, - document_id: document.id, - user_id: user.id, - requested_faq_count: 10 - } - end - - context 'when generating FAQs with pagination' do - it 'calls paginated FAQ generator service' do - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).with( - document: document, - assistant: assistant, - requested_faq_count: 10 - ).and_return(paginated_service) - expect(paginated_service).to receive(:perform) - - described_class.perform_now(job_params) - end - - it 'handles different FAQ count requests' do - [5, 10, 15, 20].each do |count| - params = job_params.merge(requested_faq_count: count) - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - - expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).with( - document: document, - assistant: assistant, - requested_faq_count: count - ).and_return(paginated_service) - expect(paginated_service).to receive(:perform) - - described_class.perform_now(params) - end - end - - it 'stores metadata about FAQ generation' do - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).and_return(paginated_service) - allow(paginated_service).to receive(:perform).and_return({ - faqs_generated: 10, - pages_processed: 2, - generation_time: 5.2 - }) - - described_class.perform_now(job_params) - - expect(document.reload.metadata['faq_generation']).to include( - 'faqs_generated' => 10, - 'pages_processed' => 2, - 'generation_time' => 5.2 - ) - end - end - - context 'when FAQ count is not specified' do - let(:job_params_without_count) do - { - account_id: account.id, - assistant_id: assistant.id, - document_id: document.id, - user_id: user.id - } - end - - it 'uses default FAQ count' do - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).with( - document: document, - assistant: assistant, - requested_faq_count: 10 # default value - ).and_return(paginated_service) - expect(paginated_service).to receive(:perform) - - described_class.perform_now(job_params_without_count) - end - end - - context 'when generation strategy is specified' do - let(:job_params_with_strategy) do - job_params.merge(generation_strategy: 'chunked') - end - - it 'passes strategy to the service' do - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).with( - document: document, - assistant: assistant, - requested_faq_count: 10, - strategy: 'chunked' - ).and_return(paginated_service) - expect(paginated_service).to receive(:perform) - - described_class.perform_now(job_params_with_strategy) - end - end - - context 'when pagination is required for large documents' do - it 'processes document in chunks' do - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).and_return(paginated_service) - - expect(paginated_service).to receive(:perform) do - # Simulate paginated processing - 3.times do |page| - # Process each page - end - { faqs_generated: 30, pages_processed: 3 } - end - - described_class.perform_now(job_params.merge(requested_faq_count: 30)) - end - end - - context 'when service raises an error' do - it 'propagates the error' do - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).and_return(paginated_service) - allow(paginated_service).to receive(:perform).and_raise(StandardError, 'Generation failed') - - expect { described_class.perform_now(job_params) } - .to raise_error(StandardError, 'Generation failed') - end - - it 'logs the error details' do - paginated_service = instance_double(Captain::Llm::PaginatedFaqGeneratorService) - allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).and_return(paginated_service) - allow(paginated_service).to receive(:perform).and_raise(StandardError, 'Generation failed') - - expect(Rails.logger).to receive(:error).with(/FAQ generation failed/) - - expect { described_class.perform_now(job_params) } - .to raise_error(StandardError) - end - end - - context 'when document is not found' do - it 'raises ActiveRecord::RecordNotFound' do - invalid_params = job_params.merge(document_id: -1) - - expect { described_class.perform_now(invalid_params) } - .to raise_error(ActiveRecord::RecordNotFound) - end - end - - context 'when assistant is not found' do - it 'raises ActiveRecord::RecordNotFound' do - invalid_params = job_params.merge(assistant_id: -1) - - expect { described_class.perform_now(invalid_params) } - .to raise_error(ActiveRecord::RecordNotFound) - end - end - end - - describe 'job queuing' do - it 'is enqueued in the default queue' do - expect do - described_class.perform_later( - account_id: account.id, - assistant_id: assistant.id, - document_id: document.id, - user_id: user.id - ) - end.to have_enqueued_job(described_class).on_queue('default') - end - - it 'can be scheduled for later execution' do - expect do - described_class.set(wait: 5.minutes).perform_later( - account_id: account.id, - assistant_id: assistant.id, - document_id: document.id, - user_id: user.id - ) - end.to have_enqueued_job(described_class).at(5.minutes.from_now) - end - end -end diff --git a/spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb b/spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb index e366e10d6..aa283f4b0 100644 --- a/spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb +++ b/spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb @@ -4,344 +4,308 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) } let(:document) { create(:captain_document, assistant: assistant, account: account) } - let(:service) { described_class.new(document: document, assistant: assistant, requested_faq_count: 10) } + let(:openai_client) { instance_double(OpenAI::Client) } + + let(:service) do + # Mock the InstallationConfig for base class initialization + allow(InstallationConfig).to receive(:find_by!).with(name: 'CAPTAIN_OPEN_AI_API_KEY') + .and_return(instance_double(InstallationConfig, value: 'test-api-key')) + allow(OpenAI::Client).to receive(:new).and_return(openai_client) + described_class.new(document, { pages_per_chunk: 10 }) + end describe '#initialize' do - it 'initializes with document, assistant, and requested FAQ count' do + it 'initializes with document and options' do expect(service.instance_variable_get(:@document)).to eq(document) - expect(service.instance_variable_get(:@assistant)).to eq(assistant) - expect(service.instance_variable_get(:@requested_faq_count)).to eq(10) + expect(service.instance_variable_get(:@pages_per_chunk)).to eq(10) end - it 'uses default FAQ count when not specified' do - default_service = described_class.new(document: document, assistant: assistant) - expect(default_service.instance_variable_get(:@requested_faq_count)).to eq(10) + it 'uses default pages per chunk when not specified' do + default_service = described_class.new(document) + expect(default_service.instance_variable_get(:@pages_per_chunk)).to eq(10) end - it 'accepts custom strategy' do - chunked_service = described_class.new( - document: document, - assistant: assistant, - requested_faq_count: 15, - strategy: 'chunked' - ) - expect(chunked_service.instance_variable_get(:@strategy)).to eq('chunked') + it 'accepts max_pages option' do + service_with_max = described_class.new(document, { max_pages: 50 }) + expect(service_with_max.instance_variable_get(:@max_pages)).to eq(50) end end - describe '#perform' do - let(:openai_client) { instance_double(OpenAI::Client) } - let(:completions_api) { instance_double(OpenAI::Completions) } + describe '#generate' do + context 'when document has no openai_file_id' do + before do + allow(document).to receive(:openai_file_id).and_return(nil) + end - before do - allow(service).to receive(:openai_client).and_return(openai_client) - allow(openai_client).to receive(:completions).and_return(completions_api) + it 'raises an error' do + expect { service.generate }.to raise_error('Document must have openai_file_id for paginated processing') + end end - context 'when generating FAQs successfully' do - let(:faq_response) do - { - 'choices' => [ - { - 'message' => { - 'content' => JSON.generate([ - { 'question' => 'What is the product?', 'answer' => 'It is a great product.' }, - { 'question' => 'How does it work?', 'answer' => 'It works seamlessly.' }, - { 'question' => 'What are the features?', 'answer' => 'Many amazing features.' }, - { 'question' => 'Is it secure?', 'answer' => 'Yes, very secure.' }, - { 'question' => 'What is the pricing?', 'answer' => 'Competitive pricing.' } - ]) + context 'when document has openai_file_id' do + before do + allow(document).to receive(:openai_file_id).and_return('file-123') + end + + context 'when generating FAQs successfully' do + let(:faq_response) do + { + 'choices' => [ + { + 'message' => { + 'content' => JSON.generate({ + 'faqs' => [ + { 'question' => 'What is the product?', 'answer' => 'It is a great product.' }, + { 'question' => 'How does it work?', 'answer' => 'It works seamlessly.' } + ], + 'has_content' => true + }) + } } - } - ] - } + ] + } + end + + let(:empty_response) do + { + 'choices' => [ + { + 'message' => { + 'content' => JSON.generate({ + 'faqs' => [], + 'has_content' => false + }) + } + } + ] + } + end + + it 'generates FAQs from document' do + allow(openai_client).to receive(:chat).and_return(faq_response, empty_response) + + result = service.generate + expect(result).to be_an(Array) + expect(result.first).to include('question', 'answer') + end + + it 'processes multiple chunks until no more content' do + allow(openai_client).to receive(:chat).and_return(faq_response, faq_response, empty_response) + + result = service.generate + expect(result.count).to eq(2) # Deduplication removes duplicates + end + + it 'deduplicates similar FAQs' do + duplicate_response = { + 'choices' => [ + { + 'message' => { + 'content' => JSON.generate({ + 'faqs' => [ + { 'question' => 'What is the product?', 'answer' => 'It is a great product.' }, + { 'question' => 'What is the product?', 'answer' => 'Different answer.' } + ], + 'has_content' => false + }) + } + } + ] + } + + allow(openai_client).to receive(:chat).and_return(duplicate_response) + + result = service.generate + expect(result.count).to eq(1) + end + + it 'tracks total pages processed' do + allow(openai_client).to receive(:chat).and_return(faq_response, faq_response, empty_response) + + service.generate + expect(service.total_pages_processed).to eq(30) # 3 chunks of 10 pages each + end + + it 'tracks iterations completed' do + allow(openai_client).to receive(:chat).and_return(faq_response, faq_response, empty_response) + + service.generate + expect(service.iterations_completed).to eq(3) + end end - before do - allow(completions_api).to receive(:create).and_return(faq_response) - allow(service).to receive(:fetch_document_content).and_return('Document content') + context 'when API returns error' do + before do + allow(openai_client).to receive(:chat).and_raise(OpenAI::Error, 'API error') + end + + it 'returns empty array and logs error' do + expect(Rails.logger).to receive(:error).with(/Error processing pages/) + + result = service.generate + expect(result).to eq([]) + end end - it 'generates requested number of FAQs' do - result = service.perform - expect(result[:faqs_generated]).to eq(5) + context 'when response is malformed' do + let(:malformed_response) do + { + 'choices' => [ + { + 'message' => { + 'content' => 'Invalid JSON' + } + } + ] + } + end + + before do + allow(openai_client).to receive(:chat).and_return(malformed_response) + end + + it 'handles JSON parsing errors gracefully' do + expect(Rails.logger).to receive(:error).with(/Error parsing chunk response/) + + result = service.generate + expect(result).to eq([]) + end + end + end + end + + describe '#should_continue_processing?' do + let(:chunk_result_with_faqs) { { faqs: [{ 'question' => 'Q1', 'answer' => 'A1' }], has_content: true } } + let(:chunk_result_empty) { { faqs: [], has_content: false } } + + it 'returns false when max iterations reached' do + service.instance_variable_set(:@iterations_completed, 20) + expect(service.should_continue_processing?(chunk_result_with_faqs)).to be false + end + + it 'returns false when max pages reached' do + service.instance_variable_set(:@max_pages, 30) + service.instance_variable_set(:@total_pages_processed, 30) + expect(service.should_continue_processing?(chunk_result_with_faqs)).to be false + end + + it 'returns false when no FAQs returned' do + expect(service.should_continue_processing?({ faqs: [], has_content: true })).to be false + end + + it 'returns false when has_content is false' do + expect(service.should_continue_processing?({ faqs: ['faq'], has_content: false })).to be false + end + + it 'returns true when should continue' do + expect(service.should_continue_processing?(chunk_result_with_faqs)).to be true + end + end + + describe 'private methods' do + describe '#similarity_score' do + it 'returns 1 for identical strings' do + score = service.send(:similarity_score, 'Hello world', 'Hello world') + expect(score).to eq(1.0) end - it 'returns FAQ data' do - result = service.perform - expect(result[:faqs]).to be_an(Array) - expect(result[:faqs].first).to include('question', 'answer') + it 'returns 0 for completely different strings' do + score = service.send(:similarity_score, 'Hello', 'Goodbye') + expect(score).to eq(0.0) end - it 'tracks generation metadata' do - result = service.perform - expect(result).to include(:faqs_generated, :pages_processed, :generation_time) + it 'calculates partial similarity correctly' do + score = service.send(:similarity_score, 'Hello world', 'Hello there') + expect(score).to be_between(0.3, 0.4) end end - context 'when using pagination for large documents' do - let(:large_content) { 'Large document content ' * 5000 } - - before do - allow(service).to receive(:fetch_document_content).and_return(large_content) - allow(service).to receive(:needs_pagination?).and_return(true) - end - - it 'processes document in chunks' do - expect(service).to receive(:generate_faqs_in_chunks).and_call_original - - allow(completions_api).to receive(:create).and_return( - 'choices' => [{ 'message' => { 'content' => '[]' } }] - ) - - service.perform - end - - it 'aggregates FAQs from multiple pages' do - page1_faqs = [ - { 'question' => 'Q1', 'answer' => 'A1' }, - { 'question' => 'Q2', 'answer' => 'A2' } + describe '#deduplicate_faqs' do + let(:faqs_with_duplicates) do + [ + { 'question' => 'What is the product?', 'answer' => 'Answer 1' }, + { 'question' => 'What is the product?', 'answer' => 'Answer 2' }, + { 'question' => 'What is the product really?', 'answer' => 'Answer 3' }, + { 'question' => 'How does it work?', 'answer' => 'Answer 4' } ] - page2_faqs = [ - { 'question' => 'Q3', 'answer' => 'A3' }, - { 'question' => 'Q4', 'answer' => 'A4' } - ] - - allow(completions_api).to receive(:create).and_return( - { 'choices' => [{ 'message' => { 'content' => JSON.generate(page1_faqs) } }] }, - { 'choices' => [{ 'message' => { 'content' => JSON.generate(page2_faqs) } }] } - ) - - result = service.perform - expect(result[:faqs]).to eq(page1_faqs + page2_faqs) - expect(result[:pages_processed]).to eq(2) end - it 'respects requested FAQ count limit' do - many_faqs = (1..20).map { |i| { 'question' => "Q#{i}", 'answer' => "A#{i}" } } + it 'removes exact duplicates' do + result = service.send(:deduplicate_faqs, faqs_with_duplicates) + questions = result.map { |f| f['question'] } + expect(questions.count('What is the product?')).to eq(1) + end - allow(completions_api).to receive(:create).and_return( - 'choices' => [{ 'message' => { 'content' => JSON.generate(many_faqs) } }] - ) - - service_with_limit = described_class.new( - document: document, - assistant: assistant, - requested_faq_count: 10 - ) - allow(service_with_limit).to receive(:fetch_document_content).and_return(large_content) - - result = service_with_limit.perform - expect(result[:faqs].count).to be <= 10 + it 'removes similar questions' do + result = service.send(:deduplicate_faqs, faqs_with_duplicates) + expect(result.count).to be < faqs_with_duplicates.count end end - context 'when using different generation strategies' do - it 'uses default strategy' do - allow(service).to receive(:fetch_document_content).and_return('Content') - expect(service).to receive(:generate_with_default_strategy) - - service.perform + describe '#determine_stop_reason' do + it 'identifies max iterations reason' do + service.instance_variable_set(:@iterations_completed, 20) + reason = service.send(:determine_stop_reason, { faqs: ['faq'] }) + expect(reason).to eq('Maximum iterations reached') end - it 'uses chunked strategy when specified' do - chunked_service = described_class.new( - document: document, - assistant: assistant, - strategy: 'chunked' - ) - allow(chunked_service).to receive(:fetch_document_content).and_return('Content') - expect(chunked_service).to receive(:generate_with_chunked_strategy) - - chunked_service.perform + it 'identifies max pages reason' do + service.instance_variable_set(:@max_pages, 30) + service.instance_variable_set(:@total_pages_processed, 30) + reason = service.send(:determine_stop_reason, { faqs: ['faq'] }) + expect(reason).to eq('Maximum pages processed') end - it 'uses smart chunking for optimal results' do - smart_service = described_class.new( - document: document, - assistant: assistant, - strategy: 'smart' - ) - allow(smart_service).to receive(:fetch_document_content).and_return('Content') - expect(smart_service).to receive(:generate_with_smart_chunking) + it 'identifies no content reason' do + reason = service.send(:determine_stop_reason, { faqs: [] }) + expect(reason).to eq('No content found in last chunk') + end - smart_service.perform + it 'identifies end of document reason' do + reason = service.send(:determine_stop_reason, { faqs: ['faq'], has_content: false }) + expect(reason).to eq('End of document reached') end end - context 'when generation fails' do + describe '#calculate_end_page' do + it 'calculates end page correctly' do + service.instance_variable_set(:@pages_per_chunk, 10) + expect(service.send(:calculate_end_page, 1)).to eq(10) + expect(service.send(:calculate_end_page, 11)).to eq(20) + end + + it 'respects max_pages limit' do + service.instance_variable_set(:@pages_per_chunk, 10) + service.instance_variable_set(:@max_pages, 15) + expect(service.send(:calculate_end_page, 11)).to eq(15) + end + end + + describe '#build_chunk_parameters' do before do - allow(service).to receive(:fetch_document_content).and_return('Content') - allow(completions_api).to receive(:create).and_raise(StandardError, 'API error') + allow(document).to receive(:openai_file_id).and_return('file-123') + allow(Captain::Llm::SystemPromptsService).to receive(:paginated_faq_generator) + .and_return('Generate FAQs from pages') end - it 'raises the error' do - expect { service.perform }.to raise_error(StandardError, 'API error') + it 'builds correct parameters structure' do + params = service.send(:build_chunk_parameters, 1, 10) + + expect(params[:model]).to eq('gpt-4.1-mini') + expect(params[:response_format]).to eq({ type: 'json_object' }) + expect(params[:messages]).to be_an(Array) + expect(params[:messages].first[:role]).to eq('user') end - it 'logs the error with context' do - expect(Rails.logger).to receive(:error).with(/FAQ generation failed for document/) + it 'includes file reference and prompt' do + params = service.send(:build_chunk_parameters, 1, 10) + user_content = params[:messages].first[:content] - expect { service.perform }.to raise_error(StandardError) + expect(user_content).to be_an(Array) + expect(user_content.first[:type]).to eq('file') + expect(user_content.first[:file][:file_id]).to eq('file-123') + expect(user_content.last[:type]).to eq('text') end end - - context 'when response is malformed' do - before do - allow(service).to receive(:fetch_document_content).and_return('Content') - allow(completions_api).to receive(:create).and_return( - 'choices' => [{ 'message' => { 'content' => 'Invalid JSON' } }] - ) - end - - it 'handles JSON parsing errors gracefully' do - result = service.perform - expect(result[:faqs]).to eq([]) - expect(result[:error]).to include('Failed to parse FAQ response') - end - end - end - - describe '#fetch_document_content' do - context 'when document has PDF file' do - before do - document.pdf_file.attach( - io: StringIO.new('PDF content'), - filename: 'test.pdf', - content_type: 'application/pdf' - ) - document.metadata['openai_file_id'] = 'file-123' - document.save! - end - - it 'retrieves content from OpenAI files' do - openai_client = instance_double(OpenAI::Client) - files_api = instance_double(OpenAI::Files) - - allow(service).to receive(:openai_client).and_return(openai_client) - allow(openai_client).to receive(:files).and_return(files_api) - allow(files_api).to receive(:content).with(id: 'file-123').and_return('PDF text content') - - content = service.send(:fetch_document_content) - expect(content).to eq('PDF text content') - end - end - - context 'when document has external link' do - before do - document.update!(external_link: 'https://example.com/doc') - end - - it 'fetches content from URL' do - expect(service).to receive(:fetch_url_content).with('https://example.com/doc').and_return('Web content') - - content = service.send(:fetch_document_content) - expect(content).to eq('Web content') - end - end - end - - describe '#needs_pagination?' do - it 'returns true for large content' do - large_content = 'a' * 50_000 - expect(service.send(:needs_pagination?, large_content)).to be true - end - - it 'returns false for small content' do - small_content = 'a' * 1000 - expect(service.send(:needs_pagination?, small_content)).to be false - end - - it 'uses configurable threshold' do - allow(service).to receive(:pagination_threshold).and_return(100) - - expect(service.send(:needs_pagination?, 'a' * 101)).to be true - expect(service.send(:needs_pagination?, 'a' * 99)).to be false - end - end - - describe '#chunk_content' do - let(:content) { 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5' } - - it 'splits content into chunks' do - chunks = service.send(:chunk_content, content, chunk_size: 2) - expect(chunks.count).to eq(3) - end - - it 'maintains chunk size limit' do - chunks = service.send(:chunk_content, content, chunk_size: 3) - expect(chunks[0]).to eq("Line 1\nLine 2\nLine 3") - expect(chunks[1]).to eq("Line 4\nLine 5") - end - - it 'handles overlap between chunks' do - chunks = service.send(:chunk_content, content, chunk_size: 3, overlap: 1) - expect(chunks[0]).to eq("Line 1\nLine 2\nLine 3") - expect(chunks[1]).to eq("Line 3\nLine 4\nLine 5") - end - end - - describe '#build_faq_prompt' do - it 'includes document content in prompt' do - prompt = service.send(:build_faq_prompt, 'Document content', 5) - expect(prompt).to include('Document content') - end - - it 'specifies requested FAQ count' do - prompt = service.send(:build_faq_prompt, 'Content', 7) - expect(prompt).to include('7') - end - - it 'includes JSON format instructions' do - prompt = service.send(:build_faq_prompt, 'Content', 5) - expect(prompt).to include('JSON') - expect(prompt).to include('question') - expect(prompt).to include('answer') - end - end - - describe '#parse_faq_response' do - it 'parses valid JSON array' do - json_string = '[{"question": "Q1", "answer": "A1"}]' - result = service.send(:parse_faq_response, json_string) - expect(result).to eq([{ 'question' => 'Q1', 'answer' => 'A1' }]) - end - - it 'handles empty response' do - result = service.send(:parse_faq_response, '[]') - expect(result).to eq([]) - end - - it 'returns empty array for invalid JSON' do - result = service.send(:parse_faq_response, 'not json') - expect(result).to eq([]) - end - - it 'validates FAQ structure' do - invalid_faqs = '[{"q": "Question without proper key"}]' - result = service.send(:parse_faq_response, invalid_faqs) - expect(result).to eq([]) - end - end - - describe 'performance tracking' do - before do - allow(service).to receive(:fetch_document_content).and_return('Content') - allow(service).to receive(:generate_faqs).and_return([]) - end - - it 'tracks generation time' do - result = service.perform - expect(result[:generation_time]).to be_a(Float) - expect(result[:generation_time]).to be >= 0 - end - - it 'tracks number of API calls' do - allow(service).to receive(:needs_pagination?).and_return(true) - allow(service).to receive(:chunk_content).and_return(%w[chunk1 chunk2]) - - result = service.perform - expect(result[:api_calls]).to eq(2) - end end end