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 <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Aakash Bakhle
2026-07-24 12:12:13 +05:30
committed by GitHub
parent e10b236871
commit 7948ea09ac
16 changed files with 447 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: @response

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion

View File

@@ -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

View File

@@ -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