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:
Shivam Mishra
2026-08-03 16:23:51 +05:30
committed by GitHub
parent eb375c45ce
commit 871130f566
5 changed files with 218 additions and 1 deletions

View 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