From bd732f1fa993fbb1de7299946e82032b8c677820 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Mon, 9 Feb 2026 17:25:11 +0530
Subject: [PATCH] fix: search faqs in account language (#13428)
# Pull Request Template
## Description
Reply suggestions uses `search_documentation`. While this is useful,
there is a subtle bug, a user's message may be in a different language
(say spanish) than the FAQs present (english).
This results in embedding search in spanish and compared against english
vectors, which results in poor retrieval and poor suggestions.
Fixes # (issue)
This PR fixes the above behaviour by making a small llm call translate
the query before searching in the search documentation tool
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.
before:
after:
test on rails console:
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
---
Gemfile | 2 +
Gemfile.lock | 2 +
.../captain/llm/translate_query_service.rb | 49 +++++++++++++++++++
.../tools/search_documentation_service.rb | 6 ++-
.../search_reply_documentation_service.rb | 6 ++-
lib/captain/tool_instrumentation.rb | 29 ++++++-----
.../openai/openai_prompts/reply.liquid | 2 +-
7 files changed, 80 insertions(+), 16 deletions(-)
create mode 100644 enterprise/app/services/captain/llm/translate_query_service.rb
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 %}