diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb index c5aa59f88..89a657efa 100644 --- a/app/listeners/reporting_event_listener.rb +++ b/app/listeners/reporting_event_listener.rb @@ -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) diff --git a/app/models/csat_survey_response.rb b/app/models/csat_survey_response.rb index 212530493..32e1d9fe5 100644 --- a/app/models/csat_survey_response.rb +++ b/app/models/csat_survey_response.rb @@ -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') diff --git a/db/migrate/20260701001000_create_captain_conversation_facts.rb b/db/migrate/20260701001000_create_captain_conversation_facts.rb new file mode 100644 index 000000000..64964ec77 --- /dev/null +++ b/db/migrate/20260701001000_create_captain_conversation_facts.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 0ca0c5539..8fe4e3406 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 0735987de..c4ac93414 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -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 diff --git a/enterprise/app/models/captain/conversation_fact.rb b/enterprise/app/models/captain/conversation_fact.rb new file mode 100644 index 000000000..d40e432b7 --- /dev/null +++ b/enterprise/app/models/captain/conversation_fact.rb @@ -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 diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 1ef112fb5..7b66f3ed6 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -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 diff --git a/enterprise/app/models/enterprise/concerns/conversation.rb b/enterprise/app/models/enterprise/concerns/conversation.rb index 0f7595e0d..8efd031ec 100644 --- a/enterprise/app/models/enterprise/concerns/conversation.rb +++ b/enterprise/app/models/enterprise/concerns/conversation.rb @@ -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 diff --git a/enterprise/app/models/enterprise/csat_survey_response.rb b/enterprise/app/models/enterprise/csat_survey_response.rb new file mode 100644 index 000000000..457632214 --- /dev/null +++ b/enterprise/app/models/enterprise/csat_survey_response.rb @@ -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 diff --git a/enterprise/app/models/enterprise/message.rb b/enterprise/app/models/enterprise/message.rb index a6336fc55..dc77ada8e 100644 --- a/enterprise/app/models/enterprise/message.rb +++ b/enterprise/app/models/enterprise/message.rb @@ -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? diff --git a/enterprise/app/services/captain/conversation_fact_updater.rb b/enterprise/app/services/captain/conversation_fact_updater.rb new file mode 100644 index 000000000..22fe0ddee --- /dev/null +++ b/enterprise/app/services/captain/conversation_fact_updater.rb @@ -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