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 |
70 lines
2.2 KiB
Ruby
70 lines
2.2 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe ConversationOutcome, type: :model do
|
|
describe 'associations' do
|
|
it { is_expected.to belong_to(:account) }
|
|
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
|
|
it { is_expected.to belong_to(:conversation).class_name('::Conversation') }
|
|
it { is_expected.to belong_to(:inbox) }
|
|
end
|
|
|
|
describe 'enums' do
|
|
it {
|
|
expect(subject).to define_enum_for(:handoff_reason_category)
|
|
.with_values(described_class::HANDOFF_REASON_CATEGORIES.index_by(&:itself))
|
|
.backed_by_column_of_type(:string)
|
|
.with_prefix(:handoff_reason)
|
|
}
|
|
end
|
|
|
|
describe 'validations' do
|
|
it 'allows a handoff without a reason category for unclassified handoffs' do
|
|
outcome = build(:conversation_outcome, handoff_at: Time.current)
|
|
|
|
expect(outcome).to be_valid
|
|
end
|
|
|
|
it 'rejects an assistant from another account' do
|
|
outcome = build(:conversation_outcome)
|
|
outcome.assistant = create(:captain_assistant, account: create(:account))
|
|
|
|
expect(outcome).not_to be_valid
|
|
expect(outcome.errors[:assistant]).to be_present
|
|
end
|
|
|
|
it 'rejects a conversation from another account' do
|
|
outcome = build(:conversation_outcome)
|
|
outcome.conversation = create(:conversation, account: create(:account))
|
|
|
|
expect(outcome).not_to be_valid
|
|
expect(outcome.errors[:conversation]).to be_present
|
|
end
|
|
|
|
it 'rejects an inbox from another account' do
|
|
outcome = build(:conversation_outcome)
|
|
outcome.inbox = create(:inbox, account: create(:account))
|
|
|
|
expect(outcome).not_to be_valid
|
|
expect(outcome.errors[:inbox]).to be_present
|
|
end
|
|
|
|
it 'requires unique conversations per assistant and account' do
|
|
existing = create(:conversation_outcome)
|
|
duplicate = build(
|
|
:conversation_outcome,
|
|
account: existing.account,
|
|
assistant: existing.assistant,
|
|
conversation: existing.conversation,
|
|
inbox: existing.inbox
|
|
)
|
|
|
|
expect(duplicate).not_to be_valid
|
|
expect(duplicate.errors[:conversation_id]).to be_present
|
|
end
|
|
end
|
|
|
|
it 'builds a valid outcome' do
|
|
expect(build(:conversation_outcome)).to be_valid
|
|
end
|
|
end
|