feat: add captain conversation facts

This commit is contained in:
Shivam Mishra
2026-07-01 17:47:36 +05:30
parent afa2b49e45
commit 02d4de7357
11 changed files with 285 additions and 13 deletions
+20 -12
View File
@@ -21,8 +21,7 @@ class ReportingEventListener < BaseListener
)
create_bot_resolved_event(conversation, reporting_event)
reporting_event.save!
safe_rollup(reporting_event)
persist_reporting_event(reporting_event)
end
def first_reply_created(event)
@@ -44,8 +43,7 @@ class ReportingEventListener < BaseListener
**actor_attributes(actor_from_event(event) || message.sender)
)
reporting_event.save!
safe_rollup(reporting_event)
persist_reporting_event(reporting_event)
end
def reply_created(event)
@@ -70,8 +68,7 @@ class ReportingEventListener < BaseListener
event_end_time: message.created_at,
**actor_attributes(actor_from_event(event) || message.sender)
)
reporting_event.save!
safe_rollup(reporting_event)
persist_reporting_event(reporting_event)
end
def conversation_bot_handoff(event)
@@ -98,8 +95,7 @@ class ReportingEventListener < BaseListener
event_end_time: event_end_time,
**actor_attributes(actor_from_event(event))
)
reporting_event.save!
safe_rollup(reporting_event)
persist_reporting_event(reporting_event)
end
def conversation_captain_inference_resolved(event)
@@ -159,14 +155,14 @@ class ReportingEventListener < BaseListener
**event_attributes,
**actor_attributes(actor)
)
reporting_event.save!
persist_reporting_event(reporting_event, rollup: false)
end
def create_captain_inference_event(event, event_name)
conversation = extract_conversation_and_account(event)[0]
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
ReportingEvent.create!(
reporting_event = ReportingEvent.new(
name: event_name,
value: time_to_event,
account_id: conversation.account_id,
@@ -177,6 +173,7 @@ class ReportingEventListener < BaseListener
event_end_time: event.timestamp,
**actor_attributes(actor_from_event(event))
)
persist_reporting_event(reporting_event, rollup: false)
end
def create_bot_resolved_event(conversation, reporting_event)
@@ -186,8 +183,19 @@ class ReportingEventListener < BaseListener
bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.save!
safe_rollup(bot_resolved_event)
persist_reporting_event(bot_resolved_event)
end
def persist_reporting_event(reporting_event, rollup: true)
reporting_event.save!
safe_rollup(reporting_event) if rollup
update_captain_conversation_fact(reporting_event)
end
def update_captain_conversation_fact(reporting_event)
return unless defined?(Captain::ConversationFactUpdater)
Captain::ConversationFactUpdater.record_reporting_event(reporting_event)
end
def actor_from_event(event)
+2
View File
@@ -45,3 +45,5 @@ class CsatSurveyResponse < ApplicationRecord
# filter by rating value
scope :filter_by_rating, ->(rating) { where(rating: rating) if rating.present? }
end
CsatSurveyResponse.include_mod_with('CsatSurveyResponse')
@@ -0,0 +1,41 @@
class CreateCaptainConversationFacts < ActiveRecord::Migration[7.1]
def change
create_captain_conversation_facts_table
add_captain_conversation_facts_indexes
end
private
def create_captain_conversation_facts_table
create_table :captain_conversation_facts do |t|
t.bigint :account_id, null: false
t.bigint :conversation_id, null: false
t.bigint :assistant_id, null: false
t.bigint :inbox_id, null: false
t.datetime :first_captain_message_at
t.datetime :last_captain_message_at
t.datetime :captain_resolved_at
t.datetime :captain_handed_off_at
t.datetime :first_human_reply_after_captain_at
t.datetime :reopened_after_captain_resolution_at
t.bigint :csat_response_id
t.integer :csat_rating
t.datetime :csat_submitted_at
t.timestamps
end
end
def add_captain_conversation_facts_indexes
add_index :captain_conversation_facts, :account_id
add_index :captain_conversation_facts, :conversation_id, unique: true
add_index :captain_conversation_facts, [:account_id, :assistant_id, :first_captain_message_at],
name: 'idx_captain_facts_on_account_assistant_first_message'
add_index :captain_conversation_facts, [:account_id, :captain_resolved_at],
name: 'idx_captain_facts_on_account_resolved_at'
add_index :captain_conversation_facts, [:account_id, :captain_handed_off_at],
name: 'idx_captain_facts_on_account_handed_off_at'
add_index :captain_conversation_facts, [:account_id, :csat_submitted_at],
name: 'idx_captain_facts_on_account_csat_submitted_at'
end
end
+25 -1
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_01_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_07_01_001000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -350,6 +350,30 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_01_000000) do
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
create_table "captain_conversation_facts", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "conversation_id", null: false
t.bigint "assistant_id", null: false
t.bigint "inbox_id", null: false
t.datetime "first_captain_message_at"
t.datetime "last_captain_message_at"
t.datetime "captain_resolved_at"
t.datetime "captain_handed_off_at"
t.datetime "first_human_reply_after_captain_at"
t.datetime "reopened_after_captain_resolution_at"
t.bigint "csat_response_id"
t.integer "csat_rating"
t.datetime "csat_submitted_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "assistant_id", "first_captain_message_at"], name: "idx_captain_facts_on_account_assistant_first_message"
t.index ["account_id", "captain_handed_off_at"], name: "idx_captain_facts_on_account_handed_off_at"
t.index ["account_id", "captain_resolved_at"], name: "idx_captain_facts_on_account_resolved_at"
t.index ["account_id", "csat_submitted_at"], name: "idx_captain_facts_on_account_csat_submitted_at"
t.index ["account_id"], name: "index_captain_conversation_facts_on_account_id"
t.index ["conversation_id"], name: "index_captain_conversation_facts_on_conversation_id", unique: true
end
create_table "captain_custom_tools", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "slug", null: false
@@ -35,6 +35,7 @@ class Captain::Assistant < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
has_many :conversation_facts, class_name: 'Captain::ConversationFact', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
@@ -0,0 +1,41 @@
# == Schema Information
#
# Table name: captain_conversation_facts
#
# id :bigint not null, primary key
# captain_handed_off_at :datetime
# captain_resolved_at :datetime
# csat_rating :integer
# csat_submitted_at :datetime
# first_captain_message_at :datetime
# first_human_reply_after_captain_at :datetime
# last_captain_message_at :datetime
# reopened_after_captain_resolution_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
# csat_response_id :bigint
# inbox_id :bigint not null
#
# Indexes
#
# idx_captain_facts_on_account_assistant_first_message (account_id,assistant_id,first_captain_message_at)
# idx_captain_facts_on_account_csat_submitted_at (account_id,csat_submitted_at)
# idx_captain_facts_on_account_handed_off_at (account_id,captain_handed_off_at)
# idx_captain_facts_on_account_resolved_at (account_id,captain_resolved_at)
# index_captain_conversation_facts_on_account_id (account_id)
# index_captain_conversation_facts_on_conversation_id (conversation_id) UNIQUE
#
class Captain::ConversationFact < ApplicationRecord
self.table_name = 'captain_conversation_facts'
belongs_to :account
belongs_to :conversation
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :inbox
belongs_to :csat_response, class_name: 'CsatSurveyResponse', optional: true
validates :conversation_id, uniqueness: true
end
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :captain_conversation_facts, dependent: :destroy_async, class_name: 'Captain::ConversationFact'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
@@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation
has_many :sla_events, dependent: :destroy_async
has_many :calls, dependent: :destroy_async
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_one :captain_conversation_fact, class_name: 'Captain::ConversationFact', dependent: :destroy_async
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
around_save :ensure_applied_sla_is_created, if: -> { sla_policy_id_changed? }
end
@@ -0,0 +1,13 @@
module Enterprise::CsatSurveyResponse
extend ActiveSupport::Concern
included do
after_commit :update_captain_conversation_fact, on: [:create, :update]
end
private
def update_captain_conversation_fact
Captain::ConversationFactUpdater.record_csat_response(self)
end
end
@@ -4,6 +4,8 @@ module Enterprise::Message
has_one :call, class_name: 'Call', foreign_key: :message_id, dependent: :nullify, inverse_of: :message
scope :with_call, -> { includes(call: [:contact, { inbox: :channel }]) }
after_create_commit :update_captain_conversation_fact
end
end
@@ -15,6 +17,10 @@ module Enterprise::Message
private
def update_captain_conversation_fact
Captain::ConversationFactUpdater.record_message(self)
end
def mark_pending_conversation_as_open_for_human_response
return unless captain_pending_conversation?
return unless human_response?
@@ -0,0 +1,134 @@
class Captain::ConversationFactUpdater
RESOLVED_EVENT_NAMES = %w[
conversation_resolved
conversation_bot_resolved
conversation_captain_inference_resolved
].freeze
HANDOFF_EVENT_NAMES = %w[
conversation_bot_handoff
conversation_captain_inference_handoff
].freeze
class << self
def record_message(message)
return unless message.outgoing?
return if message.private?
if captain_message?(message)
record_captain_message(message)
elsif human_message?(message)
record_human_message(message)
end
end
def record_reporting_event(reporting_event)
if RESOLVED_EVENT_NAMES.include?(reporting_event.name)
record_captain_resolution(reporting_event)
elsif HANDOFF_EVENT_NAMES.include?(reporting_event.name)
record_captain_handoff(reporting_event)
elsif reporting_event.name == 'conversation_opened'
record_reopen_after_captain_resolution(reporting_event)
end
end
def record_csat_response(csat_response)
fact = Captain::ConversationFact.find_by(conversation_id: csat_response.conversation_id)
return if fact.blank?
fact.assign_attributes(
csat_response_id: csat_response.id,
csat_rating: csat_response.rating,
csat_submitted_at: csat_response.created_at
)
fact.save! if fact.changed?
end
private
def captain_message?(message)
message.sender_type == 'Captain::Assistant'
end
def human_message?(message)
message.sender_type == 'User'
end
def record_captain_message(message)
fact = find_or_create_fact!(
conversation_id: message.conversation_id,
account_id: message.account_id,
inbox_id: message.inbox_id,
assistant_id: message.sender_id
)
fact.first_captain_message_at ||= message.created_at
fact.last_captain_message_at = latest_time(fact.last_captain_message_at, message.created_at)
fact.save! if fact.changed?
end
def record_human_message(message)
fact = Captain::ConversationFact.find_by(conversation_id: message.conversation_id)
return if fact.blank?
return if fact.first_captain_message_at.blank?
return if fact.first_human_reply_after_captain_at.present?
return if message.created_at <= fact.first_captain_message_at
fact.update!(first_human_reply_after_captain_at: message.created_at)
end
def record_captain_resolution(reporting_event)
return unless captain_actor?(reporting_event)
fact = find_or_create_fact_from_event!(reporting_event)
fact.captain_resolved_at = earliest_time(fact.captain_resolved_at, reporting_event.event_end_time)
fact.save! if fact.changed?
end
def record_captain_handoff(reporting_event)
return unless captain_actor?(reporting_event)
fact = find_or_create_fact_from_event!(reporting_event)
fact.captain_handed_off_at = earliest_time(fact.captain_handed_off_at, reporting_event.event_end_time)
fact.save! if fact.changed?
end
def record_reopen_after_captain_resolution(reporting_event)
fact = Captain::ConversationFact.find_by(conversation_id: reporting_event.conversation_id)
return if fact.blank?
return if fact.captain_resolved_at.blank?
return if fact.reopened_after_captain_resolution_at.present?
return if reporting_event.event_end_time < fact.captain_resolved_at
fact.update!(reopened_after_captain_resolution_at: reporting_event.event_end_time)
end
def captain_actor?(reporting_event)
reporting_event.actor_type == 'Captain::Assistant' && reporting_event.actor_id.present?
end
def find_or_create_fact_from_event!(reporting_event)
find_or_create_fact!(
conversation_id: reporting_event.conversation_id,
account_id: reporting_event.account_id,
inbox_id: reporting_event.inbox_id,
assistant_id: reporting_event.actor_id
)
end
def find_or_create_fact!(conversation_id:, account_id:, inbox_id:, assistant_id:)
Captain::ConversationFact.create_or_find_by!(conversation_id: conversation_id) do |fact|
fact.account_id = account_id
fact.inbox_id = inbox_id
fact.assistant_id = assistant_id
end
end
def earliest_time(current_time, candidate_time)
[current_time, candidate_time].compact.min
end
def latest_time(current_time, candidate_time)
[current_time, candidate_time].compact.max
end
end
end