feat(captain): use Responses API for PDF FAQs
This commit is contained in:
+2
-2
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user