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:
Aakash Bakhle
2026-07-14 14:31:08 +05:30
committed by GitHub
parent 1b6a80d84d
commit 102f19fe41
7 changed files with 179 additions and 1 deletions
@@ -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
View File
@@ -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? }