Files
moreminimore-chat/enterprise/app/models/captain/assistant.rb
Pranav 0f3bb640f5 feat(captain): Add audience and schedule controls for assistants (#14902)
Captain assistants now support **audience** and **schedule** controls,
so you can decide *who* an assistant replies to and *when* it's on duty.
By default nothing changes, an assistant still responds to every
conversation in its connected inboxes but you can now narrow that down.

- **Audience**: build a condition tree (contact attributes, conversation
attributes, and custom attributes) with and/or groups, mirroring the
contact-segment filter semantics. Only conversations whose contact
matches the audience get a Captain reply.
- **Schedule**: choose when Captain replies — *Anytime*, *During
business hours*, or *Outside business hours* (based on each inbox's
configured working hours; inboxes without business hours are always
covered).

When an assistant opts out of a conversation (contact outside the
audience, or off-schedule), the conversation is routed to the human
queue instead of being parked pending on a silent bot — both on initial
creation and on reopen.

Fixes
https://linear.app/chatwoot/issue/CW-7414/audience-and-availability-controls

|Audience|Availability|
|--|--|
| <img width="1132" height="627" alt="Screenshot 2026-06-30 at 5 52
09 PM"
src="https://github.com/user-attachments/assets/866910e0-e1d7-4248-8630-d91afc758688"
/> | <img width="1131" height="539" alt="Screenshot 2026-06-30 at 5 52
13 PM"
src="https://github.com/user-attachments/assets/aad0d6f7-ceb7-4546-a049-095c5b46b483"
/> |

## How to test

1. Open **Captain → Assistants → (an assistant) → Settings**.
2. Under **Audience**, add a condition or condition group (e.g. `Contact
language equal_to en`) and save. Start a conversation from a contact
that does *not* match — Captain should stay silent and the conversation
should land in the human (open) queue instead of pending.
3. With a matching contact, Captain should respond as before.
4. Under **Schedule**, pick **During business hours** (or **Outside
business hours**) on an inbox that has working hours configured, and
confirm Captain only engages within/outside that window. An
empty/`Anytime` schedule always responds.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-08-07 13:25:48 +05:30

202 lines
6.0 KiB
Ruby

# == Schema Information
#
# Table name: captain_assistants
#
# id :bigint not null, primary key
# config :jsonb not null
# description :text
# guardrails :jsonb
# name :string not null
# response_guidelines :jsonb
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_captain_assistants_on_account_id (account_id)
#
class Captain::Assistant < ApplicationRecord
DESCRIPTION_LENGTH_LIMIT = 500
CITATION_SOURCES_STATE_KEY = :captain_v2_citation_sources
AUTO_RESOLVE_MODES = %w[disabled legacy evaluated].freeze
RESPONSE_WINDOWS = %w[always business_hours outside_business_hours].freeze
include Avatarable
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
self.table_name = 'captain_assistants'
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,
dependent: :destroy_async
has_many :inboxes,
through: :captain_inboxes
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
has_many :conversation_outcomes, dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name,
:auto_resolve_mode, :response_window
before_validation :set_default_auto_resolve_mode, on: :create
validates :name, presence: true
validates :description, presence: true, length: { maximum: DESCRIPTION_LENGTH_LIMIT }
validates :account_id, presence: true
validates_with Captain::AudienceValidator
validate :validate_response_window
validates :auto_resolve_mode, inclusion: { in: AUTO_RESOLVE_MODES }
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
def available_name
name
end
def engages?(contact, conversation)
responds_to_audience?(contact, conversation) && available_now?(conversation)
end
def responds_to_audience?(contact, conversation)
return true if config['audience'].blank?
Captain::AudienceMatcher.new(config['audience']).matches?(contact, conversation)
end
def available_now?(conversation)
response_window = config['response_window']
return true if response_window.blank? || response_window == 'always'
inbox = conversation.inbox
return true unless inbox.working_hours_enabled?
response_window == 'business_hours' ? !inbox.out_of_office? : inbox.out_of_office?
end
def auto_resolve_mode
config.fetch('auto_resolve_mode') { account&.captain_auto_resolve_mode || 'evaluated' }
end
def inactive_conversation_resolution_disabled?
auto_resolve_mode == 'disabled'
end
def evaluate_inactive_conversations_before_resolving?
auto_resolve_mode == 'evaluated'
end
def available_agent_tools
tools = self.class.built_in_agent_tools.dup
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
tools.concat(custom_tools)
tools
end
def available_tool_ids
available_agent_tools.pluck(:id)
end
def push_event_data
{
id: id,
name: name,
avatar_url: avatar_url.presence || default_avatar_url,
description: description,
created_at: created_at,
type: 'captain_assistant'
}
end
def webhook_data
{
id: id,
name: name,
avatar_url: avatar_url.presence || default_avatar_url,
description: description,
created_at: created_at,
type: 'captain_assistant'
}
end
def customer_visible_citation_urls(citation_document_ids)
citation_documents = documents.where(id: citation_document_ids.values).index_by(&:id)
citation_urls = citation_document_ids.transform_values do |document_id|
citation_documents[document_id.to_i]&.customer_visible_source_url
end
citation_urls.compact.transform_keys(&:to_i)
end
def citations_enabled?
config['feature_citation']
end
def trusted_citation_urls(run_result)
return {} unless citations_enabled?
citation_document_ids = run_result&.context&.dig(:state, CITATION_SOURCES_STATE_KEY) || {}
customer_visible_citation_urls(citation_document_ids)
end
private
def validate_response_window
response_window = config['response_window']
return if response_window.blank?
errors.add(:config, 'invalid response_window') unless RESPONSE_WINDOWS.include?(response_window)
end
def set_default_auto_resolve_mode
return if config.key?('auto_resolve_mode')
self.auto_resolve_mode = account&.captain_auto_resolve_mode || 'evaluated'
end
def agent_name
name.parameterize(separator: '_')
end
def agent_tools
[
self.class.resolve_tool_class('faq_lookup').new(self),
self.class.resolve_tool_class('handoff').new(self),
*account.captain_custom_tools.enabled.map { |custom_tool| custom_tool.tool(self) }
]
end
def prompt_context
{
name: name,
description: description,
product_name: config['product_name'] || 'this product',
citation_enabled: citations_enabled?,
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
key: scenario.handoff_key,
description: scenario.description
}
end,
response_guidelines: response_guidelines || [],
guardrails: guardrails || []
}
end
def default_avatar_url
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
end
end