Compare commits

...
10 changed files with 231 additions and 54 deletions
+2 -2
View File
@@ -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:
+9 -1
View File
@@ -10,7 +10,8 @@ module Concerns::Agentable
tools: agent_tools,
model: agent_model,
temperature: temperature.presence&.to_f || DEFAULT_TEMPERATURE,
response_schema: agent_response_schema
response_schema: agent_response_schema,
params: agent_params
)
end
@@ -52,6 +53,13 @@ module Concerns::Agentable
installation_model.presence || route[:model]
end
def agent_params
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
return {} if route[:reasoning_effort].blank?
{ reasoning_effort: route[:reasoning_effort] }
end
def installation_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
end
@@ -1,6 +1,23 @@
module Enterprise::Concerns::Article
extend ActiveSupport::Concern
SEARCH_TERMS_FEATURE = 'help_center_article_generation'
SEARCH_TERMS_SCHEMA = {
name: 'article_search_terms',
schema: {
type: 'object',
properties: {
search_terms: {
type: 'array',
items: { type: 'string' }
}
},
required: %w[search_terms],
additionalProperties: false
},
strict: true
}.freeze
included do
after_save :add_article_embedding, if: -> { saved_change_to_title? || saved_change_to_description? || saved_change_to_content? }
@@ -67,23 +84,38 @@ module Enterprise::Concerns::Article
{ role: 'system', content: article_to_search_terms_prompt },
{ role: 'user', content: "title: #{title} \n description: #{description} \n content: #{content}" }
]
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{openai_api_key}" }
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
Rails.logger.info "Requesting Chat GPT with body: #{body}"
response = HTTParty.post(openai_api_url, headers: headers, body: body)
Rails.logger.info "Chat GPT response: #{response.body}"
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
response = responses_client.create(
model: search_terms_route[:model],
messages: messages,
schema: SEARCH_TERMS_SCHEMA,
reasoning_effort: search_terms_route[:reasoning_effort],
metadata: {
account_id: account_id,
article_id: id,
feature: 'article_search_terms'
}
)
JSON.parse(response[:message])['search_terms']
end
private
def search_terms_route
@search_terms_route ||= Llm::FeatureRouter.resolve(feature: SEARCH_TERMS_FEATURE, account: account)
end
def responses_client
@responses_client ||= Llm::ResponsesClient.new(api_key: openai_api_key, api_base: openai_api_base)
end
def openai_api_key
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || raise(I18n.t('captain.api_key_missing'))
end
def openai_api_url
def openai_api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1/chat/completions"
endpoint.chomp('/')
end
end
@@ -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
+12
View File
@@ -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
@@ -50,7 +50,8 @@ RSpec.describe Concerns::Agentable do
tools: [],
model: Llm::Models.default_model_for('assistant'),
temperature: 0.8,
response_schema: Captain::ResponseSchema
response_schema: Captain::ResponseSchema,
params: { reasoning_effort: Llm::Models.reasoning_effort_for('assistant') }
)
dummy_instance.agent
@@ -194,6 +195,14 @@ RSpec.describe Concerns::Agentable do
end
end
describe '#agent_params' do
it 'returns reasoning effort for the assistant feature' do
expect(dummy_instance.send(:agent_params)).to eq(
reasoning_effort: Llm::Models.reasoning_effort_for('assistant')
)
end
end
describe '#agent_response_schema' do
it 'returns Captain::ResponseSchema' do
expect(dummy_instance.send(:agent_response_schema)).to eq(Captain::ResponseSchema)
@@ -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)
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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' }
]
}
]
+52
View File
@@ -208,6 +208,58 @@ RSpec.describe Article do
end
end
describe '#generate_article_search_terms' do
let(:responses_client) { instance_double(Llm::ResponsesClient) }
let(:article) do
create(
:article,
account: account,
category: category_1,
content: 'How to configure billing invoices and payment reminders',
description: 'Billing setup guide',
portal: portal_1,
author: user,
title: 'Configure billing'
)
end
before do
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_API_KEY').tap do |config|
config.value = 'sk-test'
config.save!
end
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').tap do |config|
config.value = 'https://api.openai.test/v1'
config.save!
end
allow(Llm::ResponsesClient).to receive(:new).and_return(responses_client)
allow(responses_client).to receive(:create).and_return(
{ message: { search_terms: ['billing setup', 'invoice reminders'] }.to_json }
)
end
it 'uses Responses API with the help center article model route' do
expect(article.generate_article_search_terms).to eq(['billing setup', 'invoice reminders'])
expect(Llm::ResponsesClient).to have_received(:new).with(
api_key: 'sk-test',
api_base: 'https://api.openai.test/v1'
)
expect(responses_client).to have_received(:create).with(
hash_including(
model: Llm::Models.default_model_for('help_center_article_generation'),
reasoning_effort: Llm::Models.reasoning_effort_for('help_center_article_generation'),
schema: Enterprise::Concerns::Article::SEARCH_TERMS_SCHEMA,
metadata: hash_including(
account_id: account.id,
article_id: article.id,
feature: 'article_search_terms'
)
)
)
end
end
describe '.update_positions' do
let!(:article_a) { create(:article, portal: portal_1, category: category_1, author: user, position: 10) }
let!(:article_b) { create(:article, portal: portal_1, category: category_1, author: user, position: 11) }