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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user