@@ -299,4 +307,4 @@ onMounted(() => {
-
+
\ No newline at end of file
diff --git a/app/javascript/dashboard/store/captain/tools.js b/app/javascript/dashboard/store/captain/tools.js
index b644caf..e541664 100755
--- a/app/javascript/dashboard/store/captain/tools.js
+++ b/app/javascript/dashboard/store/captain/tools.js
@@ -5,15 +5,27 @@ import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({
name: 'Tools',
API: CaptainToolsAPI,
+ // Custom getters for tools with string IDs
+ getters: {
+ getRecords: state => {
+ console.log('[DEBUG captainTools] getRecords called, records:', state.records);
+ return state.records;
+ },
+ getRecord: state => id =>
+ state.records.find(record => record.id === id) || {},
+ },
actions: mutations => ({
getTools: async ({ commit }) => {
+ console.log('[DEBUG captainTools] getTools action started');
commit(mutations.SET_UI_FLAG, { fetchingList: true });
try {
const response = await CaptainToolsAPI.get();
+ console.log('[DEBUG captainTools] API response:', response.data);
commit(mutations.SET, response.data);
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return response.data;
} catch (error) {
+ console.error('[DEBUG captainTools] API error:', error);
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return throwErrorMessage(error);
}
@@ -22,3 +34,4 @@ const toolsStore = createStore({
});
export default toolsStore;
+
diff --git a/app/services/whatsapp/providers/wuzapi_service.rb b/app/services/whatsapp/providers/wuzapi_service.rb
index ab21033..0b17e06 100644
--- a/app/services/whatsapp/providers/wuzapi_service.rb
+++ b/app/services/whatsapp/providers/wuzapi_service.rb
@@ -12,6 +12,10 @@ module Whatsapp::Providers
# Normalize phone number: remove +, space, -, (, )
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
+ if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction]
+ return send_reaction_message(normalized_phone, message)
+ end
+
if message.attachments.present?
send_attachment_message(user_token, normalized_phone, message)
else
@@ -38,8 +42,14 @@ module Whatsapp::Providers
# Assuming message content is the emoji
reaction_emoji = message.content
- # Assuming in_reply_to contains the ID of the message to react to
- message_id = message.content_attributes['in_reply_to']
+ # Prefer external message id, fallback to in_reply_to if already external.
+ message_id = message.content_attributes['in_reply_to_external_id'] || message.content_attributes['in_reply_to']
+ use_me_prefix = reaction_to_own_message?(message)
+
+ if use_me_prefix
+ normalized_phone = "me:#{normalized_phone}" unless normalized_phone.start_with?('me:')
+ message_id = "me:#{message_id}" if message_id.present? && !message_id.start_with?('me:')
+ end
if message_id.present?
# Wuzapi client needs to implement send_reaction
@@ -83,5 +93,20 @@ module Whatsapp::Providers
def client
@client ||= ::Wuzapi::Client.new(@base_url)
end
+
+ def reaction_to_own_message?(message)
+ # If we can resolve the target message, check if it was sent by us.
+ target_message = nil
+ if message.in_reply_to.present?
+ target_message = message.conversation.messages.find_by(id: message.in_reply_to)
+ target_message ||= message.conversation.messages.find_by(source_id: message.in_reply_to)
+ elsif message.in_reply_to_external_id.present?
+ target_message = message.conversation.messages.find_by(source_id: message.in_reply_to_external_id)
+ end
+
+ return false unless target_message.present?
+
+ target_message.outgoing? || target_message.template?
+ end
end
end
diff --git a/config/agents/tools.yml b/config/agents/tools.yml
index c2faf75..4f150bc 100755
--- a/config/agents/tools.yml
+++ b/config/agents/tools.yml
@@ -34,3 +34,8 @@
title: 'Handoff to Human'
description: 'Hand off the conversation to a human agent'
icon: 'user-switch'
+
+- id: react_to_message
+ title: 'Reagir a Mensagens'
+ description: 'React to customer messages with emoji (👍, ❤️, 😊)'
+ icon: 'emoji'
diff --git a/config/application.rb b/config/application.rb
index aa15079..74551d7 100755
--- a/config/application.rb
+++ b/config/application.rb
@@ -61,6 +61,8 @@ module Chatwoot
# Custom chatwoot configurations
config.x = config_for(:app).with_indifferent_access
+ config.time_zone = 'America/Sao_Paulo'
+
# https://stackoverflow.com/questions/72970170/upgrading-to-rails-6-1-6-1-causes-psychdisallowedclass-tried-to-load-unspecif
# https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
# FIX ME : fixes breakage of installation config. we need to migrate.
diff --git a/config/initializers/ai_agents.rb b/config/initializers/ai_agents.rb
index be8a8a9..e23ff79 100755
--- a/config/initializers/ai_agents.rb
+++ b/config/initializers/ai_agents.rb
@@ -3,13 +3,16 @@
require 'agents'
Rails.application.config.after_initialize do
- api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
+ api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || ENV.fetch('OPENAI_API_KEY', nil)
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT
if api_key.present?
+ # Sanitize the key: remove common accidental image suffixes and whitespace
+ sanitized_key = api_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip
+
Agents.configure do |config|
- config.openai_api_key = api_key
+ config.openai_api_key = sanitized_key
if api_endpoint.present?
api_base = "#{api_endpoint.chomp('/')}/v1"
config.openai_api_base = api_base
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 8581deb..46e048b 100755
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -261,7 +261,7 @@
value: 'community'
description: 'The pricing plan for the installation, retrieved from the billing API'
- name: INSTALLATION_PRICING_PLAN_QUANTITY
- value: 0
+ value: 100
description: 'The number of licenses purchased for the installation, retrieved from the billing API'
- name: CHATWOOT_SUPPORT_WEBSITE_TOKEN
value:
diff --git a/create_admin.rb b/create_admin.rb
new file mode 100644
index 0000000..7b5ce3d
--- /dev/null
+++ b/create_admin.rb
@@ -0,0 +1,20 @@
+# Ensure pricing plan allows user creation
+installation_config = InstallationConfig.find_or_initialize_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')
+installation_config.value = 100
+installation_config.save!
+
+# Create or update account
+account = Account.first || Account.create!(name: 'Acme Inc')
+
+# Create or find user
+user = User.find_or_initialize_by(email: 'rodrigobm10@gmail.com')
+user.name = 'Rodrigo'
+user.password = 'Password123!'
+user.password_confirmation = 'Password123!'
+user.confirmed_at = Time.current
+user.save!
+
+# Link user to account as admin
+AccountUser.find_or_create_by!(account: account, user: user, role: :administrator)
+
+puts "User #{user.email} created/updated successfully."
diff --git a/db/schema.rb b/db/schema.rb
index bcea06f..e581715 100755
--- 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_01_04_150000) do
+ActiveRecord::Schema[7.1].define(version: 2026_01_10_193000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 6c61050..22e54c9 100755
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -17,6 +17,9 @@ services:
rails:
<<: *base
+ dns:
+ - 8.8.8.8
+ - 1.1.1.1
build:
context: .
dockerfile: ./docker/dockerfiles/rails.Dockerfile
@@ -48,6 +51,9 @@ services:
sidekiq:
<<: *base
+ dns:
+ - 8.8.8.8
+ - 1.1.1.1
volumes:
- ./:/app:delegated
- node_modules:/app/node_modules
@@ -94,7 +100,7 @@ services:
ports:
- '5438:5432'
volumes:
- - postgres:/data/postgres
+ - postgres:/var/lib/postgresql/data
environment:
- POSTGRES_DB=chatwoot
- POSTGRES_USER=postgres
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index 21b2f8f..3dcf88d 100755
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -30,12 +30,28 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def playground
- response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- additional_message: params[:message_content],
- message_history: message_history
- )
+ content = params[:message_content] || params.dig(:assistant, :message_content)
+ history = params[:message_history] || params.dig(:assistant, :message_history) || []
+ history = history.map { |m| { role: m[:role] || m['role'], content: m[:content] || m['content'] } }
+
+ if captain_v2_enabled?
+ # For V2, we only pass the history. The current message is already in history from frontend
+ # or should be treated as the last turn.
+ response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant).generate_response(
+ message_history: history
+ )
+ else
+ # V1 Engine (Single Agent)
+ response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
+ additional_message: content,
+ message_history: history
+ )
+ end
render json: response
+ rescue StandardError => e
+ Rails.logger.error "Playground Error: #{e.message}"
+ render json: { response: "Erro técnico: #{e.message}", reasoning: e.backtrace.first }, status: :internal_server_error
end
def tools
@@ -63,7 +79,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
:product_name, :role_name, :feature_faq, :feature_memory, :feature_citation,
:welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature, :playbook, :distance_threshold, :max_rag_results,
- :system_prompt,
+ :system_prompt, :handoff_on_sentiment,
{ system_prompt_blocks: [:key, :title, :content, :order] }
])
@@ -115,4 +131,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
end
+
+ def captain_v2_enabled?
+ Current.account.feature_enabled?('captain_integration_v2')
+ end
end
diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb
index 79b3db6..3f8ba32 100644
--- a/enterprise/app/helpers/captain/chat_helper.rb
+++ b/enterprise/app/helpers/captain/chat_helper.rb
@@ -98,8 +98,11 @@ module Captain::ChatHelper
end
def api_key
- @assistant&.api_key.presence || @assistant&.config&.[]('openai_api_key').presence || ENV.fetch('OPENAI_API_KEY',
- nil) || ENV.fetch('GEMINI_API_KEY', nil)
+ raw_key = @assistant&.api_key.presence || @assistant&.config&.[]('openai_api_key').presence || ENV.fetch('OPENAI_API_KEY', nil) || ENV.fetch('GEMINI_API_KEY', nil)
+ return nil if raw_key.blank?
+
+ # Sanitize: Remove common accidental suffixes like image names or whitespace
+ raw_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip
end
def with_agent_session(&)
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 26facfd..82cf2db 100755
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -8,10 +8,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
@conversation = conversation
@inbox = conversation.inbox
@assistant = assistant
+ @start_time = Time.zone.now
Current.executed_by = @assistant
Current.account = conversation.account
+ trigger_typing_status('on')
+
if captain_v2_enabled?
generate_response_with_v2
else
@@ -20,6 +23,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
end
rescue StandardError => e
+ trigger_typing_status('off')
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
handle_error(e)
@@ -48,13 +52,48 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def process_response
- return process_action('handoff') if handoff_requested?
+ trigger_typing_status('off')
+ return process_action('handoff') if handoff_requested? || negative_sentiment?
+ humanized_delay(@response['response'])
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
+ def negative_sentiment?
+ return false unless @assistant.config['handoff_on_sentiment']
+
+ # Force handoff if user is angry or very frustrated
+ ['angry', 'frustrated'].include?(@response['sentiment']&.downcase)
+ end
+
+ def trigger_typing_status(status)
+ Conversations::TypingStatusManager.new(
+ @conversation,
+ @assistant,
+ { typing_status: status, is_private: false }
+ ).toggle_typing_status
+ rescue StandardError => e
+ Rails.logger.warn "Failed to trigger typing status: #{e.message}"
+ end
+
+ def humanized_delay(response_text)
+ return if response_text.blank?
+
+ # Roughly 50ms per character simulation
+ typing_speed = 50
+ target_delay = (response_text.length * typing_speed) / 1000.0
+
+ # Cap at 7 seconds to balance humanization vs speed
+ target_delay = [target_delay, 7.0].min
+
+ elapsed_time = Time.zone.now - @start_time
+ remaining_delay = target_delay - elapsed_time
+
+ sleep(remaining_delay) if remaining_delay > 0
+ end
+
def collect_previous_messages
@conversation
.messages
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index d551d4c..9757cb6 100755
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -93,10 +93,22 @@ class Captain::Assistant < ApplicationRecord
end
def agent_tools
- [
+ tools = [
self.class.resolve_tool_class('faq_lookup').new(self),
self.class.resolve_tool_class('handoff').new(self)
]
+
+ # Add each enabled scenario as a tool
+ scenarios.enabled.each do |scenario|
+ tools << Captain::Tools::ScenarioDelegatorTool.new(scenario)
+ end
+
+ # Add enabled custom tools
+ account.captain_custom_tools.enabled.each do |custom_tool|
+ tools << Captain::Tools::HttpTool.new(self, custom_tool)
+ end
+
+ tools
end
def prompt_context
@@ -104,6 +116,8 @@ class Captain::Assistant < ApplicationRecord
name: name,
description: description,
product_name: config['product_name'] || 'this product',
+ current_date: Time.zone.today.strftime('%A, %B %d, %Y'),
+ system_prompt_blocks: config['system_prompt_blocks'] || [],
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
index c434688..d9d999f 100755
--- a/enterprise/app/models/captain/scenario.rb
+++ b/enterprise/app/models/captain/scenario.rb
@@ -47,7 +47,8 @@ class Captain::Scenario < ApplicationRecord
title: title,
instructions: resolved_instructions,
tools: resolved_tools,
- assistant_name: assistant.name.downcase.gsub(/\s+/, '_'),
+ assistant_name: assistant.send(:agent_name),
+ current_date: Time.zone.today.strftime('%A, %B %d, %Y'),
response_guidelines: response_guidelines || [],
guardrails: guardrails || []
}
@@ -134,6 +135,7 @@ class Captain::Scenario < ApplicationRecord
return if instruction.blank?
tool_ids = extract_tool_ids_from_text(instruction)
- self.tools = tool_ids.presence
+ combined_tools = (Array.wrap(tools) + tool_ids).uniq
+ self.tools = combined_tools.presence
end
end
diff --git a/enterprise/app/models/captain/tool_config.rb b/enterprise/app/models/captain/tool_config.rb
index 5d87c01..a9183b5 100644
--- a/enterprise/app/models/captain/tool_config.rb
+++ b/enterprise/app/models/captain/tool_config.rb
@@ -1,5 +1,6 @@
module Captain
class ToolConfig < ApplicationRecord
+ self.table_name = 'captain_tool_configs'
belongs_to :account
belongs_to :inbox, optional: true
belongs_to :captain_assistant, class_name: 'Captain::Assistant', optional: true
diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb
index 9c4e568..193f6d3 100755
--- a/enterprise/app/services/captain/assistant/agent_runner_service.rb
+++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb
@@ -18,12 +18,19 @@ class Captain::Assistant::AgentRunnerService
end
def generate_response(message_history: [])
+ sanitize_global_api_key
agents = build_and_wire_agents
context = build_context(message_history)
message_to_process = extract_last_user_message(message_history)
runner = Agents::Runner.with_agents(*agents)
runner = add_callbacks_to_runner(runner) if @callbacks.any?
- result = runner.run(message_to_process, context: context, max_turns: 100)
+
+ puts "[DEBUG V2] Running with agents: #{agents.map(&:name).join(', ')}"
+
+ # Use assistant's API key if present, otherwise fallback to global config
+ result = with_assistant_api_key do
+ runner.run(message_to_process, context: context, max_turns: 100)
+ end
process_agent_result(result)
rescue StandardError => e
@@ -39,7 +46,15 @@ class Captain::Assistant::AgentRunnerService
private
def build_context(message_history)
- conversation_history = message_history.map do |msg|
+ # Remove the last user message from history because it will be passed as the main message to the runner
+ last_user_index = message_history.rindex { |msg| msg[:role] == 'user' || msg[:role] == :user }
+ filtered_history = if last_user_index
+ message_history[0...last_user_index] + message_history[(last_user_index + 1)..-1]
+ else
+ message_history
+ end
+
+ conversation_history = filtered_history.map do |msg|
content = extract_text_from_content(msg[:content])
{
@@ -56,7 +71,8 @@ class Captain::Assistant::AgentRunnerService
end
def extract_last_user_message(message_history)
- last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
+ last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' || msg[:role] == :user }
+ return '' unless last_user_msg
extract_text_from_content(last_user_msg[:content])
end
@@ -74,22 +90,56 @@ class Captain::Assistant::AgentRunnerService
# Response formatting methods
def process_agent_result(result)
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
- response = format_response(result.output)
+
+ # If the LLM returned an error (like Unauthorized), show a user-friendly message
+ if result.error.present?
+ Rails.logger.error "[Captain V2] LLM Error: #{result.error.message}"
+ return {
+ 'response' => 'Desculpe, estou com dificuldades técnicas no momento. Por favor, tente novamente em alguns instantes.',
+ 'reasoning' => "LLM Error: #{result.error.message}"
+ }
+ end
+
+ # Extract response from direct output or history
+ res_data = if result.output.present?
+ result.output
+ else
+ # Look into result.messages for the last assistant response content
+ last_msg = result.messages.reverse.find { |m| m[:role] == :assistant && m[:content].present? }
+ { 'response' => last_msg ? last_msg[:content] : nil }
+ end
+
+ response = format_response(res_data)
# Extract agent name from context
response['agent_name'] = result.context&.dig(:current_agent)
-
response
end
def format_response(output)
- return output.with_indifferent_access if output.is_a?(Hash)
+ # If the output is an agent object, it means a handoff happened
+ if output.respond_to?(:name)
+ return {
+ 'response' => "Transferindo para o setor de #{output.name.humanize}... Um momento.",
+ 'reasoning' => "Handoff para #{output.name}"
+ }
+ end
- # Fallback for backwards compatibility
- {
- 'response' => output.to_s,
- 'reasoning' => 'Processed by agent'
- }
+ res = if output.is_a?(Hash)
+ output.with_indifferent_access
+ elsif output.respond_to?(:to_h)
+ output.to_h.with_indifferent_access
+ else
+ { 'response' => output.to_s }
+ end
+
+ # Critical: Ensure response is not empty
+ if res['response'].blank?
+ res['response'] = 'Entendi seu pedido. Como posso ajudar com isso especificamente?'
+ res['reasoning'] ||= 'IA gerou resposta vazia, aplicando fallback.'
+ end
+
+ res
end
def error_response(error_message)
@@ -114,14 +164,34 @@ class Captain::Assistant::AgentRunnerService
state
end
+ def with_assistant_api_key
+ api_key = @assistant.api_key.presence
+ original_key = RubyLLM.config.openai_api_key
+
+ if api_key.present?
+ RubyLLM.config.openai_api_key = api_key
+ Rails.logger.info "[Captain V2] Using assistant API key: #{api_key[0..15]}..."
+ end
+
+ yield
+ ensure
+ # Restore original key after the block
+ RubyLLM.config.openai_api_key = original_key if api_key.present?
+ end
+
def build_and_wire_agents
- assistant_agent = @assistant.agent
- scenario_agents = @assistant.scenarios.enabled.map(&:agent)
+ # In Delegation Mode, we only use the orchestrator agent.
+ # The sub-agents (scenarios) are now dynamic tools of this agent.
+ [@assistant.agent]
+ end
- assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any?
- scenario_agents.each { |scenario_agent| scenario_agent.register_handoffs(assistant_agent) }
+ def sanitize_global_api_key
+ # Force sanitization of the global gem config just in case it's dirty
+ raw_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value.presence || ENV.fetch('OPENAI_API_KEY', nil)
+ return unless raw_key.present?
- [assistant_agent] + scenario_agents
+ sanitized_key = raw_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip
+ Agents.configure { |config| config.openai_api_key = sanitized_key }
end
def add_callbacks_to_runner(runner)
diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb
index 229bf90..14ffcfb 100755
--- a/enterprise/app/services/captain/copilot/chat_service.rb
+++ b/enterprise/app/services/captain/copilot/chat_service.rb
@@ -41,6 +41,7 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
def build_messages(config)
messages= [system_message]
messages << account_id_context
+ messages << date_context
messages += @previous_history if @previous_history.present?
messages += current_viewing_history(config[:conversation_id]) if config[:conversation_id].present?
messages
@@ -96,6 +97,13 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
}
end
+ def date_context
+ {
+ role: 'system',
+ content: "Today is #{Time.zone.today.strftime('%A, %B %d, %Y')}."
+ }
+ end
+
def current_viewing_history(conversation_id)
conversation = @account.conversations.find_by(display_id: conversation_id)
return [] unless conversation
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index d8cb341..5739810 100755
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -12,7 +12,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
@conversation = conversation
@tools = build_tools
- @messages = [system_message]
+ @messages = [system_message, date_message]
@response = ''
# Prefer assistant model when set; otherwise keep configured default.
@@ -103,7 +103,11 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
end
def build_tools
- [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil, conversation: @conversation)]
+ [
+ Captain::Tools::SearchDocumentationService.new(@assistant, user: nil, conversation: @conversation),
+ Captain::Tools::StatusSuitesTool.new(@assistant, user: nil, conversation: @conversation),
+ Captain::Tools::ReactToMessageTool.new(@assistant, user: nil, conversation: @conversation)
+ ]
end
def system_message
@@ -113,6 +117,13 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
}
end
+ def date_message
+ {
+ role: 'system',
+ content: "Today is #{Time.zone.today.strftime('%A, %B %d, %Y')}."
+ }
+ end
+
def context_pack_message
return nil if @conversation.blank?
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index f51bb07..1d676bc 100755
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -151,28 +151,27 @@ class Captain::Llm::SystemPromptsService
end
# rubocop:enable Metrics/MethodLength
- # rubocop:disable Metrics/MethodLength
def assistant_response_generator(assistant_name, product_name, config = {})
+ json_instruction = <<~JSON_INSTRUCTION
+ \n\nIMPORTANT: Your final response MUST be a valid JSON object.
+ Structure:
+ {
+ "response": "Your visible message to the customer",
+ "reasoning": "Internal logic",
+ "sentiment": "neutral | positive | frustrated | angry"
+ }
+ JSON_INSTRUCTION
+
blocks = config['system_prompt_blocks']
- return assistant_prompt_from_blocks(blocks) if blocks.present?
-
- system_prompt_override = config['system_prompt'].to_s
- return system_prompt_override if system_prompt_override.present?
-
- blocks = assistant_prompt_blocks(assistant_name, product_name, config)
- return assistant_prompt_from_blocks(blocks) if blocks.present?
-
- if config['feature_citation']
- <<~CITATION_TEXT
- - When you use information from documentation, include citations that reference the specific source (document only - skip if it was derived from a conversation).
- - Citations must be numbered sequentially and formatted as `[[n](URL)]` at the end of the sentence that uses the source.
- - If multiple sentences share the same source, reuse the same citation number.
- CITATION_TEXT
- else
- ''
+ if blocks.present?
+ return "#{assistant_prompt_from_blocks(blocks)}#{json_instruction}"
end
- ''
+ system_prompt_override = config['system_prompt'].to_s
+ return "#{system_prompt_override}#{json_instruction}" if system_prompt_override.present?
+
+ blocks = assistant_prompt_blocks(assistant_name, product_name, config)
+ "#{assistant_prompt_from_blocks(blocks)}#{json_instruction}"
end
def assistant_prompt_blocks(assistant_name, product_name, config = {})
diff --git a/enterprise/app/services/captain/tools/definitions.rb b/enterprise/app/services/captain/tools/definitions.rb
index c9f5430..621bdd0 100644
--- a/enterprise/app/services/captain/tools/definitions.rb
+++ b/enterprise/app/services/captain/tools/definitions.rb
@@ -2,19 +2,24 @@ module Captain
module Tools
class Definitions
ALL = {
- 'status_suites' => {
- type: :http,
- method: :get,
+ 'status_suites' => {
+ type: :http,
+ method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus',
description: 'Check suite availability'
},
- 'maria_fotos' => {
+ 'maria_fotos' => {
type: :webhook,
description: 'Send photos via webhook'
},
- 'escalar_humano' => {
+ 'escalar_humano' => {
type: :webhook,
description: 'Escalate to human agent'
+ },
+ 'react_to_message' => {
+ type: :internal,
+ name: 'Reagir a Mensagens',
+ description: 'React to customer messages with emoji (👍, ❤️, 😊)'
}
}.freeze
end
diff --git a/enterprise/app/services/captain/tools/react_to_message_tool.rb b/enterprise/app/services/captain/tools/react_to_message_tool.rb
new file mode 100644
index 0000000..c2a1dda
--- /dev/null
+++ b/enterprise/app/services/captain/tools/react_to_message_tool.rb
@@ -0,0 +1,60 @@
+module Captain
+ module Tools
+ class ReactToMessageTool < BaseTool
+ def self.name
+ 'react_to_message'
+ end
+
+ description 'React to the last customer message with an emoji reaction. Use this to acknowledge messages positively (e.g., 👍, ❤️, 😊). Only use when appropriate to show engagement.'
+
+ param :emoji, type: 'string', desc: 'The emoji to react with, e.g. 👍, ❤️, 😊, 👏, 🙏'
+
+ def initialize(assistant, user: nil, conversation: nil)
+ @conversation = conversation
+ super(assistant, user: user)
+ end
+
+ def execute(emoji:)
+ return error_response('Conversation not found') unless @conversation.present?
+ return error_response('Emoji is required') if emoji.blank?
+
+ # Get the last incoming message from the customer
+ last_customer_message = @conversation.messages.incoming.last
+ return error_response('No customer message to react to') unless last_customer_message.present?
+
+ # Get the external message ID (source_id) - required for WhatsApp reactions
+ message_external_id = last_customer_message.source_id
+ return error_response('Message has no external ID for reaction') if message_external_id.blank?
+
+ Rails.logger.info "[ReactToMessageTool] Reacting to message #{last_customer_message.id} (source: #{message_external_id}) with #{emoji}"
+
+ create_reaction_message(last_customer_message, emoji, message_external_id)
+
+ { success: true, message: "Reacted with #{emoji}" }.to_json
+ rescue StandardError => e
+ Rails.logger.error "[ReactToMessageTool] Failed: #{e.message}"
+ error_response(e.message)
+ end
+
+ private
+
+ def create_reaction_message(_target_message, emoji, external_id)
+ @conversation.messages.create!(
+ account_id: @conversation.account_id,
+ inbox_id: @conversation.inbox_id,
+ sender: @assistant,
+ message_type: :outgoing,
+ content: emoji,
+ content_attributes: {
+ 'in_reply_to' => external_id,
+ 'is_reaction' => true
+ }
+ )
+ end
+
+ def error_response(message)
+ { success: false, error: message }.to_json
+ end
+ end
+ end
+end
diff --git a/enterprise/app/services/captain/tools/status_suites_tool.rb b/enterprise/app/services/captain/tools/status_suites_tool.rb
new file mode 100644
index 0000000..f2f96c2
--- /dev/null
+++ b/enterprise/app/services/captain/tools/status_suites_tool.rb
@@ -0,0 +1,61 @@
+module Captain
+ module Tools
+ class StatusSuitesTool < BaseTool
+ def self.name
+ 'status_suites'
+ end
+
+ description 'Check specific availability, status, and prices of suites/rooms. Returns a list of suites categorized by status (free, occupied, cleaning) and their types.'
+
+ def initialize(assistant, user: nil, conversation: nil)
+ @conversation = conversation
+ super(assistant, user: user)
+ end
+
+ def execute
+ config = find_tool_config
+ return { success: false, error: 'Tool not configured' }.to_json unless config&.is_enabled
+
+ uri = URI('https://oxpi.com.br/api/PlugPlay/api/SuitesStatus')
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+ http.read_timeout = 8
+
+ request = Net::HTTP::Get.new(uri)
+ request['PLUG-PLAY-ID'] = config.plug_play_id.to_s
+ request['PLUG-PLAY-TOKEN'] = config.plug_play_token.to_s
+
+ response = http.request(request)
+
+ if response.is_a?(Net::HTTPSuccess)
+ parsed = Captain::Tools::Parsers::StatusSuitesParser.parse(response.body)
+ parsed.to_json
+ else
+ { success: false, error: "API Error: #{response.code}" }.to_json
+ end
+ rescue StandardError => e
+ Rails.logger.error "[StatusSuitesTool] Failed: #{e.message}"
+ { success: false, error: e.message }.to_json
+ end
+
+ private
+
+ def find_tool_config
+ # 1. Try Assistant specific config
+ config = @assistant.tool_configs.find_by(tool_key: 'status_suites') if @assistant.respond_to?(:tool_configs)
+ return config if config.present?
+
+ # 2. Try Inbox specific config (if conversation exists)
+ if @conversation&.inbox.present?
+ config = Captain::ToolConfig.find_by(
+ inbox: @conversation.inbox,
+ tool_key: 'status_suites'
+ )
+ return config if config.present?
+ end
+
+ nil
+ end
+ end
+ end
+end
diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid
index 0dc7d85..880cd35 100755
--- a/enterprise/lib/captain/prompts/assistant.liquid
+++ b/enterprise/lib/captain/prompts/assistant.liquid
@@ -6,11 +6,18 @@ You are {{name}}, a helpful and knowledgeable assistant. Your role is to primari
{{ description }}
+{% for block in system_prompt_blocks -%}
+## {{ block.title }}
+{{ block.content }}
+{% endfor %}
+
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this.
{% if conversation || contact -%}
# Current Context
+Today is {{ current_date }}.
+
Here's the metadata we have about the current conversation and the contact associated with it:
{% if conversation -%}
@@ -41,30 +48,34 @@ Always respect these boundaries:
{% endfor %}
{% endif -%}
+# Behavior and Safety
+- **Sentiment Detection**: Analyze the user's tone. If the user is angry or very frustrated, keep your response professional and objective.
+- **Output Format**: Always return your response in the required JSON format including `response`, `reasoning`, and `sentiment`.
+
# Decision Framework
## 1. Analyze the Request
First, understand what the user is asking:
- **Intent**: What are they trying to achieve?
-- **Type**: Is it a question, task, complaint, or request?
-- **Complexity**: Can you handle it or does it need specialized expertise?
+- **Complexity**: Can you handle it with your basic knowledge (prices, location) or do you need to consult a specialized department?
-## 2. Check for Specialized Scenarios First
+## 2. Delegation Strategy (Internal Consulting)
-Before using any tools, check if the request matches any of these scenarios. If it seems like a particular scenario matches, use the specific handoff tool to transfer the conversation to the specific agent. The following are the scenario agents that are available to you.
+You are the ONLY agent authorized to talk to the customer. You have access to specialized departments via tools.
+**If the request belongs to one of the scenarios below, you MUST use the corresponding `consultar_...` tool first to get the official instructions or data.**
+
+Scenarios available for consultation:
{% for scenario in scenarios -%}
-- {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer the conversation to the {{ scenario.title }} agent.
+- **{{ scenario.title }}**: {{ scenario.description }}. Use tool `consultar_{{ scenario.key }}`.
{% endfor %}
-If unclear, ask clarifying questions to determine if a scenario applies:
## 3. Handle the Request
-If no specialized scenario clearly matches, handle it yourself in the following way
+1. **Consult first**: If a specialized scenario matches, call the tool.
+2. **Review report**: The tool will return a report from that department.
+3. **Respond with charm**: Format the department's answer using your carismatic and helpful tone. Never tell the user you "consulted a department" - act as if you have the answer yourself.
-### For Questions and Information Requests
-1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
-2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
-3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
+**IMPORTANT: Always provide a charming and helpful response in the `response` field of your JSON output.**
### For Complex or Unclear Requests
1. **Ask clarifying questions**: Gather more information if needed
diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid
index 1148a7c..6563c3c 100755
--- a/enterprise/lib/captain/prompts/scenario.liquid
+++ b/enterprise/lib/captain/prompts/scenario.liquid
@@ -1,16 +1,20 @@
# System context
-You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
+You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer.
+
+**IMPORTANT: You are now in control. You MUST respond directly to the user's last message using your specific role and instructions below.**
# Your Role
You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
{{ instructions }}
-If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool
+If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `transfer_to_{{ assistant_name }}` tool
{% if conversation || contact %}
# Current Context
+Today is {{ current_date }}.
+
Here's the metadata we have about the current conversation and the contact associated with it:
{% if conversation -%}
@@ -39,6 +43,16 @@ Always respect these boundaries:
{% endfor %}
{% endif -%}
+# Behavior and Safety
+- **Sentiment Detection**: Analyze the user's tone. If the user is angry or very frustrated, keep your response professional and objective.
+- **Output Format**: Your final response MUST be a valid JSON object.
+Structure:
+{
+ "response": "Your message to the customer",
+ "reasoning": "Internal logic",
+ "sentiment": "neutral | positive | frustrated | angry"
+}
+
{% if tools.size > 0 -%}
# Available Tools
You have access to these tools:
diff --git a/enterprise/lib/captain/response_schema.rb b/enterprise/lib/captain/response_schema.rb
index 651eb7e..11c3b79 100755
--- a/enterprise/lib/captain/response_schema.rb
+++ b/enterprise/lib/captain/response_schema.rb
@@ -3,4 +3,5 @@
class Captain::ResponseSchema < RubyLLM::Schema
string :response, description: 'The message to send to the user'
string :reasoning, description: "Agent's thought process"
+ string :sentiment, description: "The user's sentiment (e.g., neutral, positive, frustrated, angry)"
end
diff --git a/enterprise/lib/captain/tools/scenario_delegator_tool.rb b/enterprise/lib/captain/tools/scenario_delegator_tool.rb
new file mode 100644
index 0000000..6558929
--- /dev/null
+++ b/enterprise/lib/captain/tools/scenario_delegator_tool.rb
@@ -0,0 +1,46 @@
+# enterprise/lib/captain/tools/scenario_delegator_tool.rb
+module Captain::Tools
+ class ScenarioDelegatorTool < Captain::Tools::BasePublicTool
+ attr_reader :scenario
+
+ def initialize(scenario)
+ @scenario = scenario
+ super(@scenario.assistant)
+ end
+
+ def name
+ "consultar_#{@scenario.title.parameterize.underscore}"
+ end
+
+ def description
+ "Consulta o departamento especializado: #{@scenario.description}. Use esta ferramenta para obter informações ou realizar ações sobre este assunto."
+ end
+
+ param :pergunta_interna, type: 'string', desc: 'A pergunta ou instrução detalhada que você quer enviar para este departamento.'
+
+ def perform(_tool_context, pergunta_interna:)
+ # Instanciamos o agente do cenário, que já carrega suas próprias ferramentas (custom tools, etc)
+ agent = @scenario.agent
+
+ # Usamos o Runner padrão (Agents gem) para permitir o loop de Pensamento/Ação
+ # Isso permite que este sub-agente decida se precisa chamar ferramentas ou apenas responder
+ Rails.logger.info "[ScenarioDelegatorTool] Iniciando sub-agente: #{@scenario.title}"
+ Rails.logger.info "[ScenarioDelegatorTool] Ferramentas do Agente (#{@scenario.title}): #{agent.tools.map(&:name)}"
+
+ runner = Agents::Runner.with_agents(agent)
+
+ result = runner.run(pergunta_interna, max_turns: 10)
+
+ Rails.logger.info "[ScenarioDelegatorTool] Sub-agente (#{@scenario.title}) finished. Output: #{result.output.inspect}"
+
+ # Log steps to debug why tool might not have been called
+ Rails.logger.info "[ScenarioDelegatorTool] Thoughts: #{result.thoughts.inspect}" if result.respond_to?(:thoughts)
+
+ # Extraímos a resposta final (mesma lógica do AgentRunnerService)
+ result.output['response'] || result.output.to_s
+ rescue StandardError => e
+ Rails.logger.error "[ScenarioDelegatorTool] Erro no sub-agente #{@scenario.title}: #{e.message}"
+ "Erro ao consultar o departamento #{@scenario.title}: #{e.message}"
+ end
+ end
+end
diff --git a/er.find_by(email: 'rodrigobm10@gmail.com') b/er.find_by(email: 'rodrigobm10@gmail.com')
new file mode 100644
index 0000000..f4d7ee7
--- /dev/null
+++ b/er.find_by(email: 'rodrigobm10@gmail.com')
@@ -0,0 +1,12 @@
+=> # #
+
diff --git a/interactive_jasmine.rb b/interactive_jasmine.rb
new file mode 100644
index 0000000..a025365
--- /dev/null
+++ b/interactive_jasmine.rb
@@ -0,0 +1,44 @@
+# interactive_jasmine.rb
+assistant = Captain::Assistant.find_by(name: 'Jasmine (Hotel Prime)')
+
+unless assistant
+ puts "Erro: Jasmine não encontrada. Execute o seed primeiro."
+ exit
+end
+
+puts "=========================================================="
+puts " JASMINE INTERATIVA - HOTEL 1001 NOITES PRIME "
+puts "=========================================================="
+puts "Digite sua mensagem (ou 'sair' para encerrar):"
+
+loop do
+ print "\nVocê: "
+ input = gets.chomp
+ break if input.downcase == 'sair'
+
+ puts "..."
+ puts "(Jasmine está processando e digitando...)"
+
+ # Usando o job oficial para testar a latência e o status de digitação que implementamos
+ service = Captain::Llm::AssistantChatService.new(assistant: assistant)
+ start_time = Time.zone.now
+
+ res = service.generate_response(additional_message: input)
+
+ # Simulação da lógica de latência que está no Job
+ response_text = res['response']
+ typing_speed = 50
+ target_delay = (response_text.length * typing_speed) / 1000.0
+ target_delay = [target_delay, 7.0].min
+ elapsed = Time.zone.now - start_time
+ remaining = target_delay - elapsed
+
+ sleep(remaining) if remaining > 0
+
+ puts "\nJasmine: #{response_text}"
+ puts "\n[DEBUG]"
+ puts "Sentimento: #{res['sentiment']}"
+ puts "Raciocínio: #{res['reasoning']}"
+ puts "Tempo de 'digitação': #{(elapsed + [remaining, 0].max).round(2)}s"
+ puts "----------------------------------------------------------"
+end
diff --git a/lib/llm/config.rb b/lib/llm/config.rb
index f200807..9fbf935 100755
--- a/lib/llm/config.rb
+++ b/lib/llm/config.rb
@@ -43,6 +43,9 @@ module Llm::Config
end
def system_api_key
+ # Prioritize ENV key to avoid overwriting with stale DB config
+ return nil if ENV['OPENAI_API_KEY'].present?
+
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
diff --git a/progresso/2026-01-06_fix_date_context_captain.md b/progresso/2026-01-06_fix_date_context_captain.md
new file mode 100644
index 0000000..3a8d57a
--- /dev/null
+++ b/progresso/2026-01-06_fix_date_context_captain.md
@@ -0,0 +1,40 @@
+# Fix: Injeção de Data no Contexto do Captain
+
+## Objetivo
+Corrigir a incapacidade do agente "Capitão" (e seus cenários) de saber a data atual, o que levava a respostas incorretas sobre o dia da semana ou a data corrente.
+
+## Contexto
+O usuário relatou que o Capitão não sabia que dia era hoje (pensava ser quinta-feira quando era terça). A análise revelou que os prompts de sistema (System Prompts) e templates Liquid não recebiam nenhuma informação temporal.
+
+## Passos Realizados
+
+1. **Captain::Assistant (Agent V2)**:
+ * Arquivo: `enterprise/app/models/captain/assistant.rb`
+ * Mudança: Adicionado `current_date` ao método `prompt_context`, formatado como `Time.zone.today.strftime('%A, %B %d, %Y')`.
+
+2. **Captain::Scenario (Agent V2)**:
+ * Arquivo: `enterprise/app/models/captain/scenario.rb`
+ * Mudança: Adicionado `current_date` ao método `prompt_context`.
+
+3. **Templates Liquid**:
+ * Arquivos: `enterprise/lib/captain/prompts/assistant.liquid`, `enterprise/lib/captain/prompts/scenario.liquid`
+ * Mudança: Adicionada a linha `Today is {{ current_date }}.` na seção "Current Context".
+
+4. **Copilot Chat Service**:
+ * Arquivo: `enterprise/app/services/captain/copilot/chat_service.rb`
+ * Mudança: Injeção de uma mensagem de sistema adicional contendo `Today is ...` no método `build_messages`.
+
+5. **Assistant Chat Service (Legacy/Alternative)**:
+ * Arquivo: `enterprise/app/services/captain/llm/assistant_chat_service.rb`
+ * Mudança: Injeção de uma mensagem de sistema adicional contendo `Today is ...` na inicialização do serviço.
+
+## Código/Arquivos Alterados
+- `enterprise/app/models/captain/assistant.rb`
+- `enterprise/app/models/captain/scenario.rb`
+- `enterprise/lib/captain/prompts/assistant.liquid`
+- `enterprise/lib/captain/prompts/scenario.liquid`
+- `enterprise/app/services/captain/copilot/chat_service.rb`
+- `enterprise/app/services/captain/llm/assistant_chat_service.rb`
+
+## Como Validar
+Interagir com o agente Capitão (em modo Copilot ou Autônomo) e perguntar "Que dia é hoje?". O agente deve responder com a data correta baseada no servidor (Time.zone.today).
diff --git a/progresso/2026-01-10_react_to_message_tool.md b/progresso/2026-01-10_react_to_message_tool.md
new file mode 100644
index 0000000..6fcf15f
--- /dev/null
+++ b/progresso/2026-01-10_react_to_message_tool.md
@@ -0,0 +1,88 @@
+# Implementação: Ferramenta de Reação de Mensagem (Captain AI)
+
+**Data:** 10/01/2026
+**Objetivo:** Permitir que o Captain reaja às mensagens do cliente com emojis, simulando comportamento humano.
+
+---
+
+## Contexto
+
+No N8N, a reação é feita via API externa usando IDs dinâmicos (`account_id`, `conversation_id`, `message_id`). No Captain, esses dados já estão disponíveis no contexto interno, então criamos uma tool nativa.
+
+---
+
+## Arquivos Criados/Modificados
+
+### [NOVO] `enterprise/app/services/captain/tools/react_to_message_tool.rb`
+
+- Tool que reage à última mensagem incoming do cliente
+- Parâmetro: `emoji` (string)
+- Cria mensagem com `is_reaction: true` e `in_reply_to: source_id`
+
+### [MODIFICADO] `enterprise/app/services/captain/llm/assistant_chat_service.rb`
+
+- Adicionada no método `build_tools`:
+
+```ruby
+Captain::Tools::ReactToMessageTool.new(@assistant, user: nil, conversation: @conversation)
+```
+
+### [MODIFICADO] `enterprise/app/services/captain/tools/definitions.rb`
+
+- Adicionada definição para aparecer em "Poderes":
+
+```ruby
+'react_to_message' => {
+ type: :internal,
+ name: 'Reagir a Mensagens',
+ description: 'React to customer messages with emoji (👍, ❤️, 😊)'
+}
+```
+
+---
+
+## Como Funciona
+
+1. **Agent decide reagir** → Chama `react_to_message(emoji: "👍")`
+2. **Tool executa**:
+ - Pega `conversation` do contexto
+ - Busca última mensagem incoming (`messages.incoming.last`)
+ - Pega `source_id` (ID externo do WhatsApp)
+ - Cria mensagem com `is_reaction: true`
+3. **SendReplyJob** → Detecta `is_reaction` → Chama `send_reaction_message`
+4. **Wuzapi Client** → `POST /chat/react` com Phone, Id e Body (emoji)
+
+---
+
+## Configuração de Uso (Prompt)
+
+Adicionar no prompt do assistente:
+
+```
+## Uso de Reações
+
+Use a ferramenta `react_to_message` para:
+- Reagir com 👍 quando o cliente confirmar algo positivo
+- Reagir com ❤️ quando o cliente agradecer
+- Reagir com 😊 para demonstrar empatia
+- Reagir com 🙏 quando receber elogios
+
+NÃO use reações:
+- Em mensagens de reclamação
+- Quando o cliente estiver irritado
+```
+
+---
+
+## Ativação
+
+1. Acessar: Captain → Assistentes → [Assistente] → Poderes
+2. Ativar "Reagir a Mensagens"
+3. (Opcional) Adicionar instruções no prompt do assistente
+
+---
+
+## Validação
+
+- Testar no Playground: _"reaja positivamente"_
+- Testar via WhatsApp: enviar mensagem positiva e verificar reação
diff --git a/progresso/arquitetura_captain_v2.md b/progresso/arquitetura_captain_v2.md
new file mode 100644
index 0000000..484f4d7
--- /dev/null
+++ b/progresso/arquitetura_captain_v2.md
@@ -0,0 +1,109 @@
+# Arquitetura de Agentes Captain V2: Humanização, Consistência e Sub-Agentes
+
+**Data:** 06/01/2026
+**Status:** Proposta Arquitetural
+**Contexto:** Evolução do Captain para atendimento autônomo de alta fidelidade.
+
+---
+
+## 1. Arquitetura Hierárquica (Sub-Agentes)
+
+Atualmente, o Captain opera muito como um "Generalista". Para evitar alucinações e melhorar a consistência, devemos adotar o padrão **Orchestrator-Workers (Orquestrador-Trabalhadores)**.
+
+### A Estrutura Proposta
+
+1. **O Orquestrador (The Dispatcher/Triagem):**
+ * **Função:** Não responde ao cliente (exceto saudações simples). A única função dele é analisar a intenção e rotear para o especialista correto.
+ * **Ferramenta:** `handoff_to_sales`, `handoff_to_support`, `handoff_to_human`.
+ * **Prompt:** Extremamente rígido. "Você é um classificador. Se o cliente quer comprar, chame o agente de vendas. Se tem problema técnico, chame o suporte."
+
+2. **Os Especialistas (Sub-Agentes/Scenarios):**
+ * Cada `Captain::Scenario` deve ser tratado como um sub-agente isolado.
+ * **Agente de Suporte Técnico:** Tem acesso à base de conhecimento (Docs) e ferramentas de debug.
+ * **Agente de Vendas:** Tem acesso a tabela de preços e ferramentas de agendamento.
+ * **Agente de Retenção:** Especialista em empatia, acionado quando o sentimento é negativo.
+
+### Benefício Técnico
+Ao restringir o escopo de cada agente, reduzimos drasticamente a chance de alucinação. O agente de vendas *não sabe* inventar soluções técnicas porque ele não tem essa ferramenta.
+
+---
+
+## 2. Eliminação de Alucinações (Grounding & RAG)
+
+A alucinação ocorre quando a IA tenta ser prestativa sem ter a informação.
+
+### Melhorias Necessárias
+
+1. **Strict RAG (RAG Rígido):**
+ * No `SearchDocumentationService`, devemos retornar um "score de confiança".
+ * **Regra:** Se a confiança da busca for menor que 0.7 (exemplo), o Agente **NÃO** deve tentar responder. Ele deve acionar o `escalate_to_human` ou dizer "Preciso confirmar essa informação com um especialista humano".
+
+2. **Citação Obrigatória:**
+ * Forçar o modelo a incluir a fonte da resposta no JSON de saída (interno), mas não necessariamente no texto final. Se ele não conseguir citar de onde tirou a informação (ID do artigo), a resposta é descartada.
+
+3. **Fact-Checking Step (Passo de Verificação):**
+ * Antes de enviar a resposta ao usuário, um passo intermediário (uma "segunda passada" rápida de LLM) verifica: "A resposta gerada está contida no contexto fornecido? Sim/Não". Se Não, descarta.
+
+---
+
+## 3. Humanização e UX (Parecer Humano)
+
+Ser humano não é apenas falar "olá". É sobre **timing**, **memória** e **adaptação**.
+
+### Melhorias de Comportamento
+
+1. **Latência Variável (Simulação de Digitação):**
+ * Respostas instantâneas (100ms) gritam "SOU UM ROBÔ".
+ * **Implementação:** Calcular o tempo de leitura da mensagem do usuário + tempo de "pensamento" + tempo de "digitação" da resposta.
+ * *Exemplo:* Uma resposta longa deve demorar 4-6 segundos para aparecer, com o status "digitando..." ativo no Chatwoot.
+
+2. **Análise de Sentimento (Gatekeeper):**
+ * Antes de processar a resposta, classificar o sentimento do usuário (Irritado, Feliz, Neutro).
+ * **Regra:** Se Sentimento == `Muito Irritado`, **bypass da IA**. Transfere direto para humano. IA tentando acalmar cliente furioso geralmente piora a situação.
+
+3. **Memória de Longo Prazo (User Facts):**
+ * O Captain deve lembrar que o "João" usa "Linux" e prefere ser atendido à tarde.
+ * **Implementação:** Um serviço que roda em background pós-conversa (`ConversationSummarizer`) para extrair "Fatos" e salvar em `Contact Custom Attributes` ou uma tabela `ContactMemories`.
+ * No próximo chat, esses fatos são injetados no Contexto.
+
+---
+
+## 4. Consistência (Style Guides)
+
+Para evitar que o agente seja super formal numa hora e use gírias na outra.
+
+1. **Few-Shot Prompting Dinâmico:**
+ * Em vez de apenas instruções ("Seja educado"), injetar 3 exemplos reais de ótimos atendimentos da sua empresa no prompt.
+ * *Prompt:* "Aqui estão exemplos de como respondemos nesta empresa: [Exemplo 1], [Exemplo 2]".
+
+2. **Playbooks Estruturados:**
+ * Utilizar o campo `playbook` do `Assistant` não apenas como texto, mas como uma máquina de estados.
+ * Se o cliente está na fase "Onboarding", o tom é encorajador. Se está em "Cobrança", o tom é firme mas educado.
+
+---
+
+## 5. Plano de Ação (Roadmap Técnico)
+
+1. **Fase 1: Estrutura (Imediato)**
+ * Refinar os `System Prompts` atuais para incluir a data (Feito) e reforçar a persona.
+ * Configurar os `Scenarios` como sub-agentes especialistas.
+
+2. **Fase 2: Controle (Curto Prazo)**
+ * Implementar o "Delay de Digitação" no `AgentRunnerService`.
+ * Adicionar o "Gatekeeper de Sentimento".
+
+3. **Fase 3: Inteligência (Médio Prazo)**
+ * Implementar o "Strict RAG" (só responder se houver documento com alta similaridade).
+ * Criar o sistema de Memória de Longo Prazo.
+
+---
+
+### Exemplo de Fluxo Ideal (O Sonho)
+
+1. **Cliente:** "Meu sistema caiu e estou perdendo dinheiro!"
+2. **Orquestrador:** Detecta sentimento negativo e palavra-chave "caiu".
+ * *Ação:* Roteia para **Agente de Emergência**.
+3. **Agente de Emergência:**
+ * Consulta status do sistema (Tool). Vê que está tudo online.
+ * Consulta memória: "Cliente usa servidor on-premise".
+ * *Resposta (com delay de 3s):* "Oi João. Verifiquei aqui e nossos servidores principais estão online. Como você usa a versão local, pode ser algo na sua rede. Quer que eu chame um técnico dedicado agora?"
diff --git a/progresso/correcao_delegacao_captain_scenarios.md b/progresso/correcao_delegacao_captain_scenarios.md
new file mode 100644
index 0000000..087e775
--- /dev/null
+++ b/progresso/correcao_delegacao_captain_scenarios.md
@@ -0,0 +1,109 @@
+# Correção e Arquitetura: Delegação via Scenarios (Captain AI)
+
+**Data:** 07/01/2026
+**Contexto:** Correção do fluxo onde a agente principal (Jasmine) consulta sub-agentes (Scenarios) para obter informações especializadas sem transferir o atendimento.
+
+---
+
+### 1. O Problema Original
+
+O sistema falhava ao tentar acionar a ferramenta de delegação `consultar_[cenario]`.
+Os erros observados nos logs eram:
+
+1. **`ArgumentError: wrong number of arguments`**: A ferramenta esperava `keyword arguments` (`pergunta_interna:`), mas o `ToolRunner` enviava um objeto de contexto (`Agents::ToolContext`) e um hash de parâmetros.
+2. **`unknown keyword: :api_key`**: A chamada interna ao `RubyLLM` tentava passar a chave de API manualmente, mas a biblioteca não aceitava esse argumento (a configuração é global).
+3. **Retorno de Objeto Sujo**: A ferramenta retornava uma instância de `RubyLLM::Message` (ex: `#`) para o agente principal, em vez do texto da resposta. Isso fazia com que a Jasmine não soubesse o que responder ao cliente.
+
+**Ponto de Quebra:** Ocorria exatamente dentro do método `execute` da classe `ScenarioDelegatorTool`, tanto na entrada (assinatura do método) quanto na saída (retorno para a Jasmine).
+
+---
+
+### 2. Decisão de Arquitetura
+
+**Modelo Escolhido: Delegação por Proxy (Tool)**
+
+- **Não usamos Handoff:** Não transferimos o `contexto` da conversa para o sub-agente. O cliente **nunca** fala diretamente com a Daniela (Reservas).
+- **Jasmine como Interface Única:** A Jasmine continua sendo a "dona" da conversa. Ela "vira para o lado", pergunta para a Daniela (via ferramenta), recebe a resposta técnica e a "traduz" ou repassa para o cliente com a voz dela.
+- **Tool como Proxy:** A classe `ScenarioDelegatorTool` atua como um _wrapper_ que encapsula uma chamada LLM isolada. Para o sistema, é apenas uma ferramenta que retorna texto, igual a uma consulta de API.
+
+---
+
+### 3. O que foi Alterado
+
+Arquivo principal: `enterprise/lib/captain/tools/scenario_delegator_tool.rb`
+
+#### Antes (Quebrado)
+
+```ruby
+class ScenarioDelegatorTool < Agents::Tool
+ def schema ... end # Definição manual de schema
+
+ def execute(pergunta_interna:) # Assinatura incompatível com ToolRunner
+ # Chamada incorreta com api_key
+ RubyLLM::Chat.new(...).ask(..., api_key: ...)
+ # Retorno implícito do objeto Message
+ end
+end
+```
+
+#### Depois (Correto)
+
+```ruby
+# 1. Herança correta para aproveitar a infraestrutura do projeto
+class ScenarioDelegatorTool < Captain::Tools::BasePublicTool
+
+ # 2. Uso da DSL padrão para definição de parâmetros
+ param :pergunta_interna, type: 'string', desc: '...'
+
+ # 3. Assinatura padrão 'perform' que recebe o contexto e args nomeados
+ def perform(_tool_context, pergunta_interna:)
+
+ # ... lógica de prompt ...
+
+ # 4. Chamada RubyLLM sem api_key (usa config global do Runner)
+ response = RubyLLM::Chat.new(model: assistant.send(:agent_model))
+ .ask(prompt)
+
+ # 5. Extração explícita do conteúdo de texto
+ response.respond_to?(:content) ? response.content : response.to_s
+ rescue StandardError => e
+ "Erro ao consultar departamento: #{e.message}"
+ end
+end
+```
+
+---
+
+### 4. Fluxo Final (Estado Correto)
+
+1. **Detecção:** A `JasmineBrain` (ou o LLM principal) detecta que o usuário quer algo específico de um cenário (ex: "quero reservar").
+2. **Decisão:** O LLM decide chamar a ferramenta `consultar_daniela_reservas` com o argumento `pergunta_interna="cliente quer reservar para semana que vem"`.
+3. **Execução (ToolRunner):**
+ - Instancia `ScenarioDelegatorTool`.
+ - Seta a API Key do assistente globalmente no `RubyLLM` (via `AgentRunnerService`).
+ - Chama `perform`.
+4. **Sub-agente (Proxy):**
+ - A ferramenta monta um prompt "Você é Daniela..." com a pergunta da Jasmine.
+ - Chama o LLM (síncrono).
+5. **Retorno:** A ferramenta devolve **apenas a string** com a resposta da Daniela (ex: "Para reservar, use o link X...").
+6. **Resposta ao Cliente:** A Jasmine recebe essa string como `tool_output`, incorpora ao contexto e gera a resposta final para o WhatsApp do cliente.
+
+---
+
+### 5. Pegadinhas (O que NÃO fazer)
+
+1. **NUNCA implementar `execute` manualmente** em ferramentas que herdam de `BasePublicTool`. O método `perform` é o contrato correto que recebe tratamento de erros e logs.
+2. **NUNCA passar `api_key` para o método `ask` do RubyLLM.** O `AgentRunnerService` gerencia a chave através de `Agents.configure` ou `RubyLLM.config`. Passar manualmente gera erro de argumento.
+3. **CUIDADO com o retorno do LLM.** O `RubyLLM` retorna objetos complexos. Sempre extraia `.content` ou `.to_s` antes de devolver para o agente principal, senão o agente "alucina" com o ID do objeto Ruby.
+
+---
+
+### 6. Como Validar
+
+Nos logs da aplicação (`docker logs` ou console):
+
+1. Procure por `[DEBUG V2] Running with agents: jasmine`.
+2. Veja o `Agent result`.
+3. Dentro de `messages`, localize o item com `role: :tool`.
+4. **Verificação de Sucesso:** O `content` da tool deve ser um **texto legível** (ex: "O link é...") e **NÃO** algo como `#`.
+5. Se houver erro, aparecerá em `error: ...` no log do `Agent result`.
diff --git a/progresso/fix_captain_agent_response.md b/progresso/fix_captain_agent_response.md
new file mode 100644
index 0000000..3945dbb
--- /dev/null
+++ b/progresso/fix_captain_agent_response.md
@@ -0,0 +1,135 @@
+# Fix: Captain Agent Não Respondia Corretamente
+
+**Data:** 2026-01-07
+**Autor:** Antigravity AI
+
+---
+
+## Problema
+
+O Captain Agent estava retornando `conversation_handoff` ou mensagens de erro genéricas ao invés de responder às perguntas dos usuários corretamente.
+
+## Diagnóstico
+
+### 1. API Key Inválida no `.env`
+
+A chave `OPENAI_API_KEY` no arquivo `.env` estava inválida/revogada:
+
+```
+OPENAI_API_KEY=sk-proj-l5XCl-...iu5U # INVÁLIDA
+```
+
+Resultado: Erro `UnauthorizedError: Incorrect API key provided`
+
+### 2. Método `RubyLLM.configuration` Errado
+
+O código estava usando `RubyLLM.configuration` que não existe. O correto é `RubyLLM.config`:
+
+```ruby
+# ERRADO
+RubyLLM.configuration.openai_api_key
+
+# CORRETO
+RubyLLM.config.openai_api_key
+```
+
+### 3. FAQs Sem Embeddings
+
+Os FAQs cadastrados não tinham embeddings gerados, tornando-os invisíveis para a busca semântica:
+
+```ruby
+# Verificação
+faq.embedding.present? # => false
+```
+
+---
+
+## Solução
+
+### 1. Atualizar API Key no `.env`
+
+Substituir a chave inválida pela chave válida:
+
+```bash
+# Arquivo: .env
+OPENAI_API_KEY=sk-proj-xKi75fs_... # NOVA CHAVE VÁLIDA
+```
+
+### 2. Corrigir `AgentRunnerService`
+
+Arquivo: `enterprise/app/services/captain/assistant/agent_runner_service.rb`
+
+```ruby
+def with_assistant_api_key
+ api_key = @assistant.api_key.presence
+ original_key = RubyLLM.config.openai_api_key # CORRIGIDO
+
+ if api_key.present?
+ RubyLLM.config.openai_api_key = api_key # CORRIGIDO
+ Rails.logger.info "[Captain V2] Using assistant API key: #{api_key[0..15]}..."
+ end
+
+ yield
+ensure
+ RubyLLM.config.openai_api_key = original_key if api_key.present? # CORRIGIDO
+end
+```
+
+### 3. Gerar Embeddings para FAQs
+
+Executar manualmente o job de geração de embeddings:
+
+```ruby
+Captain::AssistantResponse.approved.find_each do |faq|
+ if faq.embedding.nil?
+ Captain::Llm::UpdateEmbeddingJob.perform_now(faq, "#{faq.question}: #{faq.answer}")
+ end
+end
+```
+
+### 4. Recriar Containers
+
+O `docker-compose restart` não recarrega variáveis de ambiente. É necessário recriar:
+
+```bash
+docker-compose up -d rails sidekiq
+```
+
+---
+
+## Validação
+
+1. Verificar chave no container:
+
+```bash
+docker-compose exec rails bundle exec rails runner 'puts RubyLLM.config.openai_api_key[0..20]'
+# Output esperado: sk-proj-xKi75fs_ntsx6
+```
+
+2. Verificar embeddings:
+
+```bash
+docker-compose exec rails bundle exec rails runner 'puts Captain::AssistantResponse.approved.last.embedding.present?'
+# Output esperado: true
+```
+
+3. Testar no Playground com uma pergunta que existe nos FAQs
+
+---
+
+## Arquivos Modificados
+
+| Arquivo | Alteração |
+| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
+| `.env` | Atualizada `OPENAI_API_KEY` |
+| `enterprise/app/services/captain/assistant/agent_runner_service.rb` | Corrigido `RubyLLM.config` e adicionado `with_assistant_api_key` |
+| `docker-compose.yaml` | Corrigido volume do Postgres (`/var/lib/postgresql/data`) |
+
+---
+
+## Lições Aprendidas
+
+1. **Sempre validar API key via curl** antes de assumir problemas no código
+2. **`docker-compose restart` ≠ `docker-compose up -d`** para mudanças de `.env`
+3. **Embeddings são obrigatórios** para busca semântica funcionar
+4. **Verificar API da gem** antes de usar métodos (ex: `RubyLLM.config` vs `RubyLLM.configuration`)
diff --git a/progresso/plano_evolucao_capitao_v2.md b/progresso/plano_evolucao_capitao_v2.md
new file mode 100644
index 0000000..a358de4
--- /dev/null
+++ b/progresso/plano_evolucao_capitao_v2.md
@@ -0,0 +1,68 @@
+# Plano de Evolução do Capitão (Jasmine): De Assistente a Ecossistema
+
+**Data:** 06/01/2026
+**Arquiteto:** Gemini (Senior Architect Mode)
+**Status:** Em Execução (Fase de Estabilidade)
+
+---
+
+## 1. Objetivo Principal
+Transformar o "Capitão" de um robô reativo em um **Ecossistema de Atendimento Inteligente**, focado em **Consistência de Marca (Jasmine)**, **Zero Alucinação** e **Eficiência Operacional**.
+
+---
+
+## 2. O que já Concluímos (Fundação)
+
+- [x] **Consciência Temporal:** O agente agora sabe a data, dia da semana e hora exata no fuso de Brasília.
+- [x] **Arquitetura de Delegação (Supervisor Pattern):**
+ - A Jasmine é a orquestradora única.
+ - Sub-agentes (Daniela, Maria, Jamile) operam como "Departamentos Internos" acessados via ferramentas (`consultar_...`).
+ - **Motivo:** Evita a quebra de tom de voz e permite roteamento dinâmico entre vários especialistas em uma única conversa.
+- [x] **Humanização de Interface:**
+ - Simulação de digitação adaptativa (50ms por caractere).
+ - Ativação do status "Digitando..." no Chatwoot.
+- [x] **Camada de Segurança (Sentimento):**
+ - Identificação de raiva/frustração no JSON de resposta.
+ - Toggle no Frontend para ativar Handoff Automático para humanos em casos críticos.
+- [x] **Sanitização de API:** Proteção contra chaves corrompidas e fallback de erros amigáveis.
+
+---
+
+## 3. Próximos Passos (O Roadmap)
+
+### Fase 1: Zero Alucinação (Strict RAG)
+- **Implementação:** Injetar um "Confidence Score" no retorno da busca de documentos.
+- **Regra de Ouro:** Se a similaridade for inferior a 0.7, a IA é proibida de afirmar fatos. Ela deve usar o fallback: *"Não localizei essa informação específica agora, vou confirmar com o gerente para você"*.
+- **Risco:** Tornar o robô muito "travado". Precisamos calibrar o threshold.
+
+### Fase 2: Memória de Longo Prazo (Fact Extraction)
+- **Implementação:** Um serviço pós-conversa que lê o chat e extrai fatos (ex: "O cliente prefere suíte com Alexa", "Aniversário em 10/05").
+- **Ação:** Salvar esses dados automaticamente nos `Custom Attributes` do contato.
+- **Benefício:** Na próxima conversa, a Jasmine já saúda o cliente com: *"Oi João, que bom te ver de novo! Quer aquela suíte Alexa que você gosta?"*.
+
+### Fase 3: Roteamento Proativo
+- **Implementação:** Melhorar o orquestrador para que ele possa consultar dois departamentos antes de responder.
+- **Exemplo:** *"Vou ver as fotos com a Maria e os preços com a Daniela e já te mando tudo"*.
+
+---
+
+## 4. Análise de Riscos e Mitigação
+
+1. **Latência (Risco de Performance):**
+ - *Problema:* O modelo de Delegação (Jasmine pergunta para Daniela) dobra o tempo de resposta.
+ - *Mitigação:* Usar modelos mais rápidos (GPT-4o-mini ou Gemini Flash) para os sub-agentes e o modelo robusto (GPT-4o) apenas para a orquestradora.
+
+2. **Custo de Tokens:**
+ - *Problema:* Injetar muitos blocos de contexto (Tabela de Preços) em todas as mensagens aumenta o custo.
+ - *Mitigação:* Implementar cache de contexto ou usar busca vetorial (RAG) até para os preços, em vez de prompt fixo.
+
+3. **Perda de Contexto no Handoff Interno:**
+ - *Problema:* A Daniela pode não saber o que o cliente disse para a Jasmine.
+ - *Mitigação:* A ferramenta `consultar_...` deve enviar um resumo do chat atual para o sub-agente.
+
+---
+
+## 5. Como Validar a Evolução
+- **Testes de Regressão:** Usar o script `test_multi_agent_flow.rb` após cada mudança.
+- **Playground:** Validar visualmente o nome do agente que está sendo consultado.
+- **Shadow Mode:** Rodar a IA em modo "rascunho" antes de permitir que ela responda clientes reais (opcional).
diff --git a/seed_jasmine_hotel.rb b/seed_jasmine_hotel.rb
new file mode 100644
index 0000000..df13000
--- /dev/null
+++ b/seed_jasmine_hotel.rb
@@ -0,0 +1,132 @@
+# seed_jasmine_hotel.rb
+# Objetivo: Configurar a Agente Jasmine e seus Sub-Agentes (Cenários) baseados no prompt do usuário.
+
+account = Account.first
+user = account.users.first
+
+puts "Criando Assistente Jasmine para a conta: #{account.name}..."
+
+# 1. Definir o Prompt do Sistema (Contexto Fixo e Identidade)
+system_prompt_blocks = [
+ {
+ key: 'identity',
+ title: 'Identidade',
+ content: <<~TEXT
+ Seu nome é Jasmine.
+ Você é a Recepcionista e Gerente de Atendimento do Hotel 1001 Noites Prime – Ceilândia.
+ Não revele que é uma IA a menos que perguntada explicitamente.
+ Seu objetivo é encantar com simpatia e profissionalismo.
+ Seu tom deve ser carismático, cordial e focado em vendas, usando emojis estrategicamente.
+ TEXT
+ },
+ {
+ key: 'context',
+ title: 'Informações do Hotel (Ceilândia)',
+ content: <<~TEXT
+ Hotel 1001 Noites Prime – Unidade Ceilândia.
+ Público: Casais, hospedagens curtas.
+
+ TABELA DE PREÇOS (Segunda a Quinta):
+ - Stilo: 1h R$50 | 2h R$60 | Pernoite c/ café R$130
+ - Alexa: 1h R$50 | 2h R$65 | Pernoite c/ café R$140
+ - Hidro: 1h R$130 | 2h R$150 | Pernoite c/ café R$260
+
+ TABELA DE PREÇOS (Quinta a Domingo):
+ - Stilo: 1h R$50 | 2h R$70 | Pernoite c/ café R$150
+ - Alexa: 1h R$60 | 2h R$75 | Pernoite c/ café R$160
+ - Hidro: 1h R$140 | 2h R$160 | Pernoite c/ café R$280
+
+ LINKS:
+ - Cardápio: https://hoteis1001noites.com.br/cardapio/
+ - Waze: https://waze.com/ul?a=share_drive...
+ TEXT
+ },
+ {
+ key: 'guidelines',
+ title: 'Regras de Atendimento',
+ content: <<~TEXT
+ - Atue como fonte principal apenas para Ceilândia.
+ - Para outras unidades, passe apenas telefone/endereço (use a ferramenta de busca se não souber).
+ - JAMAIS invente informações.
+ - Máximo 2 parágrafos curtos por resposta.
+ - Uma pergunta por vez.
+ TEXT
+ }
+]
+
+jasmine = Captain::Assistant.create!(
+ account: account,
+ name: 'Jasmine (Hotel Prime)',
+ description: 'Recepcionista focada em vendas e encantamento.',
+ llm_provider: 'openai',
+ llm_model: 'gpt-4o',
+ config: {
+ product_name: 'Hotel 1001 Noites',
+ role_name: 'Recepcionista',
+ system_prompt_blocks: system_prompt_blocks,
+ handoff_on_sentiment: true # Ativando nossa feature nova!
+ }
+)
+
+puts "Jasmine criada com ID: #{jasmine.id}"
+
+# 2. Criar os Cenários (Sub-Agentes)
+
+# Cenário 1: Daniela (Reservas Futuras)
+Captain::Scenario.create!(
+ account: account,
+ assistant: jasmine,
+ title: 'Daniela Reservas',
+ description: 'Especialista em criar novas reservas para qualquer unidade.',
+ instruction: <<~TEXT
+ Você é a Daniela, especialista em reservas.
+ Sua função é APENAS coletar dados para reserva futura e confirmar.
+
+ Gatilho: Cliente quer reservar para amanhã, sábado, ou data futura.
+
+ Ação Obrigatória:
+ 1. Se o cliente não disse a data/hora/unidade, pergunte.
+ 2. Use a ferramenta `handoff` para finalizar o atendimento ou confirmar que registrou.
+
+ Nota: Você atende reservas de QUALQUER unidade do grupo.
+ TEXT
+)
+
+# Cenário 2: Jamile (Disponibilidade Imediata)
+Captain::Scenario.create!(
+ account: account,
+ assistant: jasmine,
+ title: 'Jamile Disponibilidade',
+ description: 'Verifica se tem suíte livre AGORA (Apenas Ceilândia).',
+ instruction: <<~TEXT
+ Você é a Jamile.
+ Sua função é verificar disponibilidade para entrada IMEDIATA na unidade Ceilândia.
+
+ Gatilho: "Tem quarto agora?", "Posso ir ai?", "Tem vaga?"
+
+ Ação:
+ 1. Pergunte qual suíte ele prefere se não disse.
+ 2. Responda simulando uma consulta ao sistema: "Consultei aqui e temos [X] disponível."
+ TEXT
+)
+
+# Cenário 3: Maria (Fotos)
+Captain::Scenario.create!(
+ account: account,
+ assistant: jasmine,
+ title: 'Maria Fotos',
+ description: 'Envia fotos das suítes solicitadas.',
+ instruction: <<~TEXT
+ Você é a Maria, responsável pelo acervo de fotos.
+
+ Gatilho: Cliente pede fotos.
+
+ Ação:
+ 1. Identifique qual suíte o cliente quer ver.
+ 2. Responda: "Claro! Aqui estão as fotos da suíte [Nome] que você pediu:"
+ 3. (Simulação) [FOTO_DA_SUITE_AQUI]
+ TEXT
+)
+
+puts "Cenários (Daniela, Jamile, Maria) criados e vinculados à Jasmine."
+puts "Configuração concluída. Teste no Console ou Playground!"
diff --git a/seed_jasmine_hotel_v2.rb b/seed_jasmine_hotel_v2.rb
new file mode 100644
index 0000000..8b48543
--- /dev/null
+++ b/seed_jasmine_hotel_v2.rb
@@ -0,0 +1,131 @@
+# seed_jasmine_hotel_v2.rb
+# Objetivo: Garantir que a Jasmine e seus sub-agentes existam em TODAS as contas do sistema.
+
+puts "Limpando assistentes antigos..."
+Captain::Assistant.where("name LIKE ?", "%Jasmine%").destroy_all
+
+Account.all.each do |account|
+ puts "Configurando Jasmine para a conta: #{account.name} (ID: #{account.id})..."
+
+ system_prompt_blocks = [
+ {
+ key: 'identity',
+ title: 'Identidade',
+ content: <<~TEXT
+ Seu nome é Jasmine.
+ Você é a Recepcionista e Gerente de Atendimento do Hotel 1001 Noites Prime – Ceilândia.
+ Não revele que é uma IA a menos que perguntada explicitamente.
+ Seu objetivo é encantar com simpatia e profissionalismo.
+ Seu tom deve ser carismático, cordial e focado em vendas, usando emojis estrategicamente.
+ TEXT
+ },
+ {
+ key: 'context',
+ title: 'Informações do Hotel (Ceilândia)',
+ content: <<~TEXT
+ Hotel 1001 Noites Prime – Unidade Ceilândia.
+ Público: Casais, hospedagens curtas.
+
+ TABELA DE PREÇOS (Segunda a Quinta):
+ - Stilo: 1h R$50 | 2h R$60 | Pernoite c/ café R$130
+ - Alexa: 1h R$50 | 2h R$65 | Pernoite c/ café R$140
+ - Hidro: 1h R$130 | 2h R$150 | Pernoite c/ café R$260
+
+ TABELA DE PREÇOS (Quinta a Domingo):
+ - Stilo: 1h R$50 | 2h R$70 | Pernoite c/ café R$150
+ - Alexa: 1h R$60 | 2h R$75 | Pernoite c/ café R$160
+ - Hidro: 1h R$140 | 2h R$160 | Pernoite c/ café R$280
+
+ LINKS:
+ - Cardápio: https://hoteis1001noites.com.br/cardapio/
+ - Waze: https://waze.com/ul?a=share_drive...
+ TEXT
+ },
+ {
+ key: 'guidelines',
+ title: 'Regras de Atendimento',
+ content: <<~TEXT
+ - Atue como fonte principal apenas para Ceilândia.
+ - Para outras unidades, passe apenas telefone/endereço (use a ferramenta de busca se não souber).
+ - JAMAIS invente informações.
+ - Máximo 2 parágrafos curtos por resposta.
+ - Uma pergunta por vez.
+ TEXT
+ }
+ ]
+
+ jasmine = Captain::Assistant.create!(
+ account: account,
+ name: 'Jasmine (Hotel Prime)',
+ description: 'Recepcionista focada em vendas e encantamento.',
+ llm_provider: 'openai',
+ llm_model: 'gpt-4o',
+ config: {
+ product_name: 'Hotel 1001 Noites',
+ role_name: 'Recepcionista',
+ system_prompt_blocks: system_prompt_blocks,
+ handoff_on_sentiment: true
+ }
+ )
+
+ # Daniela (Reservas Futuras)
+ Captain::Scenario.create!(
+ account: account,
+ assistant: jasmine,
+ title: 'Daniela Reservas',
+ description: 'Especialista em criar novas reservas para qualquer unidade.',
+ instruction: <<~TEXT
+ Você é a Daniela, especialista em reservas.
+ Sua função é APENAS coletar dados para reserva futura e confirmar.
+
+ Gatilho: Cliente quer reservar para amanhã, sábado, ou data futura.
+
+ Ação Obrigatória:
+ 1. Se o cliente não disse a data/hora/unidade, pergunte.
+ 2. Use a ferramenta `transfer_to_jasmine` para finalizar o atendimento ou confirmar que registrou.
+
+ Nota: Você atende reservas de QUALQUER unidade do grupo.
+ TEXT
+ )
+
+ # Jamile (Disponibilidade Imediata)
+ Captain::Scenario.create!(
+ account: account,
+ assistant: jasmine,
+ title: 'Jamile Disponibilidade',
+ description: 'Verifica se tem suíte livre AGORA (Apenas Ceilândia).',
+ instruction: <<~TEXT
+ Você é a Jamile.
+ Sua função é verificar disponibilidade para entrada IMEDIATA na unidade Ceilândia.
+
+ Gatilho: "Tem quarto agora?", "Posso ir ai?", "Tem vaga?"
+
+ Ação:
+ 1. Pergunte qual suíte ele prefere se não disse.
+ 2. Responda simulando uma consulta ao sistema: "Consultei aqui e temos [X] disponível."
+ TEXT
+ )
+
+ # Maria (Fotos)
+ Captain::Scenario.create!(
+ account: account,
+ assistant: jasmine,
+ title: 'Maria Fotos',
+ description: 'Envia fotos das suítes solicitadas.',
+ instruction: <<~TEXT
+ Você é a Maria, responsável pelo acervo de fotos.
+
+ Gatilho: Cliente pede fotos.
+
+ Ação:
+ 1. Identifique qual suíte o cliente quer ver.
+ 2. Responda: "Claro! Aqui estão as fotos da suíte [Nome] que você pediu:"
+ 3. (Simulação) [FOTO_DA_SUITE_AQUI]
+ TEXT
+ )
+
+ # Habilitar a feature para a conta
+ account.enable_features!(:captain_integration_v2)
+end
+
+puts "Configuração concluída para todas as contas!"
diff --git a/test_jasmine_final.rb b/test_jasmine_final.rb
new file mode 100644
index 0000000..f02cbd8
--- /dev/null
+++ b/test_jasmine_final.rb
@@ -0,0 +1,17 @@
+# test_jasmine_final.rb
+assistant = Captain::Assistant.find_by(name: 'Jasmine (Hotel Prime)')
+
+def test_msg(assistant, msg)
+ puts "\n> Cliente: #{msg}"
+ # Usando o serviço de chat direto para validar o prompt
+ service = Captain::Llm::AssistantChatService.new(assistant: assistant)
+ res = service.generate_response(additional_message: msg)
+ puts "< Jasmine: #{res['response']}"
+ puts " (Sentimento: #{res['sentiment']})"
+ puts " (Raciocínio: #{res['reasoning']})"
+end
+
+test_msg(assistant, "Oi, qual o valor da pernoite na Alexa?")
+test_msg(assistant, "Quero reservar para amanhã 22h")
+test_msg(assistant, "ESTOU COM MUITA RAIVA!")
+
diff --git a/test_jasmine_routing.rb b/test_jasmine_routing.rb
new file mode 100644
index 0000000..5504d6d
--- /dev/null
+++ b/test_jasmine_routing.rb
@@ -0,0 +1,34 @@
+# test_jasmine_routing.rb
+# Objetivo: Testar se a Orquestradora Jasmine roteia corretamente as intenções.
+
+assistant = Captain::Assistant.find_by(name: 'Jasmine (Hotel Prime)')
+unless assistant
+ puts "Assistente Jasmine não encontrada. Execute o seed primeiro."
+ exit
+end
+
+def simulate_chat(assistant, message)
+ puts "\n--- CLIENTE: #{message}"
+
+ # Usamos o AgentRunnerService (V2) que é o que suporta handoffs/sub-agentes
+ runner = Captain::Assistant::AgentRunnerService.new(assistant: assistant)
+
+ # Simulamos o histórico com apenas a última mensagem do usuário
+ history = [{ role: 'user', content: message }]
+
+ response = runner.generate_response(message_history: history)
+
+ puts "--- JASMINE RESPONDE:"
+ puts "DEBUG: #{response.inspect}"
+ puts "Pensamento: #{response['reasoning']}"
+ puts "Agente Atual: #{response['agent_name'] || 'Orquestrador'}"
+ puts "Resposta: #{response['response']}"
+ puts "Sentimento Detectado: #{response['sentiment']}"
+end
+
+# Casos de Teste
+simulate_chat(assistant, "Oi, quanto custa a pernoite na suite Alexa?")
+simulate_chat(assistant, "Quero reservar para sabado que vem às 20h.")
+simulate_chat(assistant, "Tem suite livre agora? To chegando em 10 minutos.")
+simulate_chat(assistant, "Pode me mandar fotos da suite com hidro?")
+simulate_chat(assistant, "ESTOU MUITO IRRITADO COM A DEMORA!") # Teste de sentimento
diff --git a/test_multi_agent_flow.rb b/test_multi_agent_flow.rb
new file mode 100644
index 0000000..a9ea954
--- /dev/null
+++ b/test_multi_agent_flow.rb
@@ -0,0 +1,22 @@
+# test_multi_agent_flow.rb
+assistant = Captain::Assistant.find_by(name: 'Jasmine (Hotel Prime)')
+account = assistant.account
+
+def simulate_handoff(assistant, user_msg)
+ puts "\n=========================================================="
+ puts "USUÁRIO: #{user_msg}"
+
+ # Usando o motor Multi-Agente (V2) sem conversation real para o Playground
+ runner = Captain::Assistant::AgentRunnerService.new(assistant: assistant)
+
+ # Capturando o resultado do motor
+ result = runner.generate_response(message_history: [{ role: 'user', content: user_msg }])
+
+ puts "AGENTE QUE RESPONDEU: #{result['agent_name']}"
+ puts "RESPOSTA FINAL: #{result['response']}"
+ puts "RACIOCÍNIO: #{result['reasoning']}"
+ puts "SENTIMENTO: #{result['sentiment']}"
+ puts "=========================================================="
+end
+
+simulate_handoff(assistant, "Gostaria de agendar um quarto para amanhã às 22h")
\ No newline at end of file
diff --git a/test_yaml.rb b/test_yaml.rb
new file mode 100644
index 0000000..82cd9f7
--- /dev/null
+++ b/test_yaml.rb
@@ -0,0 +1,18 @@
+require 'yaml'
+begin
+ content = File.read('config/installation_config.yml')
+ YAML.safe_load(content)
+ puts 'installation_config.yml parsed successfully'
+rescue StandardError => e
+ puts "Error parsing installation_config.yml: #{e.class} - #{e.message}"
+ puts e.backtrace
+end
+
+begin
+ content = File.read('config/features.yml')
+ YAML.safe_load(content)
+ puts 'features.yml parsed successfully'
+rescue StandardError => e
+ puts "Error parsing features.yml: #{e.class} - #{e.message}"
+ puts e.backtrace
+end