diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue index 9d6d574ec..8ff38b2eb 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue @@ -66,6 +66,10 @@ const props = defineProps({ type: Number, default: null, }, + responsesCount: { + type: Number, + default: 0, + }, isSelected: { type: Boolean, default: false, @@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value); const menuItems = computed(() => { const allOptions = [ { - label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'), - value: 'viewRelatedQuestions', - action: 'viewRelatedQuestions', - icon: 'i-ph-tree-view-duotone', + label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'), + value: 'viewDetails', + action: 'viewDetails', + icon: 'i-lucide-eye', }, ]; @@ -143,6 +147,9 @@ const menuItems = computed(() => { }); const createdAtLabel = computed(() => dynamicTime(props.createdAt)); +const responsesCountLabel = computed(() => + t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount }) +); const displayLink = computed(() => isPdf.value @@ -158,6 +165,10 @@ const handleAction = ({ action, value }) => { emit('action', { action, value, id: props.id }); }; +const handleViewDetails = () => { + emit('action', { action: 'viewDetails', id: props.id }); +}; + const handleRetry = () => { emit('action', { action: 'sync', id: props.id }); }; @@ -177,9 +188,13 @@ const handleRetry = () => {
- +
{ {{ displayLink }} + + {{ responsesCountLabel }} + ({ + dispatch: vi.fn(), + getterValues: { + 'captainResponses/getUIFlags': { value: { fetchingList: false } }, + 'captainResponses/getRecords': { value: [] }, + 'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } }, + }, +})); + +vi.mock('dashboard/composables/store', () => ({ + useStore: () => ({ dispatch }), + useMapGetter: key => getterValues[key], +})); + +vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() })); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +const captainDocument = { + id: 42, + name: 'FAQ source', + external_link: 'https://example.com/docs', + assistant: { id: 7 }, + content: 'Document content', + pdf_document: false, +}; + +const DialogStub = { + name: 'Dialog', + template: '
', +}; + +const TabBarStub = { + name: 'TabBar', + template: + '
- ''; + } + get formattedMessage() { return this.formatMessage(); } diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js index 3350399eb..20d64005a 100644 --- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js +++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js @@ -68,6 +68,25 @@ describe('#MessageFormatter', () => { }); }); + describe('#disableImageRendering', () => { + it('omits nested and reference images with relative URLs', () => { + const message = `Before ![nested [alt]](/relative.png) + +![reference][logo] + +[logo]: /logo.png + +After`; + const formatter = new MessageFormatter(message); + + formatter.disableImageRendering(); + + expect(formatter.formattedMessage).not.toContain(' { it('should return the same string if not tags or @mentions', () => { const message = 'Chatwoot is an opensource tool'; diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb index e55355f3a..9c5b8e27b 100644 --- a/app/services/imap/base_fetch_email_service.rb +++ b/app/services/imap/base_fetch_email_service.rb @@ -38,7 +38,8 @@ class Imap::BaseFetchEmailService end def email_already_present?(channel, message_id) - channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(message_id) + # exists? avoids Message's default_scope ORDER BY, which full-scans large inboxes + channel.inbox.messages.exists?(source_id: message_id) || deleted_message_tracker.deleted?(message_id) end def deleted_message_tracker diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 69631c468..373e47b3c 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def fetch_whatsapp_templates(url) response = HTTParty.get(url) - return [] unless response.success? + unless response.success? + Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \ + "inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}" + return [] + end next_url = next_url(response) @@ -155,7 +159,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def error_message(response) # https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response - response.parsed_response&.dig('error', 'message') + response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash) end def voice_message?(type, attachment) diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb index 948d84f04..de794f8e3 100644 --- a/app/services/whatsapp/webhook_teardown_service.rb +++ b/app/services/whatsapp/webhook_teardown_service.rb @@ -23,7 +23,6 @@ class Whatsapp::WebhookTeardownService def should_teardown_webhook? @channel.provider == 'whatsapp_cloud' && - provider_config['source'] == 'embedded_signup' && provider_config['api_key'].present? && (provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?) end @@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}" end - # The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one. + # Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe. + # The subscription is shared across the WABA, so only unsubscribe when this is the last inbox. def unsubscribe_app_if_last_inbox(api_client) + return unless provider_config['source'] == 'embedded_signup' + waba_id = provider_config['business_account_id'] return if waba_id.blank? return if waba_sibling_exists?(waba_id) diff --git a/db/migrate/20260709091147_create_agent_sessions.rb b/db/migrate/20260709091147_create_agent_sessions.rb new file mode 100644 index 000000000..a2e3e9f0f --- /dev/null +++ b/db/migrate/20260709091147_create_agent_sessions.rb @@ -0,0 +1,24 @@ +class CreateAgentSessions < ActiveRecord::Migration[7.1] + def change + create_table :agent_sessions do |t| + t.integer :session_type, null: false + t.references :subject, polymorphic: true, null: false, index: false + t.references :result, polymorphic: true, index: false + t.references :account, null: false, index: true + t.references :assistant, null: false, index: true + t.references :user, index: true + t.string :llm_model + t.float :credits_consumed + t.jsonb :faq_ids, default: [] + t.jsonb :document_ids, default: [] + t.jsonb :scenario_ids, default: [] + t.jsonb :run_context, default: {} + + t.timestamps + end + + add_index :agent_sessions, [:account_id, :session_type, :created_at] + add_index :agent_sessions, [:account_id, :subject_type, :subject_id] + add_index :agent_sessions, [:account_id, :result_type, :result_id] + end +end diff --git a/db/migrate/20260713184351_create_captain_faq_suggestions.rb b/db/migrate/20260713184351_create_captain_faq_suggestions.rb new file mode 100644 index 000000000..6bc03f387 --- /dev/null +++ b/db/migrate/20260713184351_create_captain_faq_suggestions.rb @@ -0,0 +1,48 @@ +class CreateCaptainFaqSuggestions < ActiveRecord::Migration[7.1] + def change + create_faq_suggestions + create_faq_observations + end + + private + + def create_faq_suggestions + create_table :captain_faq_suggestions do |t| + t.string :question, null: false + t.text :answer, null: false + t.vector :embedding, limit: 1536 + t.references :assistant, null: false, index: true + t.references :account, null: false, index: true + t.string :language, null: false, default: 'en' + t.integer :source_count, null: false, default: 0 + t.integer :status, null: false, default: 0 + + t.timestamps + end + + add_index :captain_faq_suggestions, [:account_id, :assistant_id, :status, :language], + name: 'idx_cap_faq_suggestions_on_account_assistant_status_language' + add_index :captain_faq_suggestions, :embedding, using: :ivfflat, + name: 'vector_idx_captain_faq_suggestions_embedding', + opclass: :vector_cosine_ops + end + + def create_faq_observations + create_table :captain_faq_observations do |t| + t.references :account, null: false, index: true + t.references :conversation, null: false, index: true + t.references :faq_suggestion, index: true + t.string :generated_question, null: false + t.text :generated_answer, null: false + t.string :language, null: false, default: 'en' + t.integer :status, null: false, default: 0 + + t.timestamps + end + + add_index :captain_faq_observations, [:conversation_id, :faq_suggestion_id], + unique: true, + where: 'faq_suggestion_id IS NOT NULL', + name: 'idx_captain_faq_observations_on_conversation_and_suggestion' + end +end diff --git a/db/schema.rb b/db/schema.rb index e01dc34c1..43e7135b9 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_10_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -146,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id" end + create_table "agent_sessions", force: :cascade do |t| + t.integer "session_type", null: false + t.string "subject_type", null: false + t.bigint "subject_id", null: false + t.string "result_type" + t.bigint "result_id" + t.bigint "account_id", null: false + t.bigint "assistant_id", null: false + t.bigint "user_id" + t.string "llm_model" + t.float "credits_consumed" + t.jsonb "faq_ids", default: [] + t.jsonb "document_ids", default: [] + t.jsonb "scenario_ids", default: [] + t.jsonb "run_context", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7" + t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e" + t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d" + t.index ["account_id"], name: "index_agent_sessions_on_account_id" + t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id" + t.index ["user_id"], name: "index_agent_sessions_on_user_id" + end + create_table "applied_slas", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "sla_policy_id", null: false @@ -392,6 +417,39 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do t.index ["status"], name: "index_captain_documents_on_status" end + create_table "captain_faq_observations", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "conversation_id", null: false + t.bigint "faq_suggestion_id" + t.string "generated_question", null: false + t.text "generated_answer", null: false + t.string "language", default: "en", null: false + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_captain_faq_observations_on_account_id" + t.index ["conversation_id", "faq_suggestion_id"], name: "idx_captain_faq_observations_on_conversation_and_suggestion", unique: true, where: "(faq_suggestion_id IS NOT NULL)" + t.index ["conversation_id"], name: "index_captain_faq_observations_on_conversation_id" + t.index ["faq_suggestion_id"], name: "index_captain_faq_observations_on_faq_suggestion_id" + end + + create_table "captain_faq_suggestions", force: :cascade do |t| + t.string "question", null: false + t.text "answer", null: false + t.vector "embedding", limit: 1536 + t.bigint "assistant_id", null: false + t.bigint "account_id", null: false + t.string "language", default: "en", null: false + t.integer "source_count", default: 0, null: false + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_captain_faq_suggestions_on_account_id" + t.index ["account_id", "assistant_id", "status", "language"], name: "idx_cap_faq_suggestions_on_account_assistant_status_language" + t.index ["assistant_id"], name: "index_captain_faq_suggestions_on_assistant_id" + t.index ["embedding"], name: "vector_idx_captain_faq_suggestions_embedding", opclass: :vector_cosine_ops, using: :ivfflat + end + create_table "captain_inboxes", force: :cascade do |t| t.bigint "captain_assistant_id", null: false t.bigint "inbox_id", null: false diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb index 273c082b1..d88cc6b48 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb @@ -9,16 +9,10 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC RESULTS_PER_PAGE = 25 def index - base_query = @documents - base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present? - base_query = apply_source_filter(base_query, permitted_params[:source]) - base_query = apply_filter(base_query, permitted_params[:filter]) - base_query = apply_search(base_query, permitted_params[:search_key]) - base_query = apply_sort(base_query, permitted_params[:sort]) - - @documents_count = base_query.count + @documents = filtered_documents + @documents_count = @documents.count @sync_interval_hours = current_sync_interval&.in_hours&.to_i - @documents = base_query.page(@current_page).per(RESULTS_PER_PAGE) + @documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE) end def show; end @@ -59,6 +53,21 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC @documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant) end + def filtered_documents + documents = @documents + documents = documents.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present? + documents = apply_source_filter(documents, permitted_params[:source]) + documents = apply_filter(documents, permitted_params[:filter]) + documents = apply_search(documents, permitted_params[:search_key]) + apply_sort(documents, permitted_params[:sort]) + end + + def with_responses_count(scope) + scope.left_joins(:responses) + .select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count') + .group('captain_documents.id') + end + def set_document @document = @documents.find(permitted_params[:id]) end diff --git a/enterprise/app/models/captain/agent_session.rb b/enterprise/app/models/captain/agent_session.rb new file mode 100644 index 000000000..d02dffcab --- /dev/null +++ b/enterprise/app/models/captain/agent_session.rb @@ -0,0 +1,86 @@ +# == Schema Information +# +# Table name: agent_sessions +# +# id :bigint not null, primary key +# credits_consumed :float +# document_ids :jsonb +# faq_ids :jsonb +# llm_model :string +# result_type :string +# run_context :jsonb +# scenario_ids :jsonb +# session_type :integer not null +# subject_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# result_id :bigint +# subject_id :bigint not null +# user_id :bigint +# +# Indexes +# +# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id) +# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at) +# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id) +# index_agent_sessions_on_account_id (account_id) +# index_agent_sessions_on_assistant_id (assistant_id) +# index_agent_sessions_on_user_id (user_id) +# +class Captain::AgentSession < ApplicationRecord + self.table_name = 'agent_sessions' + + SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze + RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze + + belongs_to :account + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :user, optional: true + belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true + belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true + + enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session + + before_validation :ensure_account + + validate :subject_type_matches_session_type + validate :result_type_matches_session_type, if: -> { result_type.present? } + validate :subject_belongs_to_account + validate :result_belongs_to_account, if: -> { result_id.present? } + + private + + def ensure_account + self.account = assistant&.account + end + + def subject_type_matches_session_type + expected_type = SUBJECT_TYPES[session_type] + return if subject_type == expected_type + + errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions") + end + + def result_type_matches_session_type + expected_type = RESULT_TYPES[session_type] + return if result_type == expected_type + + errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions") + end + + def subject_belongs_to_account + return if subject.nil? || subject.account_id == account_id + + errors.add(:subject, 'must belong to the session account') + end + + def result_belongs_to_account + target_class = result_type.safe_constantize + actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id) + return if actual_account_id == account_id + + errors.add(:result, 'must belong to the session account') + end +end diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index d3f6cda8a..bf4691e2c 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -28,6 +28,7 @@ class Captain::Assistant < ApplicationRecord belongs_to :account has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async + has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async has_many :captain_inboxes, class_name: 'CaptainInbox', foreign_key: :captain_assistant_id, @@ -37,6 +38,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 :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name diff --git a/enterprise/app/models/captain/faq_observation.rb b/enterprise/app/models/captain/faq_observation.rb new file mode 100644 index 000000000..15c5e1284 --- /dev/null +++ b/enterprise/app/models/captain/faq_observation.rb @@ -0,0 +1,42 @@ +# == Schema Information +# +# Table name: captain_faq_observations +# +# id :bigint not null, primary key +# generated_answer :text not null +# generated_question :string not null +# language :string default("en"), not null +# status :integer default("attached"), not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# conversation_id :bigint not null +# faq_suggestion_id :bigint +# +class Captain::FaqObservation < ApplicationRecord + self.table_name = 'captain_faq_observations' + + belongs_to :account + belongs_to :conversation, class_name: '::Conversation' + belongs_to :faq_suggestion, class_name: 'Captain::FaqSuggestion', optional: true, inverse_of: :observations + + enum status: { attached: 0, discarded: 1 } + + validates :generated_question, :generated_answer, :language, presence: true + validates :faq_suggestion, presence: true, if: :attached? + validate :faq_suggestion_belongs_to_account + + before_validation :ensure_account + + private + + def ensure_account + self.account = conversation&.account + end + + def faq_suggestion_belongs_to_account + return if faq_suggestion.blank? || faq_suggestion.account_id == account_id + + errors.add(:faq_suggestion, :invalid) + end +end diff --git a/enterprise/app/models/captain/faq_suggestion.rb b/enterprise/app/models/captain/faq_suggestion.rb new file mode 100644 index 000000000..047d5e1fe --- /dev/null +++ b/enterprise/app/models/captain/faq_suggestion.rb @@ -0,0 +1,51 @@ +# == Schema Information +# +# Table name: captain_faq_suggestions +# +# id :bigint not null, primary key +# answer :text not null +# embedding :vector(1536) +# language :string default("en"), not null +# question :string not null +# source_count :integer default(0), not null +# status :integer default("open"), not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# +class Captain::FaqSuggestion < ApplicationRecord + self.table_name = 'captain_faq_suggestions' + + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :account + has_many :observations, + class_name: 'Captain::FaqObservation', + dependent: :delete_all, + inverse_of: :faq_suggestion + has_neighbors :embedding, normalize: true + + enum status: { open: 0, approved: 1, dismissed: 2 } + + validates :question, :answer, :language, presence: true + + before_validation :ensure_account + after_commit :update_embedding, on: [:create, :update] + + scope :ordered, -> { order(source_count: :desc, updated_at: :desc) } + scope :by_language, ->(language) { where(language: language) } + + private + + def ensure_account + self.account = assistant&.account + end + + def update_embedding + return unless open? + return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil? + return if previously_new_record? && embedding.present? + + Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}") + end +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 1ef112fb5..427b1e1af 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -11,8 +11,11 @@ module Enterprise::Concerns::Account has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' + has_many :captain_faq_observations, dependent: :destroy_async, class_name: 'Captain::FaqObservation' + has_many :captain_faq_suggestions, dependent: :destroy_async, class_name: 'Captain::FaqSuggestion' 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_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession' 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 a075704d1..c247e01e8 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_many :captain_faq_observations, class_name: 'Captain::FaqObservation', dependent: :delete_all scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) } before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? } diff --git a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder index 56260f675..0ab031dbf 100644 --- a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder +++ b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder @@ -9,6 +9,8 @@ json.external_link resource.external_link json.display_url resource.display_url json.file_size resource.file_size json.pdf_document resource.pdf_document? +responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count +json.responses_count responses_count.to_i json.id resource.id json.name resource.name json.status resource.status diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb index c45559165..37f9add3e 100644 --- a/enterprise/lib/captain/conversation_completion_service.rb +++ b/enterprise/lib/captain/conversation_completion_service.rb @@ -12,7 +12,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService pattr_initialize [:account!, :conversation_display_id!] def perform - content = format_messages_as_string + content = format_evaluation_input return default_incomplete_response('No messages found') if content.blank? response = make_api_call( @@ -35,12 +35,58 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read end - def format_messages_as_string - messages = conversation_messages(start_from: 0) - messages.map do |msg| - sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant' - "#{sender_type}: #{msg[:content]}" + def format_evaluation_input + messages = conversation_message_records(start_from: 0) + return if messages.blank? + + [ + "Conversation status: #{conversation.status}", + format_messages_as_string(messages) + ].join("\n\n") + end + + def conversation_message_records(start_from: 0) + messages = [] + character_count = start_from + + conversation.messages + .where(message_type: [:incoming, :outgoing]) + .where(private: false) + .reorder('id desc') + .each do |message| + content = message.content_for_llm + next if content.blank? + break if character_count + content.length > TOKEN_LIMIT + + messages.prepend({ message: message, content: content }) + character_count += content.length + end + + messages + end + + def format_messages_as_string(messages) + transcript = messages.map do |message_context| + "#{message_sender_label(message_context[:message])}: #{message_context[:content]}" end.join("\n") + + "Conversation transcript:\n#{transcript}" + end + + def message_sender_label(message) + return 'Customer' if message.incoming? + return 'Captain' if captain_reply?(message) + return 'Bot' if bot_reply?(message) + + 'Assistant' + end + + def captain_reply?(message) + message.outgoing? && message.sender_type == 'Captain::Assistant' + end + + def bot_reply?(message) + message.outgoing? && message.sender_type.in?(['AgentBot', 'Captain::Assistant']) end def parse_response(message) diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid index ed81039af..e039f60b0 100644 --- a/enterprise/lib/captain/prompts/conversation_completion.liquid +++ b/enterprise/lib/captain/prompts/conversation_completion.liquid @@ -2,18 +2,39 @@ You are evaluating whether a customer support conversation is complete and can b The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language. +You will receive: +- Conversation status +- Conversation transcript where messages are labeled as Customer, Captain, Bot, or Assistant + +This evaluator runs for inactive pending conversations. Focus on the latest pending exchange or latest unresolved customer request. Older messages may be present only for context. +If the conversation status is "pending", the conversation is still with Captain. Do not assume a handoff happened because Captain mentioned one. + A conversation is INCOMPLETE (keep open) if ANY of these apply: - The assistant asked a question or requested information that the customer hasn't provided - The customer asked a question that wasn't fully answered - The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet - The customer raised multiple questions or issues and not all were addressed +- In the latest pending exchange, Captain, Bot, or Assistant said it handed off, will hand off, escalated, will escalate, or that a human/team/another party will continue the work +- In the latest pending exchange, Captain, Bot, or Assistant promised future action or follow-up instead of resolving the customer's request +- In the latest pending exchange, the customer is waiting for another party's action, response, status update, or investigation result +- The latest customer message is only an attachment placeholder such as "[Attachment]" and there is no later text explaining what it contains or showing the issue was answered +- The customer says they were not helped, asks why nobody replied, repeats the unresolved issue after a previous answer, or otherwise indicates dissatisfaction with the current help + +Do NOT treat these as incomplete by themselves: +- A generic greeting or broad optional offer from Captain/Bot/Assistant, such as "How can I help?", "What would you like to know?", or "Anything else?", when the customer has not made a recognizable request +- A customer greeting, single-word reply, name, phone number, or gibberish with no recognizable question/request, followed only by Captain/Bot/Assistant asking what the customer needs +- An optional invitation for the customer to ask more questions after the assistant already answered the actual request +- Older handoff, escalation, or follow-up messages from a previous exchange when the latest customer message starts a new topic, has no recognizable request, or has already been answered + +Important handoff rule: +- A handoff, escalation, transfer, acknowledgement, or promise of future follow-up is not a resolution by itself +- If conversation status is "pending" and Captain/Bot/Assistant says it handed off, will hand off, or that another party will continue the work in the latest pending exchange, keep the conversation INCOMPLETE. A conversation is COMPLETE only if ALL of these are true: - The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer - There are no unanswered questions, unmet requests, or outstanding follow-ups from either side - Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation. -- If the customer sent only one or two short messages (single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and the - assistant has responded asking for clarification, the conversation is COMPLETE. +- If the customer sent only one or two short text messages (greetings, single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and Captain/Bot/Assistant has responded asking what they need or offering help, the conversation is COMPLETE. Analyze the conversation and respond with ONLY a JSON object (no other text): {"complete": true, "reason": "brief explanation"} diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb index 77cb25f49..4d4b10fcb 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb @@ -51,6 +51,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do expect(json_response[:payload].length).to eq(5) expect(json_response[:meta]).to eq({ page: 2, total_count: 30 }) end + + it 'returns the generated FAQ count for each document' do + document = create(:captain_document, assistant: assistant, account: account) + create_list(:captain_assistant_response, 2, + assistant: assistant, account: account, documentable: document) + + get "/api/v1/accounts/#{account.id}/captain/documents", + headers: agent.create_new_auth_token, as: :json + + matching_document = json_response[:payload].find { |item| item[:id] == document.id } + expect(matching_document[:responses_count]).to eq(2) + end end context 'when filtering by assistant_id' do @@ -142,6 +154,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do expect(json_response[:external_link]).to eq(document.external_link) end + it 'returns the crawled content for the document' do + expect(json_response[:content]).to eq(document.content) + end + it 'returns sync metadata when the document has been synced' do synced_at = 1.hour.ago document.update!(sync_status: :synced, last_synced_at: synced_at) diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 80b9ab1d8..9cdfc822c 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -68,6 +68,110 @@ RSpec.describe Captain::ConversationCompletionService do end end + context 'when building evaluation context' do + let(:captain_assistant) { create(:captain_assistant, account: account) } + let(:mock_response) do + instance_double( + RubyLLM::Message, + content: { 'complete' => false, 'reason' => 'Human follow-up is still pending' }, + input_tokens: 100, + output_tokens: 20 + ) + end + + it 'includes conversation status and speaker labels' do + conversation.update!(status: :pending, waiting_since: 2.hours.ago) + create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'I need help with a refund') + create( + :message, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :outgoing, + sender: captain_assistant, + content: 'I will transfer this to support for review.' + ) + + expect(mock_chat).to receive(:ask) do |content| + expect(content).to include( + 'Conversation status: pending', + 'Conversation transcript:', + 'Customer: I need help with a refund', + 'Captain: I will transfer this to support for review.' + ) + + mock_response + end + + result = service.perform + + expect(result[:complete]).to be false + end + + it 'includes pending captain handoff evidence in the transcript' do + conversation.update!(status: :pending) + create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'Please cancel my order') + create( + :message, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :outgoing, + sender: captain_assistant, + content: 'I will transfer this to a specialist and they will follow up here.' + ) + + expect(mock_chat).to receive(:ask) do |content| + expect(content).to include( + 'Conversation status: pending', + 'Captain: I will transfer this to a specialist and they will follow up here.' + ) + + mock_response + end + + result = service.perform + + expect(result[:complete]).to be false + end + + it 'reuses computed message content while formatting the transcript' do + content_for_llm_calls_by_message_id = Hash.new(0) + allow_any_instance_of(Message).to receive(:content_for_llm).and_wrap_original do |method, *args| # rubocop:disable RSpec/AnyInstance + content_for_llm_calls_by_message_id[method.receiver.id] += 1 + method.call(*args) + end + + incoming_message = create( + :message, + :with_attachment, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :incoming, + content: nil + ) + outgoing_message = create( + :message, + conversation: conversation, + inbox: inbox, + account: account, + message_type: :outgoing, + sender: captain_assistant, + content: 'What do you need help with?' + ) + + allow(mock_chat).to receive(:ask).and_return(mock_response) + + service.perform + + expect(content_for_llm_calls_by_message_id).to include( + incoming_message.id => 1, + outgoing_message.id => 1 + ) + end + end + context 'when conversation has no messages' do it 'returns incomplete with appropriate reason' do result = service.perform diff --git a/spec/enterprise/models/captain/agent_session_spec.rb b/spec/enterprise/models/captain/agent_session_spec.rb new file mode 100644 index 000000000..b4306a11e --- /dev/null +++ b/spec/enterprise/models/captain/agent_session_spec.rb @@ -0,0 +1,160 @@ +require 'rails_helper' + +RSpec.describe Captain::AgentSession, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + 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(:user).optional } + it { is_expected.to belong_to(:subject) } + it { is_expected.to belong_to(:result).optional } + end + + describe 'enums' do + it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) } + end + + describe '#subject' do + it 'returns the conversation for an assistant session' do + conversation = create(:conversation, account: account) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation) + + expect(session.subject).to eq(conversation) + end + + it 'returns the copilot thread for a copilot session' do + user = create(:user, account: account) + copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant) + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread) + + expect(session.subject).to eq(copilot_thread) + end + + it 'returns nil when the subject record no longer exists' do + conversation = create(:conversation, account: account) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation) + conversation.destroy + + expect(session.reload.subject).to be_nil + end + + it 'is not valid when the subject type does not match the session type' do + copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread) + + expect(session).not_to be_valid + expect(session.errors[:subject_type]).to be_present + end + + it 'is not valid when the subject belongs to a different account' do + foreign_conversation = create(:conversation, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation) + + expect(session).not_to be_valid + expect(session.errors[:subject]).to be_present + end + end + + describe '#result' do + it 'returns the message for an assistant session' do + conversation = create(:conversation, account: account) + message = create(:message, account: account, conversation: conversation) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message) + + expect(session.result).to eq(message) + end + + it 'returns the copilot message for a copilot session' do + user = create(:user, account: account) + copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant) + copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread) + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, + subject: copilot_thread, result: copilot_message) + + expect(session.result).to eq(copilot_message) + end + + it 'returns nil when result_id is nil' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session.result).to be_nil + end + + it 'is not valid when the result belongs to a different account' do + conversation = create(:conversation, account: account) + foreign_message = create(:message, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message) + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + + it 'is not valid when result_id/result_type are set directly for a different account' do + conversation = create(:conversation, account: account) + foreign_message = create(:message, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, + result_id: foreign_message.id, result_type: 'Message') + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + + it 'is not valid when result_id/result_type are set directly for a stale id' do + conversation = create(:conversation, account: account) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, + result_id: 0, result_type: 'Message') + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + end + + describe 'account' do + it 'is derived from the assistant when created via the assistant association' do + conversation = create(:conversation, account: account) + session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant) + + expect(session.account).to eq(account) + end + + it 'overrides a mismatched explicit account with the assistant account' do + conversation = create(:conversation, account: account) + session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation) + + expect(session).to be_valid + expect(session.account).to eq(account) + end + end + + describe 'defaults' do + it 'defaults faq_ids, document_ids, scenario_ids and run_context' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session.faq_ids).to eq([]) + expect(session.document_ids).to eq([]) + expect(session.scenario_ids).to eq([]) + expect(session.run_context).to eq({}) + end + end + + describe 'factory' do + it 'builds a valid assistant session' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session).to be_valid + expect(session).to be_session_assistant + expect(session.subject).to be_a(Conversation) + end + + it 'builds a valid copilot session' do + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant) + + expect(session).to be_valid + expect(session).to be_session_copilot + expect(session.subject).to be_a(CopilotThread) + expect(session.user).to be_present + end + end +end diff --git a/spec/factories/captain/agent_session.rb b/spec/factories/captain/agent_session.rb new file mode 100644 index 000000000..a7b369b7d --- /dev/null +++ b/spec/factories/captain/agent_session.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :captain_agent_session, class: 'Captain::AgentSession' do + account + association :assistant, factory: :captain_assistant + session_type { :assistant } + subject { create(:conversation, account: account) } + + trait :copilot do + session_type { :copilot } + user + subject { create(:captain_copilot_thread, account: account, user: user) } + end + end +end diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index be94f3c44..a5bdeef0b 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do end end - context 'when channel is whatsapp_cloud but not embedded_signup' do + context 'when channel is whatsapp_cloud with manual setup' do before do + allow(channel).to receive(:setup_webhooks).and_return(true) + channel.update!( provider: 'whatsapp_cloud', - provider_config: { 'source' => 'manual' } + provider_config: { + 'source' => 'manual', + 'phone_number_id' => 'manual_phone_id', + 'business_account_id' => 'manual_waba_id', + 'api_key' => 'manual_api_key' + } ) end - it 'does not attempt to unsubscribe webhook' do - expect(Whatsapp::FacebookApiClient).not_to receive(:new) + it 'clears the phone number callback override' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id') service.perform + + expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id') + end + + # The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove. + it 'does not unsubscribe the app from the WABA' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override) + allow(api_client).to receive(:unsubscribe_app_from_waba) + + service.perform + + expect(api_client).not_to have_received(:unsubscribe_app_from_waba) end end