diff --git a/enterprise/app/services/captain/llm/contact_attributes_service.rb b/enterprise/app/services/captain/llm/contact_attributes_service.rb index 803c06f09..79ba97769 100644 --- a/enterprise/app/services/captain/llm/contact_attributes_service.rb +++ b/enterprise/app/services/captain/llm/contact_attributes_service.rb @@ -1,5 +1,6 @@ class Captain::Llm::ContactAttributesService < Llm::BaseAiService include Integrations::LlmInstrumentation + def initialize(assistant, conversation) super() @assistant = assistant @@ -52,7 +53,7 @@ class Captain::Llm::ContactAttributesService < Llm::BaseAiService def parse_response(content) return [] if content.nil? - JSON.parse(content.strip).fetch('attributes', []) + JSON.parse(sanitize_json_response(content)).fetch('attributes', []) rescue JSON::ParserError => e Rails.logger.error "Error in parsing GPT processed response: #{e.message}" [] diff --git a/enterprise/app/services/captain/llm/contact_notes_service.rb b/enterprise/app/services/captain/llm/contact_notes_service.rb index 46965a7c1..975b1f0cd 100644 --- a/enterprise/app/services/captain/llm/contact_notes_service.rb +++ b/enterprise/app/services/captain/llm/contact_notes_service.rb @@ -1,5 +1,6 @@ class Captain::Llm::ContactNotesService < Llm::BaseAiService include Integrations::LlmInstrumentation + def initialize(assistant, conversation) super() @assistant = assistant @@ -55,7 +56,7 @@ class Captain::Llm::ContactNotesService < Llm::BaseAiService def parse_response(response) return [] if response.nil? - JSON.parse(response.strip).fetch('notes', []) + JSON.parse(sanitize_json_response(response)).fetch('notes', []) rescue JSON::ParserError => e Rails.logger.error "Error in parsing GPT processed response: #{e.message}" [] diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 3cc74ed52..31234fda7 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -1,5 +1,6 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService include Integrations::LlmInstrumentation + DISTANCE_THRESHOLD = 0.3 def initialize(assistant, conversation) @@ -118,7 +119,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService def parse_response(response) return [] if response.nil? - JSON.parse(response.strip).fetch('faqs', []) + JSON.parse(sanitize_json_response(response)).fetch('faqs', []) rescue JSON::ParserError => e Rails.logger.error "Error in parsing GPT processed response: #{e.message}" [] diff --git a/enterprise/app/services/captain/llm/faq_generator_service.rb b/enterprise/app/services/captain/llm/faq_generator_service.rb index b22a631b3..5f85ae467 100644 --- a/enterprise/app/services/captain/llm/faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/faq_generator_service.rb @@ -47,7 +47,7 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService def parse_response(content) return [] if content.nil? - JSON.parse(content.strip).fetch('faqs', []) + JSON.parse(sanitize_json_response(content)).fetch('faqs', []) rescue JSON::ParserError => e Rails.logger.error "Error in parsing GPT processed response: #{e.message}" [] 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 93957a7f3..3fe81c2ae 100644 --- a/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb +++ b/enterprise/app/services/captain/llm/paginated_faq_generator_service.rb @@ -163,7 +163,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService content = response.dig('choices', 0, 'message', 'content') return [] if content.nil? - JSON.parse(content.strip).fetch('faqs', []) + JSON.parse(sanitize_json_response(content)).fetch('faqs', []) rescue JSON::ParserError => e Rails.logger.error "Error parsing response: #{e.message}" [] @@ -173,7 +173,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService content = response.dig('choices', 0, 'message', 'content') return { 'faqs' => [], 'has_content' => false } if content.nil? - JSON.parse(content.strip) + JSON.parse(sanitize_json_response(content)) rescue JSON::ParserError => e Rails.logger.error "Error parsing chunk response: #{e.message}" { 'faqs' => [], 'has_content' => false } diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb index a5a91cf24..0df5e6a67 100644 --- a/enterprise/app/services/llm/base_ai_service.rb +++ b/enterprise/app/services/llm/base_ai_service.rb @@ -20,6 +20,14 @@ class Llm::BaseAiService private + # Strips markdown code fences (```json ... ``` or ``` ... ```) that some + # LLM providers/gateways wrap around JSON responses despite response_format hints. + def sanitize_json_response(response) + return response if response.nil? + + response.strip.sub(/\A```(?:\w*)\s*\n?/, '').sub(/\n?\s*```\s*\z/, '').strip + end + def setup_model config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value @model = (config_value.presence || DEFAULT_MODEL) diff --git a/enterprise/app/services/llm/legacy_base_open_ai_service.rb b/enterprise/app/services/llm/legacy_base_open_ai_service.rb index c13e1de1c..2c1ac8c1d 100644 --- a/enterprise/app/services/llm/legacy_base_open_ai_service.rb +++ b/enterprise/app/services/llm/legacy_base_open_ai_service.rb @@ -24,6 +24,14 @@ class Llm::LegacyBaseOpenAiService private + # Strips markdown code fences (```json ... ``` or ``` ... ```) that some + # LLM providers/gateways wrap around JSON responses despite response_format hints. + def sanitize_json_response(response) + return response if response.nil? + + response.strip.sub(/\A```(?:\w*)\s*\n?/, '').sub(/\n?\s*```\s*\z/, '').strip + end + def uri_base endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value endpoint.presence || 'https://api.openai.com/' diff --git a/spec/enterprise/services/llm/base_ai_service_spec.rb b/spec/enterprise/services/llm/base_ai_service_spec.rb new file mode 100644 index 000000000..c45fff522 --- /dev/null +++ b/spec/enterprise/services/llm/base_ai_service_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe Llm::BaseAiService do + subject(:service) { described_class.new } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + end + + describe '#sanitize_json_response' do + it 'strips ```json fences' do + input = "```json\n{\"key\": \"value\"}\n```" + expect(service.send(:sanitize_json_response, input)).to eq('{"key": "value"}') + end + + it 'strips bare ``` fences' do + input = "```\n{\"key\": \"value\"}\n```" + expect(service.send(:sanitize_json_response, input)).to eq('{"key": "value"}') + end + + it 'passes through plain JSON unchanged' do + input = '{"key": "value"}' + expect(service.send(:sanitize_json_response, input)).to eq('{"key": "value"}') + end + + it 'returns nil for nil input' do + expect(service.send(:sanitize_json_response, nil)).to be_nil + end + + it 'strips surrounding whitespace' do + input = " \n{\"key\": \"value\"}\n " + expect(service.send(:sanitize_json_response, input)).to eq('{"key": "value"}') + end + end +end