From 7948ea09ac98a7b414d602f5c71d124990459b85 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:12:13 +0530 Subject: [PATCH] feat(captain): add FAQ suggestion review API (3/4) (#14979) Agents can review recurring FAQ suggestions when they can access at least one supporting conversation. The detail view returns only source conversations the agent can access. Administrators can review every suggestion and can edit, approve, or dismiss it. Approval creates one approved Captain FAQ and removes the stored source observations. This is the third PR in the CW-7495 stack. It is built on [#14978](https://github.com/chatwoot/chatwoot/pull/14978), which adds the FAQ suggestion models and generation flow. ## Closes Closes [CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions). ## What changed 1. Added a paginated suggestion list with assistant, status, and search filters. 2. Limited agents to suggestions that have at least one source conversation they can access. 3. Limited the detail response to the 50 most recent source conversations the current user can access. 4. Allowed administrators to edit, approve, and dismiss open suggestions. 5. Added approval that creates one approved Captain FAQ, closes the suggestion, and removes its source observations. 6. Rejected approval when the suggestion language does not match the account language. 7. Added row locking so an edit or dismissal cannot overwrite an approval. 8. Prevented FAQ generation from attaching a new observation after a suggestion has closed. ## How to test 1. Sign in as an agent who has access to one inbox but not another. 2. Confirm the agent sees only suggestions with at least one source conversation from an accessible inbox. 3. Open a suggestion and confirm the source list does not contain conversations from restricted inboxes. 4. Sign in as an administrator and confirm all account suggestions are available. 5. Edit an open suggestion and approve it. Confirm one approved FAQ is created and the suggestion no longer has source observations. 6. Try to approve a suggestion in a different language from the account language. Confirm the request is rejected. 7. Dismiss another open suggestion and confirm it leaves the open review queue. --------- Co-authored-by: Sony Mathew Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- config/routes.rb | 4 + .../captain/faq_suggestions_controller.rb | 80 +++++++ .../finders/captain/faq_suggestion_finder.rb | 26 +++ .../app/policies/captain/assistant_policy.rb | 8 + .../policies/captain/faq_suggestion_policy.rb | 21 ++ .../faq_suggestion_approval_service.rb | 26 +++ .../captain/llm/conversation_faq_service.rb | 10 +- .../faq_suggestions/approve.json.jbuilder | 1 + .../faq_suggestions/dismiss.json.jbuilder | 1 + .../faq_suggestions/index.json.jbuilder | 10 + .../faq_suggestions/show.json.jbuilder | 6 + .../faq_suggestions/update.json.jbuilder | 1 + .../captain/_faq_observation.json.jbuilder | 10 + .../captain/_faq_suggestion.json.jbuilder | 12 + .../faq_suggestions_controller_spec.rb | 216 ++++++++++++++++++ .../llm/conversation_faq_service_spec.rb | 17 ++ 16 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb create mode 100644 enterprise/app/finders/captain/faq_suggestion_finder.rb create mode 100644 enterprise/app/policies/captain/faq_suggestion_policy.rb create mode 100644 enterprise/app/services/captain/faq_suggestion_approval_service.rb create mode 100644 enterprise/app/views/api/v1/accounts/captain/faq_suggestions/approve.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/faq_suggestions/dismiss.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/faq_suggestions/index.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/faq_suggestions/show.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/faq_suggestions/update.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/captain/_faq_observation.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/captain/_faq_suggestion.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb diff --git a/config/routes.rb b/config/routes.rb index 7314dbda5..fdef3be23 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,6 +79,10 @@ Rails.application.routes.draw do end resources :agent_sessions, only: [:show] resources :assistant_responses + resources :faq_suggestions, only: [:index, :show, :update] do + post :approve, on: :member + post :dismiss, on: :member + end resources :message_reports, only: [:create] resources :bulk_actions, only: [:create] resources :copilot_threads, only: [:index, :create] do 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 new file mode 100644 index 000000000..494c77bb6 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/faq_suggestions_controller.rb @@ -0,0 +1,80 @@ +class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::FaqSuggestion) } + before_action :set_accessible_suggestions + before_action :set_suggestion, except: [:index] + + RESULTS_PER_PAGE = 25 + SOURCE_PREVIEW_LIMIT = 50 + + def index + @current_page = permitted_params[:page] || 1 + filtered_query = apply_filters(@suggestions) + @suggestions_count = filtered_query.count + @suggestions = filtered_query.page(@current_page).per(RESULTS_PER_PAGE) + end + + def show + @observations = @suggestion.observations + .where(conversation_id: accessible_conversations.select(:id)) + .includes(:conversation) + .order(created_at: :desc) + .limit(SOURCE_PREVIEW_LIMIT) + end + + def update + @suggestion.with_lock do + raise ActiveRecord::RecordNotFound unless @suggestion.open? + + @suggestion.update!(suggestion_params) + end + end + + def approve + attributes = params[:faq_suggestion].present? ? suggestion_params : {} + @response = Captain::FaqSuggestionApprovalService.new(@suggestion, attributes).perform + end + + def dismiss + @suggestion.with_lock do + raise ActiveRecord::RecordNotFound unless @suggestion.open? + + @suggestion.dismissed! + end + end + + private + + def apply_filters(base_query) + base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present? + 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 + + base_query + end + + def set_accessible_suggestions + @suggestions = Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform.includes(:assistant).ordered + end + + def set_suggestion + @suggestion = @suggestions.find(permitted_params[:id]) + end + + def accessible_conversations + Conversations::PermissionFilterService.new(Current.account.conversations, Current.user, Current.account).perform + end + + def permitted_params + params.permit(:id, :assistant_id, :page, :status, :search) + end + + def suggestion_params + params.require(:faq_suggestion).permit(:question, :answer) + end +end diff --git a/enterprise/app/finders/captain/faq_suggestion_finder.rb b/enterprise/app/finders/captain/faq_suggestion_finder.rb new file mode 100644 index 000000000..070ed72fa --- /dev/null +++ b/enterprise/app/finders/captain/faq_suggestion_finder.rb @@ -0,0 +1,26 @@ +class Captain::FaqSuggestionFinder + def initialize(current_user, current_account) + @current_user = current_user + @current_account = current_account + end + + def perform + suggestions = @current_account.captain_faq_suggestions + return suggestions if account_user&.administrator? + + accessible_suggestion_ids = Captain::FaqObservation + .where(conversation_id: accessible_conversations.select(:id)) + .select(:faq_suggestion_id) + suggestions.where(id: accessible_suggestion_ids) + end + + private + + def accessible_conversations + Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform + end + + def account_user + @account_user ||= @current_account.account_users.find_by(user_id: @current_user.id) + end +end diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb index fdcb2db89..7e72bf4c8 100644 --- a/enterprise/app/policies/captain/assistant_policy.rb +++ b/enterprise/app/policies/captain/assistant_policy.rb @@ -35,6 +35,14 @@ class Captain::AssistantPolicy < ApplicationPolicy @account_user.administrator? end + def approve? + update? + end + + def dismiss? + update? + end + def destroy? @account_user.administrator? end 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/enterprise/app/services/captain/faq_suggestion_approval_service.rb b/enterprise/app/services/captain/faq_suggestion_approval_service.rb new file mode 100644 index 000000000..fd165ab9c --- /dev/null +++ b/enterprise/app/services/captain/faq_suggestion_approval_service.rb @@ -0,0 +1,26 @@ +class Captain::FaqSuggestionApprovalService + def initialize(suggestion, attributes = {}) + @suggestion = suggestion + @attributes = attributes + end + + def perform + suggestion.with_lock do + raise ActiveRecord::RecordNotFound unless suggestion.open? + + suggestion.update!(attributes) if attributes.present? + + response = suggestion.assistant.responses.create!( + question: suggestion.question, + answer: suggestion.answer, + status: :approved + ) + suggestion.approved! + response + end + end + + private + + attr_reader :suggestion, :attributes +end diff --git a/enterprise/app/services/captain/llm/conversation_faq_service.rb b/enterprise/app/services/captain/llm/conversation_faq_service.rb index 28e2b5654..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,8 +99,11 @@ 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/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/approve.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/approve.json.jbuilder new file mode 100644 index 000000000..c4c7d2508 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/approve.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: @response diff --git a/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/dismiss.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/dismiss.json.jbuilder new file mode 100644 index 000000000..ae04aed8a --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/dismiss.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion diff --git a/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/index.json.jbuilder new file mode 100644 index 000000000..1688806d7 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/index.json.jbuilder @@ -0,0 +1,10 @@ +json.payload do + json.array! @suggestions do |suggestion| + json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: suggestion + end +end + +json.meta do + json.total_count @suggestions_count + json.page @current_page +end diff --git a/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/show.json.jbuilder new file mode 100644 index 000000000..769b1094f --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/show.json.jbuilder @@ -0,0 +1,6 @@ +json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion +json.observations do + json.array! @observations do |observation| + json.partial! 'api/v1/models/captain/faq_observation', formats: [:json], resource: observation + end +end diff --git a/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/update.json.jbuilder new file mode 100644 index 000000000..ae04aed8a --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/faq_suggestions/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion diff --git a/enterprise/app/views/api/v1/models/captain/_faq_observation.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_faq_observation.json.jbuilder new file mode 100644 index 000000000..50dc36d7f --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_faq_observation.json.jbuilder @@ -0,0 +1,10 @@ +json.id resource.id +json.generated_question resource.generated_question +json.generated_answer resource.generated_answer +json.language resource.language +json.status resource.status +json.created_at resource.created_at.to_i +json.conversation do + json.id resource.conversation.id + json.display_id resource.conversation.display_id +end diff --git a/enterprise/app/views/api/v1/models/captain/_faq_suggestion.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_faq_suggestion.json.jbuilder new file mode 100644 index 000000000..a3025b91f --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_faq_suggestion.json.jbuilder @@ -0,0 +1,12 @@ +json.id resource.id +json.account_id resource.account_id +json.question resource.question +json.answer resource.answer +json.language resource.language +json.source_count resource.source_count +json.status resource.status +json.created_at resource.created_at.to_i +json.updated_at resource.updated_at.to_i +json.assistant do + json.partial! 'api/v1/models/captain/assistant', formats: [:json], resource: resource.assistant +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 new file mode 100644 index 000000000..92ee22fbf --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/faq_suggestions_controller_spec.rb @@ -0,0 +1,216 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do + let(:account) { create(:account, locale: 'en') } + let(:assistant) { create(:captain_assistant, account: account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:suggestion) do + assistant.faq_suggestions.create!( + question: 'How do I enable the feature?', + answer: 'Turn it on in settings.', + source_count: 1 + ) + end + + before do + suggestion.observations.create!( + conversation: conversation, + generated_question: suggestion.question, + generated_answer: suggestion.answer, + language: suggestion.language + ) + end + + describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions' do + it 'returns suggestions and their count to an administrator' do + get "/api/v1/accounts/#{account.id}/captain/faq_suggestions", + params: { assistant_id: assistant.id }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['payload']).to contain_exactly( + include('id' => suggestion.id, 'question' => suggestion.question, 'status' => 'open') + ) + expect(response.parsed_body['meta']).to include('total_count' => 1) + end + + it 'returns only suggestions backed by conversations the agent can access' do + create(:inbox_member, user: agent, inbox: inbox) + hidden_inbox = create(:inbox, account: account) + hidden_conversation = create(:conversation, account: account, inbox: hidden_inbox) + hidden_suggestion = assistant.faq_suggestions.create!(question: 'Hidden question', answer: 'Hidden answer') + hidden_suggestion.observations.create!( + conversation: hidden_conversation, + generated_question: hidden_suggestion.question, + generated_answer: hidden_suggestion.answer, + language: hidden_suggestion.language + ) + + get "/api/v1/accounts/#{account.id}/captain/faq_suggestions", + params: { assistant_id: assistant.id }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['payload'].pluck('id')).to contain_exactly(suggestion.id) + expect(response.parsed_body['meta']).to include('total_count' => 1) + end + end + + describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do + it 'returns the suggestion with its source conversation' do + get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body).to include('id' => suggestion.id, 'question' => suggestion.question) + expect(response.parsed_body['observations']).to contain_exactly( + 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 + it 'lets an administrator edit an open suggestion' do + patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}", + params: { faq_suggestion: { question: 'Updated question' } }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['question']).to eq('Updated question') + expect(suggestion.reload.question).to eq('Updated question') + end + + 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(: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 + + describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/approve' do + it 'lets an administrator approve an edited suggestion as an FAQ' do + expect do + post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve", + params: { faq_suggestion: { answer: 'Enable it in account settings.' } }, + 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(response.parsed_body['answer']).to eq('Enable it in account settings.') + expect(suggestion.reload).to be_approved + expect(suggestion.observations.pluck(:conversation_id)).to contain_exactly(conversation.id) + end + + 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 '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", + headers: agent.create_new_auth_token, + as: :json + end.not_to change(assistant.responses, :count) + + expect(response).to have_http_status(:not_found) + expect(suggestion.reload).to be_open + end + end + + describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/dismiss' do + it 'lets an administrator dismiss an open suggestion without creating an FAQ' do + expect do + post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss", + headers: admin.create_new_auth_token, + as: :json + end.not_to change(assistant.responses, :count) + + 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 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