feat: add Captain conversation outcome model [CW-7792] (#15233)
Adds the `Captain::ConversationOutcome` model and its table: one row per (account, assistant, conversation) that folds Captain lifecycle events into flat facts for the upcoming value metrics report. This is the second PR in the outcomes stack, on top of the lifecycle event layer (#15213); the tracker and listener that populate it come next. The table deliberately stores only timestamps, counts, and the handoff reason. Every classification and duration (coverage, autonomous vs assisted, durable resolution, resolution time) is derived at query time in the stats layer, so definitions can change later without backfills. The row is created on the first qualifying customer message, making `created_at` the demand-start anchor. Commit messages carry the field-by-field rationale for what was left out. | Field | Meaning | | --- | --- | | `account_id`, `assistant_id`, `conversation_id`, `inbox_id` | Reporting dimensions; unique on the first three | | `first_captain_reply_at` / `last_captain_reply_at` | First and latest public Captain reply | | `captain_reply_count` | Public Captain replies in the conversation | | `first_human_reply_at` | First public human agent reply, used to classify assisted resolutions | | `handoff_at` | When Captain handed the conversation to a human | | `handoff_reason_category` | Why it handed off (customer_request, missing_knowledge, unsupported_request, policy_restriction, tool_failure, pending_clarification, usage_limit) | | `resolved_at` | When the conversation was resolved | | `last_reopened_at` / `reopen_count` | Reopen facts backing reopen rate and durable-resolution checks | | `csat_rating` / `csat_received_at` | CSAT for Captain-involved conversations |
This commit is contained in:
42
db/migrate/20260731140853_create_conversation_outcomes.rb
Normal file
42
db/migrate/20260731140853_create_conversation_outcomes.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class CreateConversationOutcomes < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :conversation_outcomes do |t|
|
||||
t.references :account, null: false
|
||||
t.references :assistant, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :inbox, null: false
|
||||
|
||||
t.datetime :first_captain_reply_at
|
||||
t.datetime :last_captain_reply_at
|
||||
t.integer :captain_reply_count, null: false, default: 0
|
||||
t.datetime :first_human_reply_at
|
||||
|
||||
t.datetime :handoff_at
|
||||
t.string :handoff_reason_category
|
||||
|
||||
t.datetime :resolved_at
|
||||
t.datetime :last_reopened_at
|
||||
t.integer :reopen_count, null: false, default: 0
|
||||
|
||||
t.integer :csat_rating
|
||||
t.datetime :csat_received_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_reporting_indexes
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_reporting_indexes
|
||||
add_index :conversation_outcomes, [:account_id, :assistant_id, :conversation_id],
|
||||
unique: true, name: 'idx_conversation_outcomes_unique_conversation'
|
||||
add_index :conversation_outcomes, [:account_id, :assistant_id, :resolved_at],
|
||||
name: 'idx_conversation_outcomes_on_assistant_resolved_at'
|
||||
add_index :conversation_outcomes, [:account_id, :assistant_id, :handoff_at],
|
||||
name: 'idx_conversation_outcomes_on_assistant_handoff_at'
|
||||
add_index :conversation_outcomes, [:account_id, :assistant_id, :created_at],
|
||||
name: 'idx_conversation_outcomes_on_assistant_created_at'
|
||||
end
|
||||
end
|
||||
30
db/schema.rb
30
db/schema.rb
@@ -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_29_051500) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_31_140853) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -775,6 +775,34 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_29_051500) do
|
||||
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
|
||||
end
|
||||
|
||||
create_table "conversation_outcomes", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
t.datetime "first_captain_reply_at"
|
||||
t.datetime "last_captain_reply_at"
|
||||
t.integer "captain_reply_count", default: 0, null: false
|
||||
t.datetime "first_human_reply_at"
|
||||
t.datetime "handoff_at"
|
||||
t.string "handoff_reason_category"
|
||||
t.datetime "resolved_at"
|
||||
t.datetime "last_reopened_at"
|
||||
t.integer "reopen_count", default: 0, null: false
|
||||
t.integer "csat_rating"
|
||||
t.datetime "csat_received_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "assistant_id", "conversation_id"], name: "idx_conversation_outcomes_unique_conversation", unique: true
|
||||
t.index ["account_id", "assistant_id", "created_at"], name: "idx_conversation_outcomes_on_assistant_created_at"
|
||||
t.index ["account_id", "assistant_id", "handoff_at"], name: "idx_conversation_outcomes_on_assistant_handoff_at"
|
||||
t.index ["account_id", "assistant_id", "resolved_at"], name: "idx_conversation_outcomes_on_assistant_resolved_at"
|
||||
t.index ["account_id"], name: "index_conversation_outcomes_on_account_id"
|
||||
t.index ["assistant_id"], name: "index_conversation_outcomes_on_assistant_id"
|
||||
t.index ["conversation_id"], name: "index_conversation_outcomes_on_conversation_id"
|
||||
t.index ["inbox_id"], name: "index_conversation_outcomes_on_inbox_id"
|
||||
end
|
||||
|
||||
create_table "conversation_participants", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "user_id", null: false
|
||||
|
||||
67
enterprise/app/models/conversation_outcome.rb
Normal file
67
enterprise/app/models/conversation_outcome.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: conversation_outcomes
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# captain_reply_count :integer default(0), not null
|
||||
# csat_rating :integer
|
||||
# csat_received_at :datetime
|
||||
# first_captain_reply_at :datetime
|
||||
# first_human_reply_at :datetime
|
||||
# handoff_at :datetime
|
||||
# handoff_reason_category :string
|
||||
# last_captain_reply_at :datetime
|
||||
# last_reopened_at :datetime
|
||||
# reopen_count :integer default(0), not null
|
||||
# resolved_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_conversation_outcomes_on_assistant_created_at (account_id,assistant_id,created_at)
|
||||
# idx_conversation_outcomes_on_assistant_handoff_at (account_id,assistant_id,handoff_at)
|
||||
# idx_conversation_outcomes_on_assistant_resolved_at (account_id,assistant_id,resolved_at)
|
||||
# idx_conversation_outcomes_unique_conversation (account_id,assistant_id,conversation_id) UNIQUE
|
||||
# index_conversation_outcomes_on_account_id (account_id)
|
||||
# index_conversation_outcomes_on_assistant_id (assistant_id)
|
||||
# index_conversation_outcomes_on_conversation_id (conversation_id)
|
||||
# index_conversation_outcomes_on_inbox_id (inbox_id)
|
||||
#
|
||||
class ConversationOutcome < ApplicationRecord
|
||||
HANDOFF_REASON_CATEGORIES = %w[
|
||||
customer_request
|
||||
missing_knowledge
|
||||
unsupported_request
|
||||
policy_restriction
|
||||
tool_failure
|
||||
pending_clarification
|
||||
usage_limit
|
||||
].freeze
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :inbox
|
||||
|
||||
enum :handoff_reason_category,
|
||||
HANDOFF_REASON_CATEGORIES.index_by(&:itself),
|
||||
prefix: :handoff_reason,
|
||||
validate: { allow_nil: true }
|
||||
|
||||
validates :conversation_id, uniqueness: { scope: [:account_id, :assistant_id] }
|
||||
validate :associations_must_belong_to_account
|
||||
|
||||
private
|
||||
|
||||
def associations_must_belong_to_account
|
||||
[:assistant, :conversation, :inbox].each do |association|
|
||||
record = public_send(association)
|
||||
errors.add(association, 'must belong to the same account') if record && record.account_id != account_id
|
||||
end
|
||||
end
|
||||
end
|
||||
69
spec/enterprise/models/conversation_outcome_spec.rb
Normal file
69
spec/enterprise/models/conversation_outcome_spec.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ConversationOutcome, type: :model do
|
||||
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(:conversation).class_name('::Conversation') }
|
||||
it { is_expected.to belong_to(:inbox) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it {
|
||||
expect(subject).to define_enum_for(:handoff_reason_category)
|
||||
.with_values(described_class::HANDOFF_REASON_CATEGORIES.index_by(&:itself))
|
||||
.backed_by_column_of_type(:string)
|
||||
.with_prefix(:handoff_reason)
|
||||
}
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'allows a handoff without a reason category for unclassified handoffs' do
|
||||
outcome = build(:conversation_outcome, handoff_at: Time.current)
|
||||
|
||||
expect(outcome).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects an assistant from another account' do
|
||||
outcome = build(:conversation_outcome)
|
||||
outcome.assistant = create(:captain_assistant, account: create(:account))
|
||||
|
||||
expect(outcome).not_to be_valid
|
||||
expect(outcome.errors[:assistant]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects a conversation from another account' do
|
||||
outcome = build(:conversation_outcome)
|
||||
outcome.conversation = create(:conversation, account: create(:account))
|
||||
|
||||
expect(outcome).not_to be_valid
|
||||
expect(outcome.errors[:conversation]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects an inbox from another account' do
|
||||
outcome = build(:conversation_outcome)
|
||||
outcome.inbox = create(:inbox, account: create(:account))
|
||||
|
||||
expect(outcome).not_to be_valid
|
||||
expect(outcome.errors[:inbox]).to be_present
|
||||
end
|
||||
|
||||
it 'requires unique conversations per assistant and account' do
|
||||
existing = create(:conversation_outcome)
|
||||
duplicate = build(
|
||||
:conversation_outcome,
|
||||
account: existing.account,
|
||||
assistant: existing.assistant,
|
||||
conversation: existing.conversation,
|
||||
inbox: existing.inbox
|
||||
)
|
||||
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:conversation_id]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
it 'builds a valid outcome' do
|
||||
expect(build(:conversation_outcome)).to be_valid
|
||||
end
|
||||
end
|
||||
11
spec/factories/conversation_outcomes.rb
Normal file
11
spec/factories/conversation_outcomes.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
FactoryBot.define do
|
||||
factory :conversation_outcome do
|
||||
account { create(:account) }
|
||||
|
||||
after(:build) do |outcome|
|
||||
outcome.assistant ||= create(:captain_assistant, account: outcome.account)
|
||||
outcome.inbox ||= create(:inbox, account: outcome.account)
|
||||
outcome.conversation ||= create(:conversation, account: outcome.account, inbox: outcome.inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user