feat(captain): add FAQ suggestion review interface (4/4) (#15017)

Captain now groups recurring questions from resolved conversations into
FAQ suggestions and orders them by the number of source conversations.
Agents can view suggestions and open source conversations they can
access. Administrators can edit, approve, or dismiss suggestions.

The old pending FAQ flow is removed. The Captain overview and FAQ page
now use open suggestion counts and link to the same review page.
Approved FAQs remain unchanged.

## Depends on

#14979

## Closes

https://linear.app/chatwoot/issue/CW-7496/fe-and-ux

## How to test

1. Open Captain and choose an assistant with open FAQ suggestions.
2. Open FAQ suggestions from the overview or the FAQ banner. Confirm
that suggestions are ordered by conversation count.
3. Switch assistants without leaving the page. Confirm that the previous
results clear and the new assistant results load.
4. Search for suggestions and move between pages. Change the search or
page again before the first request finishes, and confirm that the
latest request controls the results and loading state.
5. Open a suggestion and review its source conversations.
6. Make the source conversation request fail. Confirm that the dialog
keeps the error visible and that Retry loads the sources.
7. Sign in as an agent. Confirm that you can read suggestions and source
conversations you can access, but cannot edit, approve, or dismiss
suggestions.
8. Sign in as an administrator. Edit and save a suggestion, approve one
suggestion, and dismiss another.
9. Confirm that the approved suggestion appears in the assistant FAQ
list.
10. Open the old pending FAQ URL and confirm that it redirects to FAQ
suggestions.

## What changed

1. Added the FAQ suggestion list, cards, search, pagination, and empty
state.
2. Added a review dialog with source conversation links, a clear error
message, and a Retry button.
3. Added edit, approve, and dismiss actions for administrators.
4. Removed the old pending FAQ status, count, page, and bulk approval
action.
5. Updated the Captain overview and FAQ banner to use open suggestion
counts and link to FAQ suggestions.
6. Made each FAQ page load data for the selected assistant and ignore
results from older requests.
7. Kept the old pending FAQ URL as a redirect so saved links continue to
work.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Aakash Bakhle
2026-07-24 12:53:09 +05:30
committed by GitHub
parent 7786e38df9
commit 7910eabefc
32 changed files with 1494 additions and 636 deletions

View File

@@ -232,22 +232,26 @@ RSpec.describe Captain::AssistantStatsBuilder do
describe '#faq_stats' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
assistant.faq_suggestions.create!(
question: 'How do I enable the feature?',
answer: 'Turn it on in settings.'
)
create_list(:captain_document, 2, assistant: assistant, account: account)
end
it 'returns approved, pending, document counts and coverage' do
knowledge = described_class.new(assistant).faq_stats
it 'returns approved FAQ, open suggestion, document counts and coverage' do
stats = described_class.new(assistant).faq_stats
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
expect(stats).to eq(approved: 3, suggestions: 1, documents: 2, coverage: 75)
end
it 'reports zero coverage when there are no responses' do
it 'reports zero coverage when there are no FAQs or suggestions' do
Captain::AssistantResponse.where(assistant: assistant).delete_all
Captain::FaqSuggestion.where(assistant: assistant).delete_all
knowledge = described_class.new(assistant).faq_stats
stats = described_class.new(assistant).faq_stats
expect(knowledge[:coverage]).to eq(0)
expect(stats).to eq(approved: 0, suggestions: 0, documents: 2, coverage: 0)
end
end

View File

@@ -177,6 +177,19 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:question]).to eq('Test question?')
expect(json_response[:answer]).to eq('Test answer')
expect(json_response[:status]).to eq('approved')
end
it 'does not accept the removed pending status' do
params = valid_params.deep_merge(assistant_response: { status: 'pending' })
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Captain::AssistantResponse.last).to be_approved
end
context 'with invalid params' do

View File

@@ -252,6 +252,61 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/faq_stats' do
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns approved FAQ, open suggestion, document counts and coverage' do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.')
create_list(:captain_document, 2, assistant: assistant, account: account)
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response).to eq(approved: 3, suggestions: 1, documents: 2, coverage: 75)
end
it 'returns zero coverage when there are no FAQs or suggestions' do
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response).to include(approved: 0, suggestions: 0, coverage: 0)
end
it 'counts only suggestions backed by conversations the agent can access' do
accessible_inbox = create(:inbox, account: account)
hidden_inbox = create(:inbox, account: account)
create(:inbox_member, user: agent, inbox: accessible_inbox)
create(:captain_assistant_response, assistant: assistant, account: account, status: :approved)
accessible_suggestion = assistant.faq_suggestions.create!(question: 'Visible question', answer: 'Visible answer')
accessible_suggestion.observations.create!(
conversation: create(:conversation, account: account, inbox: accessible_inbox),
generated_question: accessible_suggestion.question,
generated_answer: accessible_suggestion.answer,
language: accessible_suggestion.language
)
hidden_suggestion = assistant.faq_suggestions.create!(question: 'Hidden question', answer: 'Hidden answer')
hidden_suggestion.observations.create!(
conversation: create(:conversation, account: account, inbox: hidden_inbox),
generated_question: hidden_suggestion.question,
generated_answer: hidden_suggestion.answer,
language: hidden_suggestion.language
)
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response).to include(approved: 1, suggestions: 1, coverage: 50)
end
end
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }

View File

@@ -5,13 +5,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
let(:assistant) { create(:captain_assistant, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:pending_responses) do
let!(:responses) do
create_list(
:captain_assistant_response,
2,
assistant: assistant,
account: account,
status: 'pending'
account: account
)
end
let!(:documents) do
@@ -29,29 +28,20 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
end
describe 'POST /api/v1/accounts/:account_id/captain/bulk_actions' do
context 'when approving responses' do
let(:valid_params) do
{
type: 'AssistantResponse',
ids: pending_responses.map(&:id),
fields: { status: 'approve' }
}
end
it 'approves the responses and returns the updated records' do
context 'when using the removed bulk approval action' do
it 'returns unprocessable content without changing responses' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: valid_params,
params: {
type: 'AssistantResponse',
ids: responses.map(&:id),
fields: { status: 'approve' }
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(2)
# Verify responses were approved
pending_responses.each do |response|
expect(response.reload.status).to eq('approved')
end
expect(response).to have_http_status(:unprocessable_content)
expect(json_response[:success]).to be(false)
expect(responses.map(&:reload)).to all(be_approved)
end
end
@@ -59,7 +49,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
let(:delete_params) do
{
type: 'AssistantResponse',
ids: pending_responses.map(&:id),
ids: responses.map(&:id),
fields: { status: 'delete' }
}
end
@@ -76,7 +66,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
expect(json_response).to eq([])
# Verify responses were deleted
pending_responses.each do |response|
responses.each do |response|
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
@@ -86,8 +76,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
let(:invalid_params) do
{
type: 'InvalidType',
ids: pending_responses.map(&:id),
fields: { status: 'approve' }
ids: responses.map(&:id),
fields: { status: 'delete' }
}
end
@@ -100,10 +90,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:success]).to be(false)
# Verify no changes were made
pending_responses.each do |response|
expect(response.reload.status).to eq('pending')
end
expect(responses.map(&:reload)).to all(be_approved)
end
end
@@ -245,7 +232,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
let(:missing_params) do
{
type: 'AssistantResponse',
fields: { status: 'approve' }
fields: { status: 'delete' }
}
end
@@ -258,10 +245,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:success]).to be(false)
# Verify no changes were made
pending_responses.each do |response|
expect(response.reload.status).to eq('pending')
end
expect(responses.map(&:reload)).to all(be_approved)
end
end
@@ -270,16 +254,13 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: { type: 'AssistantResponse', ids: [1], fields: { status: 'approve' } },
params: { type: 'AssistantResponse', ids: [1], fields: { status: 'delete' } },
headers: unauthorized_user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
# Verify no changes were made
pending_responses.each do |response|
expect(response.reload.status).to eq('pending')
end
expect(responses.map(&:reload)).to all(be_approved)
end
end
end

View File

@@ -80,24 +80,6 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do
end.not_to(change { assistant.responses.count })
end
it 'leaves pending FAQ responses untouched' do
pending_response = assistant.responses.create!(
question: faq_document_candidate['question'],
answer: faq_document_candidate['answer'],
status: :pending
)
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
expect(pending_response.reload).to be_pending
expect(assistant.responses.approved).to contain_exactly(
have_attributes(
question: faq_document_candidate['question'],
answer: faq_document_candidate['answer']
)
)
end
it 'rejects conflicting FAQ answers within the same draft' do
conflicting_draft = draft.merge(
faq_document_candidates: [