feat: fix custom tool headers auth and add test endpoint

This commit is contained in:
Rodrigo Borba 2026-01-10 18:46:32 -03:00
parent 3c3ba175c5
commit 0b77706caa
52 changed files with 1592 additions and 91 deletions

View File

@ -5,11 +5,13 @@ import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { vOnClickOutside } from '@vueuse/components';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['add']);
@ -22,6 +24,16 @@ const state = reactive({
title: '',
description: '',
instruction: '',
tools: [],
});
const allTools = useMapGetter('captainTools/getRecords');
const toolOptions = computed(() => {
return allTools.value.map(tool => ({
label: tool.title,
value: tool.id,
}));
});
const rules = {
@ -56,6 +68,7 @@ const resetState = () => {
title: '',
description: '',
instruction: '',
tools: [],
});
};
@ -94,7 +107,7 @@ const onClickCancel = () => {
{{ t(`CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.TITLE`) }}
</h3>
<div class="flex flex-col gap-4">
<div class="max-h-[31.25rem] overflow-y-auto flex flex-col gap-4">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
@ -134,6 +147,18 @@ const onClickCancel = () => {
:show-character-count="false"
enable-captain-tools
/>
<div class="flex flex-col gap-2">
<label class="text-xs font-medium text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TOOLS.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.tools"
:options="toolOptions"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TOOLS.PLACEHOLDER')
"
/>
</div>
</div>
<div class="flex items-center justify-between w-full gap-3">

View File

@ -62,6 +62,7 @@ const sendMessage = async () => {
messages.value.push({
content: data.response,
sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(),
});
} catch (error) {

View File

@ -69,6 +69,13 @@ watch(() => props.messages.length, scrollToBottom);
:size="24"
class="shrink-0"
/>
<div class="flex flex-col gap-1">
<span
v-if="!isUserMessage(message.sender) && message.agentName"
class="text-[10px] text-n-slate-10 uppercase font-bold px-1"
>
{{ message.agentName }}
</span>
<div
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
:class="getMessageStyle(message.sender)"
@ -77,6 +84,7 @@ watch(() => props.messages.length, scrollToBottom);
</div>
</div>
</div>
</div>
<div v-if="isLoading" class="flex justify-start">
<div class="flex items-start gap-1.5">
<Avatar :name="getAvatarName('assistant')" rounded-full :size="24" />

View File

@ -5,6 +5,7 @@ import { useToggle, useElementSize } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
@ -12,10 +13,11 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const props = defineProps({
id: {
type: Number,
type: [Number, String],
required: true,
},
title: {
@ -59,6 +61,7 @@ const state = reactive({
title: '',
description: '',
instruction: '',
tools: [],
});
const instructionContentRef = ref();
@ -69,13 +72,23 @@ const [isInstructionExpanded, toggleInstructionExpanded] = useToggle();
const { height: contentHeight } = useElementSize(instructionContentRef);
const needsOverlay = computed(() => contentHeight.value > 160);
const allTools = useMapGetter('captainTools/getRecords');
const toolOptions = computed(() => {
const options = allTools.value.map(tool => ({
label: tool.title,
value: tool.id,
}));
return options;
});
const startEdit = () => {
Object.assign(state, {
id: props.id,
title: props.title,
description: props.description,
instruction: props.instruction,
tools: props.tools,
tools: props.tools || [],
});
toggleEditing(true);
};
@ -200,7 +213,7 @@ const renderInstruction = instruction => () =>
{{ tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
<div v-else class="overflow-hidden flex flex-col gap-4 w-full">
<div v-else class="flex flex-col gap-4 w-full">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
@ -236,6 +249,18 @@ const renderInstruction = instruction => () =>
:show-character-count="false"
enable-captain-tools
/>
<div class="flex flex-col gap-2">
<label class="text-xs font-medium text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TOOLS.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.tools"
:options="toolOptions"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TOOLS.PLACEHOLDER')
"
/>
</div>
<div class="flex items-center gap-3">
<Button
faded

View File

@ -36,6 +36,7 @@ const initialState = {
conversationFaqs: false,
memories: false,
citations: false,
handoffOnSentiment: false,
},
};
@ -73,6 +74,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
handoffOnSentiment: config.handoff_on_sentiment || false,
};
};
@ -97,6 +99,7 @@ const handleBasicInfoUpdate = async () => {
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
handoff_on_sentiment: state.features.handoffOnSentiment,
},
};
@ -250,6 +253,10 @@ onMounted(() => {
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.handoffOnSentiment" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_SENTIMENT_HANDOFF') }}
</label>
</div>
</div>

View File

@ -45,7 +45,6 @@ const runTest = async () => {
testResult.value = data;
} catch (error) {
useAlert(t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR_MESSAGE'));
console.error(error);
} finally {
isLoading.value = false;
}
@ -107,7 +106,7 @@ defineExpose({ dialogRef });
: 'bg-red-100 text-red-700'
"
>
{{ testResult.status }} {{ testResult.success ? 'OK' : 'Error' }}
{{ testResult.status }} {{ testResult.success ? 'OK' : 'Fail' }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t('CAPTAIN.CUSTOM_TOOLS.TEST.RESPONSE_TIME', { ms: 'N/A' }) }}

View File

@ -54,9 +54,10 @@ const comboboxRef = ref(null);
const filteredOptions = computed(() => {
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
const result = props.options.filter(option =>
option.label?.toLowerCase().includes(searchTerm)
);
return result;
});
const selectPlaceholder = computed(() => {

View File

@ -544,7 +544,8 @@
"TITLE": "Features",
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
"ALLOW_CITATIONS": "Include source citations in responses"
"ALLOW_CITATIONS": "Include source citations in responses",
"ALLOW_SENTIMENT_HANDOFF": "Automatically handoff to human on negative sentiment (angry/frustrated)"
},
"LLM_PROVIDER": {
"LABEL": "LLM Provider"
@ -732,6 +733,10 @@
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
},
"TOOLS": {
"LABEL": "Capabilities / Tools",
"PLACEHOLDER": "Select the tools this sub-agent can use"
},
"CREATE": "Create",
"CANCEL": "Cancel"
}

View File

@ -533,7 +533,8 @@
"TITLE": "Funcionalidades",
"ALLOW_CONVERSATION_FAQS": "Gerar perguntas frequentes a partir de conversas resolvidas",
"ALLOW_MEMORIES": "Capture os principais detalhes como memórias de interações do cliente.",
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas"
"ALLOW_CITATIONS": "Incluir fonte de citações nas respostas",
"ALLOW_SENTIMENT_HANDOFF": "Transferir automaticamente para humano em caso de sentimento negativo (raiva/frustração)"
}
},
"EDIT": {
@ -710,6 +711,10 @@
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
"ERROR": "Conteúdo do cenário é obrigatório"
},
"TOOLS": {
"LABEL": "Capacidades / Poderes",
"PLACEHOLDER": "Selecione os poderes que este sub-agente pode usar"
},
"CREATE": "Criar",
"CANCEL": "Cancelar"
}

View File

@ -33,7 +33,7 @@ const searchQuery = ref('');
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
const renderInstruction = instruction =>
h('span', {
class: `text-sm text-n-slate-12 py-4 prose prose-sm min-w-0 break-words ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
@ -103,11 +103,15 @@ const getToolsFromInstruction = instruction => [
const updateScenario = async scenario => {
try {
const instructionTools = getToolsFromInstruction(scenario.instruction);
const combinedTools = [
...new Set([...(scenario.tools || []), ...instructionTools]),
];
await store.dispatch('captainScenarios/update', {
id: scenario.id,
assistantId: assistantId.value,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
tools: combinedTools,
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.SUCCESS'));
} catch (error) {
@ -150,10 +154,14 @@ const bulkDeleteScenarios = async ids => {
const addScenario = async scenario => {
try {
const instructionTools = getToolsFromInstruction(scenario.instruction);
const combinedTools = [
...new Set([...(scenario.tools || []), ...instructionTools]),
];
await store.dispatch('captainScenarios/create', {
assistantId: assistantId.value,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
tools: combinedTools,
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS'));
} catch (error) {

View File

@ -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;

View File

@ -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

View File

@ -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'

View File

@ -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.

View File

@ -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

View File

@ -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:

20
create_admin.rb Normal file
View File

@ -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."

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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(&)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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,24 +90,58 @@ 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)
# Fallback for backwards compatibility
{
'response' => output.to_s,
'reasoning' => 'Processed by agent'
# 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
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)
{
'response' => 'conversation_handoff',
@ -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)

View File

@ -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

View File

@ -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?

View File

@ -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 = {})

View File

@ -15,6 +15,11 @@ module Captain
'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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,12 @@
=> #<Account:0x0000ffff5f679468
id: 1,
name: "Acme Inc",
created_at: Tue, 06 Jan 2026 22:14:10.147780000 -03 -03:00,
updated_at: Tue, 06 Jan 2026 22:14:10.147780000 -03 -03:00,
locale: "en",
domain: nil,
support_email: nil,
feature_flags: 288235736303927183,
auto_resolve_duration: nil,
limits: {},
custom_attributes: {},

View File

@ -0,0 +1,16 @@
=> #<Account:0x0000ffff5f679468
id: 1,
name: "Acme Inc",
created_at: Tue, 06 Jan 2026 22:14:10.147780000 -03 -03:00,
updated_at: Tue, 06 Jan 2026 22:14:10.147780000 -03 -03:00,
locale: "en",
domain: nil,
support_email: nil,
feature_flags: 288235736303927183,
auto_resolve_duration: nil,
limits: {},
custom_attributes: {},
status: "active",
internal_attributes: {},
settings: {}>

44
interactive_jasmine.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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?"

View File

@ -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: `#<RubyLLM::Message:0x...>`) 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 `#<RubyLLM::Message...>`.
5. Se houver erro, aparecerá em `error: ...` no log do `Agent result`.

View File

@ -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`)

View File

@ -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).

132
seed_jasmine_hotel.rb Normal file
View File

@ -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!"

131
seed_jasmine_hotel_v2.rb Normal file
View File

@ -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!"

17
test_jasmine_final.rb Normal file
View File

@ -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!")

34
test_jasmine_routing.rb Normal file
View File

@ -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

22
test_multi_agent_flow.rb Normal file
View File

@ -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")

18
test_yaml.rb Normal file
View File

@ -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