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 |
68 lines
2.5 KiB
Ruby
68 lines
2.5 KiB
Ruby
# == 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
|