diff --git a/Gemfile b/Gemfile index 1ae6cf093..2023c32b1 100644 --- a/Gemfile +++ b/Gemfile @@ -197,6 +197,8 @@ gem 'ai-agents', '>= 0.7.0' gem 'ruby_llm', '>= 1.8.2' gem 'ruby_llm-schema' +gem 'cld3', '~> 3.7' + # OpenTelemetry for LLM observability gem 'opentelemetry-sdk' gem 'opentelemetry-exporter-otlp' diff --git a/Gemfile.lock b/Gemfile.lock index b7b7301d3..ddac60fd7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,6 +186,7 @@ GEM byebug (11.1.3) childprocess (5.1.0) logger (~> 1.5) + cld3 (3.7.0) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -1037,6 +1038,7 @@ DEPENDENCIES bullet bundle-audit byebug + cld3 (~> 3.7) climate_control commonmarker csv-safe diff --git a/enterprise/app/services/captain/llm/translate_query_service.rb b/enterprise/app/services/captain/llm/translate_query_service.rb new file mode 100644 index 000000000..404a44755 --- /dev/null +++ b/enterprise/app/services/captain/llm/translate_query_service.rb @@ -0,0 +1,49 @@ +class Captain::Llm::TranslateQueryService < Captain::BaseTaskService + MODEL = 'gpt-4.1-nano'.freeze + + pattr_initialize [:account!] + + def translate(query, target_language:) + return query if query_in_target_language?(query) + + messages = [ + { role: 'system', content: system_prompt(target_language) }, + { role: 'user', content: query } + ] + + response = make_api_call(model: MODEL, messages: messages) + return query if response[:error] + + response[:message].strip + rescue StandardError => e + Rails.logger.warn "TranslateQueryService failed: #{e.message}, falling back to original query" + query + end + + private + + def event_name + 'translate_query' + end + + def query_in_target_language?(query) + detector = CLD3::NNetLanguageIdentifier.new(0, 1000) + result = detector.find_language(query) + + result.reliable? && result.language == account_language_code + rescue StandardError + false + end + + def account_language_code + account.locale&.split('_')&.first + end + + def system_prompt(target_language) + <<~SYSTEM_PROMPT_MESSAGE + You are a helpful assistant that translates queries from one language to another. + Translate the query to #{target_language}. + Return just the translated query, no other text. + SYSTEM_PROMPT_MESSAGE + end +end diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb index fbc8f4154..e4a237186 100644 --- a/enterprise/app/services/captain/tools/search_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -9,7 +9,11 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool def execute(query:) Rails.logger.info { "#{self.class.name}: #{query}" } - responses = assistant.responses.approved.search(query) + translated_query = Captain::Llm::TranslateQueryService + .new(account: assistant.account) + .translate(query, target_language: assistant.account.locale_english_name) + + responses = assistant.responses.approved.search(translated_query) return 'No FAQs found for the given query' if responses.empty? diff --git a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb index d2c1df42f..24c3fd379 100644 --- a/enterprise/app/services/captain/tools/search_reply_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_reply_documentation_service.rb @@ -18,7 +18,11 @@ class Captain::Tools::SearchReplyDocumentationService < RubyLLM::Tool def execute(query:) Rails.logger.info { "#{self.class.name}: #{query}" } - responses = search_responses(query) + translated_query = Captain::Llm::TranslateQueryService + .new(account: @account) + .translate(query, target_language: @account.locale_english_name) + + responses = search_responses(translated_query) return 'No FAQs found for the given query' if responses.empty? responses.map { |response| format_response(response) }.join diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb index a2bacce1a..af79a3fca 100644 --- a/lib/captain/tool_instrumentation.rb +++ b/lib/captain/tool_instrumentation.rb @@ -1,5 +1,6 @@ module Captain::ToolInstrumentation extend ActiveSupport::Concern + include Integrations::LlmInstrumentationConstants private @@ -10,15 +11,10 @@ module Captain::ToolInstrumentation response = nil executed = false tracer.in_span(params[:span_name]) do |span| - span.set_attribute('langfuse.user.id', params[:account_id].to_s) if params[:account_id] - span.set_attribute('langfuse.tags', [params[:feature_name]].to_json) - span.set_attribute('langfuse.observation.input', params[:messages].to_json) - + set_tool_session_attributes(span, params) response = yield executed = true - - # Output just the message for cleaner Langfuse display - span.set_attribute('langfuse.observation.output', response[:message] || response.to_json) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json) end response rescue StandardError => e @@ -26,17 +22,24 @@ module Captain::ToolInstrumentation executed ? response : yield end + def set_tool_session_attributes(span, params) + span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id] + span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present? + span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json) + end + def record_generation(chat, message, model) return unless ChatwootApp.otel_enabled? return unless message.respond_to?(:role) && message.role.to_s == 'assistant' tracer.in_span("llm.#{event_name}.generation") do |span| - span.set_attribute('gen_ai.system', 'openai') - span.set_attribute('gen_ai.request.model', model) - span.set_attribute('gen_ai.usage.input_tokens', message.input_tokens) - span.set_attribute('gen_ai.usage.output_tokens', message.output_tokens) if message.respond_to?(:output_tokens) - span.set_attribute('langfuse.observation.input', format_chat_messages(chat)) - span.set_attribute('langfuse.observation.output', message.content.to_s) if message.respond_to?(:content) + span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai') + span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) + span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) + span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, format_chat_messages(chat)) + span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content) end rescue StandardError => e Rails.logger.warn "Failed to record generation: #{e.message}" diff --git a/lib/integrations/openai/openai_prompts/reply.liquid b/lib/integrations/openai/openai_prompts/reply.liquid index f9b95dbdf..f8067bb01 100644 --- a/lib/integrations/openai/openai_prompts/reply.liquid +++ b/lib/integrations/openai/openai_prompts/reply.liquid @@ -33,7 +33,7 @@ General guidelines: - Reply in the customer's language {% if has_search_tool %} -**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information. +**Important**: You have access to a `search_documentation` tool that can search the company's knowledge base for product details, policies, FAQs, and other information. **Use the search_documentation tool first** to find relevant information before composing your reply. This ensures your response is accurate and based on actual company documentation. {% endif %}