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,42 @@
class CreateConversationOutcomes < ActiveRecord::Migration[7.1]
def change
create_table :conversation_outcomes do |t|
t.references :account, null: false
t.references :assistant, null: false
t.references :conversation, null: false
t.references :inbox, null: false
t.datetime :first_captain_reply_at
t.datetime :last_captain_reply_at
t.integer :captain_reply_count, null: false, default: 0
t.datetime :first_human_reply_at
t.datetime :handoff_at
t.string :handoff_reason_category
t.datetime :resolved_at
t.datetime :last_reopened_at
t.integer :reopen_count, null: false, default: 0
t.integer :csat_rating
t.datetime :csat_received_at
t.timestamps
end
add_reporting_indexes
end
private
def add_reporting_indexes
add_index :conversation_outcomes, [:account_id, :assistant_id, :conversation_id],
unique: true, name: 'idx_conversation_outcomes_unique_conversation'
add_index :conversation_outcomes, [:account_id, :assistant_id, :resolved_at],
name: 'idx_conversation_outcomes_on_assistant_resolved_at'
add_index :conversation_outcomes, [:account_id, :assistant_id, :handoff_at],
name: 'idx_conversation_outcomes_on_assistant_handoff_at'
add_index :conversation_outcomes, [:account_id, :assistant_id, :created_at],
name: 'idx_conversation_outcomes_on_assistant_created_at'
end
end

View File

@@ -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_29_051500) do
ActiveRecord::Schema[7.1].define(version: 2026_07_31_140853) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -775,6 +775,34 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_29_051500) do
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
end
create_table "conversation_outcomes", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "assistant_id", null: false
t.bigint "conversation_id", null: false
t.bigint "inbox_id", null: false
t.datetime "first_captain_reply_at"
t.datetime "last_captain_reply_at"
t.integer "captain_reply_count", default: 0, null: false
t.datetime "first_human_reply_at"
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.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"], 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"
t.index ["inbox_id"], name: "index_conversation_outcomes_on_inbox_id"
end
create_table "conversation_participants", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "user_id", null: false