From 1ee939a9105432ac20073fbdeea4cb99beccc604 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:19:49 +0530 Subject: [PATCH 1/8] feat(captain): allow agents to review FAQ suggestions --- .../captain/faq_suggestions_controller.rb | 2 +- .../policies/captain/faq_suggestion_policy.rb | 21 ++++++++ .../faq_suggestions_controller_spec.rb | 53 +++++++++++++++++-- 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 enterprise/app/policies/captain/faq_suggestion_policy.rb diff --git a/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb index 52df72104..39db35bb1 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb @@ -1,6 +1,6 @@ class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts::BaseController before_action :current_account - before_action -> { check_authorization(Captain::Assistant) } + before_action -> { check_authorization(Captain::FaqSuggestion) } before_action :set_suggestions before_action :set_suggestion, except: [:index] diff --git a/enterprise/app/policies/captain/faq_suggestion_policy.rb b/enterprise/app/policies/captain/faq_suggestion_policy.rb new file mode 100644 index 000000000..cc4d5a5db --- /dev/null +++ b/enterprise/app/policies/captain/faq_suggestion_policy.rb @@ -0,0 +1,21 @@ +class Captain::FaqSuggestionPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def update? + true + end + + def approve? + true + end + + def dismiss? + true + end +end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb index f1ad3f65a..910a53ecc 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb @@ -87,13 +87,25 @@ RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do expect(suggestion.reload.question).to eq('Updated question') end - it 'does not let an agent edit a suggestion' do + it 'lets an agent edit an accessible suggestion' do + create(:inbox_member, user: agent, inbox: inbox) + patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}", params: { faq_suggestion: { question: 'Updated question' } }, headers: agent.create_new_auth_token, as: :json - expect(response).to have_http_status(:unauthorized) + expect(response).to have_http_status(:success) + expect(suggestion.reload.question).to eq('Updated question') + end + + it 'does not let an agent edit an inaccessible suggestion' do + patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}", + params: { faq_suggestion: { question: 'Updated question' } }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) expect(suggestion.reload.question).to eq('How do I enable the feature?') end end @@ -113,14 +125,27 @@ RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do expect(suggestion.observations).to be_empty end - it 'does not let an agent approve a suggestion' do + it 'lets an agent approve an accessible suggestion' do + create(:inbox_member, user: agent, inbox: inbox) + + expect do + post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve", + headers: agent.create_new_auth_token, + as: :json + end.to change(assistant.responses.approved, :count).by(1) + + expect(response).to have_http_status(:success) + expect(suggestion.reload).to be_approved + end + + it 'does not let an agent approve an inaccessible suggestion' do expect do post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve", headers: agent.create_new_auth_token, as: :json end.not_to change(assistant.responses, :count) - expect(response).to have_http_status(:unauthorized) + expect(response).to have_http_status(:not_found) expect(suggestion.reload).to be_open end end @@ -136,5 +161,25 @@ RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do expect(response).to have_http_status(:success) expect(suggestion.reload).to be_dismissed end + + it 'lets an agent dismiss an accessible suggestion' do + create(:inbox_member, user: agent, inbox: inbox) + + post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(suggestion.reload).to be_dismissed + end + + it 'does not let an agent dismiss an inaccessible suggestion' do + post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:not_found) + expect(suggestion.reload).to be_open + end end end From 45e065eca8a155a1d055fffb17703b29777ad279 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:20:13 +0530 Subject: [PATCH 2/8] docs(captain): note FAQ search indexing follow-up --- .../api/v1/accounts/captain/faq_suggestions_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb index 39db35bb1..5d326d04e 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb @@ -50,6 +50,7 @@ class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts:: base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present? if permitted_params[:search].present? + # TODO: Move FAQ suggestion search to Elasticsearch when the records are indexed there. search_term = "%#{permitted_params[:search]}%" base_query = base_query.where('question ILIKE :search OR answer ILIKE :search', search: search_term) end From fc82c7939e2cbc595092ac75b3e061b98702f375 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:20:41 +0530 Subject: [PATCH 3/8] refactor(captain): clarify accessible suggestion scope --- .../api/v1/accounts/captain/faq_suggestions_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb index 5d326d04e..494c77bb6 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts::BaseController before_action :current_account before_action -> { check_authorization(Captain::FaqSuggestion) } - before_action :set_suggestions + before_action :set_accessible_suggestions before_action :set_suggestion, except: [:index] RESULTS_PER_PAGE = 25 @@ -58,7 +58,7 @@ class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts:: base_query end - def set_suggestions + def set_accessible_suggestions @suggestions = Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform.includes(:assistant).ordered end From f1753f08b247653cdbb36999126752ce33c233dd Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:28:53 +0530 Subject: [PATCH 4/8] fix(captain): match approved FAQs across languages --- .../captain/llm/conversation_faq_service.rb | 15 +++------------ .../captain/llm/conversation_faq_service_spec.rb | 13 ++++++++----- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 5ace3d772..28e2b5654 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -14,9 +14,6 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService language.to_s.tr('-', '_').split('_').first.downcase end - def self.account_language_for(account) - normalize_language(account.locale.presence || I18n.default_locale.to_s) - end private_class_method :normalize_language def initialize(assistant, conversation) @@ -44,7 +41,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService def route_candidate(faq) embedding = embedding_service.get_embedding(candidate_text(faq)) - return discard_observation(faq) if matching_record(approved_faqs_for_language, faq, embedding) + return discard_observation(faq) if matching_record(approved_faqs, faq, embedding) return discard_observation(faq) if matching_record(dismissed_suggestions_for_language, faq, embedding) suggestion = matching_record(open_suggestions_for_language, faq, embedding) @@ -134,10 +131,8 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService assistant.faq_suggestions.where(account_id: conversation.account_id).dismissed.by_language(faq_language) end - def approved_faqs_for_language - return assistant.responses.approved if faq_language == account_language - - assistant.responses.none + def approved_faqs + assistant.responses.approved end def candidate_text(faq) @@ -197,10 +192,6 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService @faq_language ||= self.class.language_for(conversation) end - def account_language - @account_language ||= self.class.account_language_for(conversation.account) - end - def language_name(language) ISO_639.find(language)&.english_name&.downcase || 'english' end diff --git a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb index 4a028406a..51e070055 100644 --- a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb +++ b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb @@ -399,8 +399,9 @@ RSpec.describe Captain::Llm::ConversationFaqService do end end - context 'when a similar approved FAQ uses the account language' do + context 'when a similar approved FAQ uses another language' do let(:sample_faqs) { [{ 'question' => 'Como ativo o recurso?', 'answer' => 'Ative nas configuracoes.' }] } + let(:match_response) { instance_double(RubyLLM::Message, content: { same_faq: true }.to_json) } before do create(:captain_assistant_response, assistant: captain_assistant, account: captain_assistant.account, @@ -408,14 +409,16 @@ RSpec.describe Captain::Llm::ConversationFaqService do embedding: embedding_one) conversation.update!(additional_attributes: { conversation_language: 'pt-BR' }) allow(embedding_service).to receive(:get_embedding).and_return(embedding_one) + allow(mock_chat).to receive(:ask) do |input| + input.start_with?('{') ? match_response : mock_response + end end - it 'does not discard a candidate in another language' do + it 'deduplicates against the approved FAQ' do expect do service.generate_suggestions - end.to change(captain_assistant.faq_suggestions, :count).by(1) - expect(Captain::FaqObservation.discarded.count).to be_zero - expect(captain_assistant.faq_suggestions.last.language).to eq('pt') + end.to change(Captain::FaqObservation.discarded, :count).by(1) + expect(captain_assistant.faq_suggestions.count).to be_zero end end From d85ab0344470431505fbb9c365e8bde12cea1836 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:29:40 +0530 Subject: [PATCH 5/8] fix(captain): retain approved FAQ suggestion sources --- .../app/services/captain/faq_suggestion_approval_service.rb | 1 - .../api/v1/accounts/captain/faq_suggestions_controller_spec.rb | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/enterprise/app/services/captain/faq_suggestion_approval_service.rb b/enterprise/app/services/captain/faq_suggestion_approval_service.rb index 10dd1e4b2..b894b27cf 100644 --- a/enterprise/app/services/captain/faq_suggestion_approval_service.rb +++ b/enterprise/app/services/captain/faq_suggestion_approval_service.rb @@ -16,7 +16,6 @@ class Captain::FaqSuggestionApprovalService answer: suggestion.answer, status: :approved ) - suggestion.observations.delete_all suggestion.approved! response end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb index 910a53ecc..fd404f82a 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb @@ -122,7 +122,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do expect(response).to have_http_status(:success) expect(response.parsed_body['answer']).to eq('Enable it in account settings.') expect(suggestion.reload).to be_approved - expect(suggestion.observations).to be_empty + expect(suggestion.observations.pluck(:conversation_id)).to contain_exactly(conversation.id) end it 'lets an agent approve an accessible suggestion' do From a8ef787878f20314bfbf9b72ad2618c268ea9106 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:30:08 +0530 Subject: [PATCH 6/8] fix(captain): allow FAQ approval across languages --- .../captain/faq_suggestion_approval_service.rb | 12 ------------ .../captain/faq_suggestions_controller_spec.rb | 13 +++++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/enterprise/app/services/captain/faq_suggestion_approval_service.rb b/enterprise/app/services/captain/faq_suggestion_approval_service.rb index b894b27cf..fd165ab9c 100644 --- a/enterprise/app/services/captain/faq_suggestion_approval_service.rb +++ b/enterprise/app/services/captain/faq_suggestion_approval_service.rb @@ -9,7 +9,6 @@ class Captain::FaqSuggestionApprovalService raise ActiveRecord::RecordNotFound unless suggestion.open? suggestion.update!(attributes) if attributes.present? - validate_language! response = suggestion.assistant.responses.create!( question: suggestion.question, @@ -24,15 +23,4 @@ class Captain::FaqSuggestionApprovalService private attr_reader :suggestion, :attributes - - def validate_language! - return if base_language(suggestion.language) == base_language(suggestion.account.locale) - - suggestion.errors.add(:language, 'must match the account locale before approval') - raise ActiveRecord::RecordInvalid, suggestion - end - - def base_language(language) - language.to_s.tr('-', '_').split('_').first - end end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb index fd404f82a..10023b52c 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb @@ -138,6 +138,19 @@ RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do expect(suggestion.reload).to be_approved end + it 'approves a suggestion written in a language other than the account locale' do + suggestion.update!(language: 'pt') + + expect do + post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve", + headers: admin.create_new_auth_token, + as: :json + end.to change(assistant.responses.approved, :count).by(1) + + expect(response).to have_http_status(:success) + expect(suggestion.reload).to be_approved + end + it 'does not let an agent approve an inaccessible suggestion' do expect do post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve", From d5a9747b8176de700fd8d2f19ca17bb476d312fb Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:30:35 +0530 Subject: [PATCH 7/8] test(captain): cover restricted FAQ suggestion sources --- .../captain/faq_suggestions_controller_spec.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb index 10023b52c..92ee22fbf 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb @@ -73,6 +73,24 @@ RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do include('conversation' => include('id' => conversation.id, 'display_id' => conversation.display_id)) ) end + + it 'returns only source conversations the agent can access' do + create(:inbox_member, user: agent, inbox: inbox) + hidden_conversation = create(:conversation, account: account, inbox: create(:inbox, account: account)) + suggestion.observations.create!( + conversation: hidden_conversation, + generated_question: suggestion.question, + generated_answer: suggestion.answer, + language: suggestion.language + ) + + get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['observations'].pluck('conversation').pluck('id')).to contain_exactly(conversation.id) + end end describe 'PATCH /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do From b11f2e4a0c3d2a03d29a8dc54ffc9e181691fd12 Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Wed, 22 Jul 2026 23:32:24 +0530 Subject: [PATCH 8/8] fix(captain): revalidate FAQ matches after edits --- .../captain/llm/conversation_faq_service.rb | 8 ++++++-- .../llm/conversation_faq_service_spec.rb | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 7c0a8f0b0..4127f11d7 100644 --- a/enterprise/app/services/captain/llm/conversation_faq_service.rb +++ b/enterprise/app/services/captain/llm/conversation_faq_service.rb @@ -1,6 +1,8 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService include Integrations::LlmInstrumentation + class SuggestionChangedError < StandardError; end + DISTANCE_THRESHOLD = 0.3 MATCH_LIMIT = 5 LLM_FEATURE = 'conversation_faq_generation'.freeze @@ -45,6 +47,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService return discard_observation(faq) if matching_record(dismissed_suggestions_for_language, faq, embedding) suggestion = matching_record(open_suggestions_for_language, faq, embedding) + matched_content = suggestion&.slice('question', 'answer') suggestion ||= assistant.faq_suggestions.create!( question: faq.fetch('question'), answer: faq.fetch('answer'), @@ -52,7 +55,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService language: faq_language ) - attach_observation(suggestion, faq) + attach_observation(suggestion, faq, matched_content) end def matching_record(relation, faq, embedding) @@ -96,9 +99,10 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService raise end - def attach_observation(suggestion, faq) + def attach_observation(suggestion, faq, matched_content) suggestion.with_lock do next unless suggestion.open? + raise SuggestionChangedError if matched_content && suggestion.slice('question', 'answer') != matched_content existing_observation = suggestion.observations.find_by(conversation: conversation) next existing_observation if existing_observation diff --git a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb index 51e070055..c38d04e0f 100644 --- a/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb +++ b/spec/enterprise/services/captain/llm/conversation_faq_service_spec.rb @@ -340,6 +340,23 @@ RSpec.describe Captain::Llm::ConversationFaqService do expect(existing_suggestion.reload.source_count).to eq(2) expect(captain_assistant.faq_suggestions.count).to eq(1) end + + it 'does not attach the observation when the suggestion changes after classification' do + allow(mock_chat).to receive(:ask) do |input| + if input.start_with?('{') + existing_suggestion.update!(question: 'Edited after classification started') + match_response + else + mock_response + end + end + + expect do + service.generate_suggestions + end.to raise_error(described_class::SuggestionChangedError) + expect(existing_suggestion.observations.count).to eq(1) + expect(existing_suggestion.reload.source_count).to eq(1) + end end context 'when a similar open suggestion uses another language' do