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)