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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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? }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user