diff --git a/config/llm.yml b/config/llm.yml index 86455f25b..afd2c5d1b 100644 --- a/config/llm.yml +++ b/config/llm.yml @@ -183,8 +183,8 @@ features: default: gpt-5.6-terra reasoning_effort: medium pdf_faq_generation: - models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2] - default: gpt-4.1-mini + models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2, gpt-5.6-luna, gpt-5.6-terra, gpt-5.6-sol] + default: gpt-5.6-terra reasoning_effort: medium help_center_article_generation: models: diff --git a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb index 4d842b071..d87998963 100644 --- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb @@ -4,6 +4,30 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService # Default pages per chunk - easily configurable DEFAULT_PAGES_PER_CHUNK = 10 MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops + FAQ_RESPONSE_SCHEMA = { + name: 'pdf_faq_generation', + schema: { + type: 'object', + properties: { + faqs: { + type: 'array', + items: { + type: 'object', + properties: { + question: { type: 'string' }, + answer: { type: 'string' } + }, + required: %w[question answer], + additionalProperties: false + } + }, + has_content: { type: 'boolean' } + }, + required: %w[faqs has_content], + additionalProperties: false + }, + strict: true + }.freeze attr_reader :total_pages_processed, :iterations_completed @@ -15,7 +39,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService @max_pages = options[:max_pages] # Optional limit from UI @total_pages_processed = 0 @iterations_completed = 0 - @model = Llm::FeatureRouter.resolve(feature: 'pdf_faq_generation', account: document.account)[:model] + @route = Llm::FeatureRouter.resolve(feature: 'pdf_faq_generation', account: document.account) + @model = @route[:model] end def generate @@ -98,12 +123,17 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService end def process_page_chunk(start_page, end_page) - params = build_chunk_parameters(start_page, end_page) - - instrumentation_params = build_instrumentation_params(params, start_page, end_page) + messages = build_chunk_messages(start_page, end_page) + instrumentation_params = build_instrumentation_params(messages, start_page, end_page) response = instrument_llm_call(instrumentation_params) do - @client.chat(parameters: params) + responses_client.create( + model: @model, + messages: messages, + schema: FAQ_RESPONSE_SCHEMA, + reasoning_effort: @route[:reasoning_effort], + metadata: document_metadata.merge(start_page: start_page, end_page: end_page) + ) end result = parse_chunk_response(response) @@ -113,17 +143,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService { faqs: [], has_content: false } end - def build_chunk_parameters(start_page, end_page) - { - model: @model, - response_format: { type: 'json_object' }, - messages: [ - { - role: 'user', - content: build_user_content(start_page, end_page) - } - ] - } + def build_chunk_messages(start_page, end_page) + [ + { + role: 'user', + content: build_user_content(start_page, end_page) + } + ] end def build_user_content(start_page, end_page) @@ -171,7 +197,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService end def parse_chunk_response(response) - content = response.dig('choices', 0, 'message', 'content') + content = response[:message] return { 'faqs' => [], 'has_content' => false } if content.nil? JSON.parse(sanitize_json_response(content)) @@ -208,13 +234,13 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService common_words.size.to_f / total_words end - def build_instrumentation_params(params, start_page, end_page) + def build_instrumentation_params(messages, start_page, end_page) { span_name: 'llm.paginated_faq_generation', account_id: @document&.account_id, feature_name: 'paginated_faq_generation', model: @model, - messages: params[:messages], + messages: messages, metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1) } end @@ -222,4 +248,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService def document_metadata @document&.to_llm_metadata || {} end + + def responses_client + @responses_client ||= Llm::ResponsesClient.new(api_key: nil, client: @client) + end end diff --git a/lib/llm/responses_client.rb b/lib/llm/responses_client.rb index ff36970f3..8081b2859 100644 --- a/lib/llm/responses_client.rb +++ b/lib/llm/responses_client.rb @@ -129,12 +129,24 @@ class Llm::ResponsesClient { type: 'input_text', text: normalized_part[:text] } when 'image_url' { type: 'input_image', image_url: normalized_part.dig(:image_url, :url) || normalized_part[:image_url] } + when 'file' + input_file_part(normalized_part) else normalized_part end end end + def input_file_part(part) + file = part[:file] || {} + { + type: 'input_file', + file_id: file[:file_id] || part[:file_id], + file_url: file[:file_url] || part[:file_url], + detail: part[:detail] + }.compact + end + def text_format(schema) return DEFAULT_TEXT_FORMAT unless schema 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 7fc22dab9..4c163a579 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 @@ -35,28 +35,38 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do context 'when generating FAQs from PDF pages' do let(:faq_response) do { - 'choices' => [{ - 'message' => { - 'content' => JSON.generate({ - 'faqs' => [ - { 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' } - ], - 'has_content' => true - }) - } + 'id' => 'resp-123', + 'status' => 'completed', + 'model' => service.model, + 'output' => [{ + 'type' => 'message', + 'content' => [{ + 'type' => 'output_text', + 'text' => JSON.generate({ + 'faqs' => [ + { 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' } + ], + 'has_content' => true + }) + }] }] } end let(:empty_response) do { - 'choices' => [{ - 'message' => { - 'content' => JSON.generate({ - 'faqs' => [], - 'has_content' => false - }) - } + 'id' => 'resp-456', + 'status' => 'completed', + 'model' => service.model, + 'output' => [{ + 'type' => 'message', + 'content' => [{ + 'type' => 'output_text', + 'text' => JSON.generate({ + 'faqs' => [], + 'has_content' => false + }) + }] }] } end @@ -66,16 +76,38 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do end it 'generates FAQs from paginated content' do - allow(openai_client).to receive(:chat).and_return(faq_response, empty_response) + allow(openai_client).to receive(:json_post).and_return(faq_response, empty_response) faqs = service.generate expect(faqs).to have_attributes(size: 1) expect(faqs.first['question']).to eq('What is this document about?') + expect(openai_client).to have_received(:json_post).with( + path: '/responses', + parameters: hash_including( + model: service.model, + reasoning: { effort: Llm::Models.reasoning_effort_for('pdf_faq_generation') }, + text: { + format: hash_including( + type: 'json_schema', + name: 'pdf_faq_generation', + strict: true + ) + }, + input: [ + hash_including( + content: array_including( + { type: 'input_file', file_id: 'file-123' }, + hash_including(type: 'input_text') + ) + ) + ] + ) + ).at_least(:once) end it 'stops when no more content' do - allow(openai_client).to receive(:chat).and_return(empty_response) + allow(openai_client).to receive(:json_post).and_return(empty_response) faqs = service.generate @@ -83,7 +115,7 @@ RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do end it 'respects max iterations limit' do - allow(openai_client).to receive(:chat).and_return(faq_response) + allow(openai_client).to receive(:json_post).and_return(faq_response) # Force max iterations service.instance_variable_set(:@iterations_completed, 19) diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb index a2c02b198..c36ca2459 100644 --- a/spec/lib/llm/models_spec.rb +++ b/spec/lib/llm/models_spec.rb @@ -45,7 +45,7 @@ RSpec.describe Llm::Models do it 'exposes GPT-5.6 models for Captain V2 workflows without changing the legacy PDF path' do expect(described_class.models_for('assistant')).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol') - expect(described_class.models_for('pdf_faq_generation')).not_to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol') + expect(described_class.models_for('pdf_faq_generation')).to include('gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol') end end diff --git a/spec/lib/llm/responses_client_spec.rb b/spec/lib/llm/responses_client_spec.rb index 61c120b2f..41ce0ef94 100644 --- a/spec/lib/llm/responses_client_spec.rb +++ b/spec/lib/llm/responses_client_spec.rb @@ -193,7 +193,8 @@ RSpec.describe Llm::ResponsesClient do role: 'user', content: [ { type: 'text', text: 'What is in this image?' }, - { type: 'image_url', image_url: { url: 'https://example.com/image.png' } } + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, + { type: 'file', file: { file_id: 'file-123' } } ] } ] @@ -205,7 +206,8 @@ RSpec.describe Llm::ResponsesClient do role: 'user', content: [ { type: 'input_text', text: 'What is in this image?' }, - { type: 'input_image', image_url: 'https://example.com/image.png' } + { type: 'input_image', image_url: 'https://example.com/image.png' }, + { type: 'input_file', file_id: 'file-123' } ] } ]