From 94e9727eb4ea9657b03d16b47ee7f1dee2d78cd2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 4 Aug 2026 15:58:28 +0530 Subject: [PATCH] feat: move Captain conversation outcomes to episode grain [CW-7792] (#15315) Moves Captain conversation outcomes from one row per conversation to one row per **engagement episode**: a new row each time demand for Captain (re)starts - first eligible message, a reopen after resolution, or (reserved) explicit assignment. Each episode has its own demand anchor, window, and trigger, so returning customers count as new demand and later cycles can't overwrite an earlier episode's handoff reason, resolution, or CSAT. Also adds `conversation_outcomes` associations on Account, Inbox, Conversation, and Captain::Assistant. ## Why this wasn't in #15233 The episode design came out of reviewing the wiring PR: per-conversation grain couldn't answer per-cycle questions without a patch per field. The table is unreleased with no writers, so changing the grain now is a pure schema swap - and landing it first means the tracker gets reviewed against the final model. ## What changed - Adds `episode_trigger`, `started_at`, `ended_at` - Drops `reopen_count` and `last_reopened_at` - reopens are episode rows now - Uniqueness moves from `(account, assistant, conversation)` to per-boundary `(account, conversation, started_at)` - Two partial unique indexes: one open episode per conversation, one initial episode per stream - Model: trigger enum, `started_at` uniqueness validation, `chronological`/`covering` scopes --- ..._episode_grain_to_conversation_outcomes.rb | 36 +++++++++++++++++++ db/schema.rb | 13 ++++--- enterprise/app/models/captain/assistant.rb | 1 + enterprise/app/models/conversation_outcome.rb | 32 ++++++++++++++--- .../app/models/enterprise/concerns/account.rb | 1 + .../enterprise/concerns/conversation.rb | 1 + .../app/models/enterprise/concerns/inbox.rb | 1 + .../models/conversation_outcome_spec.rb | 31 +++++++++++++--- spec/factories/conversation_outcomes.rb | 1 + 9 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 db/migrate/20260803130000_add_episode_grain_to_conversation_outcomes.rb diff --git a/db/migrate/20260803130000_add_episode_grain_to_conversation_outcomes.rb b/db/migrate/20260803130000_add_episode_grain_to_conversation_outcomes.rb new file mode 100644 index 000000000..472f9df02 --- /dev/null +++ b/db/migrate/20260803130000_add_episode_grain_to_conversation_outcomes.rb @@ -0,0 +1,36 @@ +class AddEpisodeGrainToConversationOutcomes < ActiveRecord::Migration[7.1] + # The table has no writers yet (outcome tracking is unreleased), so no data + # handling is needed: columns can be added NOT NULL and indexes swapped freely. + def change + change_table :conversation_outcomes, bulk: true do |t| + t.string :episode_trigger, null: false, default: 'initial' + t.datetime :started_at, null: false # rubocop:disable Rails/NotNullColumn -- table is empty, no default is meaningful + t.datetime :ended_at + + t.remove :reopen_count, type: :integer, null: false, default: 0 + t.remove :last_reopened_at, type: :datetime + end + + swap_episode_indexes + end + + private + + def swap_episode_indexes + remove_index :conversation_outcomes, name: 'idx_conversation_outcomes_unique_conversation', + column: [:account_id, :assistant_id, :conversation_id], unique: true + add_index :conversation_outcomes, [:account_id, :conversation_id, :started_at], + unique: true, name: 'idx_conversation_outcomes_unique_boundary' + add_index :conversation_outcomes, [:account_id, :conversation_id], + unique: true, where: 'ended_at IS NULL', + name: 'idx_conversation_outcomes_open_episode' + add_index :conversation_outcomes, [:account_id, :conversation_id], + unique: true, where: "episode_trigger = 'initial'", + name: 'idx_conversation_outcomes_initial_episode' + + remove_index :conversation_outcomes, name: 'idx_conversation_outcomes_on_assistant_created_at', + column: [:account_id, :assistant_id, :created_at] + add_index :conversation_outcomes, [:account_id, :assistant_id, :started_at], + name: 'idx_conversation_outcomes_on_assistant_started_at' + end +end diff --git a/db/schema.rb b/db/schema.rb index 682d2dc76..b2b2ac870 100644 --- a/db/schema.rb +++ b/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_31_140853) do +ActiveRecord::Schema[7.1].define(version: 2026_08_03_130000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -787,16 +787,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_31_140853) do 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.string "episode_trigger", default: "initial", null: false + t.datetime "started_at", null: false + t.datetime "ended_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", "assistant_id", "started_at"], name: "idx_conversation_outcomes_on_assistant_started_at" + t.index ["account_id", "conversation_id", "started_at"], name: "idx_conversation_outcomes_unique_boundary", unique: true + t.index ["account_id", "conversation_id"], name: "idx_conversation_outcomes_initial_episode", unique: true, where: "((episode_trigger)::text = 'initial'::text)" + t.index ["account_id", "conversation_id"], name: "idx_conversation_outcomes_open_episode", unique: true, where: "(ended_at IS NULL)" 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" diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index dc0969cd4..a738455ab 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -39,6 +39,7 @@ class Captain::Assistant < ApplicationRecord 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 + has_many :conversation_outcomes, dependent: :destroy_async store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name diff --git a/enterprise/app/models/conversation_outcome.rb b/enterprise/app/models/conversation_outcome.rb index f79fc00fc..06003b59a 100644 --- a/enterprise/app/models/conversation_outcome.rb +++ b/enterprise/app/models/conversation_outcome.rb @@ -6,14 +6,15 @@ # captain_reply_count :integer default(0), not null # csat_rating :integer # csat_received_at :datetime +# ended_at :datetime +# episode_trigger :string default("initial"), not null # 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 +# started_at :datetime not null # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null @@ -23,10 +24,12 @@ # # Indexes # -# idx_conversation_outcomes_on_assistant_created_at (account_id,assistant_id,created_at) +# idx_conversation_outcomes_initial_episode (account_id,conversation_id) UNIQUE WHERE ((episode_trigger)::text = 'initial'::text) # 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 +# idx_conversation_outcomes_on_assistant_started_at (account_id,assistant_id,started_at) +# idx_conversation_outcomes_open_episode (account_id,conversation_id) UNIQUE WHERE (ended_at IS NULL) +# idx_conversation_outcomes_unique_boundary (account_id,conversation_id,started_at) 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) @@ -43,6 +46,12 @@ class ConversationOutcome < ApplicationRecord usage_limit ].freeze + EPISODE_TRIGGERS = %w[ + initial + reopen + assignment + ].freeze + belongs_to :account belongs_to :assistant, class_name: 'Captain::Assistant' belongs_to :conversation, class_name: '::Conversation' @@ -53,9 +62,22 @@ class ConversationOutcome < ApplicationRecord prefix: :handoff_reason, validate: { allow_nil: true } - validates :conversation_id, uniqueness: { scope: [:account_id, :assistant_id] } + enum :episode_trigger, + EPISODE_TRIGGERS.index_by(&:itself), + prefix: :trigger, + validate: true + + validates :started_at, presence: true, uniqueness: { scope: [:account_id, :conversation_id] } validate :associations_must_belong_to_account + scope :chronological, -> { order(:started_at) } + + # The half-open episode window [started_at, ended_at); ended_at is nil for + # the stream's latest episode. + scope :covering, lambda { |at| + where(started_at: ..at).where('ended_at IS NULL OR ended_at > ?', at) + } + private def associations_must_belong_to_account diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 427b1e1af..5226cbedd 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -16,6 +16,7 @@ module Enterprise::Concerns::Account 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 :conversation_outcomes, dependent: :destroy_async has_many :copilot_threads, dependent: :destroy_async has_many :companies, dependent: :destroy_async diff --git a/enterprise/app/models/enterprise/concerns/conversation.rb b/enterprise/app/models/enterprise/concerns/conversation.rb index c247e01e8..b18a7b01a 100644 --- a/enterprise/app/models/enterprise/concerns/conversation.rb +++ b/enterprise/app/models/enterprise/concerns/conversation.rb @@ -8,6 +8,7 @@ module Enterprise::Concerns::Conversation 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 + has_many :conversation_outcomes, dependent: :destroy_async scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) } before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } diff --git a/enterprise/app/models/enterprise/concerns/inbox.rb b/enterprise/app/models/enterprise/concerns/inbox.rb index b327878b4..4c021f79b 100644 --- a/enterprise/app/models/enterprise/concerns/inbox.rb +++ b/enterprise/app/models/enterprise/concerns/inbox.rb @@ -8,6 +8,7 @@ module Enterprise::Concerns::Inbox class_name: 'Captain::Assistant' has_many :inbox_capacity_limits, dependent: :destroy has_many :calls, dependent: :destroy_async + has_many :conversation_outcomes, dependent: :destroy_async before_create :ensure_create_permitted end diff --git a/spec/enterprise/models/conversation_outcome_spec.rb b/spec/enterprise/models/conversation_outcome_spec.rb index 4183490c6..eae122785 100644 --- a/spec/enterprise/models/conversation_outcome_spec.rb +++ b/spec/enterprise/models/conversation_outcome_spec.rb @@ -15,6 +15,13 @@ RSpec.describe ConversationOutcome, type: :model do .backed_by_column_of_type(:string) .with_prefix(:handoff_reason) } + + it { + expect(subject).to define_enum_for(:episode_trigger) + .with_values(described_class::EPISODE_TRIGGERS.index_by(&:itself)) + .backed_by_column_of_type(:string) + .with_prefix(:trigger) + } end describe 'validations' do @@ -48,18 +55,34 @@ RSpec.describe ConversationOutcome, type: :model do expect(outcome.errors[:inbox]).to be_present end - it 'requires unique conversations per assistant and account' do - existing = create(:conversation_outcome) + it 'allows a later episode on the same conversation' do + existing = create(:conversation_outcome, started_at: 1.hour.ago, ended_at: 30.minutes.ago) + second_episode = build( + :conversation_outcome, + account: existing.account, + assistant: existing.assistant, + conversation: existing.conversation, + inbox: existing.inbox, + episode_trigger: 'reopen', + started_at: 30.minutes.ago + ) + + expect(second_episode).to be_valid + end + + it 'rejects a duplicate boundary on the same conversation' do + existing = create(:conversation_outcome, started_at: 1.hour.ago) duplicate = build( :conversation_outcome, account: existing.account, assistant: existing.assistant, conversation: existing.conversation, - inbox: existing.inbox + inbox: existing.inbox, + started_at: existing.started_at ) expect(duplicate).not_to be_valid - expect(duplicate.errors[:conversation_id]).to be_present + expect(duplicate.errors[:started_at]).to be_present end end diff --git a/spec/factories/conversation_outcomes.rb b/spec/factories/conversation_outcomes.rb index b560825aa..ad569e3f5 100644 --- a/spec/factories/conversation_outcomes.rb +++ b/spec/factories/conversation_outcomes.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :conversation_outcome do account { create(:account) } + started_at { Time.current } after(:build) do |outcome| outcome.assistant ||= create(:captain_assistant, account: outcome.account)