feat: fix custom tool headers auth and add test endpoint
This commit is contained in:
parent
3c3ba175c5
commit
0b77706caa
@ -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">
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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' }) }}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
@ -233,7 +241,7 @@ onMounted(() => {
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11 font-medium mb-1">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
|
||||
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
|
||||
{{ item.tools?.map(tool => `@${tool}`).join(', ')}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
20
create_admin.rb
Normal 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."
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(&)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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 = {})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
61
enterprise/app/services/captain/tools/status_suites_tool.rb
Normal file
61
enterprise/app/services/captain/tools/status_suites_tool.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
46
enterprise/lib/captain/tools/scenario_delegator_tool.rb
Normal file
46
enterprise/lib/captain/tools/scenario_delegator_tool.rb
Normal 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
|
||||
12
er.find_by(email: 'rodrigobm10@gmail.com')
Normal file
12
er.find_by(email: 'rodrigobm10@gmail.com')
Normal 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: {},
|
||||
@ -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
44
interactive_jasmine.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
40
progresso/2026-01-06_fix_date_context_captain.md
Normal file
40
progresso/2026-01-06_fix_date_context_captain.md
Normal 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).
|
||||
88
progresso/2026-01-10_react_to_message_tool.md
Normal file
88
progresso/2026-01-10_react_to_message_tool.md
Normal 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
|
||||
109
progresso/arquitetura_captain_v2.md
Normal file
109
progresso/arquitetura_captain_v2.md
Normal 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?"
|
||||
109
progresso/correcao_delegacao_captain_scenarios.md
Normal file
109
progresso/correcao_delegacao_captain_scenarios.md
Normal 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`.
|
||||
135
progresso/fix_captain_agent_response.md
Normal file
135
progresso/fix_captain_agent_response.md
Normal 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`)
|
||||
68
progresso/plano_evolucao_capitao_v2.md
Normal file
68
progresso/plano_evolucao_capitao_v2.md
Normal 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
132
seed_jasmine_hotel.rb
Normal 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
131
seed_jasmine_hotel_v2.rb
Normal 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
17
test_jasmine_final.rb
Normal 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
34
test_jasmine_routing.rb
Normal 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
22
test_multi_agent_flow.rb
Normal 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
18
test_yaml.rb
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user