Files
chatwoot/enterprise/app/models/captain/assistant.rb
Aakash BakhleandGitHub 102f19fe41 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.
2026-07-14 14:31:08 +05:30

126 lines
3.4 KiB
Ruby

# == Schema Information
#
# Table name: captain_assistants
#
# id :bigint not null, primary key
# config :jsonb not null
# description :text
# guardrails :jsonb
# name :string not null
# response_guidelines :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_captain_assistants_on_account_id (account_id)
#
class Captain::Assistant < ApplicationRecord
DESCRIPTION_LENGTH_LIMIT = 500
include Avatarable
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
self.table_name = 'captain_assistants'
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,
dependent: :destroy_async
has_many :inboxes,
through: :captain_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
validates :name, presence: true
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :account_id, presence: true
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
def available_name
name
end
def available_agent_tools
tools = self.class.built_in_agent_tools.dup
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
tools.concat(custom_tools)
tools
end
def available_tool_ids
available_agent_tools.pluck(:id)
end
def push_event_data
{
id: id,
name: name,
avatar_url: avatar_url.presence || default_avatar_url,
description: description,
created_at: created_at,
type: 'captain_assistant'
}
end
def webhook_data
{
id: id,
name: name,
avatar_url: avatar_url.presence || default_avatar_url,
description: description,
created_at: created_at,
type: 'captain_assistant'
}
end
private
def agent_name
name.parameterize(separator: '_')
end
def agent_tools
[
self.class.resolve_tool_class('faq_lookup').new(self),
self.class.resolve_tool_class('handoff').new(self)
]
end
def prompt_context
{
name: name,
description: description,
product_name: config['product_name'] || 'this product',
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
key: scenario.handoff_key,
description: scenario.description
}
end,
response_guidelines: response_guidelines || [],
guardrails: guardrails || []
}
end
def default_avatar_url
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
end
end