feat: add captain sessions model [CW-7485] (#14970)
This adds a `captain_sessions` table to log every Captain run, starting with Assistant Responses and Copilot Responses. Each session records the assistant, model, credits consumed, the FAQs/documents/scenario that contributed to the response, and the full run context — giving customers visibility into how a response was generated and giving us durable stats on credit, FAQ, and document usage (which today only exist as ephemeral trace metadata and an aggregate account counter). ## What changed - New `Captain::Session` model with a `session_type` enum (`assistant`, `copilot`). The subject (`Conversation` / `CopilotThread`) and result (`Message` / `CopilotMessage`) classes are inferred from the session type, so the table stores plain `subject_id` / `result_id` ids. `result_id` is nullable so failed runs that still consumed credits can be logged. - Composite indexes on `[session_type, subject_id]`, `[session_type, result_id]`, and `[account_id, session_type, created_at]` for lookup and usage-stats queries. - Factory and model specs. This PR is schema + model only; the writer/instrumentation that records sessions from the assistant and copilot flows will follow. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
co-authored by
Sony Mathew
parent
056b5eb89d
commit
df7f137657
@@ -0,0 +1,24 @@
|
||||
class CreateAgentSessions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :agent_sessions do |t|
|
||||
t.integer :session_type, null: false
|
||||
t.references :subject, polymorphic: true, null: false, index: false
|
||||
t.references :result, polymorphic: true, index: false
|
||||
t.references :account, null: false, index: true
|
||||
t.references :assistant, null: false, index: true
|
||||
t.references :user, index: true
|
||||
t.string :llm_model
|
||||
t.float :credits_consumed
|
||||
t.jsonb :faq_ids, default: []
|
||||
t.jsonb :document_ids, default: []
|
||||
t.jsonb :scenario_ids, default: []
|
||||
t.jsonb :run_context, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :agent_sessions, [:account_id, :session_type, :created_at]
|
||||
add_index :agent_sessions, [:account_id, :subject_type, :subject_id]
|
||||
add_index :agent_sessions, [:account_id, :result_type, :result_id]
|
||||
end
|
||||
end
|
||||
@@ -146,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id"
|
||||
end
|
||||
|
||||
create_table "agent_sessions", force: :cascade do |t|
|
||||
t.integer "session_type", null: false
|
||||
t.string "subject_type", null: false
|
||||
t.bigint "subject_id", null: false
|
||||
t.string "result_type"
|
||||
t.bigint "result_id"
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "user_id"
|
||||
t.string "llm_model"
|
||||
t.float "credits_consumed"
|
||||
t.jsonb "faq_ids", default: []
|
||||
t.jsonb "document_ids", default: []
|
||||
t.jsonb "scenario_ids", default: []
|
||||
t.jsonb "run_context", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7"
|
||||
t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e"
|
||||
t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d"
|
||||
t.index ["account_id"], name: "index_agent_sessions_on_account_id"
|
||||
t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id"
|
||||
t.index ["user_id"], name: "index_agent_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "applied_slas", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: agent_sessions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# credits_consumed :float
|
||||
# document_ids :jsonb
|
||||
# faq_ids :jsonb
|
||||
# llm_model :string
|
||||
# result_type :string
|
||||
# run_context :jsonb
|
||||
# scenario_ids :jsonb
|
||||
# session_type :integer not null
|
||||
# subject_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
# result_id :bigint
|
||||
# subject_id :bigint not null
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id)
|
||||
# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at)
|
||||
# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id)
|
||||
# index_agent_sessions_on_account_id (account_id)
|
||||
# index_agent_sessions_on_assistant_id (assistant_id)
|
||||
# index_agent_sessions_on_user_id (user_id)
|
||||
#
|
||||
class Captain::AgentSession < ApplicationRecord
|
||||
self.table_name = 'agent_sessions'
|
||||
|
||||
SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze
|
||||
RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true
|
||||
belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true
|
||||
|
||||
enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session
|
||||
|
||||
before_validation :ensure_account
|
||||
|
||||
validate :subject_type_matches_session_type
|
||||
validate :result_type_matches_session_type, if: -> { result_type.present? }
|
||||
validate :subject_belongs_to_account
|
||||
validate :result_belongs_to_account, if: -> { result_id.present? }
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = assistant&.account
|
||||
end
|
||||
|
||||
def subject_type_matches_session_type
|
||||
expected_type = SUBJECT_TYPES[session_type]
|
||||
return if subject_type == expected_type
|
||||
|
||||
errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions")
|
||||
end
|
||||
|
||||
def result_type_matches_session_type
|
||||
expected_type = RESULT_TYPES[session_type]
|
||||
return if result_type == expected_type
|
||||
|
||||
errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions")
|
||||
end
|
||||
|
||||
def subject_belongs_to_account
|
||||
return if subject.nil? || subject.account_id == account_id
|
||||
|
||||
errors.add(:subject, 'must belong to the session account')
|
||||
end
|
||||
|
||||
def result_belongs_to_account
|
||||
target_class = result_type.safe_constantize
|
||||
actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id)
|
||||
return if actual_account_id == account_id
|
||||
|
||||
errors.add(:result, 'must belong to the session account')
|
||||
end
|
||||
end
|
||||
@@ -37,6 +37,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
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'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::AgentSession, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
|
||||
it { is_expected.to belong_to(:user).optional }
|
||||
it { is_expected.to belong_to(:subject) }
|
||||
it { is_expected.to belong_to(:result).optional }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) }
|
||||
end
|
||||
|
||||
describe '#subject' do
|
||||
it 'returns the conversation for an assistant session' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
|
||||
|
||||
expect(session.subject).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'returns the copilot thread for a copilot session' do
|
||||
user = create(:user, account: account)
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread)
|
||||
|
||||
expect(session.subject).to eq(copilot_thread)
|
||||
end
|
||||
|
||||
it 'returns nil when the subject record no longer exists' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
|
||||
conversation.destroy
|
||||
|
||||
expect(session.reload.subject).to be_nil
|
||||
end
|
||||
|
||||
it 'is not valid when the subject type does not match the session type' do
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant)
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:subject_type]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when the subject belongs to a different account' do
|
||||
foreign_conversation = create(:conversation, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:subject]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe '#result' do
|
||||
it 'returns the message for an assistant session' do
|
||||
conversation = create(:conversation, account: account)
|
||||
message = create(:message, account: account, conversation: conversation)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message)
|
||||
|
||||
expect(session.result).to eq(message)
|
||||
end
|
||||
|
||||
it 'returns the copilot message for a copilot session' do
|
||||
user = create(:user, account: account)
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
|
||||
copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread)
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user,
|
||||
subject: copilot_thread, result: copilot_message)
|
||||
|
||||
expect(session.result).to eq(copilot_message)
|
||||
end
|
||||
|
||||
it 'returns nil when result_id is nil' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session.result).to be_nil
|
||||
end
|
||||
|
||||
it 'is not valid when the result belongs to a different account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
foreign_message = create(:message, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when result_id/result_type are set directly for a different account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
foreign_message = create(:message, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
|
||||
result_id: foreign_message.id, result_type: 'Message')
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when result_id/result_type are set directly for a stale id' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
|
||||
result_id: 0, result_type: 'Message')
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account' do
|
||||
it 'is derived from the assistant when created via the assistant association' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant)
|
||||
|
||||
expect(session.account).to eq(account)
|
||||
end
|
||||
|
||||
it 'overrides a mismatched explicit account with the assistant account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'defaults' do
|
||||
it 'defaults faq_ids, document_ids, scenario_ids and run_context' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session.faq_ids).to eq([])
|
||||
expect(session.document_ids).to eq([])
|
||||
expect(session.scenario_ids).to eq([])
|
||||
expect(session.run_context).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'builds a valid assistant session' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session).to be_session_assistant
|
||||
expect(session.subject).to be_a(Conversation)
|
||||
end
|
||||
|
||||
it 'builds a valid copilot session' do
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session).to be_session_copilot
|
||||
expect(session.subject).to be_a(CopilotThread)
|
||||
expect(session.user).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
FactoryBot.define do
|
||||
factory :captain_agent_session, class: 'Captain::AgentSession' do
|
||||
account
|
||||
association :assistant, factory: :captain_assistant
|
||||
session_type { :assistant }
|
||||
subject { create(:conversation, account: account) }
|
||||
|
||||
trait :copilot do
|
||||
session_type { :copilot }
|
||||
user
|
||||
subject { create(:captain_copilot_thread, account: account, user: user) }
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user