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:
@@ -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
|
||||
26
enterprise/app/finders/captain/faq_suggestion_finder.rb
Normal file
26
enterprise/app/finders/captain/faq_suggestion_finder.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
21
enterprise/app/policies/captain/faq_suggestion_policy.rb
Normal file
21
enterprise/app/policies/captain/faq_suggestion_policy.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: @response
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user