diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index 390ec6cf8..ad119c413 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -2,12 +2,11 @@ module Captain::ChatHelper def request_chat_completion Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{@messages}" } - available_tools = @tool_registry&.registered_tools || [] response = @client.chat( parameters: { model: @model, messages: @messages, - tools: available_tools, + tools: @tool_registry&.registered_tools || [], response_format: { type: 'json_object' } } ) @@ -37,17 +36,24 @@ module Captain::ChatHelper end def process_tool_call(tool_call) + arguments = JSON.parse(tool_call['function']['arguments']) + function_name = tool_call['function']['name'] tool_call_id = tool_call['id'] function_name = tool_call['function']['name'] arguments = JSON.parse(tool_call['function']['arguments']) if @tool_registry.respond_to?(function_name) - execute_tool_call(tool_call_id, function_name, arguments) + execute_tool(function_name, arguments, tool_call_id) else process_invalid_tool_call(tool_call_id, function_name) end end + def execute_tool(function_name, arguments, tool_call_id) + result = @tool_registry.send(function_name, arguments) + append_tool_response(result, tool_call_id) + end + def append_tool_calls(tool_calls) @messages << { role: 'assistant', diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index bec0682e9..8d9009f01 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -61,6 +61,11 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService private + def register_tools + @tool_registry = Captain::ToolRegistryService.new(@assistant) + @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) + end + def system_message Rails.logger.info("[CAPTAIN][CopilotChatService] Generating system message for product=#{@assistant.config['product_name']} language=#{@language}") { diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 689e16dfe..7f2113498 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -9,6 +9,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService @tool_registry = Captain::ToolRegistryService.new(assistant) @messages = [system_message] @response = '' + register_tools end def generate_response(input, previous_messages = [], role = 'user') @@ -19,6 +20,11 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService private + def register_tools + @tool_registry = Captain::ToolRegistryService.new(@assistant) + @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) + end + def system_message { role: 'system', diff --git a/enterprise/app/services/captain/tool_registry_service.rb b/enterprise/app/services/captain/tool_registry_service.rb index 6063242b4..088d89483 100644 --- a/enterprise/app/services/captain/tool_registry_service.rb +++ b/enterprise/app/services/captain/tool_registry_service.rb @@ -5,7 +5,6 @@ class Captain::ToolRegistryService @assistant = assistant @registered_tools = [] @tools = {} - register_default_tools end def register_tool(tool_class) @@ -14,10 +13,6 @@ class Captain::ToolRegistryService @registered_tools << tool.to_registry_format end - def register_default_tools - register_tool(Captain::Tools::SearchDocumentationService) - end - def method_missing(method_name, *arguments) if @tools.key?(method_name.to_s) @tools[method_name.to_s].execute(*arguments) diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb index 72a85fdab..672baf24a 100644 --- a/enterprise/app/services/captain/tools/search_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -22,13 +22,13 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService def execute(arguments) query = arguments['search_query'] - Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" } - assistant - .responses - .approved - .search(query) - .map { |response| format_response(response) } - .join + Rails.logger.info { "#{self.class.name}: #{query}" } + + responses = assistant.responses.approved.search(query) + + return 'No FAQs found for the given query' if responses.empty? + + responses.map { |response| format_response(response) }.join end private diff --git a/spec/enterprise/services/captain/tool_registry_service_spec.rb b/spec/enterprise/services/captain/tool_registry_service_spec.rb new file mode 100644 index 000000000..e355dea0a --- /dev/null +++ b/spec/enterprise/services/captain/tool_registry_service_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +# Test tool implementation +class TestTool < Captain::Tools::BaseService + def name + 'test_tool' + end + + def description + 'A test tool for specs' + end + + def parameters + { + type: 'object', + properties: { + test_param: { + type: 'string' + } + } + } + end + + def execute(*args) + args + end +end + +RSpec.describe Captain::ToolRegistryService do + let(:assistant) { create(:captain_assistant) } + let(:service) { described_class.new(assistant) } + + describe '#initialize' do + it 'initializes with empty tools and registered_tools' do + expect(service.tools).to be_empty + expect(service.registered_tools).to be_empty + end + end + + describe '#register_tool' do + let(:tool_class) { TestTool } + + it 'registers a new tool' do + service.register_tool(tool_class) + + expect(service.tools['test_tool']).to be_a(TestTool) + expect(service.registered_tools).to include( + { + type: 'function', + function: { + name: 'test_tool', + description: 'A test tool for specs', + parameters: { + type: 'object', + properties: { + test_param: { + type: 'string' + } + } + } + } + } + ) + end + end + + describe 'method_missing' do + let(:tool_class) { TestTool } + + before do + service.register_tool(tool_class) + end + + context 'when method corresponds to a registered tool' do + it 'executes the tool with given arguments' do + result = service.test_tool(test_param: 'arg1') + expect(result).to eq([{ test_param: 'arg1' }]) + end + end + + context 'when method does not correspond to a registered tool' do + it 'raises NoMethodError' do + expect { service.unknown_tool }.to raise_error(NoMethodError) + end + end + end +end diff --git a/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb new file mode 100644 index 000000000..9f5586e6b --- /dev/null +++ b/spec/enterprise/services/captain/tools/search_documentation_service_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::SearchDocumentationService do + let(:assistant) { create(:captain_assistant) } + let(:service) { described_class.new(assistant) } + let(:question) { 'How to create a new account?' } + let(:answer) { 'You can create a new account by clicking on the Sign Up button.' } + let(:external_link) { 'https://example.com/docs/create-account' } + + describe '#name' do + it 'returns the correct service name' do + expect(service.name).to eq('search_documentation') + end + end + + describe '#description' do + it 'returns the service description' do + expect(service.description).to eq('Search and retrieve documentation from knowledge base') + end + end + + describe '#parameters' do + it 'returns the required parameters schema' do + expected_schema = { + type: 'object', + properties: { + search_query: { + type: 'string', + description: 'The search query to look up in the documentation.' + } + }, + required: ['search_query'] + } + + expect(service.parameters).to eq(expected_schema) + end + end + + describe '#execute' do + let!(:response) do + create( + :captain_assistant_response, + assistant: assistant, + question: question, + answer: answer, + status: 'approved' + ) + end + + let(:documentable) { create(:captain_document, external_link: external_link) } + + context 'when matching responses exist' do + before do + response.update(documentable: documentable) + allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([response]) + end + + it 'returns formatted responses for the search query' do + result = service.execute({ 'search_query' => question }) + + expect(result).to include(question) + expect(result).to include(answer) + expect(result).to include(external_link) + end + end + + context 'when no matching responses exist' do + before do + allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([]) + end + + it 'returns an empty string' do + expect(service.execute({ 'search_query' => question })).to eq('No FAQs found for the given query') + end + end + end +end