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