feat(captain): add FAQ suggestion data model (1/3) (#14977)
Resolved conversations need a separate suggestion layer so repeated FAQ signals can be grouped without creating untrusted knowledge entries. This PR adds the persistence foundation only; it introduces no user-facing behavior by itself. ## Closes - [CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions) (stacked PR 1/3; the issue is complete after the full stack lands) ## What changed - Added `captain_faq_suggestions` with question, answer, embedding, source count, and review status. - Added `captain_faq_observations` to retain conversation-level signals. - Added Captain assistant, account, and conversation associations. - Added vector and lookup indexes for semantic grouping. ## How to test This layer has no standalone UI behavior. Apply the migration and confirm Captain assistants can persist open FAQ suggestions with attached conversation observations.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
class CreateCaptainFaqSuggestions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_faq_suggestions
|
||||
create_faq_observations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_faq_suggestions
|
||||
create_table :captain_faq_suggestions do |t|
|
||||
t.string :question, null: false
|
||||
t.text :answer, null: false
|
||||
t.vector :embedding, limit: 1536
|
||||
t.references :assistant, null: false, index: true
|
||||
t.references :account, null: false, index: true
|
||||
t.string :language, null: false, default: 'en'
|
||||
t.integer :source_count, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_faq_suggestions, [:account_id, :assistant_id, :status, :language],
|
||||
name: 'idx_cap_faq_suggestions_on_account_assistant_status_language'
|
||||
add_index :captain_faq_suggestions, :embedding, using: :ivfflat,
|
||||
name: 'vector_idx_captain_faq_suggestions_embedding',
|
||||
opclass: :vector_cosine_ops
|
||||
end
|
||||
|
||||
def create_faq_observations
|
||||
create_table :captain_faq_observations do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.references :conversation, null: false, index: true
|
||||
t.references :faq_suggestion, index: true
|
||||
t.string :generated_question, null: false
|
||||
t.text :generated_answer, null: false
|
||||
t.string :language, null: false, default: 'en'
|
||||
t.integer :status, null: false, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_faq_observations, [:conversation_id, :faq_suggestion_id],
|
||||
unique: true,
|
||||
where: 'faq_suggestion_id IS NOT NULL',
|
||||
name: 'idx_captain_faq_observations_on_conversation_and_suggestion'
|
||||
end
|
||||
end
|
||||
+34
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -417,6 +417,39 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.index ["status"], name: "index_captain_documents_on_status"
|
||||
end
|
||||
|
||||
create_table "captain_faq_observations", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "faq_suggestion_id"
|
||||
t.string "generated_question", null: false
|
||||
t.text "generated_answer", null: false
|
||||
t.string "language", default: "en", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_faq_observations_on_account_id"
|
||||
t.index ["conversation_id", "faq_suggestion_id"], name: "idx_captain_faq_observations_on_conversation_and_suggestion", unique: true, where: "(faq_suggestion_id IS NOT NULL)"
|
||||
t.index ["conversation_id"], name: "index_captain_faq_observations_on_conversation_id"
|
||||
t.index ["faq_suggestion_id"], name: "index_captain_faq_observations_on_faq_suggestion_id"
|
||||
end
|
||||
|
||||
create_table "captain_faq_suggestions", force: :cascade do |t|
|
||||
t.string "question", null: false
|
||||
t.text "answer", null: false
|
||||
t.vector "embedding", limit: 1536
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.string "language", default: "en", null: false
|
||||
t.integer "source_count", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_faq_suggestions_on_account_id"
|
||||
t.index ["account_id", "assistant_id", "status", "language"], name: "idx_cap_faq_suggestions_on_account_assistant_status_language"
|
||||
t.index ["assistant_id"], name: "index_captain_faq_suggestions_on_assistant_id"
|
||||
t.index ["embedding"], name: "vector_idx_captain_faq_suggestions_embedding", opclass: :vector_cosine_ops, using: :ivfflat
|
||||
end
|
||||
|
||||
create_table "captain_inboxes", force: :cascade do |t|
|
||||
t.bigint "captain_assistant_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
|
||||
@@ -28,6 +28,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
|
||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
|
||||
has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async
|
||||
has_many :captain_inboxes,
|
||||
class_name: 'CaptainInbox',
|
||||
foreign_key: :captain_assistant_id,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_faq_observations
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# generated_answer :text not null
|
||||
# generated_question :string not null
|
||||
# language :string default("en"), not null
|
||||
# status :integer default("attached"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# faq_suggestion_id :bigint
|
||||
#
|
||||
class Captain::FaqObservation < ApplicationRecord
|
||||
self.table_name = 'captain_faq_observations'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :faq_suggestion, class_name: 'Captain::FaqSuggestion', optional: true, inverse_of: :observations
|
||||
|
||||
enum status: { attached: 0, discarded: 1 }
|
||||
|
||||
validates :generated_question, :generated_answer, :language, presence: true
|
||||
validates :faq_suggestion, presence: true, if: :attached?
|
||||
validate :faq_suggestion_belongs_to_account
|
||||
|
||||
before_validation :ensure_account
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = conversation&.account
|
||||
end
|
||||
|
||||
def faq_suggestion_belongs_to_account
|
||||
return if faq_suggestion.blank? || faq_suggestion.account_id == account_id
|
||||
|
||||
errors.add(:faq_suggestion, :invalid)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_faq_suggestions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# answer :text not null
|
||||
# embedding :vector(1536)
|
||||
# language :string default("en"), not null
|
||||
# question :string not null
|
||||
# source_count :integer default(0), not null
|
||||
# status :integer default("open"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
#
|
||||
class Captain::FaqSuggestion < ApplicationRecord
|
||||
self.table_name = 'captain_faq_suggestions'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :account
|
||||
has_many :observations,
|
||||
class_name: 'Captain::FaqObservation',
|
||||
dependent: :delete_all,
|
||||
inverse_of: :faq_suggestion
|
||||
has_neighbors :embedding, normalize: true
|
||||
|
||||
enum status: { open: 0, approved: 1, dismissed: 2 }
|
||||
|
||||
validates :question, :answer, :language, presence: true
|
||||
|
||||
before_validation :ensure_account
|
||||
after_commit :update_embedding, on: [:create, :update]
|
||||
|
||||
scope :ordered, -> { order(source_count: :desc, updated_at: :desc) }
|
||||
scope :by_language, ->(language) { where(language: language) }
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = assistant&.account
|
||||
end
|
||||
|
||||
def update_embedding
|
||||
return unless open?
|
||||
return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
|
||||
return if previously_new_record? && embedding.present?
|
||||
|
||||
Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}")
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,8 @@ module Enterprise::Concerns::Account
|
||||
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_faq_observations, dependent: :destroy_async, class_name: 'Captain::FaqObservation'
|
||||
has_many :captain_faq_suggestions, dependent: :destroy_async, class_name: 'Captain::FaqSuggestion'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
|
||||
|
||||
@@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :calls, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_many :captain_faq_observations, class_name: 'Captain::FaqObservation', dependent: :delete_all
|
||||
scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) }
|
||||
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
|
||||
Reference in New Issue
Block a user