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:
Shivam Mishra
2026-08-04 15:58:28 +05:30
committed by GitHub
parent 1c28df49e5
commit 94e9727eb4
9 changed files with 103 additions and 14 deletions

View File

@@ -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

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_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"