feat: add captain conversation facts
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user