From 2672d2113655da55d311dd5197acb26f1c34544f Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Sun, 25 Jan 2026 09:26:30 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Implementa=20e=20aprimora=20funcionalid?= =?UTF-8?q?ades=20relacionadas=20a=20Captain=20e=20Jasmine,=20incluindo=20?= =?UTF-8?q?ferramentas,=20servi=C3=A7os=20LLM,=20integra=C3=A7=C3=B5es=20W?= =?UTF-8?q?hatsApp=20e=20ajustes=20de=20configura=C3=A7=C3=A3o.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .rubocop.yml | 8 + .rubocop_todo.yml | 331 +++++++ .../v1/accounts/inboxes/wuzapi_controller.rb | 30 +- .../integrations/llm_models_controller.rb | 2 +- .../jasmine/collections_controller.rb | 79 +- .../accounts/jasmine/documents_controller.rb | 64 +- .../jasmine/inbox_collections_controller.rb | 64 +- .../jasmine/inbox_configs_controller.rb | 52 +- .../api/v1/captain/payments_controller.rb | 30 +- .../webhooks/whatsapp_controller.rb | 239 +++-- app/controllers/webhooks/wuzapi_controller.rb | 2 +- app/jobs/conversations/auto_label_job.rb | 232 +++-- app/jobs/conversations/cluster_job.rb | 130 ++- app/jobs/crm_insights/update_job.rb | 22 +- app/jobs/jasmine/response_job.rb | 96 +- app/models/captain/pricing.rb | 10 +- app/models/captain/reservation.rb | 60 +- app/models/captain/unit.rb | 28 +- app/models/channel/whatsapp.rb | 8 +- app/models/jasmine/collection.rb | 34 +- app/models/jasmine/document.rb | 39 +- app/models/jasmine/inbox_collection.rb | 46 +- app/models/jasmine/inbox_config.rb | 25 +- app/models/jasmine/tool_config.rb | 66 +- app/services/captain/assistant.rb | 7 +- app/services/captain/inter_service.rb | 204 +++-- .../captain/reservations/sync_service.rb | 314 ++++--- .../crm_insights/contact_session_counter.rb | 44 +- app/services/crm_insights/generate_service.rb | 282 +++--- app/services/crm_insights/update_service.rb | 628 +++++++------ app/services/jasmine/brain_service.rb | 526 ++++++----- app/services/jasmine/embedding_service.rb | 217 +++-- .../jasmine/media_analyzer_service.rb | 70 +- .../jasmine/semantic_search_service.rb | 288 +++--- app/services/jasmine/tool_runner.rb | 179 ++-- app/services/jasmine/vision_service.rb | 104 ++- app/services/llm/base_ai_service.rb | 8 +- .../whatsapp/baileys_handlers/helpers.rb | 8 +- .../baileys_handlers/messages_upsert.rb | 2 +- app/services/whatsapp/decryption_service.rb | 220 +++-- .../incoming_message_service_helpers.rb | 2 +- .../incoming_message_wuzapi_service.rb | 422 +++++---- .../providers/wuzapi/payload_parser.rb | 336 ++++--- .../whatsapp/providers/wuzapi_service.rb | 330 ++++--- .../zapi_handlers/received_callback.rb | 2 +- app/services/wuzapi/provisioning_service.rb | 55 +- .../initializers/active_record_encryption.rb | 6 +- config/initializers/ai_agents.rb | 2 +- config/initializers/ruby_llm.rb | 2 +- configure_captain_webhook.rb | 0 .../20251227054034_create_jasmine_tables.rb | 6 +- ..._brain_fields_to_jasmine_inbox_settings.rb | 1 - ...51227145727_create_jasmine_tool_configs.rb | 4 +- ...0260110193000_fix_status_suites_headers.rb | 12 +- enable_features.rb | 14 +- .../v1/accounts/captain/brands_controller.rb | 84 +- .../captain/configurations_controller.rb | 50 +- .../v1/accounts/captain/extras_controller.rb | 84 +- .../accounts/captain/pricings_controller.rb | 100 +-- .../v1/accounts/captain/units_controller.rb | 88 +- .../captain/reminder_settings_controller.rb | 78 +- .../api/v1/captain/booking_app_controller.rb | 18 +- .../api/v1/captain/master_data_controller.rb | 48 +- .../api/v1/captain/reservations_controller.rb | 310 ++++--- .../api/v1/captain/webhooks_controller.rb | 74 +- enterprise/app/helpers/captain/chat_helper.rb | 3 +- .../conversation/response_builder_job.rb | 8 +- .../jobs/captain/intent_classification_job.rb | 130 ++- enterprise/app/models/captain/assistant.rb | 2 +- .../app/models/captain/configuration.rb | 12 +- enterprise/app/models/captain/reminder.rb | 30 +- enterprise/app/models/captain/reservation.rb | 58 +- enterprise/app/models/captain/suite.rb | 12 +- enterprise/app/models/captain/tool_config.rb | 22 +- .../captain/assistant/agent_runner_service.rb | 10 +- .../captain/handoff_webhook_service.rb | 174 ++-- .../services/captain/inter/auth_service.rb | 82 +- .../app/services/captain/inter/cob_service.rb | 134 ++- .../captain/llm/assistant_chat_service.rb | 4 +- .../captain/llm/contact_identity_service.rb | 2 +- .../app/services/captain/llm/jasmine_brain.rb | 678 +++++++------- .../captain/llm/system_prompts_service.rb | 6 +- .../captain/reminders/create_service.rb | 4 +- .../services/captain/reminders/processor.rb | 6 +- .../captain/reservations/create_service.rb | 8 +- .../app/services/captain/tools/base_tool.rb | 2 +- .../captain/tools/check_availability_tool.rb | 840 +++++++++--------- .../tools/create_reservation_intent_tool.rb | 683 +++++++------- .../app/services/captain/tools/definitions.rb | 110 ++- .../captain/tools/generate_pix_tool.rb | 268 +++--- .../tools/parsers/status_suites_parser.rb | 48 +- .../captain/tools/react_to_message_tool.rb | 138 ++- .../services/captain/tools/reminder_tool.rb | 92 +- .../tools/search_documentation_service.rb | 2 +- .../captain/tools/status_suites_tool.rb | 98 +- .../captain/tools/suite_watchdog_tool.rb | 92 +- .../app/services/captain/tools/tool_runner.rb | 512 ++++++----- .../captain/tools/update_contact_tool.rb | 167 ++-- .../captain/webhook_sender_service.rb | 110 ++- .../captain/whatsapp_notification_service.rb | 166 ++-- .../hook_execution_service.rb | 2 +- .../app/services/llm/base_ai_service.rb | 3 +- .../app/services/llm/model_test_service.rb | 2 +- .../lib/captain/tools/faq_lookup_tool.rb | 26 +- .../captain/tools/list_reservations_tool.rb | 2 +- .../captain/tools/scenario_delegator_tool.rb | 666 +++++++------- interactive_jasmine.rb | 24 +- lib/tasks/captain_tool_keys.rake | 4 +- lib/wuzapi/client.rb | 330 ++++--- local_test_ai.rb | 4 +- local_test_wuzapi.rb | 22 +- promote_super_admin.rb | 6 +- replay_job.rb | 2 +- reproduction_search.rb | 4 +- script/generate_test_questions.rb | 2 +- script/test_auto_resolve_inbox.rb | 2 +- scripts/check_pending_embeddings.rb | 2 +- scripts/test_real_pix_conversational.rb | 6 +- scripts/test_whatsapp_notification.rb | 2 +- seed_captain_tools.rb | 6 +- seed_jasmine_hotel.rb | 26 +- seed_jasmine_hotel_v2.rb | 28 +- setup_docker_env.rb | 2 +- 123 files changed, 6435 insertions(+), 6377 deletions(-) create mode 100644 .rubocop_todo.yml mode change 100644 => 100755 configure_captain_webhook.rb diff --git a/.rubocop.yml b/.rubocop.yml index d87f08b..f197caf 100755 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,3 +1,9 @@ +inherit_from: .rubocop_todo.yml + +inherit_mode: + merge: + - Exclude + plugins: - rubocop-performance - rubocop-rails @@ -23,6 +29,7 @@ Metrics/MethodLength: Max: 19 Exclude: - 'enterprise/lib/captain/agent.rb' + - 'enterprise/app/services/captain/llm/system_prompts_service.rb' RSpec/ExampleLength: Max: 50 @@ -230,6 +237,7 @@ AllCops: - 'tmp/**/*' - 'storage/**/*' - 'db/migrate/20230426130150_init_schema.rb' + - 'reference_chatwoot/**/*' FactoryBot/SyntaxMethods: Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..60c0d16 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,331 @@ +# This configuration was generated by +# `rubocop --auto-gen-config --exclude-limit 10000` +# on 2026-01-25 12:20:43 UTC using RuboCop version 1.75.6. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 2 +Chatwoot/AttachmentDownload: + Exclude: + - 'app/services/whatsapp/providers/wuzapi_service.rb' + - 'debug_media.rb' + +# Offense count: 32 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. +# URISchemes: http, https +Layout/LineLength: + Exclude: + - 'app/jobs/conversations/cluster_job.rb' + - 'app/models/channel/whatsapp.rb' + - 'app/services/captain/reservations/sync_service.rb' + - 'app/services/whatsapp/providers/wuzapi_service.rb' + - 'enterprise/app/jobs/captain/conversation/debounce_response_job.rb' + - 'enterprise/app/services/captain/llm/system_prompts_service.rb' + - 'enterprise/app/services/captain/tools/check_availability_tool.rb' + - 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb' + - 'enterprise/app/services/captain/tools/generate_pix_tool.rb' + - 'enterprise/app/services/captain/tools/status_suites_tool.rb' + - 'enterprise/app/services/enterprise/message_templates/hook_execution_service.rb' + - 'enterprise/lib/captain/tools/scenario_delegator_tool.rb' + - 'lib/wuzapi/client.rb' + - 'scripts/debug_faq_search.rb' + - 'spec/factories/contacts.rb' + +# Offense count: 4 +# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch. +Lint/DuplicateBranch: + Exclude: + - 'app/services/whatsapp/providers/wuzapi/payload_parser.rb' + - 'app/services/whatsapp/providers/wuzapi_service.rb' + - 'enterprise/app/services/captain/assistant/agent_runner_service.rb' + +# Offense count: 2 +Lint/DuplicateMethods: + Exclude: + - 'app/services/jasmine/semantic_search_service.rb' + +# Offense count: 1 +# Configuration parameters: MaximumRangeSize. +Lint/MissingCopEnableDirective: + Exclude: + - 'db/migrate/20260114100000_create_captain_inbox_automations.rb' + +# Offense count: 1 +Lint/NonLocalExitFromIterator: + Exclude: + - 'app/services/whatsapp/incoming_message_service_helpers.rb' + +# Offense count: 1 +Lint/ShadowedException: + Exclude: + - 'enterprise/app/services/captain/llm/paginated_faq_generator_service.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AutoCorrect, AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. +# NotImplementedExceptions: NotImplementedError +Lint/UnusedMethodArgument: + Exclude: + - 'enterprise/app/services/llm/base_ai_service.rb' + +# Offense count: 63 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max. +Metrics/AbcSize: + Exclude: + - 'app/jobs/conversations/auto_label_job.rb' + - 'app/jobs/conversations/resolution_job.rb' + - 'app/jobs/webhooks/whatsapp_events_job.rb' + - 'app/listeners/jasmine_listener.rb' + - 'app/models/channel/whatsapp.rb' + - 'app/models/message.rb' + - 'app/services/captain/reservations/sync_service.rb' + - 'app/services/crm_insights/update_service.rb' + - 'app/services/jasmine/brain_service.rb' + - 'app/services/jasmine/semantic_search_service.rb' + - 'app/services/jasmine/tool_runner.rb' + - 'app/services/whatsapp/decryption_service.rb' + - 'app/services/whatsapp/incoming_message_wuzapi_service.rb' + - 'app/services/whatsapp/providers/wuzapi/payload_parser.rb' + - 'app/services/whatsapp/providers/wuzapi_service.rb' + - 'config/initializers/fix_null_message_crash.rb' + - 'db/migrate/20251227054034_create_jasmine_tables.rb' + - 'db/migrate/20260110193000_fix_status_suites_headers.rb' + - 'debug_token.rb' + - 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb' + - 'enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb' + - 'enterprise/app/controllers/api/v1/accounts/captain/tools_controller.rb' + - 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb' + - 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb' + - 'enterprise/app/jobs/captain/conversation/response_builder_job.rb' + - 'enterprise/app/models/captain/assistant.rb' + - 'enterprise/app/services/captain/assistant/agent_runner_service.rb' + - 'enterprise/app/services/captain/inter/auth_service.rb' + - 'enterprise/app/services/captain/inter/cob_service.rb' + - 'enterprise/app/services/captain/llm/assistant_chat_service.rb' + - 'enterprise/app/services/captain/llm/jasmine_brain.rb' + - 'enterprise/app/services/captain/reminders/create_service.rb' + - 'enterprise/app/services/captain/reservations/create_service.rb' + - 'enterprise/app/services/captain/tools/check_availability_tool.rb' + - 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb' + - 'enterprise/app/services/captain/tools/generate_pix_tool.rb' + - 'enterprise/app/services/captain/tools/react_to_message_tool.rb' + - 'enterprise/app/services/captain/tools/status_suites_tool.rb' + - 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb' + - 'enterprise/app/services/captain/tools/update_contact_tool.rb' + - 'enterprise/lib/captain/tools/faq_lookup_tool.rb' + - 'enterprise/lib/captain/tools/http_tool.rb' + - 'enterprise/lib/captain/tools/scenario_delegator_tool.rb' + - 'lib/wuzapi/client.rb' + +# Offense count: 6 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. +# AllowedMethods: refine +Metrics/BlockLength: + Exclude: + - 'db/migrate/20260110193000_fix_status_suites_headers.rb' + - 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb' + - 'enterprise/app/models/concerns/captain_tools_helpers.rb' + - 'script/test_auto_resolve_inbox.rb' + - 'seed_captain_tools.rb' + - 'seed_jasmine_hotel_v2.rb' + +# Offense count: 11 +# Configuration parameters: CountComments, Max, CountAsOne. +Metrics/ClassLength: + Exclude: + - 'app/models/channel/whatsapp.rb' + - 'app/services/crm_insights/update_service.rb' + - 'app/services/whatsapp/incoming_message_wuzapi_service.rb' + - 'enterprise/app/jobs/captain/conversation/response_builder_job.rb' + - 'enterprise/app/services/captain/assistant/agent_runner_service.rb' + - 'enterprise/app/services/captain/llm/assistant_chat_service.rb' + - 'enterprise/app/services/captain/llm/jasmine_brain.rb' + - 'enterprise/app/services/captain/tools/check_availability_tool.rb' + - 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb' + - 'enterprise/app/services/captain/tools/tool_runner.rb' + - 'enterprise/lib/captain/tools/scenario_delegator_tool.rb' + +# Offense count: 67 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 39 + +# Offense count: 78 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Exclude: + - 'app/jobs/conversations/auto_label_job.rb' + - 'app/jobs/conversations/cluster_job.rb' + - 'app/jobs/jasmine/response_job.rb' + - 'app/listeners/jasmine_listener.rb' + - 'app/models/channel/whatsapp.rb' + - 'app/services/captain/inter_service.rb' + - 'app/services/captain/reservations/sync_service.rb' + - 'app/services/crm_insights/update_service.rb' + - 'app/services/jasmine/brain_service.rb' + - 'app/services/jasmine/embedding_service.rb' + - 'app/services/jasmine/semantic_search_service.rb' + - 'app/services/jasmine/tool_runner.rb' + - 'app/services/jasmine/vision_service.rb' + - 'app/services/whatsapp/decryption_service.rb' + - 'app/services/whatsapp/incoming_message_service_helpers.rb' + - 'app/services/whatsapp/incoming_message_wuzapi_service.rb' + - 'app/services/whatsapp/providers/wuzapi/payload_parser.rb' + - 'app/services/whatsapp/providers/wuzapi_service.rb' + - 'db/migrate/20251227054034_create_jasmine_tables.rb' + - 'db/migrate/20260104150000_add_crm_insights_history_fields.rb' + - 'db/migrate/20260110193000_fix_status_suites_headers.rb' + - 'db/migrate/20260114090200_create_captain_reminders.rb' + - 'debug_token.rb' + - 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb' + - 'enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb' + - 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb' + - 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb' + - 'enterprise/app/jobs/captain/conversation/response_builder_job.rb' + - 'enterprise/app/jobs/captain/intent_classification_job.rb' + - 'enterprise/app/services/captain/assistant/agent_runner_service.rb' + - 'enterprise/app/services/captain/llm/assistant_chat_service.rb' + - 'enterprise/app/services/captain/llm/jasmine_brain.rb' + - 'enterprise/app/services/captain/llm/system_prompts_service.rb' + - 'enterprise/app/services/captain/reminders/create_service.rb' + - 'enterprise/app/services/captain/reservations/create_service.rb' + - 'enterprise/app/services/captain/tools/check_availability_tool.rb' + - 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb' + - 'enterprise/app/services/captain/tools/generate_pix_tool.rb' + - 'enterprise/app/services/captain/tools/react_to_message_tool.rb' + - 'enterprise/app/services/captain/tools/reminder_tool.rb' + - 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb' + - 'enterprise/app/services/captain/tools/update_contact_tool.rb' + - 'enterprise/app/services/captain/webhook_sender_service.rb' + - 'enterprise/app/services/captain/whatsapp_notification_service.rb' + - 'enterprise/lib/captain/tools/faq_lookup_tool.rb' + - 'enterprise/lib/captain/tools/scenario_delegator_tool.rb' + - 'lib/wuzapi/client.rb' + - 'scripts/force_create_captain_tables.rb' + +# Offense count: 2 +# Configuration parameters: CountComments, CountAsOne. +Metrics/ModuleLength: + Max: 132 + +# Offense count: 4 +# Configuration parameters: CountKeywordArgs, MaxOptionalParameters. +Metrics/ParameterLists: + Max: 7 + +# Offense count: 46 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/PerceivedComplexity: + Max: 40 + +# Offense count: 2 +Naming/AccessorMethodName: + Exclude: + - 'app/services/captain/inter_service.rb' + - 'app/services/jasmine/vision_service.rb' + +# Offense count: 7 +# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. +# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to +Naming/MethodParameterName: + Exclude: + - 'app/services/whatsapp/decryption_service.rb' + - 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb' + - 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb' + +# Offense count: 2 +# Configuration parameters: MinSize. +Performance/CollectionLiteralInLoop: + Exclude: + - 'db/migrate/20260110193000_fix_status_suites_headers.rb' + +# Offense count: 1 +Rails/AfterCommitOverride: + Exclude: + - 'app/models/channel/whatsapp.rb' + +# Offense count: 5 +# Configuration parameters: Include. +# Include: **/app/models/**/*.rb +Rails/HasManyOrHasOneDependent: + Exclude: + - 'enterprise/app/models/captain/brand.rb' + - 'enterprise/app/models/captain/unit.rb' + +# Offense count: 17 +# Configuration parameters: IgnoreScopes, Include. +# Include: **/app/models/**/*.rb +Rails/InverseOf: + Exclude: + - 'app/models/captain/unit.rb' + - 'app/models/captain_assistant.rb' + - 'enterprise/app/models/captain/brand.rb' + - 'enterprise/app/models/captain/pricing.rb' + - 'enterprise/app/models/captain/pricing_inbox.rb' + - 'enterprise/app/models/captain/reservation.rb' + - 'enterprise/app/models/captain/unit.rb' + - 'enterprise/app/models/captain_inbox.rb' + +# Offense count: 9 +# Configuration parameters: ForbiddenMethods, AllowedMethods. +# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all +Rails/SkipsModelValidations: + Exclude: + - 'app/services/jasmine/tool_runner.rb' + - 'enterprise/app/jobs/captain/documents/response_builder_job.rb' + - 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb' + - 'enterprise/app/services/captain/tools/generate_pix_tool.rb' + - 'promote_super_admin.rb' + - 'script/test_auto_resolve_inbox.rb' + +# Offense count: 8 +# Configuration parameters: Include. +# Include: db/**/*.rb +Rails/ThreeStateBooleanColumn: + Exclude: + - 'db/migrate/20240523120000_create_captain_configurations.rb' + - 'db/migrate/20251227054034_create_jasmine_tables.rb' + - 'db/migrate/20251227201733_create_captain_tool_configs.rb' + - 'db/migrate/20260114101004_create_captain_extras.rb' + - 'db/migrate/20260120141736_add_message_signature_enabled_to_inboxes.rb' + - 'db/migrate/20260121165034_add_plug_play_to_captain_units.rb' + +# Offense count: 2 +# Configuration parameters: TransactionMethods. +Rails/TransactionExitStatement: + Exclude: + - 'app/services/jasmine/embedding_service.rb' + - 'app/services/whatsapp/incoming_message_wuzapi_service.rb' + +# Offense count: 1 +# Configuration parameters: Include. +# Include: **/app/models/**/*.rb +Rails/UniqueValidationWithoutIndex: + Exclude: + - 'app/models/jasmine/inbox_collection.rb' + +# Offense count: 3 +# Configuration parameters: MinBranchesCount. +Style/HashLikeCase: + Exclude: + - 'app/services/whatsapp/providers/wuzapi/payload_parser.rb' + - 'enterprise/app/jobs/captain/conversation/response_builder_job.rb' + +# Offense count: 4 +Style/OneClassPerFile: + Exclude: + - 'app/services/jasmine/brain_service.rb' + +# Offense count: 8 +UseFromEmail: + Exclude: + - 'app/services/captain/reservations/sync_service.rb' + - 'clear_chat_history.rb' + - 'debug_auth.rb' + - 'debug_token.rb' + - 'force_reset.rb' + - 'promote_super_admin.rb' + - 'setup_docker_env.rb' diff --git a/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb b/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb index ec7272b..0007f12 100644 --- a/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb @@ -26,18 +26,10 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont status = status_data['status'] || status_data['state'] || status_data Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}" - if %w[CONNECTED inChat success].include?(status) - Rails.logger.info 'Wuzapi is already connected. Skipping QR.' - render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' } - return - end + return if already_connected?(status) qr_data = client.get_qr_code(user_token) - Rails.logger.info "Wuzapi QR Data Response keys: #{begin - qr_data.keys - rescue StandardError - 'nil' - end}" + log_qr_data_keys(qr_data) render json: qr_data rescue Wuzapi::Client::Error => e Rails.logger.error "Wuzapi QR Error: #{e.message}" @@ -118,8 +110,24 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}" raise 'Token Wuzapi ausente; reprovisionar usuário' else - Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..-1]}" + Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..]}" end token end + + def already_connected?(status) + if %w[CONNECTED inChat success].include?(status) + Rails.logger.info 'Wuzapi is already connected. Skipping QR.' + render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' } + true + else + false + end + end + + def log_qr_data_keys(qr_data) + Rails.logger.info "Wuzapi QR Data Response keys: #{qr_data.keys}" + rescue StandardError + Rails.logger.info 'Wuzapi QR Data Response keys: nil' + end end diff --git a/app/controllers/api/v1/accounts/integrations/llm_models_controller.rb b/app/controllers/api/v1/accounts/integrations/llm_models_controller.rb index 1245c4e..eb8a27f 100644 --- a/app/controllers/api/v1/accounts/integrations/llm_models_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/llm_models_controller.rb @@ -54,6 +54,6 @@ class Api::V1::Accounts::Integrations::LlmModelsController < Api::V1::Accounts:: Rails.logger.error( "[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}" ) - hook.update_columns(settings: settings) + hook.update_columns(settings: settings) # rubocop:disable Rails/SkipsModelValidations end end diff --git a/app/controllers/api/v1/accounts/jasmine/collections_controller.rb b/app/controllers/api/v1/accounts/jasmine/collections_controller.rb index ac8975c..e9c876f 100644 --- a/app/controllers/api/v1/accounts/jasmine/collections_controller.rb +++ b/app/controllers/api/v1/accounts/jasmine/collections_controller.rb @@ -1,51 +1,42 @@ -module Api - module V1 - module Accounts - module Jasmine - class CollectionsController < Api::V1::Accounts::BaseController - before_action :find_collection, only: [:destroy] +class Api::V1::Accounts::Jasmine::CollectionsController < Api::V1::Accounts::BaseController + before_action :find_collection, only: [:destroy] - def index - scope = Current.account.jasmine_collections - scope = scope.where(visibility: params[:visibility]) if params[:visibility] - render json: scope - end + def index + scope = Current.account.jasmine_collections + scope = scope.where(visibility: params[:visibility]) if params[:visibility] + render json: scope + end - def create - @collection = Current.account.jasmine_collections.new(collection_params) + def create + @collection = Current.account.jasmine_collections.new(collection_params) - if @collection.save - # Auto-link to inbox if owner_inbox_id provided - if @collection.owner_inbox_id - inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id) - inbox&.inbox_collections&.create(collection: @collection, priority: 10) - end - render json: @collection - else - render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end - - def destroy - if @collection.destroy - head :no_content - else - render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end - - private - - def find_collection - @collection = Current.account.jasmine_collections.find(params[:id]) - end - - def collection_params - params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id) - end - end + if @collection.save + # Auto-link to inbox if owner_inbox_id provided + if @collection.owner_inbox_id + inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id) + inbox&.inbox_collections&.create(collection: @collection, priority: 10) end + render json: @collection + else + render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity end end -end + def destroy + if @collection.destroy + head :no_content + else + render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + + private + + def find_collection + @collection = Current.account.jasmine_collections.find(params[:id]) + end + + def collection_params + params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id) + end +end diff --git a/app/controllers/api/v1/accounts/jasmine/documents_controller.rb b/app/controllers/api/v1/accounts/jasmine/documents_controller.rb index c2f887b..32f52d9 100644 --- a/app/controllers/api/v1/accounts/jasmine/documents_controller.rb +++ b/app/controllers/api/v1/accounts/jasmine/documents_controller.rb @@ -1,42 +1,34 @@ -module Api - module V1 - module Accounts - module Jasmine - class DocumentsController < Api::V1::Accounts::BaseController - before_action :fetch_collection - - def index - render json: @collection.documents.order(created_at: :desc) - end +class Api::V1::Accounts::Jasmine::DocumentsController < Api::V1::Accounts::BaseController + before_action :fetch_collection - def create - @document = @collection.documents.new(document_params) - @document.account = Current.account - - if @document.save - render json: @document - else - render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end + def index + render json: @collection.documents.order(created_at: :desc) + end - def destroy - @document = @collection.documents.find(params[:id]) - @document.destroy! - head :no_content - end + def create + @document = @collection.documents.new(document_params) + @document.account = Current.account - private - - def fetch_collection - @collection = Current.account.jasmine_collections.find(params[:collection_id]) - end - - def document_params - params.require(:document).permit(:title, :content) - end - end - end + if @document.save + render json: @document + else + render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity end end + + def destroy + @document = @collection.documents.find(params[:id]) + @document.destroy! + head :no_content + end + + private + + def fetch_collection + @collection = Current.account.jasmine_collections.find(params[:collection_id]) + end + + def document_params + params.require(:document).permit(:title, :content) + end end diff --git a/app/controllers/api/v1/accounts/jasmine/inbox_collections_controller.rb b/app/controllers/api/v1/accounts/jasmine/inbox_collections_controller.rb index 377a430..82c1176 100644 --- a/app/controllers/api/v1/accounts/jasmine/inbox_collections_controller.rb +++ b/app/controllers/api/v1/accounts/jasmine/inbox_collections_controller.rb @@ -1,43 +1,35 @@ -module Api - module V1 - module Accounts - module Jasmine - class InboxCollectionsController < Api::V1::Accounts::BaseController - before_action :fetch_inbox +class Api::V1::Accounts::Jasmine::InboxCollectionsController < Api::V1::Accounts::BaseController + before_action :fetch_inbox - def index - render json: @inbox.inbox_collections.includes(:collection) - end + def index + render json: @inbox.inbox_collections.includes(:collection) + end - def create - collection = Current.account.jasmine_collections.find(params[:collection_id]) - - link = @inbox.inbox_collections.new( - collection: collection, - account: Current.account, - priority: params[:priority] || 0 - ) + def create + collection = Current.account.jasmine_collections.find(params[:collection_id]) - if link.save - render json: link - else - render json: { error: link.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end + link = @inbox.inbox_collections.new( + collection: collection, + account: Current.account, + priority: params[:priority] || 0 + ) - def destroy - link = @inbox.inbox_collections.find_by!(collection_id: params[:collection_id]) - link.destroy - head :no_content - end - - private - - def fetch_inbox - @inbox = Current.account.inboxes.find(params[:inbox_id]) - end - end - end + if link.save + render json: link + else + render json: { error: link.errors.full_messages.join(', ') }, status: :unprocessable_entity end end + + def destroy + link = @inbox.inbox_collections.find_by!(collection_id: params[:collection_id]) + link.destroy + head :no_content + end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + end end diff --git a/app/controllers/api/v1/accounts/jasmine/inbox_configs_controller.rb b/app/controllers/api/v1/accounts/jasmine/inbox_configs_controller.rb index 520b3ab..fcd0247 100644 --- a/app/controllers/api/v1/accounts/jasmine/inbox_configs_controller.rb +++ b/app/controllers/api/v1/accounts/jasmine/inbox_configs_controller.rb @@ -1,36 +1,28 @@ -module Api - module V1 - module Accounts - module Jasmine - class InboxConfigsController < Api::V1::Accounts::BaseController - before_action :fetch_inbox +class Api::V1::Accounts::Jasmine::InboxConfigsController < Api::V1::Accounts::BaseController + before_action :fetch_inbox - def show - config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) - render json: config - end + def show + config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) + render json: config + end - def update - config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) - - if config.update(config_params) - render json: config - else - render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end + def update + config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) - private - - def fetch_inbox - @inbox = Current.account.inboxes.find(params[:inbox_id]) - end - - def config_params - params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode) - end - end - end + if config.update(config_params) + render json: config + else + render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity end end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + end + + def config_params + params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode) + end end diff --git a/app/controllers/public/api/v1/captain/payments_controller.rb b/app/controllers/public/api/v1/captain/payments_controller.rb index 99a138a..b46985d 100644 --- a/app/controllers/public/api/v1/captain/payments_controller.rb +++ b/app/controllers/public/api/v1/captain/payments_controller.rb @@ -1,25 +1,17 @@ -module Public - module Api - module V1 - module Captain - class PaymentsController < ApplicationController - layout false - skip_before_action :authenticate_user!, raise: false - skip_before_action :check_current_user_is_active, raise: false +class Public::Api::V1::Captain::PaymentsController < ApplicationController + layout false + skip_before_action :authenticate_user!, raise: false + skip_before_action :check_current_user_is_active, raise: false - def show - @charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment) + def show + @charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment) - return unless @charge.nil? + return unless @charge.nil? - render plain: 'Link de pagamento inválido ou expirado.', status: :not_found - return + render plain: 'Link de pagamento inválido ou expirado.', status: :not_found + return - # @charge is available for the view - # It should be a Captain::PixCharge model - end - end - end - end + # @charge is available for the view + # It should be a Captain::PixCharge model end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index 1ec8514..7ac46b8 100755 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -37,9 +37,22 @@ class Webhooks::WhatsappController < ActionController::API # This prevents ANY binary data from leaking into JSON serialization def sanitize_payload_for_sidekiq raw = params.to_unsafe_hash + clean_payload = build_base_payload(raw) - # Build a completely NEW payload with only safe fields - clean_payload = { + clean_payload['event'] = build_clean_event(raw['event']) if raw['event'].is_a?(Hash) + + if raw['whatsapp'].is_a?(Hash) + clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge( + raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID') + ) + end + + Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)' + deep_force_utf8(clean_payload) + end + + def build_base_payload(raw) + { 'type' => raw['type'], 'state' => raw['state'], 'instanceName' => raw['instanceName'], @@ -48,142 +61,102 @@ class Webhooks::WhatsappController < ActionController::API 'action' => raw['action'], 'phone_number' => raw['phone_number'] } + end - # Only copy safe event fields - if raw['event'].is_a?(Hash) - clean_event = {} + def build_clean_event(raw_event) + clean_event = {} - # Info fields (all safe strings/ids) - if raw['event']['Info'].is_a?(Hash) - clean_event['Info'] = raw['event']['Info'].slice( - 'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup', - 'Timestamp', 'PushName', 'MessageSource' - ) - end - - # Safe event metadata - %w[Chat Sender IsFromMe IsGroup Timestamp AddressingMode BroadcastListOwner - BroadcastRecipients RecipientAlt SenderAlt MessageIDs MessageSender].each do |key| - clean_event[key] = raw['event'][key] if raw['event'].key?(key) - end - - # Message content - WHITELIST only safe fields - if raw['event']['Message'].is_a?(Hash) - msg = raw['event']['Message'] - clean_msg = {} - - # Text messages - clean_msg['conversation'] = msg['conversation'] if msg['conversation'].is_a?(String) - - if msg['extendedTextMessage'].is_a?(Hash) - clean_msg['extendedTextMessage'] = { - 'text' => msg['extendedTextMessage']['text'] - } - # Only copy contextInfo if it doesn't have quotedMessage with binaries - # Only copy contextInfo if it doesn't have quotedMessage with binaries - if msg['extendedTextMessage']['contextInfo'].is_a?(Hash) - ctx = msg['extendedTextMessage']['contextInfo'] - clean_msg['extendedTextMessage']['contextInfo'] = { - 'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'], - 'participant' => ctx['participant'] - }.compact - end - end - - # Image messages - ONLY safe metadata, NO binaries - if msg['imageMessage'].is_a?(Hash) - img = msg['imageMessage'] - clean_msg['imageMessage'] = { - 'URL' => img['URL'] || img['url'], - 'directPath' => img['directPath'], - 'mediaKey' => img['mediaKey'], - 'fileEncSha256' => img['fileEncSha256'] || img['fileEncSHA256'], - 'fileSha256' => img['fileSha256'] || img['fileSHA256'], - 'fileLength' => img['fileLength'], - 'mimetype' => img['mimetype'], - 'width' => img['width'], - 'height' => img['height'], - 'caption' => img['caption'], - 'contextInfo' => { - 'stanzaID' => img.dig('contextInfo', 'stanzaID') || img.dig('contextInfo', 'stanzaId'), - 'participant' => img.dig('contextInfo', 'participant') - }.compact - }.compact - # EXPLICITLY NO: JPEGThumbnail, scansSidecar, firstScanSidecar, etc - end - - # Video messages - ONLY safe metadata - if msg['videoMessage'].is_a?(Hash) - vid = msg['videoMessage'] - clean_msg['videoMessage'] = { - 'URL' => vid['URL'] || vid['url'], - 'directPath' => vid['directPath'], - 'mediaKey' => vid['mediaKey'], - 'fileEncSha256' => vid['fileEncSha256'] || vid['fileEncSHA256'], - 'fileSha256' => vid['fileSha256'] || vid['fileSHA256'], - 'fileLength' => vid['fileLength'], - 'mimetype' => vid['mimetype'], - 'seconds' => vid['seconds'], - 'caption' => vid['caption'], - 'contextInfo' => { - 'stanzaID' => vid.dig('contextInfo', 'stanzaID') || vid.dig('contextInfo', 'stanzaId'), - 'participant' => vid.dig('contextInfo', 'participant') - }.compact - }.compact - end - - # Audio messages - if msg['audioMessage'].is_a?(Hash) - aud = msg['audioMessage'] - clean_msg['audioMessage'] = { - 'URL' => aud['URL'] || aud['url'], - 'directPath' => aud['directPath'], - 'mediaKey' => aud['mediaKey'], - 'fileEncSha256' => aud['fileEncSha256'] || aud['fileEncSHA256'], - 'fileSha256' => aud['fileSha256'] || aud['fileSHA256'], - 'fileLength' => aud['fileLength'], - 'mimetype' => aud['mimetype'], - 'seconds' => aud['seconds'], - 'ptt' => aud['ptt'], - 'contextInfo' => { - 'stanzaID' => aud.dig('contextInfo', 'stanzaID') || aud.dig('contextInfo', 'stanzaId'), - 'participant' => aud.dig('contextInfo', 'participant') - }.compact - }.compact - end - - # Document messages - if msg['documentMessage'].is_a?(Hash) - doc = msg['documentMessage'] - clean_msg['documentMessage'] = { - 'URL' => doc['URL'] || doc['url'], - 'directPath' => doc['directPath'], - 'mediaKey' => doc['mediaKey'], - 'fileEncSha256' => doc['fileEncSha256'] || doc['fileEncSHA256'], - 'fileSha256' => doc['fileSha256'] || doc['fileSHA256'], - 'fileLength' => doc['fileLength'], - 'mimetype' => doc['mimetype'], - 'fileName' => doc['fileName'], - 'title' => doc['title'] - }.compact - end - - clean_event['Message'] = clean_msg unless clean_msg.empty? - end - - clean_payload['event'] = clean_event - end - - # Also copy whatsapp key if present (but sanitize it too) - if raw['whatsapp'].is_a?(Hash) - # Just reference the same clean event structure - clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge( - raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID') + if raw_event['Info'].is_a?(Hash) + clean_event['Info'] = raw_event['Info'].slice( + 'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup', + 'Timestamp', 'PushName', 'MessageSource' ) end - Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)' - deep_force_utf8(clean_payload) + # Safe event metadata + %w[Chat Sender IsFromMe IsGroup Timestamp AddressingMode BroadcastListOwner + BroadcastRecipients RecipientAlt SenderAlt MessageIDs MessageSender].each do |key| + clean_event[key] = raw_event[key] if raw_event.key?(key) + end + + if raw_event['Message'].is_a?(Hash) + clean_msg = build_clean_message(raw_event['Message']) + clean_event['Message'] = clean_msg unless clean_msg.empty? + end + + clean_event + end + + def build_clean_message(msg) + clean_msg = {} + clean_msg['conversation'] = msg['conversation'] if msg['conversation'].is_a?(String) + + clean_msg.merge!(clean_extended_text_message(msg['extendedTextMessage'])) + clean_msg.merge!(clean_media_message(msg, 'imageMessage')) + clean_msg.merge!(clean_media_message(msg, 'videoMessage')) + clean_msg.merge!(clean_media_message(msg, 'audioMessage')) + clean_msg.merge!(clean_document_message(msg['documentMessage'])) + + clean_msg + end + + def clean_extended_text_message(ext_msg) + return {} unless ext_msg.is_a?(Hash) + + result = { 'extendedTextMessage' => { 'text' => ext_msg['text'] } } + + result['extendedTextMessage']['contextInfo'] = clean_context_info(ext_msg['contextInfo']) if ext_msg['contextInfo'].is_a?(Hash) + + result + end + + def clean_context_info(ctx) + { + 'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'], + 'participant' => ctx['participant'] + }.compact + end + + def clean_media_message(msg, type) + media = msg[type] + return {} unless media.is_a?(Hash) + + clean_data = { + 'URL' => media['URL'] || media['url'], + 'directPath' => media['directPath'], + 'mediaKey' => media['mediaKey'], + 'fileEncSha256' => media['fileEncSha256'] || media['fileEncSHA256'], + 'fileSha256' => media['fileSha256'] || media['fileSHA256'], + 'fileLength' => media['fileLength'], + 'mimetype' => media['mimetype'], + 'seconds' => media['seconds'], + 'caption' => media['caption'], + 'ptt' => media['ptt'], + 'width' => media['width'], + 'height' => media['height'] + } + + clean_data['contextInfo'] = clean_context_info(media['contextInfo']) if media['contextInfo'].is_a?(Hash) + + { type => clean_data.compact } + end + + def clean_document_message(doc) + return {} unless doc.is_a?(Hash) + + { + 'documentMessage' => { + 'URL' => doc['URL'] || doc['url'], + 'directPath' => doc['directPath'], + 'mediaKey' => doc['mediaKey'], + 'fileEncSha256' => doc['fileEncSha256'] || doc['fileEncSHA256'], + 'fileSha256' => doc['fileSha256'] || doc['fileSHA256'], + 'fileLength' => doc['fileLength'], + 'mimetype' => doc['mimetype'], + 'fileName' => doc['fileName'], + 'title' => doc['title'] + }.compact + } end def deep_force_utf8(obj) diff --git a/app/controllers/webhooks/wuzapi_controller.rb b/app/controllers/webhooks/wuzapi_controller.rb index 8f25be8..f90c78e 100644 --- a/app/controllers/webhooks/wuzapi_controller.rb +++ b/app/controllers/webhooks/wuzapi_controller.rb @@ -1,4 +1,4 @@ -class Webhooks::WuzapiController < ActionController::Base +class Webhooks::WuzapiController < ApplicationController skip_before_action :verify_authenticity_token before_action :fetch_inbox before_action :verify_secret diff --git a/app/jobs/conversations/auto_label_job.rb b/app/jobs/conversations/auto_label_job.rb index cbf93bf..2c82e67 100644 --- a/app/jobs/conversations/auto_label_job.rb +++ b/app/jobs/conversations/auto_label_job.rb @@ -1,134 +1,132 @@ -module Conversations - class AutoLabelJob < ApplicationJob - queue_as :low_priority +class Conversations::AutoLabelJob < ApplicationJob + queue_as :low_priority - TAXONOMY = { - 'duvida_valores' => 'Perguntas sobre preços, cotações, tarifas e valores de diárias.', - 'duvida_disponibilidade' => 'Perguntas sobre datas livres, se tem quarto vago, feriados.', - 'duvida_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.', - 'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.', - 'duvida_pet' => 'Perguntas sobre aceitar animais, taxas de pet.', - 'duvida_checkin_checkout' => 'Horários de entrada e saída, early check-in, late check-out.', - 'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.', - 'cancelamento' => 'Solicitação de cancelamento de reserva.', - 'outros' => 'Assuntos que não se encaixam nas categorias acima.' - }.freeze + TAXONOMY = { + 'duvida_valores' => 'Perguntas sobre preços, cotações, tarifas e valores de diárias.', + 'duvida_disponibilidade' => 'Perguntas sobre datas livres, se tem quarto vago, feriados.', + 'duvida_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.', + 'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.', + 'duvida_pet' => 'Perguntas sobre aceitar animais, taxas de pet.', + 'duvida_checkin_checkout' => 'Horários de entrada e saída, early check-in, late check-out.', + 'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.', + 'cancelamento' => 'Solicitação de cancelamento de reserva.', + 'outros' => 'Assuntos que não se encaixam nas categorias acima.' + }.freeze - def perform(conversation_id) - conversation = Conversation.find_by(id: conversation_id) - return unless conversation - return unless conversation.messages.count > 0 + def perform(conversation_id) + conversation = Conversation.find_by(id: conversation_id) + return unless conversation + return unless conversation.messages.count.positive? - # Evita re-classificar se já tiver alguma label de IA (opcional) - # return if (conversation.label_list & TAXONOMY.keys).any? + # Evita re-classificar se já tiver alguma label de IA (opcional) + # return if (conversation.label_list & TAXONOMY.keys).any? - process_classification(conversation) + process_classification(conversation) + rescue StandardError => e + Rails.logger.error "[AutoLabelJob] Error classifying conversation #{conversation_id}: #{e.message}" + end + + private + + def process_classification(conversation) + messages_text = prepare_history(conversation) + return if messages_text.blank? + + result = call_llm_classification(messages_text) + return unless result + + apply_label(conversation, result) + end + + def prepare_history(conversation) + # Pega últimas 20 mensagens para dar contexto suficiente + conversation.messages.chat.order(created_at: :desc).limit(20).reverse.map do |m| + sender = m.incoming? ? 'Cliente' : 'Atendente' + "#{sender}: #{m.content}" + end.join("\n") + end + + def call_llm_classification(history) + prompt = <<~PROMPT + Você é um assistente classificador de conversas para um hotel. + Analise o histórico da conversa abaixo e identifique: + 1. A INTENÇÃO PRINCIPAL do cliente (use a lista de categorias). + 2. Um RESUMO CURTO da dúvida (Dúvida Canônica) em 3 a 5 palavras. Ex: "Aceita pagamento PIX?", "Horário do café?". + + Categorias permitidas: + #{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")} + + Retorne APENAS um JSON válido no seguinte formato, sem markdown ou explicações: + { + "label": "codigo_da_categoria", + "question": "Resumo curto da dúvida" + } + + Se não tiver certeza da categoria, use 'outros'. + + --- INÍCIO DA CONVERSA --- + #{history} + --- FIM DA CONVERSA --- + PROMPT + + # Lista de modelos para tentar (Configurado > Alternativas) + models_to_try = [ + ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'), + 'gemini-1.5-flash-001', + 'gemini-pro', + 'gpt-3.5-turbo' + ].uniq.reject(&:blank?) + + last_error = nil + + models_to_try.each do |model| + # Tenta usar a infra existente + chat = RubyLLM.chat(model: model).with_temperature(0.0) + response = chat.ask(prompt) + + # Limpa markdown json se houver + clean_response = response.content.gsub('```json', '').gsub('```', '').strip + parsed = JSON.parse(clean_response) + + return parsed + rescue JSON::ParserError => e + Rails.logger.warn "[AutoLabelJob] Failed to parse JSON from model #{model}: #{e.message}" + last_error = e + next rescue StandardError => e - Rails.logger.error "[AutoLabelJob] Error classifying conversation #{conversation_id}: #{e.message}" + Rails.logger.warn "[AutoLabelJob] Failed with model #{model}: #{e.message}" + last_error = e + next end - private + # Se chegou aqui, todos falharam + Rails.logger.error "[AutoLabelJob] All models failed. Last error: #{last_error&.message}" + nil + end - def process_classification(conversation) - messages_text = prepare_history(conversation) - return if messages_text.blank? + def apply_label(conversation, result) + label_name = result['label'] + question_summary = result['question'] - result = call_llm_classification(messages_text) - return unless result - - apply_label(conversation, result) + unless TAXONOMY.key?(label_name) + Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}" + return end - def prepare_history(conversation) - # Pega últimas 20 mensagens para dar contexto suficiente - conversation.messages.chat.order(created_at: :desc).limit(20).reverse.map do |m| - sender = m.incoming? ? 'Cliente' : 'Atendente' - "#{sender}: #{m.content}" - end.join("\n") + # Garante que a label existe na conta para aparecer nos relatórios + conversation.account.labels.find_or_create_by!(title: label_name) do |l| + l.description = TAXONOMY[label_name] + l.color = '#7C3AED' # Roxo para indicar IA/Automático + l.show_on_sidebar = true end - def call_llm_classification(history) - prompt = <<~PROMPT - Você é um assistente classificador de conversas para um hotel. - Analise o histórico da conversa abaixo e identifique: - 1. A INTENÇÃO PRINCIPAL do cliente (use a lista de categorias). - 2. Um RESUMO CURTO da dúvida (Dúvida Canônica) em 3 a 5 palavras. Ex: "Aceita pagamento PIX?", "Horário do café?". + conversation.add_labels([label_name]) - Categorias permitidas: - #{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")} + # Salva a dúvida canônica nos atributos adicionais + conversation.additional_attributes ||= {} + conversation.additional_attributes['ai_canonical_question'] = question_summary + conversation.save! - Retorne APENAS um JSON válido no seguinte formato, sem markdown ou explicações: - { - "label": "codigo_da_categoria", - "question": "Resumo curto da dúvida" - } - - Se não tiver certeza da categoria, use 'outros'. - - --- INÍCIO DA CONVERSA --- - #{history} - --- FIM DA CONVERSA --- - PROMPT - - # Lista de modelos para tentar (Configurado > Alternativas) - models_to_try = [ - ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'), - 'gemini-1.5-flash-001', - 'gemini-pro', - 'gpt-3.5-turbo' - ].uniq.reject(&:blank?) - - last_error = nil - - models_to_try.each do |model| - # Tenta usar a infra existente - chat = RubyLLM.chat(model: model).with_temperature(0.0) - response = chat.ask(prompt) - - # Limpa markdown json se houver - clean_response = response.content.gsub('```json', '').gsub('```', '').strip - parsed = JSON.parse(clean_response) - - return parsed - rescue JSON::ParserError => e - Rails.logger.warn "[AutoLabelJob] Failed to parse JSON from model #{model}: #{e.message}" - last_error = e - next - rescue StandardError => e - Rails.logger.warn "[AutoLabelJob] Failed with model #{model}: #{e.message}" - last_error = e - next - end - - # Se chegou aqui, todos falharam - Rails.logger.error "[AutoLabelJob] All models failed. Last error: #{last_error&.message}" - nil - end - - def apply_label(conversation, result) - label_name = result['label'] - question_summary = result['question'] - - unless TAXONOMY.key?(label_name) - Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}" - return - end - - # Garante que a label existe na conta para aparecer nos relatórios - conversation.account.labels.find_or_create_by!(title: label_name) do |l| - l.description = TAXONOMY[label_name] - l.color = '#7C3AED' # Roxo para indicar IA/Automático - l.show_on_sidebar = true - end - - conversation.add_labels([label_name]) - - # Salva a dúvida canônica nos atributos adicionais - conversation.additional_attributes ||= {} - conversation.additional_attributes['ai_canonical_question'] = question_summary - conversation.save! - - Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}" - end + Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}" end end diff --git a/app/jobs/conversations/cluster_job.rb b/app/jobs/conversations/cluster_job.rb index 8dfdbd6..d6b37ca 100644 --- a/app/jobs/conversations/cluster_job.rb +++ b/app/jobs/conversations/cluster_job.rb @@ -1,86 +1,84 @@ -module Conversations - class ClusterJob < ApplicationJob - queue_as :low_priority +class Conversations::ClusterJob < ApplicationJob + queue_as :low_priority - def perform(account_id, days_back = 7) - account = Account.find(account_id) + def perform(account_id, days_back = 7) + account = Account.find(account_id) - # 1. Busca conversas recentes que já foram processadas pela IA - start_date = days_back.days.ago.beginning_of_day + # 1. Busca conversas recentes que já foram processadas pela IA + start_date = days_back.days.ago.beginning_of_day - # Labels que queremos agrupar (todas da taxonomia) - Conversations::AutoLabelJob::TAXONOMY.keys.each do |label| - # Busca perguntas dessa categoria - # Note: estamos queryng o campo JSONB additional_attributes - account.conversations - .where('created_at >= ?', start_date) - .where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL") - .tagged_with(label) - .pluckArel::Nodes::InfixOperation.new('->>', Arel::Nodes::SqlLiteral.new('additional_attributes'), Arel::Nodes::SqlLiteral.new("'ai_canonical_question'")) + # Labels que queremos agrupar (todas da taxonomia) + Conversations::AutoLabelJob::TAXONOMY.each_key do |label| + # Busca perguntas dessa categoria + # Note: estamos queryng o campo JSONB additional_attributes + account.conversations + .where('created_at >= ?', start_date) + .where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL") + .tagged_with(label) + .pluckArel::Nodes::InfixOperation.new('->>', Arel::Nodes::SqlLiteral.new('additional_attributes'), Arel::Nodes::SqlLiteral.new("'ai_canonical_question'")) - # O pluck acima pode ser complexo dependendo do adapter, vamos simplificar: - questions = account.conversations - .where('created_at >= ?', start_date) - .where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL") - .tagged_with(label) - .map { |c| c.additional_attributes['ai_canonical_question'] } + # O pluck acima pode ser complexo dependendo do adapter, vamos simplificar: + questions = account.conversations + .where('created_at >= ?', start_date) + .where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL") + .tagged_with(label) + .map { |c| c.additional_attributes['ai_canonical_question'] } - next if questions.empty? + next if questions.empty? - # 2. Chama LLM para agrupar - clusters = cluster_questions_with_llm(label, questions) + # 2. Chama LLM para agrupar + clusters = cluster_questions_with_llm(label, questions) - # 3. Salva no banco - save_clusters(account, label, clusters, start_date.to_date) - end + # 3. Salva no banco + save_clusters(account, label, clusters, start_date.to_date) end + end - private + private - def cluster_questions_with_llm(label, questions_list) - prompt = <<~PROMPT - Atue como um analista de dados especialista em atendimento ao cliente. - Abaixo está uma lista de dúvidas reais de clientes sobre o tópico "#{label}". + def cluster_questions_with_llm(label, questions_list) + prompt = <<~PROMPT + Atue como um analista de dados especialista em atendimento ao cliente. + Abaixo está uma lista de dúvidas reais de clientes sobre o tópico "#{label}". - Sua tarefa: - 1. Agrupar dúvidas semânticamente idênticas. - 2. Criar uma "Pergunta Padrão" clara que represente o grupo. - 3. Contar quantas vezes cada dúvida apareceu. + Sua tarefa: + 1. Agrupar dúvidas semânticamente idênticas. + 2. Criar uma "Pergunta Padrão" clara que represente o grupo. + 3. Contar quantas vezes cada dúvida apareceu. - Lista de Dúvidas: - #{questions_list.map { |q| "- #{q}" }.join("\n")} + Lista de Dúvidas: + #{questions_list.map { |q| "- #{q}" }.join("\n")} - Retorne APENAS um JSON: - [ - { "question": "Pergunta Padrão 1", "count": 10 }, - { "question": "Pergunta Padrão 2", "count": 5 } - ] - PROMPT + Retorne APENAS um JSON: + [ + { "question": "Pergunta Padrão 1", "count": 10 }, + { "question": "Pergunta Padrão 2", "count": 5 } + ] + PROMPT - model = ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini') - chat = RubyLLM.chat(model: model).with_temperature(0.0) - response = chat.ask(prompt) + model = ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini') + chat = RubyLLM.chat(model: model).with_temperature(0.0) + response = chat.ask(prompt) - clean_response = response.content.gsub('```json', '').gsub('```', '').strip - JSON.parse(clean_response) - rescue StandardError => e - Rails.logger.error "[ClusterJob] Failed to cluster for label #{label}: #{e.message}" - [] - end + clean_response = response.content.gsub('```json', '').gsub('```', '').strip + JSON.parse(clean_response) + rescue StandardError => e + Rails.logger.error "[ClusterJob] Failed to cluster for label #{label}: #{e.message}" + [] + end - def save_clusters(account, label, clusters, date) - # Limpa clusters anteriores dessa data/label para reprocessamento - FrequentQuestion.where(account: account, label: label, cluster_date: date).destroy_all + def save_clusters(account, label, clusters, date) + # Limpa clusters anteriores dessa data/label para reprocessamento + FrequentQuestion.where(account: account, label: label, cluster_date: date).destroy_all - clusters.each do |cluster| - FrequentQuestion.create!( - account: account, - label: label, - question_text: cluster['question'], - occurrence_count: cluster['count'], - cluster_date: date - ) - end + clusters.each do |cluster| + FrequentQuestion.create!( + account: account, + label: label, + question_text: cluster['question'], + occurrence_count: cluster['count'], + cluster_date: date + ) end end end diff --git a/app/jobs/crm_insights/update_job.rb b/app/jobs/crm_insights/update_job.rb index d7c03e3..7ea55e9 100644 --- a/app/jobs/crm_insights/update_job.rb +++ b/app/jobs/crm_insights/update_job.rb @@ -1,17 +1,15 @@ -module CrmInsights - class UpdateJob < ApplicationJob - queue_as :low +class CrmInsights::UpdateJob < ApplicationJob + queue_as :low - def perform(conversation_id, reason: nil) - conversation = Conversation.find_by(id: conversation_id) - return unless conversation + def perform(conversation_id, reason: nil) + conversation = Conversation.find_by(id: conversation_id) + return unless conversation - if reason == 'idle' - last_activity_at = conversation.last_activity_at - return if last_activity_at.present? && last_activity_at > 30.minutes.ago - end - - UpdateService.new(conversation: conversation, reason: reason).call + if reason == 'idle' + last_activity_at = conversation.last_activity_at + return if last_activity_at.present? && last_activity_at > 30.minutes.ago end + + UpdateService.new(conversation: conversation, reason: reason).call end end diff --git a/app/jobs/jasmine/response_job.rb b/app/jobs/jasmine/response_job.rb index 5cbf405..c1dfad0 100644 --- a/app/jobs/jasmine/response_job.rb +++ b/app/jobs/jasmine/response_job.rb @@ -1,61 +1,59 @@ -module Jasmine - class ResponseJob < ApplicationJob - queue_as :default +class Jasmine::ResponseJob < ApplicationJob + queue_as :default - retry_on StandardError, wait: :polynomially_longer, attempts: 2 + retry_on StandardError, wait: :polynomially_longer, attempts: 2 - def perform(message_id) - message = Message.find_by(id: message_id) - return unless message + def perform(message_id) + message = Message.find_by(id: message_id) + return unless message - conversation = message.conversation - inbox = message.inbox - config = inbox.jasmine_inbox_config + conversation = message.conversation + inbox = message.inbox + config = inbox.jasmine_inbox_config - # Double-check conditions (in case they changed since job was enqueued) - Rails.logger.info "[Jasmine::ResponseJob] Started for Message #{message_id}, Channel Class: #{inbox.channel.class.name}" - return unless config&.is_enabled? - return if conversation.assignee.present? + # Double-check conditions (in case they changed since job was enqueued) + Rails.logger.info "[Jasmine::ResponseJob] Started for Message #{message_id}, Channel Class: #{inbox.channel.class.name}" + return unless config&.is_enabled? + return if conversation.assignee.present? - # Send typing indicator - inbox.channel.toggle_typing_status('typing_on', conversation: conversation) + # Send typing indicator + inbox.channel.toggle_typing_status('typing_on', conversation: conversation) - begin - # Sleep for verification (optimized to 1.5s per recommendation) - sleep 1.5 + begin + # Sleep for verification (optimized to 1.5s per recommendation) + sleep 1.5 - # Get response from BrainService - response_text = BrainService.new( - inbox: inbox, - conversation: conversation, - message: message - ).respond + # Get response from BrainService + response_text = BrainService.new( + inbox: inbox, + conversation: conversation, + message: message + ).respond - return if response_text.blank? + return if response_text.blank? - # Send response as outgoing message - send_response(conversation, response_text) - ensure - # Ensure typing is turned off even if errors occur or no response - # Wait a bit to ensure the message "send" signal propagates before sending "paused" - sleep 0.5 - inbox.channel.toggle_typing_status('typing_off', conversation: conversation) - end - end - - private - - def send_response(conversation, content) - conversation.messages.create!( - message_type: :outgoing, - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - content: content, - sender: nil, # No agent, it's from Jasmine - content_type: :text - ) - rescue StandardError => e - Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}" + # Send response as outgoing message + send_response(conversation, response_text) + ensure + # Ensure typing is turned off even if errors occur or no response + # Wait a bit to ensure the message "send" signal propagates before sending "paused" + sleep 0.5 + inbox.channel.toggle_typing_status('typing_off', conversation: conversation) end end + + private + + def send_response(conversation, content) + conversation.messages.create!( + message_type: :outgoing, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + content: content, + sender: nil, # No agent, it's from Jasmine + content_type: :text + ) + rescue StandardError => e + Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}" + end end diff --git a/app/models/captain/pricing.rb b/app/models/captain/pricing.rb index dc9be78..69f477a 100644 --- a/app/models/captain/pricing.rb +++ b/app/models/captain/pricing.rb @@ -25,10 +25,8 @@ # fk_rails_... (account_id => accounts.id) # fk_rails_... (captain_brand_id => captain_brands.id) # -module Captain - class Pricing < ApplicationRecord - belongs_to :account - belongs_to :captain_brand, optional: true - belongs_to :inbox, optional: true - end +class Captain::Pricing < ApplicationRecord + belongs_to :account + belongs_to :captain_brand, optional: true + belongs_to :inbox, optional: true end diff --git a/app/models/captain/reservation.rb b/app/models/captain/reservation.rb index e7901ce..7b46b80 100644 --- a/app/models/captain/reservation.rb +++ b/app/models/captain/reservation.rb @@ -48,39 +48,37 @@ # fk_rails_... (conversation_id => conversations.id) # fk_rails_... (inbox_id => inboxes.id) # -module Captain - class Reservation < ApplicationRecord - self.table_name = 'captain_reservations' +class Captain::Reservation < ApplicationRecord + self.table_name = 'captain_reservations' - belongs_to :account - belongs_to :inbox - belongs_to :contact - belongs_to :contact_inbox - belongs_to :conversation, optional: true - belongs_to :captain_brand, optional: true - belongs_to :captain_unit, optional: true - belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true + belongs_to :account + belongs_to :inbox + belongs_to :contact + belongs_to :contact_inbox + belongs_to :conversation, optional: true + belongs_to :captain_brand, optional: true + belongs_to :captain_unit, optional: true + belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true - # Validations - validates :check_in_at, presence: true - validates :check_out_at, presence: true - validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true + # Validations + validates :check_in_at, presence: true + validates :check_out_at, presence: true + validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true - enum status: { - scheduled: 0, - active: 1, - completed: 2, - cancelled: 3, - no_show: 4, - pending_payment: 5, - expired: 6, - payment_confirmed: 7, - issues: 8, - awaiting_checkin: 9 - } + enum status: { + scheduled: 0, + active: 1, + completed: 2, + cancelled: 3, + no_show: 4, + pending_payment: 5, + expired: 6, + payment_confirmed: 7, + issues: 8, + awaiting_checkin: 9 + } - scope :active_in_date_range, lambda { |start_date, end_date| - where('check_in_at < ? AND check_out_at > ?', end_date, start_date) - } - end + scope :active_in_date_range, lambda { |start_date, end_date| + where('check_in_at < ? AND check_out_at > ?', end_date, start_date) + } end diff --git a/app/models/captain/unit.rb b/app/models/captain/unit.rb index e293c2b..e3e9235 100644 --- a/app/models/captain/unit.rb +++ b/app/models/captain/unit.rb @@ -38,23 +38,21 @@ # fk_rails_... (captain_brand_id => captain_brands.id) # fk_rails_... (inbox_id => inboxes.id) # -module Captain - class Unit < ApplicationRecord - self.table_name = 'captain_units' +class Captain::Unit < ApplicationRecord + self.table_name = 'captain_units' - belongs_to :account - belongs_to :captain_brand - belongs_to :inbox, optional: true + belongs_to :account + belongs_to :captain_brand + belongs_to :inbox, optional: true - has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy + has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy - # Encrypted fields for PlugPlay Integration - # Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase. - # Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise. - # Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive. - # For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`. - # Let's check generally used pattern later, but for now defining relations is key. + # Encrypted fields for PlugPlay Integration + # Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase. + # Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise. + # Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive. + # For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`. + # Let's check generally used pattern later, but for now defining relations is key. - validates :name, presence: true - end + validates :name, presence: true end diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index 7bffd0a..525815f 100755 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -197,7 +197,7 @@ class Channel::Whatsapp < ApplicationRecord provider_config.delete('wuzapi_user_token') end - return unless provider_config['wuzapi_admin_token'].present? + return if provider_config['wuzapi_admin_token'].blank? self.wuzapi_admin_token = provider_config['wuzapi_admin_token'] provider_config.delete('wuzapi_admin_token') @@ -209,13 +209,13 @@ class Channel::Whatsapp < ApplicationRecord def perform_webhook_setup if provider == 'wuzapi' - return unless inbox.present? + return if inbox.blank? base_url = provider_config['wuzapi_base_url'] # Use encrypted token user_token = wuzapi_user_token - return unless user_token.present? + return if user_token.blank? # Construct Chatwoot Webhook URL # Using standard route: /webhooks/whatsapp/:phone_number for WuzAPI as per fix @@ -286,7 +286,7 @@ class Channel::Whatsapp < ApplicationRecord provider_config['wuzapi_user_id'] = result[:wuzapi_user_id] self.wuzapi_user_token = result[:wuzapi_user_token] - masked_token = result[:wuzapi_user_token].to_s[-4..-1] + masked_token = result[:wuzapi_user_token].to_s[-4..] Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}" end end diff --git a/app/models/jasmine/collection.rb b/app/models/jasmine/collection.rb index 7bc7698..f39c153 100644 --- a/app/models/jasmine/collection.rb +++ b/app/models/jasmine/collection.rb @@ -24,28 +24,26 @@ # fk_rails_... (account_id => accounts.id) # fk_rails_... (owner_inbox_id => inboxes.id) # -module Jasmine - class Collection < ApplicationRecord - self.table_name = 'jasmine_collections' +class Jasmine::Collection < ApplicationRecord + self.table_name = 'jasmine_collections' - belongs_to :account - belongs_to :owner_inbox, class_name: 'Inbox', optional: true - - has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', foreign_key: :collection_id, dependent: :destroy - has_many :documents, class_name: 'Jasmine::Document', foreign_key: :collection_id, dependent: :destroy + belongs_to :account + belongs_to :owner_inbox, class_name: 'Inbox', optional: true - enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true + has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', dependent: :destroy + has_many :documents, class_name: 'Jasmine::Document', dependent: :destroy - validates :name, presence: true - validates :account_id, presence: true - validate :validate_owner_if_private + enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true - private + validates :name, presence: true + validates :account_id, presence: true + validate :validate_owner_if_private - def validate_owner_if_private - if visibility_private? && owner_inbox_id.nil? - errors.add(:owner_inbox_id, 'must be present for private collections') - end - end + private + + def validate_owner_if_private + return unless visibility_private? && owner_inbox_id.nil? + + errors.add(:owner_inbox_id, 'must be present for private collections') end end diff --git a/app/models/jasmine/document.rb b/app/models/jasmine/document.rb index 4b4b118..201aefc 100644 --- a/app/models/jasmine/document.rb +++ b/app/models/jasmine/document.rb @@ -25,32 +25,31 @@ # fk_rails_... (account_id => accounts.id) # fk_rails_... (collection_id => jasmine_collections.id) # -module Jasmine - class Document < ApplicationRecord - self.table_name = 'jasmine_documents' +class Jasmine::Document < ApplicationRecord + self.table_name = 'jasmine_documents' - belongs_to :account - belongs_to :collection, class_name: 'Jasmine::Collection' - has_many :chunks, class_name: 'Jasmine::DocumentChunk', foreign_key: :document_id, dependent: :delete_all + belongs_to :account + belongs_to :collection, class_name: 'Jasmine::Collection' + has_many :chunks, class_name: 'Jasmine::DocumentChunk', dependent: :delete_all - enum status: { pending: 0, processing: 1, indexed: 2, failed: 3 } - enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 } + enum status: { pending: 0, processing: 1, indexed: 2, failed: 3 } + enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 } - validates :content, presence: true - validate :validate_account_consistency + validates :content, presence: true + validate :validate_account_consistency - # Async processing job - after_create_commit :enqueue_embed_job + # Async processing job + after_create_commit :enqueue_embed_job - private + private - def validate_account_consistency - return if collection.nil? - errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id - end + def validate_account_consistency + return if collection.nil? - def enqueue_embed_job - Jasmine::EmbedDocumentJob.perform_later(id) - end + errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id + end + + def enqueue_embed_job + Jasmine::EmbedDocumentJob.perform_later(id) end end diff --git a/app/models/jasmine/inbox_collection.rb b/app/models/jasmine/inbox_collection.rb index fba8862..dc906a4 100644 --- a/app/models/jasmine/inbox_collection.rb +++ b/app/models/jasmine/inbox_collection.rb @@ -26,36 +26,34 @@ # fk_rails_... (collection_id => jasmine_collections.id) # fk_rails_... (inbox_id => inboxes.id) # -module Jasmine - class InboxCollection < ApplicationRecord - self.table_name = 'jasmine_inbox_collections' +class Jasmine::InboxCollection < ApplicationRecord + self.table_name = 'jasmine_inbox_collections' - belongs_to :account - belongs_to :inbox - belongs_to :collection, class_name: 'Jasmine::Collection' + belongs_to :account + belongs_to :inbox + belongs_to :collection, class_name: 'Jasmine::Collection' - validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validate :validate_account_consistency - validate :validate_visibility_rules - - validates :collection_id, uniqueness: { scope: :inbox_id } + validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - private + validate :validate_account_consistency + validate :validate_visibility_rules - def validate_account_consistency - return if inbox.nil? || collection.nil? + validates :collection_id, uniqueness: { scope: :inbox_id } - errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id - errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id - end + private - def validate_visibility_rules - return if collection.nil? || inbox.nil? + def validate_account_consistency + return if inbox.nil? || collection.nil? - if collection.visibility_private? && collection.owner_inbox_id != inbox_id - errors.add(:base, 'Private collections can only be linked to their owner inbox') - end - end + errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id + errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id + end + + def validate_visibility_rules + return if collection.nil? || inbox.nil? + + return unless collection.visibility_private? && collection.owner_inbox_id != inbox_id + + errors.add(:base, 'Private collections can only be linked to their owner inbox') end end diff --git a/app/models/jasmine/inbox_config.rb b/app/models/jasmine/inbox_config.rb index ea5ee01..5ee326c 100644 --- a/app/models/jasmine/inbox_config.rb +++ b/app/models/jasmine/inbox_config.rb @@ -29,22 +29,21 @@ # fk_rails_... (account_id => accounts.id) # fk_rails_... (inbox_id => inboxes.id) # -module Jasmine - class InboxConfig < ApplicationRecord - self.table_name = 'jasmine_inbox_settings' +class Jasmine::InboxConfig < ApplicationRecord + self.table_name = 'jasmine_inbox_settings' - belongs_to :account - belongs_to :inbox + belongs_to :account + belongs_to :inbox - validates :account_id, presence: true - validates :inbox_id, presence: true - validate :validate_account_consistency + validates :account_id, presence: true + validates :inbox_id, presence: true + validate :validate_account_consistency - private + private - def validate_account_consistency - return if inbox.nil? - errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id - end + def validate_account_consistency + return if inbox.nil? + + errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id end end diff --git a/app/models/jasmine/tool_config.rb b/app/models/jasmine/tool_config.rb index 40d753e..69a7e6c 100644 --- a/app/models/jasmine/tool_config.rb +++ b/app/models/jasmine/tool_config.rb @@ -28,44 +28,42 @@ # fk_rails_... (account_id => accounts.id) # fk_rails_... (inbox_id => inboxes.id) # -module Jasmine - class ToolConfig < ApplicationRecord - self.table_name = 'jasmine_tool_configs' +class Jasmine::ToolConfig < ApplicationRecord + self.table_name = 'jasmine_tool_configs' - belongs_to :account - belongs_to :inbox + belongs_to :account + belongs_to :inbox - # Token encryption using Rails 7 native encryption - encrypts :plug_play_token if Chatwoot.encryption_configured? + # Token encryption using Rails 7 native encryption + encrypts :plug_play_token if Chatwoot.encryption_configured? - validates :tool_key, presence: true - validates :plug_play_id, presence: true, if: :is_enabled? - validates :plug_play_token, presence: true, if: :is_enabled? + validates :tool_key, presence: true + validates :plug_play_id, presence: true, if: :is_enabled? + validates :plug_play_token, presence: true, if: :is_enabled? - # Fixed Tool Definitions - DEFINITIONS = { - 'status_suites' => { - name: 'Status das Suítes', - method: :get, - url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus', - description: 'Verifica o status atual das suítes.' - }, - 'listar_reservas' => { - name: 'Listar Reservas', - method: :get, - url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1', - description: 'Lista as reservas ativas.' - }, - 'categoria_disponibilidade' => { - name: 'Disponibilidade por Categoria', - method: :get, - url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade', - description: 'Verifica disponibilidade de categorias.' - } - }.freeze + # Fixed Tool Definitions + DEFINITIONS = { + 'status_suites' => { + name: 'Status das Suítes', + method: :get, + url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus', + description: 'Verifica o status atual das suítes.' + }, + 'listar_reservas' => { + name: 'Listar Reservas', + method: :get, + url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1', + description: 'Lista as reservas ativas.' + }, + 'categoria_disponibilidade' => { + name: 'Disponibilidade por Categoria', + method: :get, + url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade', + description: 'Verifica disponibilidade de categorias.' + } + }.freeze - def self.definitions - DEFINITIONS - end + def self.definitions + DEFINITIONS end end diff --git a/app/services/captain/assistant.rb b/app/services/captain/assistant.rb index f704bf8..f8d7a96 100644 --- a/app/services/captain/assistant.rb +++ b/app/services/captain/assistant.rb @@ -1,5 +1,4 @@ -module Captain - module Assistant - # Base module to fix NameError - end +# Base module to fix NameError +module Captain::Assistant + # Base module to fix NameError end diff --git a/app/services/captain/inter_service.rb b/app/services/captain/inter_service.rb index ad87e8c..bf142b2 100644 --- a/app/services/captain/inter_service.rb +++ b/app/services/captain/inter_service.rb @@ -4,118 +4,116 @@ require 'json' require 'openssl' require 'base64' -module Captain - class InterService - # Constants for API URLs - AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze - PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze +class Captain::InterService + # Constants for API URLs + AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze + PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze - # initialize accepts credentials dynamically - def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil) - @client_id = client_id - @client_secret = client_secret - # If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage. - @cert_path = cert_path - @key_path = key_path - @pix_key = pix_key - @account_number = account_number - end + # initialize accepts credentials dynamically + def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil) + @client_id = client_id + @client_secret = client_secret + # If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage. + @cert_path = cert_path + @key_path = key_path + @pix_key = pix_key + @account_number = account_number + end - def create_pix_charge(reservation) - token = get_token - return { success: false, error: 'Failed to authenticate with Inter' } unless token + def create_pix_charge(reservation) + token = get_token + return { success: false, error: 'Failed to authenticate with Inter' } unless token - payload = { - calendario: { - expiracao: 3600 # 1 hour - }, - devedor: { - cpf: reservation[:cpf].gsub(/\D/, ''), - nome: reservation[:contact_name] - }, - valor: { - original: format('%.2f', reservation[:total_amount].to_f / 2.0) - }, - chave: @pix_key, - solicitacaoPagador: "Reserva #{reservation[:id]}" + payload = { + calendario: { + expiracao: 3600 # 1 hour + }, + devedor: { + cpf: reservation[:cpf].gsub(/\D/, ''), + nome: reservation[:contact_name] + }, + valor: { + original: format('%.2f', reservation[:total_amount].to_f / 2.0) + }, + chave: @pix_key, + solicitacaoPagador: "Reserva #{reservation[:id]}" + } + + response = request(:post, PIX_URL, payload, token) + + if response.code.to_i == 201 + data = JSON.parse(response.body) + { + success: true, + txid: data['txid'], + pix_copy_paste: data['pixCopiaECola'], + # Inter doesn't return a QR code image URL, just the text string. + # Frontend or another service must generate the image. + qr_code_url: data['location'] # Use location if needed } - - response = request(:post, PIX_URL, payload, token) - - if response.code.to_i == 201 - data = JSON.parse(response.body) - { - success: true, - txid: data['txid'], - pix_copy_paste: data['pixCopiaECola'], - # Inter doesn't return a QR code image URL, just the text string. - # Frontend or another service must generate the image. - qr_code_url: data['location'] # Use location if needed - } - else - Rails.logger.error "Inter PIX Error: #{response.body}" - { success: false, error: response.body } - end - rescue StandardError => e - Rails.logger.error "Inter Service Error: #{e.message}" - { success: false, error: e.message } + else + Rails.logger.error "Inter PIX Error: #{response.body}" + { success: false, error: response.body } end + rescue StandardError => e + Rails.logger.error "Inter Service Error: #{e.message}" + { success: false, error: e.message } + end - private + private - def get_token - uri = URI(AUTH_URL) - request = Net::HTTP::Post.new(uri) - request.set_form_data( - 'client_id' => @client_id, - 'client_secret' => @client_secret, - 'grant_type' => 'client_credentials', - 'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read' - ) + def get_token + uri = URI(AUTH_URL) + request = Net::HTTP::Post.new(uri) + request.set_form_data( + 'client_id' => @client_id, + 'client_secret' => @client_secret, + 'grant_type' => 'client_credentials', + 'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read' + ) - response = send_request(uri, request) + response = send_request(uri, request) - if response.code.to_i == 200 - JSON.parse(response.body)['access_token'] - else - Rails.logger.error "Inter Auth Error: #{response.body}" - nil - end - end - - def request(method, url, payload, token) - uri = URI(url) - req = Net::HTTP.const_get(method.to_s.capitalize).new(uri) - req['Authorization'] = "Bearer #{token}" - req['Content-Type'] = 'application/json' - req.body = payload.to_json - - send_request(uri, req) - end - - def send_request(uri, req) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Bypass CRL/Commitment check for dev/testing - - # Prepare SSL context with client certificate - if @cert_path.present? && @key_path.present? - begin - if File.exist?(@cert_path) && File.exist?(@key_path) - cert_content = File.read(@cert_path) - key_content = File.read(@key_path) - http.cert = OpenSSL::X509::Certificate.new(cert_content) - http.key = OpenSSL::PKey::RSA.new(key_content) - else - Rails.logger.warn "Inter Cert/Key files not found at paths: #{@cert_path}, #{@key_path}" - # If configured but file missing, it will likely fail. - end - rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::RSAError => e - Rails.logger.error "Invalid Certificate/Key format: #{e.message}" - end - end - - http.request(req) + if response.code.to_i == 200 + JSON.parse(response.body)['access_token'] + else + Rails.logger.error "Inter Auth Error: #{response.body}" + nil end end + + def request(method, url, payload, token) + uri = URI(url) + req = Net::HTTP.const_get(method.to_s.capitalize).new(uri) + req['Authorization'] = "Bearer #{token}" + req['Content-Type'] = 'application/json' + req.body = payload.to_json + + send_request(uri, req) + end + + def send_request(uri, req) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Bypass CRL/Commitment check for dev/testing + + # Prepare SSL context with client certificate + if @cert_path.present? && @key_path.present? + begin + if File.exist?(@cert_path) && File.exist?(@key_path) + cert_content = File.read(@cert_path) + key_content = File.read(@key_path) + http.cert = OpenSSL::X509::Certificate.new(cert_content) + http.key = OpenSSL::PKey::RSA.new(key_content) + else + Rails.logger.warn "Inter Cert/Key files not found at paths: #{@cert_path}, #{@key_path}" + # If configured but file missing, it will likely fail. + end + rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::RSAError => e + Rails.logger.error "Invalid Certificate/Key format: #{e.message}" + end + end + + http.request(req) + end end diff --git a/app/services/captain/reservations/sync_service.rb b/app/services/captain/reservations/sync_service.rb index 3c92195..5910a49 100644 --- a/app/services/captain/reservations/sync_service.rb +++ b/app/services/captain/reservations/sync_service.rb @@ -1,175 +1,171 @@ -module Captain - module Reservations - class SyncService - PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva' +class Captain::Reservations::SyncService + PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'.freeze - def initialize(unit) - @unit = unit - @account = unit.account - @inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback + def initialize(unit) + @unit = unit + @account = unit.account + @inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback + end + + def perform + return unless @unit.reservations_sync_enabled? + return unless @unit.plug_play_id.present? && @unit.plug_play_token.present? + + page = 1 + loop do + reservations_data = fetch_page(page) + break if reservations_data.empty? + + reservations_data.each do |reservation_data| + process_reservation(reservation_data) end - def perform - return unless @unit.reservations_sync_enabled? - return unless @unit.plug_play_id.present? && @unit.plug_play_token.present? + page += 1 + # Safety break to avoid infinite loops in case of API issues + break if page > 50 + end - page = 1 - loop do - reservations_data = fetch_page(page) - break if reservations_data.empty? + @unit.update(last_synced_at: Time.current) + end - reservations_data.each do |reservation_data| - process_reservation(reservation_data) - end + private - page += 1 - # Safety break to avoid infinite loops in case of API issues - break if page > 50 - end + def fetch_page(page) + url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}" + response = HTTParty.get(url, headers: headers) - @unit.update(last_synced_at: Time.current) - end - - private - - def fetch_page(page) - url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}" - response = HTTParty.get(url, headers: headers) - - if response.success? - begin - JSON.parse(response.body) - rescue StandardError - [] - end - else - Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}" - [] - end - end - - def headers - { - 'PLUG-PLAY-ID' => @unit.plug_play_id, - 'PLUG-PLAY-TOKEN' => @unit.plug_play_token, - 'Content-Type' => 'application/json' - } - end - - def process_reservation(data) - external_id = data['id'] - return if external_id.blank? - - reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id) - - # Resolve Contact - contact = find_or_create_contact(data) - - # Map Attributes - reservation.account = @account - reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox - reservation.contact = contact - reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox) - - # If contact_inbox missing (new contact created without association to this inbox), create it - if reservation.contact_inbox.nil? - reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id) - end - - reservation.suite_identifier = data['suiteRef'] - reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00 - reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada']) - - if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank? - Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}" - return - end - - reservation.total_amount = data['totalAPagar'] - - # Status Mapping - reservation.status = map_status(data) - - reservation.metadata ||= {} - reservation.metadata['raw_plug_play_data'] = data - reservation.metadata['guest_name'] = data['nome'] - reservation.metadata['guest_email'] = data['email'] - reservation.metadata['guest_phone'] = data['telefone'] - reservation.metadata['notes'] = data['observacoes'] - reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present? - - reservation.save! - rescue StandardError => e - if e.is_a?(ActiveRecord::RecordInvalid) && e.record - Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}" - Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}" - else - Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}" - end - end - - def find_or_create_contact(data) - phone = normalize_phone_number(data['telefone']) - email = data['email'] - name = data['nome'] - - contact = nil - - # Try finding by phone - contact = @account.contacts.find_by(phone_number: phone) if phone.present? - - # Try finding by email - contact = @account.contacts.find_by(email: email) if contact.nil? && email.present? - - # Create if not found - if contact.nil? - contact = @account.contacts.create!( - name: name, - email: email, - phone_number: phone - ) - end - - contact - end - - def normalize_phone_number(raw_phone) - digits = raw_phone.to_s.gsub(/[^\d]/, '') - return nil if digits.blank? - - digits = "55#{digits}" if digits.length == 10 || digits.length == 11 - - return nil if digits.length < 10 || digits.length > 15 - - "+#{digits}" - end - - def parse_date(date_string) - return nil if date_string.blank? - - Time.zone.parse(date_string) + if response.success? + begin + JSON.parse(response.body) rescue StandardError - nil + [] end + else + Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}" + [] + end + end - def map_status(data) - # MVP Logic based on dates and 'cancelada' - return :cancelled if data['cancelada'] == true + def headers + { + 'PLUG-PLAY-ID' => @unit.plug_play_id, + 'PLUG-PLAY-TOKEN' => @unit.plug_play_token, + 'Content-Type' => 'application/json' + } + end - check_in = parse_date(data['dataInicio']) - check_out = parse_date(data['saidaPrevistaOuNegociada']) - now = Time.current + def process_reservation(data) + external_id = data['id'] + return if external_id.blank? - return :scheduled unless check_in && check_out + reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id) - if now >= check_out - :completed - elsif now >= check_in && now < check_out - :active - else - :scheduled - end - end + # Resolve Contact + contact = find_or_create_contact(data) + + # Map Attributes + reservation.account = @account + reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox + reservation.contact = contact + reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox) + + # If contact_inbox missing (new contact created without association to this inbox), create it + if reservation.contact_inbox.nil? + reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id) + end + + reservation.suite_identifier = data['suiteRef'] + reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00 + reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada']) + + if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank? + Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}" + return + end + + reservation.total_amount = data['totalAPagar'] + + # Status Mapping + reservation.status = map_status(data) + + reservation.metadata ||= {} + reservation.metadata['raw_plug_play_data'] = data + reservation.metadata['guest_name'] = data['nome'] + reservation.metadata['guest_email'] = data['email'] + reservation.metadata['guest_phone'] = data['telefone'] + reservation.metadata['notes'] = data['observacoes'] + reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present? + + reservation.save! + rescue StandardError => e + if e.is_a?(ActiveRecord::RecordInvalid) && e.record + Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}" + Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}" + else + Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}" + end + end + + def find_or_create_contact(data) + phone = normalize_phone_number(data['telefone']) + email = data['email'] + name = data['nome'] + + contact = nil + + # Try finding by phone + contact = @account.contacts.find_by(phone_number: phone) if phone.present? + + # Try finding by email + contact = @account.contacts.find_by(email: email) if contact.nil? && email.present? + + # Create if not found + if contact.nil? + contact = @account.contacts.create!( + name: name, + email: email, + phone_number: phone + ) + end + + contact + end + + def normalize_phone_number(raw_phone) + digits = raw_phone.to_s.gsub(/[^\d]/, '') + return nil if digits.blank? + + digits = "55#{digits}" if digits.length == 10 || digits.length == 11 + + return nil if digits.length < 10 || digits.length > 15 + + "+#{digits}" + end + + def parse_date(date_string) + return nil if date_string.blank? + + Time.zone.parse(date_string) + rescue StandardError + nil + end + + def map_status(data) + # MVP Logic based on dates and 'cancelada' + return :cancelled if data['cancelada'] == true + + check_in = parse_date(data['dataInicio']) + check_out = parse_date(data['saidaPrevistaOuNegociada']) + now = Time.current + + return :scheduled unless check_in && check_out + + if now >= check_out + :completed + elsif now >= check_in && now < check_out + :active + else + :scheduled end end end diff --git a/app/services/crm_insights/contact_session_counter.rb b/app/services/crm_insights/contact_session_counter.rb index 927374c..25fc04d 100644 --- a/app/services/crm_insights/contact_session_counter.rb +++ b/app/services/crm_insights/contact_session_counter.rb @@ -1,31 +1,29 @@ -module CrmInsights - class ContactSessionCounter - WINDOW = 24.hours +class CrmInsights::ContactSessionCounter + WINDOW = 24.hours - def initialize(conversation) - @conversation = conversation - end + def initialize(conversation) + @conversation = conversation + end - def call - inbound_times = @conversation.messages - .where(message_type: :incoming, private: false) - .order(:created_at) - .pluck(:created_at) + def call + inbound_times = @conversation.messages + .where(message_type: :incoming, private: false) + .order(:created_at) + .pluck(:created_at) - count = 0 - last_session_start = nil + count = 0 + last_session_start = nil - inbound_times.each do |timestamp| - if last_session_start.nil? || timestamp > last_session_start + WINDOW - count += 1 - last_session_start = timestamp - end + inbound_times.each do |timestamp| + if last_session_start.nil? || timestamp > last_session_start + WINDOW + count += 1 + last_session_start = timestamp end - - { - count: count, - last_contact_at: last_session_start - } end + + { + count: count, + last_contact_at: last_session_start + } end end diff --git a/app/services/crm_insights/generate_service.rb b/app/services/crm_insights/generate_service.rb index a7b4fb9..e76fb8d 100644 --- a/app/services/crm_insights/generate_service.rb +++ b/app/services/crm_insights/generate_service.rb @@ -1,172 +1,170 @@ -module CrmInsights - class GenerateService < Llm::BaseAiService - DEFAULT_MODEL = 'gpt-4o-mini' +class CrmInsights::GenerateService < Llm::BaseAiService + DEFAULT_MODEL = 'gpt-4o-mini'.freeze - def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil) - super() - @conversation = conversation - @insight = insight - @sessions_count = sessions_count - @last_contact_at = last_contact_at - @from_message_id = from_message_id - @to_message_id = to_message_id - @model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL) - end + def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil) + super() + @conversation = conversation + @insight = insight + @sessions_count = sessions_count + @last_contact_at = last_contact_at + @from_message_id = from_message_id + @to_message_id = to_message_id + @model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL) + end - def generate - chat = RubyLLM.chat(model: @model) - .with_temperature(0.2) - .with_params(response_format: { type: 'json_object' }) - response = chat.ask(prompt) - parsed = parse_response(response) - return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank? + def generate + chat = RubyLLM.chat(model: @model) + .with_temperature(0.2) + .with_params(response_format: { type: 'json_object' }) + response = chat.ask(prompt) + parsed = parse_response(response) + return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank? - { data: parsed, error: nil } - rescue StandardError => e - Rails.logger.error "[CRM Insights] Generation failed: #{e.message}" - { data: nil, error: e.message } - end + { data: parsed, error: nil } + rescue StandardError => e + Rails.logger.error "[CRM Insights] Generation failed: #{e.message}" + { data: nil, error: e.message } + end - private + private - def prompt - <<~PROMPT - Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente. + def prompt + <<~PROMPT + Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente. - Regras: - - Idioma: PT-BR sempre. - - Nao resuma a conversa; gere um perfil do cliente. - - Frases curtas, estilo CRM humano. - - Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes. - - Atualize o resumo existente sem perder informacoes relevantes. - - Priorize padroes recorrentes sobre eventos isolados. - - Se dados forem insuficientes, diga que faltam sinais claros. - - So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo. - - Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio. - - Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio. - - Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio. - - Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos. + Regras: + - Idioma: PT-BR sempre. + - Nao resuma a conversa; gere um perfil do cliente. + - Frases curtas, estilo CRM humano. + - Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes. + - Atualize o resumo existente sem perder informacoes relevantes. + - Priorize padroes recorrentes sobre eventos isolados. + - Se dados forem insuficientes, diga que faltam sinais claros. + - So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo. + - Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio. + - Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio. + - Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio. + - Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos. - Saida OBRIGATORIA (JSON valido): - { - "summary_text": "texto humano completo para UI", - "structured_data": { - "summary_text": "...", - "preferences": [], - "contact_pattern": { "time_range": "", "days": [] }, - "intent": "", - "price_sensitivity": "", - "urgency": "", - "frictions": [], - "commercial_status": "", - "customer_potential": "", - "agent_tip": "", - "funnel": { - "stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost - "confidence": 0.0, // float 0-1 - "reason": "justificativa curta", - "evidence_message_ids": [], // IDs das mensagens que justificam o estagio - "updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior - } + Saida OBRIGATORIA (JSON valido): + { + "summary_text": "texto humano completo para UI", + "structured_data": { + "summary_text": "...", + "preferences": [], + "contact_pattern": { "time_range": "", "days": [] }, + "intent": "", + "price_sensitivity": "", + "urgency": "", + "frictions": [], + "commercial_status": "", + "customer_potential": "", + "agent_tip": "", + "funnel": { + "stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost + "confidence": 0.0, // float 0-1 + "reason": "justificativa curta", + "evidence_message_ids": [], // IDs das mensagens que justificam o estagio + "updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior } } + } - REGRAS FUNIL DE VENDAS (CRITICO): - 1. Analise APENAS o historico fornecido abaixo para definir o estagio. - 2. Estagios: - - info: pede informacoes gerais. (Confianca minima: qualquer) - - price: discute valores. (Confianca minima: 0.6) - - availability: pergunta sobre datas/vagas. (Confianca minima: 0.6) - - confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75) - - closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85) - - closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85) - 3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio. - 4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda. - 5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info". + REGRAS FUNIL DE VENDAS (CRITICO): + 1. Analise APENAS o historico fornecido abaixo para definir o estagio. + 2. Estagios: + - info: pede informacoes gerais. (Confianca minima: qualquer) + - price: discute valores. (Confianca minima: 0.6) + - availability: pergunta sobre datas/vagas. (Confianca minima: 0.6) + - confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75) + - closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85) + - closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85) + 3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio. + 4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda. + 5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info". - Contexto: - - Canal: #{channel_name} - - Conversa ID: #{@conversation.id} - - Contatos (24h): #{@sessions_count} - - Ultimo contato valido: #{format_time(@last_contact_at)} - - Intervalo de mensagens: #{message_range_label} + Contexto: + - Canal: #{channel_name} + - Conversa ID: #{@conversation.id} + - Contatos (24h): #{@sessions_count} + - Ultimo contato valido: #{format_time(@last_contact_at)} + - Intervalo de mensagens: #{message_range_label} - Resumo anterior (se existir): - #{@insight&.summary_text || 'Sem resumo anterior.'} + Resumo anterior (se existir): + #{@insight&.summary_text || 'Sem resumo anterior.'} - JSON anterior (se existir): - #{(@insight&.structured_data || {}).to_json} + JSON anterior (se existir): + #{(@insight&.structured_data || {}).to_json} - Historico recente (ate 50 mensagens): - #{history_block} + Historico recente (ate 50 mensagens): + #{history_block} - Formato do texto humano (exemplo de estilo): - Cliente recorrente. - Demonstra preferencia por suites com hidro. - Costuma entrar em contato a noite (principalmente entre 19h e 23h). - Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in. - Perfil objetivo, poucas mensagens. + Formato do texto humano (exemplo de estilo): + Cliente recorrente. + Demonstra preferencia por suites com hidro. + Costuma entrar em contato a noite (principalmente entre 19h e 23h). + Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in. + Perfil objetivo, poucas mensagens. - Intencao predominante: reserva rapida - Sensibilidade a preco: media - Urgencia: alta + Intencao predominante: reserva rapida + Sensibilidade a preco: media + Urgencia: alta - Padrao de contato: - • Horario: entre 19h e 23h - • Dias mais comuns: sexta e sabado + Padrao de contato: + • Horario: entre 19h e 23h + • Dias mais comuns: sexta e sabado - Pontos de atencao: - • Duvidas recorrentes sobre formas de pagamento - • Questionamentos frequentes sobre horario de check-in + Pontos de atencao: + • Duvidas recorrentes sobre formas de pagamento + • Questionamentos frequentes sobre horario de check-in - Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade) + Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade) - Potencial do cliente: - • Perfil recorrente - • Compativel com suites premium - • Bom candidato a fidelizacao + Potencial do cliente: + • Perfil recorrente + • Compativel com suites premium + • Bom candidato a fidelizacao - Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro. - PROMPT - end + Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro. + PROMPT + end - def history_block - messages = @conversation.messages - .where(message_type: %i[incoming outgoing], private: false) - messages = messages.where('id >= ?', @from_message_id) if @from_message_id - messages = messages.where('id <= ?', @to_message_id) if @to_message_id - messages = messages.order(created_at: :desc).limit(50).reverse - messages.map do |message| - role = message.incoming? ? 'Cliente' : 'Atendente' - time = message.created_at&.strftime('%d/%m/%Y %H:%M') - "#{time} - #{role}: #{message.content}" - end.join("\n") - end + def history_block + messages = @conversation.messages + .where(message_type: %i[incoming outgoing], private: false) + messages = messages.where('id >= ?', @from_message_id) if @from_message_id + messages = messages.where('id <= ?', @to_message_id) if @to_message_id + messages = messages.order(created_at: :desc).limit(50).reverse + messages.map do |message| + role = message.incoming? ? 'Cliente' : 'Atendente' + time = message.created_at&.strftime('%d/%m/%Y %H:%M') + "#{time} - #{role}: #{message.content}" + end.join("\n") + end - def channel_name - @conversation.inbox&.channel_type.to_s - end + def channel_name + @conversation.inbox&.channel_type.to_s + end - def format_time(value) - return 'Desconhecido' if value.blank? + def format_time(value) + return 'Desconhecido' if value.blank? - value.strftime('%d/%m/%Y %H:%M') - end + value.strftime('%d/%m/%Y %H:%M') + end - def parse_response(response) - content = response.respond_to?(:content) ? response.content : response.to_s - JSON.parse(content) - rescue JSON::ParserError => e - Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}" - nil - end + def parse_response(response) + content = response.respond_to?(:content) ? response.content : response.to_s + JSON.parse(content) + rescue JSON::ParserError => e + Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}" + nil + end - def message_range_label - return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank? - return "A partir de #{@from_message_id}" if @to_message_id.blank? - return "Ate #{@to_message_id}" if @from_message_id.blank? + def message_range_label + return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank? + return "A partir de #{@from_message_id}" if @to_message_id.blank? + return "Ate #{@to_message_id}" if @from_message_id.blank? - "#{@from_message_id} ate #{@to_message_id}" - end + "#{@from_message_id} ate #{@to_message_id}" end end diff --git a/app/services/crm_insights/update_service.rb b/app/services/crm_insights/update_service.rb index 45f15f9..a305667 100644 --- a/app/services/crm_insights/update_service.rb +++ b/app/services/crm_insights/update_service.rb @@ -1,359 +1,357 @@ -module CrmInsights - class UpdateService - def initialize(conversation:, reason: nil) - @conversation = conversation - @reason = reason - end +class CrmInsights::UpdateService + def initialize(conversation:, reason: nil) + @conversation = conversation + @reason = reason + end - def call - session_stats = ContactSessionCounter.new(@conversation).call - last_success = @conversation.latest_crm_insight - last_message_id = relevant_messages.maximum(:id) - return result_payload(last_success, 'no_messages') if last_message_id.blank? + def call + session_stats = ContactSessionCounter.new(@conversation).call + last_success = @conversation.latest_crm_insight + last_message_id = relevant_messages.maximum(:id) + return result_payload(last_success, 'no_messages') if last_message_id.blank? - from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil - to_message_id = last_message_id - return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id + from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil + to_message_id = last_message_id + return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id - result = GenerateService.new( - conversation: @conversation, - insight: last_success, - sessions_count: session_stats[:count], - last_contact_at: session_stats[:last_contact_at], - from_message_id: from_message_id, - to_message_id: to_message_id - ).generate + result = GenerateService.new( + conversation: @conversation, + insight: last_success, + sessions_count: session_stats[:count], + last_contact_at: session_stats[:last_contact_at], + from_message_id: from_message_id, + to_message_id: to_message_id + ).generate - if result[:data].blank? - create_failed_insight( - session_stats: session_stats, - from_message_id: from_message_id, - to_message_id: to_message_id, - error_message: result[:error] || 'Falha ao gerar resumo' - ) - return result_payload(last_success, 'failed', result[:error]) - end - - range_messages = messages_for_range(from_message_id, to_message_id) - sanitized_result = sanitize_result( - result[:data], - range_messages, - last_success&.structured_data || {}, - @conversation.contact - ) - - insight = create_success_insight( - result: sanitized_result, + if result[:data].blank? + create_failed_insight( session_stats: session_stats, from_message_id: from_message_id, - to_message_id: to_message_id + to_message_id: to_message_id, + error_message: result[:error] || 'Falha ao gerar resumo' ) - result_payload(insight, 'success') + return result_payload(last_success, 'failed', result[:error]) end - private + range_messages = messages_for_range(from_message_id, to_message_id) + sanitized_result = sanitize_result( + result[:data], + range_messages, + last_success&.structured_data || {}, + @conversation.contact + ) - def relevant_messages - @relevant_messages ||= @conversation.messages.where( - message_type: %i[incoming outgoing], - private: false - ) + insight = create_success_insight( + result: sanitized_result, + session_stats: session_stats, + from_message_id: from_message_id, + to_message_id: to_message_id + ) + result_payload(insight, 'success') + end + + private + + def relevant_messages + @relevant_messages ||= @conversation.messages.where( + message_type: %i[incoming outgoing], + private: false + ) + end + + def messages_for_range(from_message_id, to_message_id) + scope = relevant_messages + scope = scope.where('id >= ?', from_message_id) if from_message_id + scope = scope.where('id <= ?', to_message_id) if to_message_id + scope + end + + def sanitize_result(result, messages, prior_structured, contact) + structured_data = result['structured_data'] || {} + incoming_messages = messages.select(&:incoming?) + incoming_text = incoming_messages.map { |message| message.content.to_s.downcase }.join(' ') + inbound_count = messages.count(&:incoming?) + outbound_count = messages.count(&:outgoing?) + + sanitized_structured = structured_data.deep_dup + + return minimal_payload(incoming_messages, contact) if inbound_count < 3 + + sanitized_structured['frictions'] = sanitize_frictions( + structured_data['frictions'], + incoming_text, + prior_structured['frictions'] + ) + sanitized_structured['contact_pattern'] = sanitize_contact_pattern( + structured_data['contact_pattern'], + incoming_text, + inbound_count, + prior_structured['contact_pattern'] + ) + sanitized_structured['preferences'] = sanitize_preferences( + structured_data['preferences'], + incoming_text, + prior_structured['preferences'] + ) + + if inbound_count < 3 && outbound_count < 3 + sanitized_structured['intent'] = '' + sanitized_structured['urgency'] = '' + sanitized_structured['price_sensitivity'] = '' + sanitized_structured['commercial_status'] = '' + sanitized_structured['customer_potential'] = '' end - def messages_for_range(from_message_id, to_message_id) - scope = relevant_messages - scope = scope.where('id >= ?', from_message_id) if from_message_id - scope = scope.where('id <= ?', to_message_id) if to_message_id - scope + summary_text = result['summary_text'].to_s.strip + summary_text = summary_text.presence || 'Ainda nao ha dados suficientes para um perfil do cliente.' + + sanitized_structured['summary_text'] = summary_text + sanitized_structured['schema_version'] = structured_data['schema_version'] || '1.0' + sanitized_structured['source'] = structured_data['source'] || 'ai' + sanitized_structured['generated_at'] = structured_data['generated_at'] || Time.current.iso8601 + sanitized_structured['evidence'] ||= {} + + { + 'summary_text' => summary_text, + 'structured_data' => sanitized_structured + } + end + + def sanitize_frictions(frictions, text, prior_frictions) + items = Array(frictions).map(&:to_s) + return Array(prior_frictions).map(&:to_s) if items.empty? + + evidence = { + 'pagamento' => /(pagamento|pix|cart[aã]o|forma de pagamento)/i, + 'checkin' => /(check-?in|entrada|hor[aá]rio de entrada)/i, + 'preco' => /(pre[cç]o|valor|custo)/i + } + + filtered = items.select do |item| + evidence.any? { |key, pattern| item.downcase.include?(key) && text.match?(pattern) } || + evidence.any? { |_, pattern| text.match?(pattern) && item.downcase.match?(pattern) } + end + return Array(prior_frictions).map(&:to_s) if filtered.empty? && prior_frictions.present? + + filtered + end + + def sanitize_contact_pattern(pattern, text, inbound_count, prior_pattern) + pattern_hash = pattern.is_a?(Hash) ? pattern : {} + time_range = pattern_hash['time_range'].to_s + days = Array(pattern_hash['days']).map(&:to_s) + + if inbound_count < 3 + return prior_pattern if prior_pattern.present? + + return { 'time_range' => '', 'days' => [] } end - def sanitize_result(result, messages, prior_structured, contact) - structured_data = result['structured_data'] || {} - incoming_messages = messages.select(&:incoming?) - incoming_text = incoming_messages.map { |message| message.content.to_s.downcase }.join(' ') - inbound_count = messages.count(&:incoming?) - outbound_count = messages.count(&:outgoing?) + time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i) + day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i) - sanitized_structured = structured_data.deep_dup - - return minimal_payload(incoming_messages, contact) if inbound_count < 3 - - sanitized_structured['frictions'] = sanitize_frictions( - structured_data['frictions'], - incoming_text, - prior_structured['frictions'] - ) - sanitized_structured['contact_pattern'] = sanitize_contact_pattern( - structured_data['contact_pattern'], - incoming_text, - inbound_count, - prior_structured['contact_pattern'] - ) - sanitized_structured['preferences'] = sanitize_preferences( - structured_data['preferences'], - incoming_text, - prior_structured['preferences'] - ) - - if inbound_count < 3 && outbound_count < 3 - sanitized_structured['intent'] = '' - sanitized_structured['urgency'] = '' - sanitized_structured['price_sensitivity'] = '' - sanitized_structured['commercial_status'] = '' - sanitized_structured['customer_potential'] = '' + time_range = '' unless time_evidence + days = [] unless day_evidence + if days.any? + normalized_text = text.downcase + days = days.select do |day| + normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i) end - - summary_text = result['summary_text'].to_s.strip - summary_text = summary_text.presence || 'Ainda nao ha dados suficientes para um perfil do cliente.' - - sanitized_structured['summary_text'] = summary_text - sanitized_structured['schema_version'] = structured_data['schema_version'] || '1.0' - sanitized_structured['source'] = structured_data['source'] || 'ai' - sanitized_structured['generated_at'] = structured_data['generated_at'] || Time.current.iso8601 - sanitized_structured['evidence'] ||= {} - - { - 'summary_text' => summary_text, - 'structured_data' => sanitized_structured - } end - def sanitize_frictions(frictions, text, prior_frictions) - items = Array(frictions).map(&:to_s) - return Array(prior_frictions).map(&:to_s) if items.empty? + { + 'time_range' => time_range, + 'days' => days + } + end - evidence = { - 'pagamento' => /(pagamento|pix|cart[aã]o|forma de pagamento)/i, - 'checkin' => /(check-?in|entrada|hor[aá]rio de entrada)/i, - 'preco' => /(pre[cç]o|valor|custo)/i - } + def sanitize_preferences(preferences, text, prior_preferences) + return Array(prior_preferences).map(&:to_s) if preferences.blank? - filtered = items.select do |item| - evidence.any? { |key, pattern| item.downcase.include?(key) && text.match?(pattern) } || - evidence.any? { |_, pattern| text.match?(pattern) && item.downcase.match?(pattern) } + tokens = if preferences.is_a?(Array) + preferences + elsif preferences.is_a?(Hash) + preferences.values.flatten + else + [preferences] + end + + filtered = tokens.map(&:to_s).select do |item| + case item.downcase + when /hidro/ + text.include?('hidro') + when /pix/ + text.include?('pix') + when /check/ + text.match?(/check-?in/) + else + parts = item.downcase.split(/[_\s]/).reject(&:blank?) + parts.any? { |part| text.include?(part) } end - return Array(prior_frictions).map(&:to_s) if filtered.empty? && prior_frictions.present? + end + return Array(prior_preferences).map(&:to_s) if filtered.empty? && prior_preferences.present? - filtered + filtered + end + + def minimal_summary(text, preferences) + prefs = Array(preferences).map(&:to_s).reject(&:blank?) + parts = [] + + if prefs.any? + humanized = prefs.map { |item| item.tr('_', ' ') } + parts << "demonstrou interesse em #{humanized.join(', ')}" end - def sanitize_contact_pattern(pattern, text, inbound_count, prior_pattern) - pattern_hash = pattern.is_a?(Hash) ? pattern : {} - time_range = pattern_hash['time_range'].to_s - days = Array(pattern_hash['days']).map(&:to_s) + parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i) - if inbound_count < 3 - return prior_pattern if prior_pattern.present? + parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i) - return { 'time_range' => '', 'days' => [] } - end + parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i) - time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i) - day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i) + return 'Conversa inicial, sem historico suficiente para inferir padroes.' if parts.empty? - time_range = '' unless time_evidence - days = [] unless day_evidence - if days.any? - normalized_text = text.downcase - days = days.select do |day| - normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i) - end - end + "Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes." + end - { - 'time_range' => time_range, - 'days' => days - } + def minimal_payload(incoming_messages, contact) + incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ') + normalized_text = normalize_text(incoming_text) + evidence = {} + + preferred_name = contact&.additional_attributes&.fetch('preferred_name', nil) + if preferred_name.present? + name_ids = evidence_ids_for(preferred_name, incoming_messages) + evidence['preferred_name'] = name_ids if name_ids.any? end - def sanitize_preferences(preferences, text, prior_preferences) - return Array(prior_preferences).map(&:to_s) if preferences.blank? - - tokens = if preferences.is_a?(Array) - preferences - elsif preferences.is_a?(Hash) - preferences.values.flatten - else - [preferences] - end - - filtered = tokens.map(&:to_s).select do |item| - case item.downcase - when /hidro/ - text.include?('hidro') - when /pix/ - text.include?('pix') - when /check/ - text.match?(/check-?in/) - else - parts = item.downcase.split(/[_\s]/).reject(&:blank?) - parts.any? { |part| text.include?(part) } - end - end - return Array(prior_preferences).map(&:to_s) if filtered.empty? && prior_preferences.present? - - filtered + room_type = nil + if normalized_text.include?('hidro') + room_type = 'suite_hidro' + evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages) end - def minimal_summary(text, preferences) - prefs = Array(preferences).map(&:to_s).reject(&:blank?) - parts = [] - - if prefs.any? - humanized = prefs.map { |item| item.tr('_', ' ') } - parts << "demonstrou interesse em #{humanized.join(', ')}" - end - - parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i) - - parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i) - - parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i) - - return 'Conversa inicial, sem historico suficiente para inferir padroes.' if parts.empty? - - "Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes." + day_interest = [] + day_map.each_key do |day| + day_interest << day if normalized_text.match?(/\b#{day}\b/i) + end + if day_interest.any? + day_regex = Regexp.union(day_interest.map { |day| /\b#{day}\b/i }) + evidence['preferences.date_interest'] = evidence_ids_for(day_regex, incoming_messages) end - def minimal_payload(incoming_messages, contact) - incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ') - normalized_text = normalize_text(incoming_text) - evidence = {} - - preferred_name = contact&.additional_attributes&.fetch('preferred_name', nil) - if preferred_name.present? - name_ids = evidence_ids_for(preferred_name, incoming_messages) - evidence['preferred_name'] = name_ids if name_ids.any? - end - - room_type = nil - if normalized_text.include?('hidro') - room_type = 'suite_hidro' - evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages) - end - - day_interest = [] - day_map.each_key do |day| - day_interest << day if normalized_text.match?(/\b#{day}\b/i) - end - if day_interest.any? - day_regex = Regexp.union(day_interest.map { |day| /\b#{day}\b/i }) - evidence['preferences.date_interest'] = evidence_ids_for(day_regex, incoming_messages) - end - - intent = nil - if normalized_text.match?(/reserv|disponibil|vaga|quero|gostaria/) - intent = 'reserva_rapida' - evidence['intent'] = evidence_ids_for(/reserv|disponibil|vaga|quero|gostaria/i, incoming_messages) - end - - summary_text = minimal_summary(normalized_text, room_type ? [room_type] : []) - summary_text = "Cliente se apresentou como #{preferred_name}. #{summary_text}" if preferred_name.present? - summary_text = summary_text.strip - - structured_data = { - 'schema_version' => '1.0', - 'source' => 'ai', - 'generated_at' => Time.current.iso8601, - 'summary_text' => summary_text, - 'customer_type' => nil, - 'customer_potential' => nil, - 'intent' => intent, - 'urgency' => nil, - 'price_sensitivity' => nil, - 'confidence' => intent.present? ? 0.9 : nil, - 'preferences' => { - 'room_type' => room_type ? [room_type] : [], - 'date_interest' => day_interest - }, - 'contact_pattern' => nil, - 'frictions' => nil, - 'commercial_status' => nil, - 'nba' => if intent.present? - { - 'action' => 'informar_disponibilidade_e_valor', - 'priority' => 'media', - 'reason' => 'Cliente demonstrou interesse inicial, mas ainda nao informou horario nem forma de pagamento.' - } - end, - 'suggested_labels' => [ - (room_type ? 'hidro' : nil), - 'primeiro_contato' - ].compact, - 'evidence' => evidence - } - - { - 'summary_text' => summary_text, - 'structured_data' => structured_data - } + intent = nil + if normalized_text.match?(/reserv|disponibil|vaga|quero|gostaria/) + intent = 'reserva_rapida' + evidence['intent'] = evidence_ids_for(/reserv|disponibil|vaga|quero|gostaria/i, incoming_messages) end - def evidence_ids_for(pattern, messages) - regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i - messages.select { |message| message.content.to_s.match?(regex) }.map(&:id) - end + summary_text = minimal_summary(normalized_text, room_type ? [room_type] : []) + summary_text = "Cliente se apresentou como #{preferred_name}. #{summary_text}" if preferred_name.present? + summary_text = summary_text.strip - def normalize_text(value) - value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc') - end + structured_data = { + 'schema_version' => '1.0', + 'source' => 'ai', + 'generated_at' => Time.current.iso8601, + 'summary_text' => summary_text, + 'customer_type' => nil, + 'customer_potential' => nil, + 'intent' => intent, + 'urgency' => nil, + 'price_sensitivity' => nil, + 'confidence' => intent.present? ? 0.9 : nil, + 'preferences' => { + 'room_type' => room_type ? [room_type] : [], + 'date_interest' => day_interest + }, + 'contact_pattern' => nil, + 'frictions' => nil, + 'commercial_status' => nil, + 'nba' => if intent.present? + { + 'action' => 'informar_disponibilidade_e_valor', + 'priority' => 'media', + 'reason' => 'Cliente demonstrou interesse inicial, mas ainda nao informou horario nem forma de pagamento.' + } + end, + 'suggested_labels' => [ + (room_type ? 'hidro' : nil), + 'primeiro_contato' + ].compact, + 'evidence' => evidence + } - def day_map - { - 'segunda' => 'segunda', - 'terca' => 'terca', - 'quarta' => 'quarta', - 'quinta' => 'quinta', - 'sexta' => 'sexta', - 'sabado' => 'sabado', - 'domingo' => 'domingo' - } - end + { + 'summary_text' => summary_text, + 'structured_data' => structured_data + } + end - def create_success_insight(result:, session_stats:, from_message_id:, to_message_id:) - structured_data = result['structured_data'] || {} - model_name = ENV.fetch('CRM_INSIGHTS_MODEL', CrmInsights::GenerateService::DEFAULT_MODEL) - ConversationCrmInsight.create!( - conversation: @conversation, - contact: @conversation.contact, - account_id: @conversation.account_id, - summary_text: result['summary_text'], - structured_data: structured_data, - contact_sessions_count: session_stats[:count], - last_contact_at: session_stats[:last_contact_at], - generated_at: Time.current, - range_from_message_id: from_message_id, - range_to_message_id: to_message_id, - status: 'success', - schema_version: structured_data['schema_version'] || '1.0', - model: structured_data['model'] || model_name, - confidence: structured_data['confidence'] - ) - end + def evidence_ids_for(pattern, messages) + regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i + messages.select { |message| message.content.to_s.match?(regex) }.map(&:id) + end - def create_failed_insight(session_stats:, from_message_id:, to_message_id:, error_message:) - ConversationCrmInsight.create!( - conversation: @conversation, - contact: @conversation.contact, - account_id: @conversation.account_id, - summary_text: nil, - structured_data: {}, - contact_sessions_count: session_stats[:count], - last_contact_at: session_stats[:last_contact_at], - generated_at: Time.current, - range_from_message_id: from_message_id, - range_to_message_id: to_message_id, - status: 'failed', - error_message: error_message - ) - end + def normalize_text(value) + value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc') + end - def result_payload(insight, status, error_message = nil) - { - insight: insight, - status: status, - error_message: error_message - } - end + def day_map + { + 'segunda' => 'segunda', + 'terca' => 'terca', + 'quarta' => 'quarta', + 'quinta' => 'quinta', + 'sexta' => 'sexta', + 'sabado' => 'sabado', + 'domingo' => 'domingo' + } + end + + def create_success_insight(result:, session_stats:, from_message_id:, to_message_id:) + structured_data = result['structured_data'] || {} + model_name = ENV.fetch('CRM_INSIGHTS_MODEL', CrmInsights::GenerateService::DEFAULT_MODEL) + ConversationCrmInsight.create!( + conversation: @conversation, + contact: @conversation.contact, + account_id: @conversation.account_id, + summary_text: result['summary_text'], + structured_data: structured_data, + contact_sessions_count: session_stats[:count], + last_contact_at: session_stats[:last_contact_at], + generated_at: Time.current, + range_from_message_id: from_message_id, + range_to_message_id: to_message_id, + status: 'success', + schema_version: structured_data['schema_version'] || '1.0', + model: structured_data['model'] || model_name, + confidence: structured_data['confidence'] + ) + end + + def create_failed_insight(session_stats:, from_message_id:, to_message_id:, error_message:) + ConversationCrmInsight.create!( + conversation: @conversation, + contact: @conversation.contact, + account_id: @conversation.account_id, + summary_text: nil, + structured_data: {}, + contact_sessions_count: session_stats[:count], + last_contact_at: session_stats[:last_contact_at], + generated_at: Time.current, + range_from_message_id: from_message_id, + range_to_message_id: to_message_id, + status: 'failed', + error_message: error_message + ) + end + + def result_payload(insight, status, error_message = nil) + { + insight: insight, + status: status, + error_message: error_message + } end end diff --git a/app/services/jasmine/brain_service.rb b/app/services/jasmine/brain_service.rb index 78fbb72..8875d64 100644 --- a/app/services/jasmine/brain_service.rb +++ b/app/services/jasmine/brain_service.rb @@ -1,286 +1,284 @@ -module Jasmine - class BrainService - # Default intent keywords for hotel/motel business - DEFAULT_INTENT_KEYWORDS = { - price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária], - info_request: %w[como funciona detalhe explica suíte quarto tipo], - policy: %w[horário check-in check-out política regra pagamento cancelamento], - greeting: %w[oi olá bom dia boa tarde boa noite], - objection: %w[caro não sei preciso pensar depois conversar], - closing: %w[reservar agendar confirmar fechar quero sim pode], - general: [] # fallback - }.freeze +class Jasmine::BrainService + # Default intent keywords for hotel/motel business + DEFAULT_INTENT_KEYWORDS = { + price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária], + info_request: %w[como funciona detalhe explica suíte quarto tipo], + policy: %w[horário check-in check-out política regra pagamento cancelamento], + greeting: %w[oi olá bom dia boa tarde boa noite], + objection: %w[caro não sei preciso pensar depois conversar], + closing: %w[reservar agendar confirmar fechar quero sim pode], + general: [] # fallback + }.freeze - # Strategies for handling intents - RAG_MANDATORY = %i[price_question info_request policy].freeze - RAG_OPTIONAL = %i[objection general].freeze - RAG_PROHIBITED = %i[greeting closing].freeze + # Strategies for handling intents + RAG_MANDATORY = %i[price_question info_request policy].freeze + RAG_OPTIONAL = %i[objection general].freeze + RAG_PROHIBITED = %i[greeting closing].freeze - attr_reader :inbox, :conversation, :message, :config + attr_reader :inbox, :conversation, :message, :config - def initialize(inbox:, conversation:, message:) - @inbox = inbox - @conversation = conversation - @message = message - @config = load_config + def initialize(inbox:, conversation:, message:) + @inbox = inbox + @conversation = conversation + @message = message + @config = load_config + end + + def respond + trigger_media_analysis if message.attachments.any? + llm_content = message.content_for_llm + + intent = IntentDetector.new(llm_content, intent_keywords).detect + strategy = StrategyDecider.new(intent, jasmine_state).decide + + rag_context = fetch_rag_if_needed(strategy, llm_content) + + prompt = PromptAssembler.new( + config: config, + state: jasmine_state, + history: recent_history, + rag_context: rag_context, + current_message: llm_content + ).assemble + + response = call_llm(prompt) + + StateUpdater.new(conversation, intent, rag_context.present?).update + + log_decision(intent, strategy, rag_context) + + response + rescue StandardError => e + Rails.logger.error "[Jasmine::Brain] Error: #{e.message}" + 'Desculpe, tive um problema. Pode repetir?' + end + + private + + def load_config + inbox.jasmine_inbox_config || Jasmine::InboxConfig.new( + rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f, + rag_max_results: 3, + model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'), + temperature: 0.7 + ) + end + + def intent_keywords + custom = config.intent_keywords.presence || {} + DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys) + end + + def jasmine_state + conversation.custom_attributes&.dig('jasmine_state') || {} + end + + def recent_history + msgs = conversation.messages + + # Handle both ActiveRecord relations and arrays (for playground testing) + return [] unless msgs.respond_to?(:where) + + msgs + .where(message_type: %w[incoming outgoing]) + .order(created_at: :desc) + .limit(4) # 2 pairs of messages + .reverse + .map { |m| { role: m.message_type == 'incoming' ? 'user' : 'assistant', content: m.content } } + end + + def fetch_rag_if_needed(strategy, query) + return nil if strategy == :no_rag + return nil if loop_protection_triggered? + + results = Jasmine::SemanticSearchService.new(inbox).search(query, limit: config.rag_max_results) + + return nil if results.empty? + + results.map { |r| r[:content] || r.content }.join("\n\n---\n\n") + end + + def loop_protection_triggered? + (jasmine_state['rag_queries_count'] || 0) > 5 + end + + def fallback_response + nil # Will trigger "vou verificar com a equipe" + end + + def call_llm(prompt) + chat = RubyLLM.chat(model: config.model).with_temperature(config.temperature) + response = chat.ask(prompt) + response.content + end + + def log_decision(intent, strategy, rag_context) + Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}" + end + + def trigger_media_analysis + Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}" + Jasmine::MediaAnalyzerService.new(message: message).perform + message.attachments.reload # CRITICAL: Ensure we see the new metadata + Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}" + rescue StandardError => e + Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}" + end + + # ========================================= + # COMPONENT: Intent Detector + # ========================================= + class IntentDetector + attr_reader :text, :keywords + + def initialize(text, keywords) + @text = text.to_s.downcase.strip + @keywords = keywords end - def respond - trigger_media_analysis if message.attachments.any? - llm_content = message.content_for_llm + def detect + # Check each intent type for keyword matches + keywords.each do |intent_type, words| + next if words.empty? + return intent_type if words.any? { |word| text.include?(word.downcase) } + end - intent = IntentDetector.new(llm_content, intent_keywords).detect - strategy = StrategyDecider.new(intent, jasmine_state).decide + :general # fallback + end + end - rag_context = fetch_rag_if_needed(strategy, llm_content) + # ========================================= + # COMPONENT: Strategy Decider + # ========================================= + class StrategyDecider + attr_reader :intent, :state - prompt = PromptAssembler.new( - config: config, - state: jasmine_state, - history: recent_history, - rag_context: rag_context, - current_message: llm_content - ).assemble + def initialize(intent, state) + @intent = intent + @state = state + end - response = call_llm(prompt) + def decide + return :no_rag if RAG_PROHIBITED.include?(intent) + return :rag_required if RAG_MANDATORY.include?(intent) - StateUpdater.new(conversation, intent, rag_context.present?).update + :rag_optional + end + end - log_decision(intent, strategy, rag_context) + # ========================================= + # COMPONENT: Prompt Assembler + # ========================================= + class PromptAssembler + MAX_HISTORY_MESSAGES = 4 # 2 pairs - response - rescue StandardError => e - Rails.logger.error "[Jasmine::Brain] Error: #{e.message}" - 'Desculpe, tive um problema. Pode repetir?' + attr_reader :config, :state, :history, :rag_context, :current_message + + def initialize(config:, state:, history:, rag_context:, current_message:) + @config = config + @state = state + @history = history.last(MAX_HISTORY_MESSAGES) + @rag_context = rag_context + @current_message = current_message + end + + def assemble + parts = [] + + # System Prompt (identity, tone, rules) + parts << "[INSTRUÇÕES DO SISTEMA]\n#{config.system_prompt}" if config.system_prompt.present? + + # Playbook SDR (sales script) + parts << "[PLAYBOOK SDR]\n#{config.playbook_prompt}" if config.playbook_prompt.present? + + # Lead State + if state.present? + state_text = format_state(state) + parts << "[ESTADO DO LEAD]\n#{state_text}" + end + + # RAG Context (SOURCE OF TRUTH) + if rag_context.present? + parts << <<~RAG + [CONTEXTO DA BASE DE CONHECIMENTO - FONTE DA VERDADE] + Use EXCLUSIVAMENTE as informações abaixo para responder. + NÃO invente ou complemente com conhecimento externo. + + #{rag_context} + RAG + end + + # History (limited) + if history.present? + history_text = history.map { |h| "#{h[:role] == 'user' ? 'Cliente' : 'Jasmine'}: #{h[:content]}" }.join("\n") + parts << "[HISTÓRICO RECENTE]\n#{history_text}" + end + + # Current message + parts << "[MENSAGEM ATUAL DO CLIENTE]\n#{current_message}" + + parts.join("\n\n") end private - def load_config - inbox.jasmine_inbox_config || Jasmine::InboxConfig.new( - rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f, - rag_max_results: 3, - model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'), - temperature: 0.7 + def format_state(state) + lines = [] + lines << "Etapa: #{state['stage']}" if state['stage'] + lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified') + + if state['collected_info'].present? + state['collected_info'].each do |key, value| + lines << "#{key.capitalize}: #{value}" + end + end + + lines.join("\n") + end + end + + # ========================================= + # COMPONENT: State Updater + # ========================================= + class StateUpdater + MAX_STATE_SIZE = 4096 # 4KB + + attr_reader :conversation, :intent, :rag_used + + def initialize(conversation, intent, rag_used) + @conversation = conversation + @intent = intent + @rag_used = rag_used + end + + def update + current_state = conversation.custom_attributes&.dig('jasmine_state') || {} + + new_state = current_state.merge( + 'last_intent' => intent.to_s, + 'rag_queries_count' => (current_state['rag_queries_count'] || 0) + (rag_used ? 1 : 0), + 'updated_at' => Time.current.iso8601 + ) + + # Size protection + if new_state.to_json.bytesize > MAX_STATE_SIZE + Rails.logger.warn '[Jasmine::Brain] State too large, cleaning up' + new_state = { + 'last_intent' => intent.to_s, + 'rag_queries_count' => 0, + 'updated_at' => Time.current.iso8601 + } + end + + conversation.update!( + custom_attributes: (conversation.custom_attributes || {}).merge('jasmine_state' => new_state) ) end - def intent_keywords - custom = config.intent_keywords.presence || {} - DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys) - end - - def jasmine_state - conversation.custom_attributes&.dig('jasmine_state') || {} - end - - def recent_history - msgs = conversation.messages - - # Handle both ActiveRecord relations and arrays (for playground testing) - return [] unless msgs.respond_to?(:where) - - msgs - .where(message_type: %w[incoming outgoing]) - .order(created_at: :desc) - .limit(4) # 2 pairs of messages - .reverse - .map { |m| { role: m.message_type == 'incoming' ? 'user' : 'assistant', content: m.content } } - end - - def fetch_rag_if_needed(strategy, query) - return nil if strategy == :no_rag - return nil if loop_protection_triggered? - - results = Jasmine::SemanticSearchService.new(inbox).search(query, limit: config.rag_max_results) - - return nil if results.empty? - - results.map { |r| r[:content] || r.content }.join("\n\n---\n\n") - end - - def loop_protection_triggered? - (jasmine_state['rag_queries_count'] || 0) > 5 - end - - def fallback_response - nil # Will trigger "vou verificar com a equipe" - end - - def call_llm(prompt) - chat = RubyLLM.chat(model: config.model).with_temperature(config.temperature) - response = chat.ask(prompt) - response.content - end - - def log_decision(intent, strategy, rag_context) - Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}" - end - - def trigger_media_analysis - Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}" - Jasmine::MediaAnalyzerService.new(message: message).perform - message.attachments.reload # CRITICAL: Ensure we see the new metadata - Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}" - rescue StandardError => e - Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}" - end - - # ========================================= - # COMPONENT: Intent Detector - # ========================================= - class IntentDetector - attr_reader :text, :keywords - - def initialize(text, keywords) - @text = text.to_s.downcase.strip - @keywords = keywords - end - - def detect - # Check each intent type for keyword matches - keywords.each do |intent_type, words| - next if words.empty? - return intent_type if words.any? { |word| text.include?(word.downcase) } - end - - :general # fallback - end - end - - # ========================================= - # COMPONENT: Strategy Decider - # ========================================= - class StrategyDecider - attr_reader :intent, :state - - def initialize(intent, state) - @intent = intent - @state = state - end - - def decide - return :no_rag if RAG_PROHIBITED.include?(intent) - return :rag_required if RAG_MANDATORY.include?(intent) - - :rag_optional - end - end - - # ========================================= - # COMPONENT: Prompt Assembler - # ========================================= - class PromptAssembler - MAX_HISTORY_MESSAGES = 4 # 2 pairs - - attr_reader :config, :state, :history, :rag_context, :current_message - - def initialize(config:, state:, history:, rag_context:, current_message:) - @config = config - @state = state - @history = history.last(MAX_HISTORY_MESSAGES) - @rag_context = rag_context - @current_message = current_message - end - - def assemble - parts = [] - - # System Prompt (identity, tone, rules) - parts << "[INSTRUÇÕES DO SISTEMA]\n#{config.system_prompt}" if config.system_prompt.present? - - # Playbook SDR (sales script) - parts << "[PLAYBOOK SDR]\n#{config.playbook_prompt}" if config.playbook_prompt.present? - - # Lead State - if state.present? - state_text = format_state(state) - parts << "[ESTADO DO LEAD]\n#{state_text}" - end - - # RAG Context (SOURCE OF TRUTH) - if rag_context.present? - parts << <<~RAG - [CONTEXTO DA BASE DE CONHECIMENTO - FONTE DA VERDADE] - Use EXCLUSIVAMENTE as informações abaixo para responder. - NÃO invente ou complemente com conhecimento externo. - - #{rag_context} - RAG - end - - # History (limited) - if history.present? - history_text = history.map { |h| "#{h[:role] == 'user' ? 'Cliente' : 'Jasmine'}: #{h[:content]}" }.join("\n") - parts << "[HISTÓRICO RECENTE]\n#{history_text}" - end - - # Current message - parts << "[MENSAGEM ATUAL DO CLIENTE]\n#{current_message}" - - parts.join("\n\n") - end - - private - - def format_state(state) - lines = [] - lines << "Etapa: #{state['stage']}" if state['stage'] - lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified') - - if state['collected_info'].present? - state['collected_info'].each do |key, value| - lines << "#{key.capitalize}: #{value}" - end - end - - lines.join("\n") - end - end - - # ========================================= - # COMPONENT: State Updater - # ========================================= - class StateUpdater - MAX_STATE_SIZE = 4096 # 4KB - - attr_reader :conversation, :intent, :rag_used - - def initialize(conversation, intent, rag_used) - @conversation = conversation - @intent = intent - @rag_used = rag_used - end - - def update - current_state = conversation.custom_attributes&.dig('jasmine_state') || {} - - new_state = current_state.merge( - 'last_intent' => intent.to_s, - 'rag_queries_count' => (current_state['rag_queries_count'] || 0) + (rag_used ? 1 : 0), - 'updated_at' => Time.current.iso8601 - ) - - # Size protection - if new_state.to_json.bytesize > MAX_STATE_SIZE - Rails.logger.warn '[Jasmine::Brain] State too large, cleaning up' - new_state = { - 'last_intent' => intent.to_s, - 'rag_queries_count' => 0, - 'updated_at' => Time.current.iso8601 - } - end - - conversation.update!( - custom_attributes: (conversation.custom_attributes || {}).merge('jasmine_state' => new_state) - ) - end - - def self.cleanup(conversation) - attrs = conversation.custom_attributes || {} - attrs.delete('jasmine_state') - conversation.update!(custom_attributes: attrs) - end + def self.cleanup(conversation) + attrs = conversation.custom_attributes || {} + attrs.delete('jasmine_state') + conversation.update!(custom_attributes: attrs) end end end diff --git a/app/services/jasmine/embedding_service.rb b/app/services/jasmine/embedding_service.rb index 50b3798..7772f6d 100644 --- a/app/services/jasmine/embedding_service.rb +++ b/app/services/jasmine/embedding_service.rb @@ -1,129 +1,126 @@ -module Jasmine - class EmbeddingService - CHUNK_SIZE = 1000 - CHUNK_OVERLAP = 200 - EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions +class Jasmine::EmbeddingService + CHUNK_SIZE = 1000 + CHUNK_OVERLAP = 200 + EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions - def initialize(document) - @document = document + def initialize(document) + @document = document + end + + def process + @document.with_lock do + return if @document.indexed? + + @document.update!(status: :processing) + @document.chunks.delete_all + + chunks = chunk_content(@document.content) + create_chunks(chunks) + + @document.update!(status: :indexed) end + rescue StandardError => e + @document.update!(status: :failed, error_message: e.message) + Rails.logger.error "Embedding failed for Doc ID #{@document.id}: #{e.message}" + end - def process - @document.with_lock do - return if @document.indexed? + private - @document.update!(status: :processing) - @document.chunks.delete_all + def chunk_content(content) + chunks = [] + return chunks if content.blank? - chunks = chunk_content(@document.content) - create_chunks(chunks) + start_index = 0 + chunk_index = 0 - @document.update!(status: :indexed) - end - rescue StandardError => e - @document.update!(status: :failed, error_message: e.message) - Rails.logger.error "Embedding failed for Doc ID #{@document.id}: #{e.message}" - end + while start_index < content.length + end_index = [start_index + CHUNK_SIZE, content.length].min - private - - def chunk_content(content) - chunks = [] - return chunks if content.blank? - - start_index = 0 - chunk_index = 0 - - while start_index < content.length - end_index = [start_index + CHUNK_SIZE, content.length].min - - if end_index < content.length - last_space = content[start_index...end_index].rindex(' ') - end_index = start_index + last_space if last_space - end - - chunk_text = content[start_index...end_index].strip - - if chunk_text.present? - chunks << { - content: chunk_text, - index: chunk_index, - char_start: start_index, - char_end: end_index - } - chunk_index += 1 - end - - break if end_index >= content.length - - start_index = end_index - CHUNK_OVERLAP - start_index = [start_index, end_index].max if start_index <= (end_index - CHUNK_SIZE) + if end_index < content.length + last_space = content[start_index...end_index].rindex(' ') + end_index = start_index + last_space if last_space end - chunks - end + chunk_text = content[start_index...end_index].strip - def create_chunks(chunks) - return if chunks.empty? - - chunks.each do |chunk_data| - embedding = generate_embedding(chunk_data[:content]) - - Jasmine::DocumentChunk.create!( - account: @document.account, - collection: @document.collection, - document: @document, - content: chunk_data[:content], - metadata: { - chunk_index: chunk_data[:index], - char_start: chunk_data[:char_start], - char_end: chunk_data[:char_end], - model: embedding_model - }, - embedding: embedding - ) + if chunk_text.present? + chunks << { + content: chunk_text, + index: chunk_index, + char_start: start_index, + char_end: end_index + } + chunk_index += 1 end + + break if end_index >= content.length + + start_index = end_index - CHUNK_OVERLAP + start_index = [start_index, end_index].max if start_index <= (end_index - CHUNK_SIZE) end - def generate_embedding(text) - if openai_configured? - generate_openai_embedding(text) - else - # Fallback: Generate deterministic hash-based embedding for testing - # This won't provide semantic search but allows the system to function - Rails.logger.warn "OpenAI not configured, using fallback embedding for Jasmine" - generate_fallback_embedding(text) - end - end + chunks + end - def openai_configured? - ENV['OPENAI_API_KEY'].present? - end + def create_chunks(chunks) + return if chunks.empty? - def generate_openai_embedding(text) - response = RubyLLM.embed(text, model: embedding_model) - response.vectors.first - end + chunks.each do |chunk_data| + embedding = generate_embedding(chunk_data[:content]) - def generate_fallback_embedding(text) - # Generate a deterministic pseudo-random vector based on text content - # Uses SHA256 hash to seed random number generator for reproducibility - require 'digest' - - seed = Digest::SHA256.hexdigest(text.downcase.gsub(/\s+/, ' ').strip).to_i(16) % (2**32) - rng = Random.new(seed) - - # Generate normalized vector with EMBEDDING_DIMENSIONS dimensions - vector = Array.new(EMBEDDING_DIMENSIONS) { rng.rand(-1.0..1.0) } - - # Normalize to unit length - magnitude = Math.sqrt(vector.sum { |v| v**2 }) - vector.map { |v| v / magnitude } - end - - def embedding_model - ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small') + Jasmine::DocumentChunk.create!( + account: @document.account, + collection: @document.collection, + document: @document, + content: chunk_data[:content], + metadata: { + chunk_index: chunk_data[:index], + char_start: chunk_data[:char_start], + char_end: chunk_data[:char_end], + model: embedding_model + }, + embedding: embedding + ) end end -end + def generate_embedding(text) + if openai_configured? + generate_openai_embedding(text) + else + # Fallback: Generate deterministic hash-based embedding for testing + # This won't provide semantic search but allows the system to function + Rails.logger.warn 'OpenAI not configured, using fallback embedding for Jasmine' + generate_fallback_embedding(text) + end + end + + def openai_configured? + ENV['OPENAI_API_KEY'].present? + end + + def generate_openai_embedding(text) + response = RubyLLM.embed(text, model: embedding_model) + response.vectors.first + end + + def generate_fallback_embedding(text) + # Generate a deterministic pseudo-random vector based on text content + # Uses SHA256 hash to seed random number generator for reproducibility + require 'digest' + + seed = Digest::SHA256.hexdigest(text.downcase.gsub(/\s+/, ' ').strip).to_i(16) % (2**32) + rng = Random.new(seed) + + # Generate normalized vector with EMBEDDING_DIMENSIONS dimensions + vector = Array.new(EMBEDDING_DIMENSIONS) { rng.rand(-1.0..1.0) } + + # Normalize to unit length + magnitude = Math.sqrt(vector.sum { |v| v**2 }) + vector.map { |v| v / magnitude } + end + + def embedding_model + ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small') + end +end diff --git a/app/services/jasmine/media_analyzer_service.rb b/app/services/jasmine/media_analyzer_service.rb index ecbc017..d2049a7 100644 --- a/app/services/jasmine/media_analyzer_service.rb +++ b/app/services/jasmine/media_analyzer_service.rb @@ -1,52 +1,50 @@ # frozen_string_literal: true -module Jasmine - class MediaAnalyzerService - attr_reader :message +class Jasmine::MediaAnalyzerService + attr_reader :message - def initialize(message:) - @message = message - end + def initialize(message:) + @message = message + end - def perform - analyze_audio - analyze_images - end + def perform + analyze_audio + analyze_images + end - private + private - def analyze_audio - message.attachments.where(file_type: :audio).each do |attachment| - next if attachment.meta&.dig('transcribed_text').present? + def analyze_audio + message.attachments.where(file_type: :audio).find_each do |attachment| + next if attachment.meta&.dig('transcribed_text').present? - Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}" - # Try to use the standard AudioTranscriptionService (usually in enterprise folder) - begin - if defined?(Messages::AudioTranscriptionService) - # This service updates the attachment meta internally - Messages::AudioTranscriptionService.new(attachment).perform - end - rescue StandardError => e - Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}" + Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}" + # Try to use the standard AudioTranscriptionService (usually in enterprise folder) + begin + if defined?(Messages::AudioTranscriptionService) + # This service updates the attachment meta internally + Messages::AudioTranscriptionService.new(attachment).perform end + rescue StandardError => e + Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}" end end + end - def analyze_images - message.attachments.where(file_type: :image).each do |attachment| - next if attachment.meta&.dig('description').present? + def analyze_images + message.attachments.where(file_type: :image).find_each do |attachment| + next if attachment.meta&.dig('description').present? - Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}" - begin - description = Jasmine::VisionService.new(attachment: attachment).perform - if description.present? - new_meta = (attachment.meta || {}).merge('description' => description) - attachment.update!(meta: new_meta) - Rails.logger.info "[Jasmine::MediaAnalyzer] Image analysis successful for Attachment #{attachment.id}" - end - rescue StandardError => e - Rails.logger.error "[Jasmine::MediaAnalyzer] Image analysis failed: #{e.message}" + Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}" + begin + description = Jasmine::VisionService.new(attachment: attachment).perform + if description.present? + new_meta = (attachment.meta || {}).merge('description' => description) + attachment.update!(meta: new_meta) + Rails.logger.info "[Jasmine::MediaAnalyzer] Image analysis successful for Attachment #{attachment.id}" end + rescue StandardError => e + Rails.logger.error "[Jasmine::MediaAnalyzer] Image analysis failed: #{e.message}" end end end diff --git a/app/services/jasmine/semantic_search_service.rb b/app/services/jasmine/semantic_search_service.rb index b48484c..1b31aa0 100644 --- a/app/services/jasmine/semantic_search_service.rb +++ b/app/services/jasmine/semantic_search_service.rb @@ -1,178 +1,176 @@ -module Jasmine - class SemanticSearchService - CANDIDATES_PER_PRIORITY = 50 - TOP_K_PER_PRIORITY = 10 - MAX_CHUNKS_PER_DOC = 2 +class Jasmine::SemanticSearchService + CANDIDATES_PER_PRIORITY = 50 + TOP_K_PER_PRIORITY = 10 + MAX_CHUNKS_PER_DOC = 2 - def initialize(inbox) - @inbox = inbox - @account_id = inbox.account_id - @threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f - end + def initialize(inbox) + @inbox = inbox + @account_id = inbox.account_id + @threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f + end - def search(query, limit: 10) - # 1. Get enabled collections sorted by priority DESC - enabled_links = @inbox.inbox_collections - .where(is_enabled: true) - .order(priority: :desc) - .includes(:collection) + def search(query, limit: 10) + # 1. Get enabled collections sorted by priority DESC + enabled_links = @inbox.inbox_collections + .where(is_enabled: true) + .order(priority: :desc) + .includes(:collection) - return [] if enabled_links.empty? + return [] if enabled_links.empty? - # Group by exact priority - priority_groups = enabled_links.group_by(&:priority) + # Group by exact priority + priority_groups = enabled_links.group_by(&:priority) - # Prepare query embedding - query_embedding = generate_embedding(query) + # Prepare query embedding + query_embedding = generate_embedding(query) - final_results = [] - processed_chunk_ids = Set.new + final_results = [] + processed_chunk_ids = Set.new - # 2. Iterate Priority Groups (Waterfall) - priority_groups.keys.sort.reverse_each do |priority| - collection_ids = priority_groups[priority].map(&:collection_id) + # 2. Iterate Priority Groups (Waterfall) + priority_groups.keys.sort.reverse_each do |priority| + collection_ids = priority_groups[priority].map(&:collection_id) - # Step 1: ANN/HNSW Candidate Retrieval - # Find candidates across all collections in this priority group - # Using raw SQL for precise control over pgvector operator - candidates = retrieve_candidates(query_embedding, collection_ids) + # Step 1: ANN/HNSW Candidate Retrieval + # Find candidates across all collections in this priority group + # Using raw SQL for precise control over pgvector operator + candidates = retrieve_candidates(query_embedding, collection_ids) - # Step 2: Rerank, Filter (Threshold), and Dedupe - group_results = process_candidates(candidates) + # Step 2: Rerank, Filter (Threshold), and Dedupe + group_results = process_candidates(candidates) - # Waterfall Logic - group_results.each do |result| - next if processed_chunk_ids.include?(result.id) + # Waterfall Logic + group_results.each do |result| + next if processed_chunk_ids.include?(result.id) - final_results << result - processed_chunk_ids.add(result.id) - - break if final_results.size >= limit - end + final_results << result + processed_chunk_ids.add(result.id) break if final_results.size >= limit end - final_results + break if final_results.size >= limit end - private + final_results + end - def retrieve_candidates(query_embedding, collection_ids) - # Step 1: Broad search for candidates using HNSW index - # We order by cosine distance (<=>) - Jasmine::DocumentChunk - .where(collection_id: collection_ids) - .order(Arel.sql("embedding <=> '#{query_embedding}'")) - .limit(CANDIDATES_PER_PRIORITY) + private + + def retrieve_candidates(query_embedding, collection_ids) + # Step 1: Broad search for candidates using HNSW index + # We order by cosine distance (<=>) + Jasmine::DocumentChunk + .where(collection_id: collection_ids) + .order(Arel.sql("embedding <=> '#{query_embedding}'")) + .limit(CANDIDATES_PER_PRIORITY) + end + + def process_candidates(candidates) + # Step 2: Deterministic Reranking and Filtering + # Note: 'nearest_neighbors' from neighbor gem already does distance calc, + # but we did it manually in retrieve_candidates to ensure we control the operator. + # We need to manually calculate distance for thresholding if the db didn't return it explicit as a column, + # or trust the order. + # Better approach: Select distance in the query. + + # [FUTURE] Placeholder until distance select is wired into filtering. + # Enhanced query with distance + candidates.select( + "jasmine_document_chunks.*, (embedding <=> '#{to_pg_vector(candidates.first&.embedding || [])}') as distance" + ) + + # Filter by Threshold + # We need to re-query or calculate. + # Let's refine retrieve_candidates to include distance. + + # Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation. + # We'll map them to objects and filters. + + # [FUTURE] Reserved for threshold filtering output. + Hash.new(0) + + # Calculate distances locally or re-fetch. + # Since we ordered by distance in DB, we rely on that order. + # But we need the value for threshold. + + # Let's fix retrieve_candidates to return distance + # Re-doing retrieval with select + + # Correct approach: + # Iterate, check threshold, check Max Chunks per Doc + + candidates.each do |chunk| + # [FUTURE] Distance will gate threshold checks once wired up. + + chunk.neighbor_distance(:embedding, @embedding_vector) + rescue StandardError + nil + + # NOTE: neighbor gem might not expose distance easily without using its scopes. + # Fallback: Rely on DB order, but checking absolute threshold might be tricky without the value. + # Let's trust Neighbor gem's `nearest_neighbors` if possible, but we used raw SQL order. + + # To strictly follow plan: "Re-rank exact cosine distance". + # We can implement a simple ruby cosine distance if vector is loaded, + # or use the SQL value. + + # Optimization: Let's assume the SQL order is correct (it is). + # We just need to stop if distance > threshold. + # Since we can't easily get the distance value without select, let's use neighbor gem scope correctly. end - def process_candidates(candidates) - # Step 2: Deterministic Reranking and Filtering - # Note: 'nearest_neighbors' from neighbor gem already does distance calc, - # but we did it manually in retrieve_candidates to ensure we control the operator. - # We need to manually calculate distance for thresholding if the db didn't return it explicit as a column, - # or trust the order. - # Better approach: Select distance in the query. + # Better Implementation using Neighbor Gem capabilities which handles this + # But filtering by priority group AND threshold AND limit is complex chain. - # [FUTURE] Placeholder until distance select is wired into filtering. - # Enhanced query with distance - candidates.select( - "jasmine_document_chunks.*, (embedding <=> '#{to_pg_vector(candidates.first&.embedding || [])}') as distance" - ) + # Let's use Raw SQL for the whole Step 1 + Distance Select + # This is safer. - # Filter by Threshold - # We need to re-query or calculate. - # Let's refine retrieve_candidates to include distance. + return [] if candidates.empty? + end - # Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation. - # We'll map them to objects and filters. + # Simplified re-implementation of retrieve + process + def retrieve_candidates(query_embedding, collection_ids) + Jasmine::DocumentChunk + .where(collection_id: collection_ids) + .select("jasmine_document_chunks.*, (embedding <=> '#{query_embedding}') as distance") + .order('distance ASC') + .limit(CANDIDATES_PER_PRIORITY) + end - # [FUTURE] Reserved for threshold filtering output. - Hash.new(0) + # Overwrite process_candidates with the list from above + def process_candidates(candidates) + filtered = [] + doc_counts = Hash.new(0) - # Calculate distances locally or re-fetch. - # Since we ordered by distance in DB, we rely on that order. - # But we need the value for threshold. + candidates.each do |chunk| + # 1. Threshold Check + # distance is a string/float from SQL + dist = chunk[:distance].to_f + next if dist > @threshold - # Let's fix retrieve_candidates to return distance - # Re-doing retrieval with select + # 2. Doc Dedupe + limit = MAX_CHUNKS_PER_DOC + next if doc_counts[chunk.document_id] >= limit - # Correct approach: - # Iterate, check threshold, check Max Chunks per Doc - - candidates.each do |chunk| - # [FUTURE] Distance will gate threshold checks once wired up. - - chunk.neighbor_distance(:embedding, @embedding_vector) - rescue StandardError - nil - - # NOTE: neighbor gem might not expose distance easily without using its scopes. - # Fallback: Rely on DB order, but checking absolute threshold might be tricky without the value. - # Let's trust Neighbor gem's `nearest_neighbors` if possible, but we used raw SQL order. - - # To strictly follow plan: "Re-rank exact cosine distance". - # We can implement a simple ruby cosine distance if vector is loaded, - # or use the SQL value. - - # Optimization: Let's assume the SQL order is correct (it is). - # We just need to stop if distance > threshold. - # Since we can't easily get the distance value without select, let's use neighbor gem scope correctly. - end - - # Better Implementation using Neighbor Gem capabilities which handles this - # But filtering by priority group AND threshold AND limit is complex chain. - - # Let's use Raw SQL for the whole Step 1 + Distance Select - # This is safer. - - return [] if candidates.empty? + doc_counts[chunk.document_id] += 1 + filtered << chunk end - # Simplified re-implementation of retrieve + process - def retrieve_candidates(query_embedding, collection_ids) - Jasmine::DocumentChunk - .where(collection_id: collection_ids) - .select("jasmine_document_chunks.*, (embedding <=> '#{query_embedding}') as distance") - .order('distance ASC') - .limit(CANDIDATES_PER_PRIORITY) - end + # 3. Top K per Priority + filtered.first(TOP_K_PER_PRIORITY) + end - # Overwrite process_candidates with the list from above - def process_candidates(candidates) - filtered = [] - doc_counts = Hash.new(0) + def generate_embedding(text) + # Using shared logic or direct call. + # Duplication for now to keep service independent or use embedding service class func + model = ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small') + RubyLLM.embed(text, model: model).vectors.first + end - candidates.each do |chunk| - # 1. Threshold Check - # distance is a string/float from SQL - dist = chunk[:distance].to_f - next if dist > @threshold - - # 2. Doc Dedupe - limit = MAX_CHUNKS_PER_DOC - next if doc_counts[chunk.document_id] >= limit - - doc_counts[chunk.document_id] += 1 - filtered << chunk - end - - # 3. Top K per Priority - filtered.first(TOP_K_PER_PRIORITY) - end - - def generate_embedding(text) - # Using shared logic or direct call. - # Duplication for now to keep service independent or use embedding service class func - model = ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small') - RubyLLM.embed(text, model: model).vectors.first - end - - def to_pg_vector(vector) - # Ensure vector is an array of floats - # PGVector accepts JSON array string e.g. "[1.0, 2.0]" - vector.to_s - end + def to_pg_vector(vector) + # Ensure vector is an array of floats + # PGVector accepts JSON array string e.g. "[1.0, 2.0]" + vector.to_s end end diff --git a/app/services/jasmine/tool_runner.rb b/app/services/jasmine/tool_runner.rb index defa4ff..d36117c 100644 --- a/app/services/jasmine/tool_runner.rb +++ b/app/services/jasmine/tool_runner.rb @@ -1,104 +1,107 @@ require 'rest-client' -module Jasmine - class ToolRunner - attr_reader :inbox, :tool_key, :config +class Jasmine::ToolRunner + attr_reader :inbox, :tool_key, :config - def initialize(inbox, tool_key) - @inbox = inbox - @tool_key = tool_key.to_s - @config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key) - end + def initialize(inbox, tool_key) + @inbox = inbox + @tool_key = tool_key.to_s + @config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key) + end - def run - definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key] - raise "Tool not found definition: #{@tool_key}" unless definition - raise "Tool not configured or disabled" unless config&.is_enabled? + def run + definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key] + raise "Tool not found definition: #{@tool_key}" unless definition + raise 'Tool not configured or disabled' unless config&.is_enabled? - start_time = Time.current - - begin - response = make_request(definition) - duration = (Time.current - start_time) * 1000 - - success = (200..299).include?(response.code) - body = response.body.to_s - - # Save stats - update_stats(response.code, nil, duration) + start_time = Time.current - { - success: success, - status: response.code, - body: body.first(2000), # Preview limited - duration_ms: duration.to_i - } - rescue RestClient::ExceptionWithResponse => e - duration = (Time.current - start_time) * 1000 - error_msg = e.message - - # Try to parse body from error response if available - error_body = e.response&.body rescue nil - error_msg = "#{error_msg} - #{error_body}" if error_body + begin + response = make_request(definition) + duration = (Time.current - start_time) * 1000 - sanitized_error = sanitize(error_msg) - update_stats(e.http_code, sanitized_error, duration) - - { - success: false, - status: e.http_code, - error: sanitized_error, - duration_ms: duration.to_i - } - rescue StandardError => e - duration = (Time.current - start_time) * 1000 - sanitized_error = sanitize(e.message) - - update_stats(0, sanitized_error, duration) - Rails.logger.error "[Jasmine::ToolRunner] Error running #{@tool_key}: #{sanitized_error}" - - { - success: false, - status: 0, - error: sanitized_error, - duration_ms: duration.to_i - } - end - end + success = (200..299).cover?(response.code) + body = response.body.to_s - private + # Save stats + update_stats(response.code, nil, duration) - def make_request(definition) - url = definition[:url] - method = definition[:method] - - headers = { - 'PLUG-PLAY-ID' => config.plug_play_id, - 'PLUG-PLAY-TOKEN' => config.plug_play_token, - 'User-Agent' => 'Chatwoot/Jasmine-Tools' + { + success: success, + status: response.code, + body: body.first(2000), # Preview limited + duration_ms: duration.to_i } + rescue RestClient::ExceptionWithResponse => e + duration = (Time.current - start_time) * 1000 + error_msg = e.message - RestClient::Request.execute( - method: method, - url: url, - headers: headers, - open_timeout: 2, - read_timeout: 8 - ) - end + # Try to parse body from error response if available + error_body = begin + e.response&.body + rescue StandardError + nil + end + error_msg = "#{error_msg} - #{error_body}" if error_body - def update_stats(status, error, duration) - config.update_columns( - last_tested_at: Time.current, - last_test_status: status, - last_test_error: error, - last_test_duration_ms: duration.to_i - ) - end + sanitized_error = sanitize(error_msg) + update_stats(e.http_code, sanitized_error, duration) - def sanitize(text) - return text unless @config&.plug_play_token.present? - text.to_s.gsub(@config.plug_play_token, '****') + { + success: false, + status: e.http_code, + error: sanitized_error, + duration_ms: duration.to_i + } + rescue StandardError => e + duration = (Time.current - start_time) * 1000 + sanitized_error = sanitize(e.message) + + update_stats(0, sanitized_error, duration) + Rails.logger.error "[Jasmine::ToolRunner] Error running #{@tool_key}: #{sanitized_error}" + + { + success: false, + status: 0, + error: sanitized_error, + duration_ms: duration.to_i + } end end + + private + + def make_request(definition) + url = definition[:url] + method = definition[:method] + + headers = { + 'PLUG-PLAY-ID' => config.plug_play_id, + 'PLUG-PLAY-TOKEN' => config.plug_play_token, + 'User-Agent' => 'Chatwoot/Jasmine-Tools' + } + + RestClient::Request.execute( + method: method, + url: url, + headers: headers, + open_timeout: 2, + read_timeout: 8 + ) + end + + def update_stats(status, error, duration) + config.update_columns( + last_tested_at: Time.current, + last_test_status: status, + last_test_error: error, + last_test_duration_ms: duration.to_i + ) + end + + def sanitize(text) + return text if @config&.plug_play_token.blank? + + text.to_s.gsub(@config.plug_play_token, '****') + end end diff --git a/app/services/jasmine/vision_service.rb b/app/services/jasmine/vision_service.rb index 2f90700..951ede6 100644 --- a/app/services/jasmine/vision_service.rb +++ b/app/services/jasmine/vision_service.rb @@ -3,67 +3,65 @@ require 'openai' require 'base64' -module Jasmine - class VisionService - attr_reader :attachment +class Jasmine::VisionService + attr_reader :attachment - def initialize(attachment:) - @attachment = attachment - end + def initialize(attachment:) + @attachment = attachment + end - def perform - return nil unless attachment.image? + def perform + return nil unless attachment.image? - api_key = ENV.fetch('OPENAI_API_KEY', nil) - return nil if api_key.blank? + api_key = ENV.fetch('OPENAI_API_KEY', nil) + return nil if api_key.blank? - client = OpenAI::Client.new(access_token: api_key) + client = OpenAI::Client.new(access_token: api_key) - image_data = get_image_data - return nil if image_data.blank? + image_data = get_image_data + return nil if image_data.blank? - response = client.chat( - parameters: { - model: 'gpt-4o-mini', - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' }, - image_data - ] - } - ], - max_tokens: 300 - } - ) - - response.dig('choices', 0, 'message', 'content') - rescue StandardError => e - Rails.logger.error "[Jasmine::VisionService] Failed to analyze image: #{e.message}" - nil - end - - private - - def get_image_data - # Always return base64 for better compatibility (OpenAI can reach it even if local) - # and it avoids issues with signed URL expiration or private buckets. - { - type: 'image_url', - image_url: { - url: "data:#{attachment.file.content_type};base64,#{encode_image}" - } + response = client.chat( + parameters: { + model: 'gpt-4o-mini', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' }, + image_data + ] + } + ], + max_tokens: 300 } - rescue StandardError => e - Rails.logger.error "[Jasmine::VisionService] Data encoding failed: #{e.message}" - nil - end + ) - def encode_image - attachment.file.blob.open do |file| - Base64.strict_encode64(file.read) - end + response.dig('choices', 0, 'message', 'content') + rescue StandardError => e + Rails.logger.error "[Jasmine::VisionService] Failed to analyze image: #{e.message}" + nil + end + + private + + def get_image_data + # Always return base64 for better compatibility (OpenAI can reach it even if local) + # and it avoids issues with signed URL expiration or private buckets. + { + type: 'image_url', + image_url: { + url: "data:#{attachment.file.content_type};base64,#{encode_image}" + } + } + rescue StandardError => e + Rails.logger.error "[Jasmine::VisionService] Data encoding failed: #{e.message}" + nil + end + + def encode_image + attachment.file.blob.open do |file| + Base64.strict_encode64(file.read) end end end diff --git a/app/services/llm/base_ai_service.rb b/app/services/llm/base_ai_service.rb index 420f88e..f1e7a10 100644 --- a/app/services/llm/base_ai_service.rb +++ b/app/services/llm/base_ai_service.rb @@ -1,7 +1,5 @@ -module Llm - class BaseAiService - def initialize - # Base initialization logic if needed - end +class Llm::BaseAiService + def initialize + # Base initialization logic if needed end end diff --git a/app/services/whatsapp/baileys_handlers/helpers.rb b/app/services/whatsapp/baileys_handlers/helpers.rb index 64faf80..da32009 100755 --- a/app/services/whatsapp/baileys_handlers/helpers.rb +++ b/app/services/whatsapp/baileys_handlers/helpers.rb @@ -15,7 +15,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength !@raw_message[:key][:fromMe] end - def jid_type # rubocop:disable Metrics/CyclomaticComplexity + def jid_type jid = @raw_message[:key][:remoteJid] server = jid.split('@').last @@ -39,7 +39,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength end end - def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength,Metrics/AbcSize + def message_type # rubocop:disable Metrics/MethodLength,Metrics/AbcSize msg = unwrap_ephemeral_message(@raw_message[:message]) if msg.key?(:conversation) || msg.dig(:extendedTextMessage, :text).present? 'text' @@ -69,7 +69,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength end end - def message_content # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength + def message_content # rubocop:disable Metrics/MethodLength msg = unwrap_ephemeral_message(@raw_message[:message]) case message_type when 'text' @@ -96,7 +96,7 @@ module Whatsapp::BaileysHandlers::Helpers # rubocop:disable Metrics/ModuleLength end end - def reply_to_message_id # rubocop:disable Metrics/CyclomaticComplexity + def reply_to_message_id msg = unwrap_ephemeral_message(@raw_message[:message]) message_key = case message_type when 'text' then :extendedTextMessage diff --git a/app/services/whatsapp/baileys_handlers/messages_upsert.rb b/app/services/whatsapp/baileys_handlers/messages_upsert.rb index dc508ed..51663a2 100755 --- a/app/services/whatsapp/baileys_handlers/messages_upsert.rb +++ b/app/services/whatsapp/baileys_handlers/messages_upsert.rb @@ -1,4 +1,4 @@ -module Whatsapp::BaileysHandlers::MessagesUpsert # rubocop:disable Metrics/ModuleLength +module Whatsapp::BaileysHandlers::MessagesUpsert include Whatsapp::BaileysHandlers::Helpers include BaileysHelper diff --git a/app/services/whatsapp/decryption_service.rb b/app/services/whatsapp/decryption_service.rb index 86d2232..724e449 100644 --- a/app/services/whatsapp/decryption_service.rb +++ b/app/services/whatsapp/decryption_service.rb @@ -1,147 +1,145 @@ -module Whatsapp - class DecryptionService - require 'openssl' - require 'base64' - require 'net/http' +class Whatsapp::DecryptionService + require 'openssl' + require 'base64' + require 'net/http' - # HKDF Info strings for different media types (WhatsApp protocol) - INFO_STRINGS = { - image: 'WhatsApp Image Keys', - video: 'WhatsApp Video Keys', - audio: 'WhatsApp Audio Keys', - document: 'WhatsApp Document Keys', - sticker: 'WhatsApp Image Keys' - }.freeze + # HKDF Info strings for different media types (WhatsApp protocol) + INFO_STRINGS = { + image: 'WhatsApp Image Keys', + video: 'WhatsApp Video Keys', + audio: 'WhatsApp Audio Keys', + document: 'WhatsApp Document Keys', + sticker: 'WhatsApp Image Keys' + }.freeze - def initialize(media_url, media_key, media_type) - @media_url = media_url - @media_key = Base64.decode64(media_key) - @media_type = media_type.to_sym - @info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document] - end + def initialize(media_url, media_key, media_type) + @media_url = media_url + @media_key = Base64.decode64(media_key) + @media_type = media_type.to_sym + @info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document] + end - def decrypt - return nil unless @media_url && @media_key + def decrypt + return nil unless @media_url && @media_key - # 1. Download encrypted bytes - encrypted_bytes = download_content - return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10 + # 1. Download encrypted bytes + encrypted_bytes = download_content + return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10 - Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes" + Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes" - # 2. Derive keys using HKDF SHA-256 (112 bytes total) - expanded_key = OpenSSL::KDF.hkdf( - @media_key, - salt: ''.b, # Empty binary string - info: @info, - length: 112, - hash: 'sha256' - ) + # 2. Derive keys using HKDF SHA-256 (112 bytes total) + expanded_key = OpenSSL::KDF.hkdf( + @media_key, + salt: ''.b, # Empty binary string + info: @info, + length: 112, + hash: 'sha256' + ) - # 3. Split derived key - iv = expanded_key[0...16] - cipher_key = expanded_key[16...48] - # mac_key = expanded_key[48...80] # For verification (optional) - # ref_key = expanded_key[80...112] # Not used + # 3. Split derived key + iv = expanded_key[0...16] + cipher_key = expanded_key[16...48] + # mac_key = expanded_key[48...80] # For verification (optional) + # ref_key = expanded_key[80...112] # Not used - # 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)] - # Remove the last 10 bytes (MAC) - cipher_text = encrypted_bytes[0...-10] + # 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)] + # Remove the last 10 bytes (MAC) + cipher_text = encrypted_bytes[0...-10] - # 5. Try AES-256-CBC first (older WhatsApp versions) - decrypted = try_aes_cbc(cipher_key, iv, cipher_text) + # 5. Try AES-256-CBC first (older WhatsApp versions) + decrypted = try_aes_cbc(cipher_key, iv, cipher_text) - # 6. If CBC fails, try CTR mode (some implementations use this) - decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text) + # 6. If CBC fails, try CTR mode (some implementations use this) + decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text) - return nil unless decrypted + return nil unless decrypted - # 7. Validate that we got a valid image (check magic bytes) - if valid_media?(decrypted) - Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected' - StringIO.new(decrypted) - else - Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format' - nil - end - rescue StandardError => e - Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}" + # 7. Validate that we got a valid image (check magic bytes) + if valid_media?(decrypted) + Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected' + StringIO.new(decrypted) + else + Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format' nil end + rescue StandardError => e + Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}" + nil + end - private + private - def try_aes_cbc(key, iv, data) - decipher = OpenSSL::Cipher.new('AES-256-CBC') - decipher.decrypt - decipher.key = key - decipher.iv = iv - decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding + def try_aes_cbc(key, iv, data) + decipher = OpenSSL::Cipher.new('AES-256-CBC') + decipher.decrypt + decipher.key = key + decipher.iv = iv + decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding - decipher.update(data) + decipher.final + decipher.update(data) + decipher.final - rescue OpenSSL::Cipher::CipherError => e - Rails.logger.debug { "AES-CBC failed: #{e.message}" } - nil - end + rescue OpenSSL::Cipher::CipherError => e + Rails.logger.debug { "AES-CBC failed: #{e.message}" } + nil + end - def try_aes_ctr(key, iv, data) - decipher = OpenSSL::Cipher.new('AES-256-CTR') - decipher.decrypt - decipher.key = key - decipher.iv = iv + def try_aes_ctr(key, iv, data) + decipher = OpenSSL::Cipher.new('AES-256-CTR') + decipher.decrypt + decipher.key = key + decipher.iv = iv - decipher.update(data) + decipher.final + decipher.update(data) + decipher.final - rescue OpenSSL::Cipher::CipherError => e - Rails.logger.debug { "AES-CTR failed: #{e.message}" } - nil - end + rescue OpenSSL::Cipher::CipherError => e + Rails.logger.debug { "AES-CTR failed: #{e.message}" } + nil + end - def valid_media?(data) - return false if data.nil? || data.bytesize < 4 + def valid_media?(data) + return false if data.nil? || data.bytesize < 4 - bytes = data.bytes[0..7] + bytes = data.bytes[0..7] - # JPEG: FF D8 FF - return true if bytes[0..2] == [0xFF, 0xD8, 0xFF] + # JPEG: FF D8 FF + return true if bytes[0..2] == [0xFF, 0xD8, 0xFF] - # PNG: 89 50 4E 47 - return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47] + # PNG: 89 50 4E 47 + return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47] - # WebP: RIFF....WEBP - return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP' + # WebP: RIFF....WEBP + return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP' - # MP4/MOV: ftyp - return true if data[4..7] == 'ftyp' + # MP4/MOV: ftyp + return true if data[4..7] == 'ftyp' - # MP3: ID3 or FF FB/FF FA - return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA] + # MP3: ID3 or FF FB/FF FA + return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA] - # OGG: OggS - return true if data[0..3] == 'OggS' + # OGG: OggS + return true if data[0..3] == 'OggS' - # PDF: %PDF - return true if data[0..3] == '%PDF' + # PDF: %PDF + return true if data[0..3] == '%PDF' - false - end + false + end - def download_content - uri = URI.parse(@media_url) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = (uri.scheme == 'https') - http.verify_mode = OpenSSL::SSL::VERIFY_NONE - http.open_timeout = 10 - http.read_timeout = 30 + def download_content + uri = URI.parse(@media_url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == 'https') + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http.open_timeout = 10 + http.read_timeout = 30 - request = Net::HTTP::Get.new(uri.request_uri) - response = http.request(request) + request = Net::HTTP::Get.new(uri.request_uri) + response = http.request(request) - response.is_a?(Net::HTTPSuccess) ? response.body.b : nil - rescue StandardError => e - Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}" - nil - end + response.is_a?(Net::HTTPSuccess) ? response.body.b : nil + rescue StandardError => e + Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}" + nil end end diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb index d2b6136..a1d5714 100755 --- a/app/services/whatsapp/incoming_message_service_helpers.rb +++ b/app/services/whatsapp/incoming_message_service_helpers.rb @@ -137,7 +137,7 @@ module Whatsapp::IncomingMessageServiceHelpers # 2. Media Messages (Image, Video, Audio, Document, Sticker) [:imageMessage, :videoMessage, :audioMessage, :documentMessage, :stickerMessage].each do |media_key| - next unless message.dig(media_key.to_s, 'contextInfo').present? + next if message.dig(media_key.to_s, 'contextInfo').blank? ctx = message[media_key.to_s]['contextInfo'] @in_reply_to_external_id = ctx['stanzaID'] || ctx['stanzaId'] diff --git a/app/services/whatsapp/incoming_message_wuzapi_service.rb b/app/services/whatsapp/incoming_message_wuzapi_service.rb index 056bcfe..9a7c215 100644 --- a/app/services/whatsapp/incoming_message_wuzapi_service.rb +++ b/app/services/whatsapp/incoming_message_wuzapi_service.rb @@ -1,250 +1,248 @@ -module Whatsapp - class IncomingMessageWuzapiService < IncomingMessageBaseService - def perform - parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params) - Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}" +class Whatsapp::IncomingMessageWuzapiService < IncomingMessageBaseService + def perform + parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params) + Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}" - # 1. Message Type Check - if parser.message_type == :ignore - Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)' - return - end - - allowed_types = [:text, :image, :audio, :video, :document, :sticker, :chat_presence] - unless allowed_types.include?(parser.message_type) - Rails.logger.info "WuzAPI: Unsupported message type: #{parser.message_type}" - return - end - - # 2. V1 Scope: Ignore Groups - if parser.group_message? - Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})" - return - end - - # 3. Strong Dedupe (Using WAID prefix) - clean_source_id = "WAID:#{parser.external_id}" - if parser.message_type != :chat_presence && parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id) - Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})" - return - end - - if parser.sender_phone_number.blank? - Rails.logger.warn "WuzAPI: Skipping processing for event with no valid phone (Type: #{parser.message_type})" - return - end - - # 4. Process - Rails.logger.info "WuzAPI: Processing message from #{parser.sender_phone_number} (Type: #{parser.message_type})" - - ActiveRecord::Base.transaction do - @contact = find_or_create_contact(parser) - @conversation = find_or_create_conversation(@contact) - - if parser.message_type == :chat_presence - status = parser.presence_state == 'composing' ? 'on' : 'off' - @conversation.toggle_typing_status(status) - return - end - - # Create Message First (Clean source_id) - Build, not save yet - @message = build_message(parser, @conversation, clean_source_id) - - # Attach media BEFORE saving (Chatwoot pattern) - attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type) - - # Now save with attachments - @message.save! - Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})" - end - rescue StandardError => e - Rails.logger.error "WuzAPI Error: #{e.message}" - Rails.logger.error e.backtrace.join("\n") - raise e + # 1. Message Type Check + if parser.message_type == :ignore + Rails.logger.info 'WuzAPI: Ignored event type (ReadReceipt/Other)' + return end - private - - def find_or_create_contact(parser) - phone = parser.sender_phone_number - normalized_phone = "+#{phone}" - - contact_inbox = ::ContactInboxWithContactBuilder.new( - source_id: phone, - inbox: inbox, - contact_attributes: { - name: parser.push_name || phone, - phone_number: normalized_phone - } - ).perform - - contact_inbox.contact + allowed_types = [:text, :image, :audio, :video, :document, :sticker, :chat_presence] + unless allowed_types.include?(parser.message_type) + Rails.logger.info "WuzAPI: Unsupported message type: #{parser.message_type}" + return end - def find_or_create_conversation(contact) - conversation = inbox.conversations.where(contact_id: contact.id) - .where.not(status: :resolved) - .last - - conversation || ::Conversation.create!( - account_id: inbox.account_id, - inbox_id: inbox.id, - contact_id: contact.id, - contact_inbox_id: ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)&.id - ) + # 2. V1 Scope: Ignore Groups + if parser.group_message? + Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})" + return end - def build_message(parser, conversation, clean_source_id) - is_outgoing = parser.from_me? + # 3. Strong Dedupe (Using WAID prefix) + clean_source_id = "WAID:#{parser.external_id}" + if parser.message_type != :chat_presence && parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id) + Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})" + return + end - msg_params = { - content: parser.text_content, - account_id: inbox.account_id, - inbox_id: inbox.id, - message_type: is_outgoing ? :outgoing : :incoming, - sender: is_outgoing ? nil : @contact, - source_id: clean_source_id, - created_at: parser.timestamp + if parser.sender_phone_number.blank? + Rails.logger.warn "WuzAPI: Skipping processing for event with no valid phone (Type: #{parser.message_type})" + return + end + + # 4. Process + Rails.logger.info "WuzAPI: Processing message from #{parser.sender_phone_number} (Type: #{parser.message_type})" + + ActiveRecord::Base.transaction do + @contact = find_or_create_contact(parser) + @conversation = find_or_create_conversation(@contact) + + if parser.message_type == :chat_presence + status = parser.presence_state == 'composing' ? 'on' : 'off' + @conversation.toggle_typing_status(status) + return + end + + # Create Message First (Clean source_id) - Build, not save yet + @message = build_message(parser, @conversation, clean_source_id) + + # Attach media BEFORE saving (Chatwoot pattern) + attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type) + + # Now save with attachments + @message.save! + Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})" + end + rescue StandardError => e + Rails.logger.error "WuzAPI Error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise e + end + + private + + def find_or_create_contact(parser) + phone = parser.sender_phone_number + normalized_phone = "+#{phone}" + + contact_inbox = ::ContactInboxWithContactBuilder.new( + source_id: phone, + inbox: inbox, + contact_attributes: { + name: parser.push_name || phone, + phone_number: normalized_phone } + ).perform - # Handle Replies - # Handle Reply Logic (Aligned with Reference) - if (reply_id = parser.in_reply_to_external_id).present? - clean_reply_id = "WAID:#{reply_id}" + contact_inbox.contact + end - # Strict lookup within conversation to prevent cross-inbox leaks - original_message = conversation.messages.find_by(source_id: clean_reply_id) + def find_or_create_conversation(contact) + conversation = inbox.conversations.where(contact_id: contact.id) + .where.not(status: :resolved) + .last - if original_message - msg_params[:in_reply_to_id] = original_message.id - else - # Fallback: Store ID for UI "Replying to..." display even if not linked - msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id } - end - end + conversation || ::Conversation.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + contact_id: contact.id, + contact_inbox_id: ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)&.id + ) + end - # Use .build so we can attach files before .save! - conversation.messages.build(msg_params) - end + def build_message(parser, conversation, clean_source_id) + is_outgoing = parser.from_me? - def attach_files(parser) - attachment_data = parser.attachment_params - return if attachment_data.blank? || attachment_data[:external_url].blank? + msg_params = { + content: parser.text_content, + account_id: inbox.account_id, + inbox_id: inbox.id, + message_type: is_outgoing ? :outgoing : :incoming, + sender: is_outgoing ? nil : @contact, + source_id: clean_source_id, + created_at: parser.timestamp + } - begin - Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})" + # Handle Replies + # Handle Reply Logic (Aligned with Reference) + if (reply_id = parser.in_reply_to_external_id).present? + clean_reply_id = "WAID:#{reply_id}" - # 1. Download/Decrypt to get a file - file_io = download_or_decrypt_media(attachment_data, parser.message_type) - return if file_io.blank? + # Strict lookup within conversation to prevent cross-inbox leaks + original_message = conversation.messages.find_by(source_id: clean_reply_id) - # 2. Determine filename - original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}" - extension = File.extname(original_filename) - extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank? - final_filename = "#{File.basename(original_filename, '.*')}#{extension}" - - # 3. Attach using Chatwoot's standard pattern - @message.attachments.new( - account_id: @message.account_id, - file_type: file_content_type(parser.message_type), - file: { - io: file_io, - filename: final_filename, - content_type: attachment_data[:mimetype] || 'application/octet-stream' - } - ) - - Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})" - - rescue StandardError => e - Rails.logger.error "WuzAPI Attachment Error: #{e.message}" - Rails.logger.error e.backtrace.first(10).join("\n") + if original_message + msg_params[:in_reply_to_id] = original_message.id + else + # Fallback: Store ID for UI "Replying to..." display even if not linked + msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id } end end - def download_or_decrypt_media(attachment_data, message_type) - media_url = attachment_data[:external_url] + # Use .build so we can attach files before .save! + conversation.messages.build(msg_params) + end - # METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media) - # This is the equivalent of Cloud API's media download - begin - Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...' - wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url) + def attach_files(parser) + attachment_data = parser.attachment_params + return if attachment_data.blank? || attachment_data[:external_url].blank? - if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present? - # WuzAPI returns base64 in 'data' field - image_data = wuzapi_response['data'] - # Strip data URI prefix if present - image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:') + begin + Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})" - decoded = Base64.decode64(image_data) - if decoded.bytesize > 1000 # Valid image should be > 1KB - Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS' - return StringIO.new(decoded) - end - end - rescue StandardError => e - Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}" - end + # 1. Download/Decrypt to get a file + file_io = download_or_decrypt_media(attachment_data, parser.message_type) + return if file_io.blank? - # METHOD 2: Try local decryption if we have mediaKey - if attachment_data[:media_key].present? - Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...' - decrypted = Whatsapp::DecryptionService.new( - media_url, - attachment_data[:media_key], - file_content_type(message_type) - ).decrypt + # 2. Determine filename + original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}" + extension = File.extname(original_filename) + extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank? + final_filename = "#{File.basename(original_filename, '.*')}#{extension}" - return decrypted if decrypted - - Rails.logger.warn 'WuzAPI: Local decryption failed...' - end - - # METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs) - Rails.logger.info "WuzAPI: Direct download from #{media_url}" - Down.download( - media_url, - open_timeout: 10, - read_timeout: 30, - ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE + # 3. Attach using Chatwoot's standard pattern + @message.attachments.new( + account_id: @message.account_id, + file_type: file_content_type(parser.message_type), + file: { + io: file_io, + filename: final_filename, + content_type: attachment_data[:mimetype] || 'application/octet-stream' + } ) + + Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})" + rescue StandardError => e - Rails.logger.error "WuzAPI: All download methods failed - #{e.message}" - nil + Rails.logger.error "WuzAPI Attachment Error: #{e.message}" + Rails.logger.error e.backtrace.first(10).join("\n") end + end - def wuzapi_client - @wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url']) - end + def download_or_decrypt_media(attachment_data, message_type) + media_url = attachment_data[:external_url] - def wuzapi_token - @inbox.channel.wuzapi_user_token - end + # METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media) + # This is the equivalent of Cloud API's media download + begin + Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...' + wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url) - def detect_extension(mimetype, message_type) - return '.jpg' if message_type == :image || message_type == :sticker - return '.mp3' if message_type == :audio - return '.mp4' if message_type == :video + if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present? + # WuzAPI returns base64 in 'data' field + image_data = wuzapi_response['data'] + # Strip data URI prefix if present + image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:') - case mimetype - when 'image/png' then '.png' - when 'image/webp' then '.webp' - when 'image/gif' then '.gif' - when 'audio/ogg' then '.ogg' - when 'video/webm' then '.webm' - else '.bin' + decoded = Base64.decode64(image_data) + if decoded.bytesize > 1000 # Valid image should be > 1KB + Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS' + return StringIO.new(decoded) + end end + rescue StandardError => e + Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}" end - def file_content_type(message_type) - case message_type - when :image, :sticker then :image - when :audio then :audio - when :video then :video - else :file - end + # METHOD 2: Try local decryption if we have mediaKey + if attachment_data[:media_key].present? + Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...' + decrypted = Whatsapp::DecryptionService.new( + media_url, + attachment_data[:media_key], + file_content_type(message_type) + ).decrypt + + return decrypted if decrypted + + Rails.logger.warn 'WuzAPI: Local decryption failed...' + end + + # METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs) + Rails.logger.info "WuzAPI: Direct download from #{media_url}" + Down.download( + media_url, + open_timeout: 10, + read_timeout: 30, + ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE + ) + rescue StandardError => e + Rails.logger.error "WuzAPI: All download methods failed - #{e.message}" + nil + end + + def wuzapi_client + @wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url']) + end + + def wuzapi_token + @inbox.channel.wuzapi_user_token + end + + def detect_extension(mimetype, message_type) + return '.jpg' if message_type == :image || message_type == :sticker + return '.mp3' if message_type == :audio + return '.mp4' if message_type == :video + + case mimetype + when 'image/png' then '.png' + when 'image/webp' then '.webp' + when 'image/gif' then '.gif' + when 'audio/ogg' then '.ogg' + when 'video/webm' then '.webm' + else '.bin' + end + end + + def file_content_type(message_type) + case message_type + when :image, :sticker then :image + when :audio then :audio + when :video then :video + else :file end end end diff --git a/app/services/whatsapp/providers/wuzapi/payload_parser.rb b/app/services/whatsapp/providers/wuzapi/payload_parser.rb index e281e31..df25601 100644 --- a/app/services/whatsapp/providers/wuzapi/payload_parser.rb +++ b/app/services/whatsapp/providers/wuzapi/payload_parser.rb @@ -1,214 +1,208 @@ -module Whatsapp - module Providers - module Wuzapi - class PayloadParser - attr_reader :params +class Whatsapp::Providers::Wuzapi::PayloadParser + attr_reader :params - def initialize(params) - @params = params.with_indifferent_access - end + def initialize(params) + @params = params.with_indifferent_access + end - def external_id - params.dig(:event, :Info, :ID) - end + def external_id + params.dig(:event, :Info, :ID) + end - def from_me? - is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe) - return false unless is_api_from_me + def from_me? + is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe) + return false unless is_api_from_me - instance_phone = params['phone_number'] - sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender) + instance_phone = params['phone_number'] + sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender) - if instance_phone.present? && sender_jid.present? - sender_phone = sender_jid.split('@').first - return false if sender_phone != instance_phone - end + if instance_phone.present? && sender_jid.present? + sender_phone = sender_jid.split('@').first + return false if sender_phone != instance_phone + end - true - end + true + end - def message_type - return :chat_presence if params['type'] == 'ChatPresence' + def message_type + return :chat_presence if params['type'] == 'ChatPresence' - # Info: Type contains the general classification (text, image, etc) - type = params.dig(:event, :Info, :Type) - media_type = params.dig(:event, :Info, :MediaType) + # Info: Type contains the general classification (text, image, etc) + type = params.dig(:event, :Info, :Type) + media_type = params.dig(:event, :Info, :MediaType) - # WuzAPI sometimes sends 'media' in Type and the actual type in MediaType - type = media_type if type == 'media' && media_type.present? + # WuzAPI sometimes sends 'media' in Type and the actual type in MediaType + type = media_type if type == 'media' && media_type.present? - case type - when 'text' then :text - when 'image' then :image - when 'audio' then :audio - when 'video' then :video - when 'document' then :document - when 'sticker' then :sticker - when 'ReadReceipt' then :ignore - else - # Fallback: Detect type from Message content keys - msg = params.dig(:event, :Message) - if msg.is_a?(Hash) - return :text if msg[:conversation].present? || msg[:extendedTextMessage].present? - return :image if msg[:imageMessage].present? - return :audio if msg[:audioMessage].present? - return :video if msg[:videoMessage].present? - return :document if msg[:documentMessage].present? - return :sticker if msg[:stickerMessage].present? - end - :unknown - end - end + case type + when 'text' then :text + when 'image' then :image + when 'audio' then :audio + when 'video' then :video + when 'document' then :document + when 'sticker' then :sticker + when 'ReadReceipt' then :ignore + else + # Fallback: Detect type from Message content keys + msg = params.dig(:event, :Message) + if msg.is_a?(Hash) + return :text if msg[:conversation].present? || msg[:extendedTextMessage].present? + return :image if msg[:imageMessage].present? + return :audio if msg[:audioMessage].present? + return :video if msg[:videoMessage].present? + return :document if msg[:documentMessage].present? + return :sticker if msg[:stickerMessage].present? + end + :unknown + end + end - def presence_state - params.dig(:event, :State) - end + def presence_state + params.dig(:event, :State) + end - def in_reply_to_external_id - msg = unwrap_ephemeral_message(params.dig(:event, :Message)) - return nil unless msg.is_a?(Hash) + def in_reply_to_external_id + msg = unwrap_ephemeral_message(params.dig(:event, :Message)) + return nil unless msg.is_a?(Hash) - # DEBUG: Log the message structure to understand reply context - Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}" + # DEBUG: Log the message structure to understand reply context + Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}" - # 1. Extended text - ctx = msg.dig(:extendedTextMessage, :contextInfo) - if ctx.present? - Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}" - stanza = ctx[:stanzaID] || ctx[:stanzaId] - return stanza if stanza.present? - end + # 1. Extended text + ctx = msg.dig(:extendedTextMessage, :contextInfo) + if ctx.present? + Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}" + stanza = ctx[:stanzaID] || ctx[:stanzaId] + return stanza if stanza.present? + end - # 2. Media Types direct contextInfo - [:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key| - ctx = msg.dig(key, :contextInfo) - next unless ctx.present? + # 2. Media Types direct contextInfo + [:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key| + ctx = msg.dig(key, :contextInfo) + next if ctx.blank? - Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}" - stanza = ctx[:stanzaID] || ctx[:stanzaId] - return stanza if stanza.present? - end + Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}" + stanza = ctx[:stanzaID] || ctx[:stanzaId] + return stanza if stanza.present? + end - # 3. Document With Caption - if msg.key?(:documentWithCaptionMessage) - ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo) - if ctx.present? - Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}" - return ctx[:stanzaID] || ctx[:stanzaId] - end - end + # 3. Document With Caption + if msg.key?(:documentWithCaptionMessage) + ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo) + if ctx.present? + Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}" + return ctx[:stanzaID] || ctx[:stanzaId] + end + end - # 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage) - if msg[:conversation].present? && msg.dig(:contextInfo).present? - ctx = msg[:contextInfo] - Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}" - stanza = ctx[:stanzaID] || ctx[:stanzaId] - return stanza if stanza.present? - end + # 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage) + if msg[:conversation].present? && msg[:contextInfo].present? + ctx = msg[:contextInfo] + Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}" + stanza = ctx[:stanzaID] || ctx[:stanzaId] + return stanza if stanza.present? + end - Rails.logger.info 'WuzAPI Reply Debug: No reply context found' - nil - end + Rails.logger.info 'WuzAPI Reply Debug: No reply context found' + nil + end - def text_content - msg = unwrap_ephemeral_message(params.dig(:event, :Message)) - return nil unless msg.is_a?(Hash) + def text_content + msg = unwrap_ephemeral_message(params.dig(:event, :Message)) + return nil unless msg.is_a?(Hash) - # 1. Simple text - return msg[:conversation] if msg[:conversation].present? + # 1. Simple text + return msg[:conversation] if msg[:conversation].present? - # 2. Extended Text - return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present? + # 2. Extended Text + return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present? - # 3. Media Captions (Image, Video, Document) - [:imageMessage, :videoMessage, :documentMessage].each do |media_key| - caption = msg.dig(media_key, :caption) - return caption if caption.present? - end + # 3. Media Captions (Image, Video, Document) + [:imageMessage, :videoMessage, :documentMessage].each do |media_key| + caption = msg.dig(media_key, :caption) + return caption if caption.present? + end - # 4. Document With Caption - return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage) + # 4. Document With Caption + return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage) - nil - end + nil + end - def attachment_params - media_key = case message_type - when :image then :imageMessage - when :audio then :audioMessage - when :video then :videoMessage - when :document then :documentMessage - when :sticker then :stickerMessage - end - return nil unless media_key + def attachment_params + media_key = case message_type + when :image then :imageMessage + when :audio then :audioMessage + when :video then :videoMessage + when :document then :documentMessage + when :sticker then :stickerMessage + end + return nil unless media_key - msg = unwrap_ephemeral_message(params.dig(:event, :Message)) - data = msg[media_key] - return nil unless data.is_a?(Hash) + msg = unwrap_ephemeral_message(params.dig(:event, :Message)) + data = msg[media_key] + return nil unless data.is_a?(Hash) - { - external_url: data['URL'], - file_name: data['fileName'] || "file_#{external_id}", - mimetype: data['mimetype'], - thumbnail: data['JPEGThumbnail'], - media_key: data['mediaKey'] - } - end + { + external_url: data['URL'], + file_name: data['fileName'] || "file_#{external_id}", + mimetype: data['mimetype'], + thumbnail: data['JPEGThumbnail'], + media_key: data['mediaKey'] + } + end - def sender_phone_number - jid = extract_jid + def sender_phone_number + jid = extract_jid - # Reject LIDs as they aren't valid E164 phone numbers - return nil if jid.blank? || jid.include?('@lid') + # Reject LIDs as they aren't valid E164 phone numbers + return nil if jid.blank? || jid.include?('@lid') - # Format: 556182098580@s.whatsapp.net -> 556182098580 - jid.split('@').first - end + # Format: 556182098580@s.whatsapp.net -> 556182098580 + jid.split('@').first + end - def timestamp - timestamp_val = params.dig(:event, :Info, :Timestamp) || params.dig(:event, :Timestamp) - return Time.current if timestamp_val.blank? + def timestamp + timestamp_val = params.dig(:event, :Info, :Timestamp) || params.dig(:event, :Timestamp) + return Time.current if timestamp_val.blank? - begin - Time.parse(timestamp_val.to_s) - rescue ArgumentError - Time.current - end - end + begin + Time.zone.parse(timestamp_val.to_s) + rescue ArgumentError + Time.current + end + end - def push_name - params.dig(:event, :Info, :PushName) || params.dig(:event, :PushName) - end + def push_name + params.dig(:event, :Info, :PushName) || params.dig(:event, :PushName) + end - def group_message? - params.dig(:event, :Info, :IsGroup) || params.dig(:event, :IsGroup) - end + def group_message? + params.dig(:event, :Info, :IsGroup) || params.dig(:event, :IsGroup) + end - private + private - def unwrap_ephemeral_message(msg) - return {} unless msg + def unwrap_ephemeral_message(msg) + return {} unless msg - msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg - end + msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg + end - def extract_jid - if from_me? - params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat) - else - sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender) - sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt) + def extract_jid + if from_me? + params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat) + else + sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender) + sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt) - # Prefer @s.whatsapp.net over @lid - if sender&.include?('@s.whatsapp.net') - sender - elsif sender_alt&.include?('@s.whatsapp.net') - sender_alt - else - sender - end - end - end + # Prefer @s.whatsapp.net over @lid + if sender&.include?('@s.whatsapp.net') + sender + elsif sender_alt&.include?('@s.whatsapp.net') + sender_alt + else + sender end end end diff --git a/app/services/whatsapp/providers/wuzapi_service.rb b/app/services/whatsapp/providers/wuzapi_service.rb index 02c527b..2351eb0 100644 --- a/app/services/whatsapp/providers/wuzapi_service.rb +++ b/app/services/whatsapp/providers/wuzapi_service.rb @@ -1,178 +1,176 @@ -module Whatsapp::Providers - class WuzapiService < BaseService - attr_reader :whatsapp_channel +class Whatsapp::Providers::WuzapiService < BaseService + attr_reader :whatsapp_channel - def initialize(whatsapp_channel:) - super(whatsapp_channel: whatsapp_channel) - @base_url = whatsapp_channel.provider_config['wuzapi_base_url'] - end + def initialize(whatsapp_channel:) + super(whatsapp_channel: whatsapp_channel) + @base_url = whatsapp_channel.provider_config['wuzapi_base_url'] + end - def send_message(phone_number, message) - user_token = whatsapp_channel.wuzapi_user_token - # Normalize phone number: remove +, space, -, (, ) - normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') + def send_message(phone_number, message) + user_token = whatsapp_channel.wuzapi_user_token + # Normalize phone number: remove +, space, -, (, ) + normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') - return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction] + return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction] - response = if message.attachments.present? - send_attachment_message(user_token, normalized_phone, message) - else - params = {} - # Extract and clean reply ID (remove WAID: prefix if stored) - if (reply_id = message.content_attributes['in_reply_to_external_id']).present? - params['MessageId'] = reply_id.gsub(/^WAID:/, '') - elsif (reply_id = message.in_reply_to_external_id).present? - params['MessageId'] = reply_id.gsub(/^WAID:/, '') - end - - client.send_text(user_token, normalized_phone, message.content, **params) + response = if message.attachments.present? + send_attachment_message(user_token, normalized_phone, message) + else + params = {} + # Extract and clean reply ID (remove WAID: prefix if stored) + if (reply_id = message.content_attributes['in_reply_to_external_id']).present? + params['MessageId'] = reply_id.gsub(/^WAID:/, '') + elsif (reply_id = message.in_reply_to_external_id).present? + params['MessageId'] = reply_id.gsub(/^WAID:/, '') end - # Extract message ID from WuzAPI response and format as WAID:xxx - extract_message_id(response) - end + client.send_text(user_token, normalized_phone, message.content, **params) + end - def send_attachment_message(user_token, phone_number, message) - attachment = message.attachments.first - base64_data = Base64.strict_encode64(attachment.file.download) - mime_type = attachment.file.content_type - data_uri = "data:#{mime_type};base64,#{base64_data}" + # Extract message ID from WuzAPI response and format as WAID:xxx + extract_message_id(response) + end - if mime_type.start_with?('image/') - client.send_image(user_token, phone_number, data_uri, message.content) - else - client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s) - end - end + def send_attachment_message(user_token, phone_number, message) + attachment = message.attachments.first + base64_data = Base64.strict_encode64(attachment.file.download) + mime_type = attachment.file.content_type + data_uri = "data:#{mime_type};base64,#{base64_data}" - def send_reaction_message(phone_number, message) - user_token = whatsapp_channel.wuzapi_user_token - normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') - - # Assuming message content is the emoji - reaction_emoji = message.content - - # Resolve the correct external message ID - message_id = message.content_attributes['in_reply_to_external_id'] - - # Fallback to internal ID resolution if external is missing - if message_id.blank? && message.content_attributes['in_reply_to'].present? - target_msg = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to']) - message_id = target_msg&.source_id - end - - # Strip WAID prefix if present - message_id = message_id.gsub(/^WAID:/, '') if message_id.present? - - 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:') - else - # Enforce JID format for customer numbers - clean_number = normalized_phone.split('@').first - normalized_phone = "#{clean_number}@s.whatsapp.net" - end - - Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{normalized_phone}, msg_id=#{message_id}, emoji=#{reaction_emoji}" - - if message_id.present? - # Wuzapi client needs to implement send_reaction - # This assumes the client wrapper has this method. If not, we might need to add it or use raw request. - # Based on typical Wuzapi forks, it might be /send-reaction-message - - # We'll assume the client wrapper will have a send_reaction method. - # If not visible in the existing codebase, we might need to add it to the client class too. - # checking... - response = client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji) - Rails.logger.info "[WuzapiService] Reaction response: #{response}" - response - else - Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID' - end - end - - def send_template(_phone_number, _template_info) - # Placeholder for template support if Wuzapi supports it. - # For now, just logging or no-op as per initial text-focused plan. - Rails.logger.warn 'Wuzapi: Templates not yet implemented or supported.' - end - - def sync_templates - # No-op for Wuzapi as it doesn't insist on syncing templates like Cloud API - end - - def validate_provider_config? - # Validate if we can connect to session status - user_token = whatsapp_channel.wuzapi_user_token - return false if user_token.blank? - - begin - client.session_status(user_token) - true - rescue Wuzapi::Client::Error - false - end - end - - def toggle_typing_status(typing_status, recipient_id: nil, **_kwargs) - # typing_status: 'typing_on', 'typing_off' - # Wuzapi expects: 'composing', 'paused' - - state = %w[typing_on on].include?(typing_status) ? 'composing' : 'paused' - user_token = whatsapp_channel.wuzapi_user_token - phone_number = recipient_id || whatsapp_channel.phone_number - - # Clean phone number (digits only) - normalized_phone = phone_number.to_s.gsub(/[\+\s\-\(\)]/, '') - - # Enforce JID format: 5561...@s.whatsapp.net - # Strip any existing suffix (like @lid or even @s.whatsapp.net to be safe) and append standard one. - clean_number = normalized_phone.split('@').first - jid = "#{clean_number}@s.whatsapp.net" - - Rails.logger.info "[WuzapiService] toggle_typing_status: Sending presence to #{jid} (raw: #{normalized_phone}), state: #{state}, token_present: #{user_token.present?}" - - begin - # Use JID in the 'Phone' field as confirmed by manual tests (Test C) - response = client.send_chat_presence(user_token, jid, state) - Rails.logger.info "[WuzapiService] toggle_typing_status response: #{response}" - rescue StandardError => e - Rails.logger.warn "Wuzapi: Failed to send typing status: #{e.message}" - end - end - - private - - def client - @client ||= ::Wuzapi::Client.new(@base_url) - end - - # Extract message ID from WuzAPI response and format it as WAID:xxx - # WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx", ...}, "success" => true} - def extract_message_id(response) - return nil unless response.is_a?(Hash) - - message_id = response.dig('data', 'Id') || response.dig(:data, :Id) - return nil if message_id.blank? - - "WAID:#{message_id}" - 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? + if mime_type.start_with?('image/') + client.send_image(user_token, phone_number, data_uri, message.content) + else + client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s) end end + + def send_reaction_message(phone_number, message) + user_token = whatsapp_channel.wuzapi_user_token + normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '') + + # Assuming message content is the emoji + reaction_emoji = message.content + + # Resolve the correct external message ID + message_id = message.content_attributes['in_reply_to_external_id'] + + # Fallback to internal ID resolution if external is missing + if message_id.blank? && message.content_attributes['in_reply_to'].present? + target_msg = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to']) + message_id = target_msg&.source_id + end + + # Strip WAID prefix if present + message_id = message_id.gsub(/^WAID:/, '') if message_id.present? + + 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:') + else + # Enforce JID format for customer numbers + clean_number = normalized_phone.split('@').first + normalized_phone = "#{clean_number}@s.whatsapp.net" + end + + Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{normalized_phone}, msg_id=#{message_id}, emoji=#{reaction_emoji}" + + if message_id.present? + # Wuzapi client needs to implement send_reaction + # This assumes the client wrapper has this method. If not, we might need to add it or use raw request. + # Based on typical Wuzapi forks, it might be /send-reaction-message + + # We'll assume the client wrapper will have a send_reaction method. + # If not visible in the existing codebase, we might need to add it to the client class too. + # checking... + response = client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji) + Rails.logger.info "[WuzapiService] Reaction response: #{response}" + response + else + Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID' + end + end + + def send_template(_phone_number, _template_info) + # Placeholder for template support if Wuzapi supports it. + # For now, just logging or no-op as per initial text-focused plan. + Rails.logger.warn 'Wuzapi: Templates not yet implemented or supported.' + end + + def sync_templates + # No-op for Wuzapi as it doesn't insist on syncing templates like Cloud API + end + + def validate_provider_config? + # Validate if we can connect to session status + user_token = whatsapp_channel.wuzapi_user_token + return false if user_token.blank? + + begin + client.session_status(user_token) + true + rescue Wuzapi::Client::Error + false + end + end + + def toggle_typing_status(typing_status, recipient_id: nil, **_kwargs) + # typing_status: 'typing_on', 'typing_off' + # Wuzapi expects: 'composing', 'paused' + + state = %w[typing_on on].include?(typing_status) ? 'composing' : 'paused' + user_token = whatsapp_channel.wuzapi_user_token + phone_number = recipient_id || whatsapp_channel.phone_number + + # Clean phone number (digits only) + normalized_phone = phone_number.to_s.gsub(/[\+\s\-\(\)]/, '') + + # Enforce JID format: 5561...@s.whatsapp.net + # Strip any existing suffix (like @lid or even @s.whatsapp.net to be safe) and append standard one. + clean_number = normalized_phone.split('@').first + jid = "#{clean_number}@s.whatsapp.net" + + Rails.logger.info "[WuzapiService] toggle_typing_status: Sending presence to #{jid} (raw: #{normalized_phone}), state: #{state}, token_present: #{user_token.present?}" + + begin + # Use JID in the 'Phone' field as confirmed by manual tests (Test C) + response = client.send_chat_presence(user_token, jid, state) + Rails.logger.info "[WuzapiService] toggle_typing_status response: #{response}" + rescue StandardError => e + Rails.logger.warn "Wuzapi: Failed to send typing status: #{e.message}" + end + end + + private + + def client + @client ||= ::Wuzapi::Client.new(@base_url) + end + + # Extract message ID from WuzAPI response and format it as WAID:xxx + # WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx", ...}, "success" => true} + def extract_message_id(response) + return nil unless response.is_a?(Hash) + + message_id = response.dig('data', 'Id') || response.dig(:data, :Id) + return nil if message_id.blank? + + "WAID:#{message_id}" + 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 if target_message.blank? + + target_message.outgoing? || target_message.template? + end end diff --git a/app/services/whatsapp/zapi_handlers/received_callback.rb b/app/services/whatsapp/zapi_handlers/received_callback.rb index fb3e4e4..840cedc 100755 --- a/app/services/whatsapp/zapi_handlers/received_callback.rb +++ b/app/services/whatsapp/zapi_handlers/received_callback.rb @@ -39,7 +39,7 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module !@raw_message.key?(:notification) end - def message_type # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity + def message_type return 'text' if @raw_message.key?(:text) return 'reaction' if @raw_message.key?(:reaction) return 'audio' if @raw_message.key?(:audio) diff --git a/app/services/wuzapi/provisioning_service.rb b/app/services/wuzapi/provisioning_service.rb index 0e0a963..98981d8 100644 --- a/app/services/wuzapi/provisioning_service.rb +++ b/app/services/wuzapi/provisioning_service.rb @@ -1,35 +1,34 @@ -module Wuzapi - class ProvisioningService - def initialize(base_url, admin_token) - @base_url = base_url - @admin_token = admin_token - @client = Wuzapi::Client.new(base_url) - end +class Wuzapi::ProvisioningService + def initialize(base_url, admin_token) + @base_url = base_url + @admin_token = admin_token + @client = Wuzapi::Client.new(base_url) + end - def provision(name) - user_token = SecureRandom.hex(32) - response = @client.create_user(@admin_token, name, user_token) + def provision(name) + user_token = SecureRandom.hex(32) + response = @client.create_user(@admin_token, name, user_token) - # Wuzapi returns the user object, or we assume success if no error raised. - # The response structure depends on Wuzapi. Assuming it returns { "ID": "...", ... } or similar. - # Based on plan, we just need to know it succeeded. - # We return the generated data to be saved. + # Wuzapi returns the user object, or we assume success if no error raised. + # The response structure depends on Wuzapi. Assuming it returns { "ID": "...", ... } or similar. + # Based on plan, we just need to know it succeeded. + # We return the generated data to be saved. - { - wuzapi_user_id: response['ID'] || response['id'], # Adjust based on actual response if known, strictly fallback - wuzapi_user_token: user_token - } - end + { + wuzapi_user_id: response['ID'] || response['id'], # Adjust based on actual response if known, strictly fallback + wuzapi_user_token: user_token + } + end - def setup_webhook(user_token, inbox_id, _webhook_secret) # [INTENTIONAL] reserved for signed webhooks - # Host logic needs to come from GlobalConfig or Rails.application.routes - # Ideally passed in or resolved. - base_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') - inbox = Inbox.find(inbox_id) - phone_number = inbox.channel.phone_number.delete('+') - webhook_url = "#{base_host}/webhooks/whatsapp/#{phone_number}" + # [INTENTIONAL] reserved for signed webhooks + def setup_webhook(user_token, inbox_id, _webhook_secret) + # Host logic needs to come from GlobalConfig or Rails.application.routes + # Ideally passed in or resolved. + base_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + inbox = Inbox.find(inbox_id) + phone_number = inbox.channel.phone_number.delete('+') + webhook_url = "#{base_host}/webhooks/whatsapp/#{phone_number}" - @client.set_webhook(user_token, webhook_url) - end + @client.set_webhook(user_token, webhook_url) end end diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb index 8ed72ab..1a8d559 100644 --- a/config/initializers/active_record_encryption.rb +++ b/config/initializers/active_record_encryption.rb @@ -1,5 +1,7 @@ Rails.application.configure do config.active_record.encryption.primary_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY', 'test_primary_key_must_be_32_bytes_long!!') - config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', 'test_deterministic_key_must_be_32_bytes!!') - config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', 'test_salt_must_be_unique_and_random') + config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', + 'test_deterministic_key_must_be_32_bytes!!') + config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', + 'test_salt_must_be_unique_and_random') end diff --git a/config/initializers/ai_agents.rb b/config/initializers/ai_agents.rb index e23ff79..4e45070 100755 --- a/config/initializers/ai_agents.rb +++ b/config/initializers/ai_agents.rb @@ -10,7 +10,7 @@ Rails.application.config.after_initialize do 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 = sanitized_key if api_endpoint.present? diff --git a/config/initializers/ruby_llm.rb b/config/initializers/ruby_llm.rb index 13f0d86..d1fe75a 100644 --- a/config/initializers/ruby_llm.rb +++ b/config/initializers/ruby_llm.rb @@ -15,7 +15,7 @@ Rails.application.config.after_initialize do Rails.logger.error "[RubyLLM] Init failed: #{e.class} #{e.message}" end Rails.logger.info "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." - puts "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." # Log to stdout for rails runner visibility + Rails.logger.debug { "[RubyLLM] Configured with OPENAI_API_KEY: #{api_key[0..10]}..." } # Log to stdout for rails runner visibility else Rails.logger.warn '[RubyLLM] No OPENAI_API_KEY found in environment' end diff --git a/configure_captain_webhook.rb b/configure_captain_webhook.rb old mode 100644 new mode 100755 diff --git a/db/migrate/20251227054034_create_jasmine_tables.rb b/db/migrate/20251227054034_create_jasmine_tables.rb index 826ff15..642f90f 100644 --- a/db/migrate/20251227054034_create_jasmine_tables.rb +++ b/db/migrate/20251227054034_create_jasmine_tables.rb @@ -19,7 +19,7 @@ class CreateJasmineTables < ActiveRecord::Migration[7.1] t.string :name, null: false t.text :description # Owner inbox is optional (nullable), but if present, must exist in inboxes table - t.references :owner_inbox, null: true, foreign_key: { to_table: :inboxes } + t.references :owner_inbox, null: true, foreign_key: { to_table: :inboxes } t.integer :visibility, default: 0 # 0=private, 1=shared, 2=global t.boolean :is_active, default: true @@ -69,9 +69,9 @@ class CreateJasmineTables < ActiveRecord::Migration[7.1] t.timestamps end add_index :jasmine_document_chunks, [:account_id, :collection_id, :document_id], name: 'index_jasmine_chunks_on_acc_coll_doc' - + # HNSW Index for Vector Search (Cosine Distance) - # Ensure pgvector extension is enabled in a previous migration or here if needed, + # Ensure pgvector extension is enabled in a previous migration or here if needed, # but based on plan it is already enabled. add_index :jasmine_document_chunks, :embedding, using: :hnsw, opclass: :vector_cosine_ops end diff --git a/db/migrate/20251227134015_add_brain_fields_to_jasmine_inbox_settings.rb b/db/migrate/20251227134015_add_brain_fields_to_jasmine_inbox_settings.rb index c6d8873..a66528d 100644 --- a/db/migrate/20251227134015_add_brain_fields_to_jasmine_inbox_settings.rb +++ b/db/migrate/20251227134015_add_brain_fields_to_jasmine_inbox_settings.rb @@ -8,4 +8,3 @@ class AddBrainFieldsToJasmineInboxSettings < ActiveRecord::Migration[7.1] add_column :jasmine_inbox_settings, :intent_keywords, :jsonb, default: {} end end - diff --git a/db/migrate/20251227145727_create_jasmine_tool_configs.rb b/db/migrate/20251227145727_create_jasmine_tool_configs.rb index a6cfb4a..331b1a7 100644 --- a/db/migrate/20251227145727_create_jasmine_tool_configs.rb +++ b/db/migrate/20251227145727_create_jasmine_tool_configs.rb @@ -4,12 +4,12 @@ class CreateJasmineToolConfigs < ActiveRecord::Migration[7.1] t.references :account, null: false, foreign_key: true t.references :inbox, null: false, foreign_key: true t.string :tool_key, null: false, index: true - + # Configs t.boolean :is_enabled, default: false, null: false t.string :plug_play_id t.text :plug_play_token - + # Stats t.datetime :last_tested_at t.integer :last_test_status diff --git a/db/migrate/20260110193000_fix_status_suites_headers.rb b/db/migrate/20260110193000_fix_status_suites_headers.rb index 4109781..0f3b706 100644 --- a/db/migrate/20260110193000_fix_status_suites_headers.rb +++ b/db/migrate/20260110193000_fix_status_suites_headers.rb @@ -6,7 +6,7 @@ class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1] tools = Captain::CustomTool.where('endpoint_url ILIKE ? OR title ILIKE ?', '%/api/PlugPlay/api/SuitesStatus%', '%Status Suites%') tools.each do |tool| - puts "Processing tool: #{tool.title} (ID: #{tool.id})" + Rails.logger.debug { "Processing tool: #{tool.title} (ID: #{tool.id})" } updated = false new_auth_config = tool.auth_config || {} @@ -20,7 +20,7 @@ class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1] %w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].each do |header_key| next unless query_params.key?(header_key) - puts " Found #{header_key} in URL query params. Moving to headers." + Rails.logger.debug { " Found #{header_key} in URL query params. Moving to headers." } new_auth_config['headers'][header_key] = query_params[header_key] query_params.delete(header_key) updated = true @@ -36,16 +36,16 @@ class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1] if tool.param_schema.is_a?(Array) original_size = tool.param_schema.size tool.param_schema.reject! { |p| %w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].include?(p['name']) } - puts ' Removed params from param_schema.' if tool.param_schema.size < original_size + Rails.logger.debug ' Removed params from param_schema.' if tool.param_schema.size < original_size end tool.save! - puts ' Tool updated successfully.' + Rails.logger.debug ' Tool updated successfully.' else - puts ' No keys found in URL query params. Manual update might be required for values.' + Rails.logger.debug ' No keys found in URL query params. Manual update might be required for values.' end rescue URI::InvalidURIError # [INTENTIONAL] keep for future logging - puts " Skipping invalid URI: #{tool.endpoint_url}" + Rails.logger.debug { " Skipping invalid URI: #{tool.endpoint_url}" } end end end diff --git a/enable_features.rb b/enable_features.rb index caff1a8..abd6b14 100644 --- a/enable_features.rb +++ b/enable_features.rb @@ -1,20 +1,20 @@ account = Account.first if account puts "Current feature flags: #{account.feature_flags}" - + # Load features from YAML features_config = YAML.safe_load(Rails.root.join('config/features.yml').read) - + # Select features that should be enabled by default - default_features = features_config.select { |f| f['enabled'] == true }.map { |f| f['name'] } - + default_features = features_config.select { |f| f['enabled'] == true }.pluck('name') + puts "Enabling default features: #{default_features.join(', ')}" - + # Enable them account.enable_features!(*default_features) - + puts "New feature flags: #{account.feature_flags}" puts "Inbox Management Enabled? #{account.feature_enabled?('inbox_management')}" else - puts "Account not found!" + puts 'Account not found!' end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/brands_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/brands_controller.rb index 9ac982f..8e404e0 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/brands_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/brands_controller.rb @@ -1,52 +1,44 @@ -module Api - module V1 - module Accounts - module Captain - class BrandsController < Api::V1::Accounts::BaseController - before_action :fetch_brand, only: [:show, :update, :destroy] +class Api::V1::Accounts::Captain::BrandsController < Api::V1::Accounts::BaseController + before_action :fetch_brand, only: [:show, :update, :destroy] - def index - @brands = Current.account.captain_brands - end + def index + @brands = Current.account.captain_brands + end - def show; end + def show; end - def create - @brand = Current.account.captain_brands.new(brand_params) - if @brand.save - render :show, status: :created - else - render_error_response(@brand) - end - end - - def update - if @brand.update(brand_params) - render :show - else - render_error_response(@brand) - end - end - - def destroy - if @brand.destroy - head :no_content - else - render_error_response(@brand) - end - end - - private - - def fetch_brand - @brand = Current.account.captain_brands.find(params[:id]) - end - - def brand_params - params.require(:brand).permit(:name, suite_categories: [], stay_durations: [], suite_images: {}, suite_keywords: {}) - end - end - end + def create + @brand = Current.account.captain_brands.new(brand_params) + if @brand.save + render :show, status: :created + else + render_error_response(@brand) end end + + def update + if @brand.update(brand_params) + render :show + else + render_error_response(@brand) + end + end + + def destroy + if @brand.destroy + head :no_content + else + render_error_response(@brand) + end + end + + private + + def fetch_brand + @brand = Current.account.captain_brands.find(params[:id]) + end + + def brand_params + params.require(:brand).permit(:name, suite_categories: [], stay_durations: [], suite_images: {}, suite_keywords: {}) + end end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/configurations_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/configurations_controller.rb index 483cc4a..7c2df4d 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/configurations_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/configurations_controller.rb @@ -1,34 +1,26 @@ -module Api - module V1 - module Accounts - module Captain - class ConfigurationsController < Api::V1::Accounts::BaseController - before_action :fetch_config +class Api::V1::Accounts::Captain::ConfigurationsController < Api::V1::Accounts::BaseController + before_action :fetch_config - def show - render json: @config - end + def show + render json: @config + end - def update - if @config.update(config_params) - render json: @config - else - render_error_response(@config) - end - end - - private - - def fetch_config - # Ensure a config exists for the account - @config = current_account.captain_configuration || current_account.create_captain_configuration! - end - - def config_params - params.require(:configuration).permit(:title, :subtitle, :primary_color, :secondary_color, :active, :logo_url, :phone_number) - end - end - end + def update + if @config.update(config_params) + render json: @config + else + render_error_response(@config) end end + + private + + def fetch_config + # Ensure a config exists for the account + @config = current_account.captain_configuration || current_account.create_captain_configuration! + end + + def config_params + params.require(:configuration).permit(:title, :subtitle, :primary_color, :secondary_color, :active, :logo_url, :phone_number) + end end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/extras_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/extras_controller.rb index 58aaf81..eaabe9a 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/extras_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/extras_controller.rb @@ -1,52 +1,44 @@ -module Api - module V1 - module Accounts - module Captain - class ExtrasController < Api::V1::Accounts::BaseController - before_action :fetch_extra, only: [:show, :update, :destroy] +class Api::V1::Accounts::Captain::ExtrasController < Api::V1::Accounts::BaseController + before_action :fetch_extra, only: [:show, :update, :destroy] - def index - @extras = Current.account.captain_extras - end + def index + @extras = Current.account.captain_extras + end - def show; end + def show; end - def create - @extra = Current.account.captain_extras.new(extra_params) - if @extra.save - render :show, status: :created - else - render_error_response(@extra) - end - end - - def update - if @extra.update(extra_params) - render :show - else - render_error_response(@extra) - end - end - - def destroy - if @extra.destroy - head :no_content - else - render_error_response(@extra) - end - end - - private - - def fetch_extra - @extra = Current.account.captain_extras.find(params[:id]) - end - - def extra_params - params.require(:extra).permit(:title, :description, :price, :category) - end - end - end + def create + @extra = Current.account.captain_extras.new(extra_params) + if @extra.save + render :show, status: :created + else + render_error_response(@extra) end end + + def update + if @extra.update(extra_params) + render :show + else + render_error_response(@extra) + end + end + + def destroy + if @extra.destroy + head :no_content + else + render_error_response(@extra) + end + end + + private + + def fetch_extra + @extra = Current.account.captain_extras.find(params[:id]) + end + + def extra_params + params.require(:extra).permit(:title, :description, :price, :category) + end end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/pricings_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/pricings_controller.rb index 8d81a9c..11e4e84 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/pricings_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/pricings_controller.rb @@ -1,67 +1,59 @@ -module Api - module V1 - module Accounts - module Captain - class PricingsController < Api::V1::Accounts::BaseController - before_action :fetch_pricing, only: [:show, :update, :destroy] +class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController + before_action :fetch_pricing, only: [:show, :update, :destroy] - def index - @pricings = Current.account.captain_pricings.includes(:brand, :inbox) - end + def index + @pricings = Current.account.captain_pricings.includes(:brand, :inbox) + end - def show; end + def show; end - def create - @pricing = Current.account.captain_pricings.new(pricing_params.except(:inbox_ids)) - @pricing.save! - sync_inboxes(@pricing, pricing_params[:inbox_ids]) - render :show, status: :created - end + def create + @pricing = Current.account.captain_pricings.new(pricing_params.except(:inbox_ids)) + @pricing.save! + sync_inboxes(@pricing, pricing_params[:inbox_ids]) + render :show, status: :created + end - def update - @pricing.update!(pricing_params.except(:inbox_ids)) - sync_inboxes(@pricing, pricing_params[:inbox_ids]) - render :show - end + def update + @pricing.update!(pricing_params.except(:inbox_ids)) + sync_inboxes(@pricing, pricing_params[:inbox_ids]) + render :show + end - def destroy - @pricing.destroy! - head :no_content - end + def destroy + @pricing.destroy! + head :no_content + end - private + private - def fetch_pricing - @pricing = Current.account.captain_pricings.find(params[:id]) - end + def fetch_pricing + @pricing = Current.account.captain_pricings.find(params[:id]) + end - def pricing_params - params.require(:pricing).permit( - :captain_brand_id, - :inbox_id, - :day_range, - :suite_category, - :duration, - :price, - :keywords, - inbox_ids: [] - ) - end + def pricing_params + params.require(:pricing).permit( + :captain_brand_id, + :inbox_id, + :day_range, + :suite_category, + :duration, + :price, + :keywords, + inbox_ids: [] + ) + end - def sync_inboxes(pricing, inbox_ids) - return if inbox_ids.nil? + def sync_inboxes(pricing, inbox_ids) + return if inbox_ids.nil? - ids = Array(inbox_ids).reject(&:blank?).map(&:to_i) - if ids.empty? - pricing.inboxes.clear - return - end - - inboxes = Current.account.inboxes.where(id: ids) - pricing.inboxes = inboxes - end - end - end + ids = Array(inbox_ids).reject(&:blank?).map(&:to_i) + if ids.empty? + pricing.inboxes.clear + return end + + inboxes = Current.account.inboxes.where(id: ids) + pricing.inboxes = inboxes end end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb index 8a8ef28..18add3e 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb @@ -1,54 +1,46 @@ -module Api - module V1 - module Accounts - module Captain - class UnitsController < Api::V1::Accounts::BaseController - before_action :current_account - before_action -> { check_authorization(::Captain::Assistant) } +class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(::Captain::Assistant) } - def index - @units = if params[:all] - Current.account.captain_units - else - Current.account.captain_units.active.select(:id, :name) - end - render json: @units - end + def index + @units = if params[:all] + Current.account.captain_units + else + Current.account.captain_units.active.select(:id, :name) + end + render json: @units + end - def create - @unit = Current.account.captain_units.new(unit_params) + def create + @unit = Current.account.captain_units.new(unit_params) - if @unit.save - render json: @unit - else - render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end - - def update - @unit = Current.account.captain_units.find(params[:id]) - if @unit.update(unit_params) - render json: @unit - else - render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end - - private - - def unit_params - params.require(:unit).permit( - :name, :status, :captain_brand_id, - :reservations_sync_enabled, - :plug_play_id, :plug_play_token, - :inter_client_id, :inter_client_secret, - :inter_pix_key, :inter_cert_path, - :inter_key_path, :inter_account_number, - :webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag - ) - end - end - end + if @unit.save + render json: @unit + else + render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity end end + + def update + @unit = Current.account.captain_units.find(params[:id]) + if @unit.update(unit_params) + render json: @unit + else + render json: { error: @unit.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + + private + + def unit_params + params.require(:unit).permit( + :name, :status, :captain_brand_id, + :reservations_sync_enabled, + :plug_play_id, :plug_play_token, + :inter_client_id, :inter_client_secret, + :inter_pix_key, :inter_cert_path, + :inter_key_path, :inter_account_number, + :webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag + ) + end end diff --git a/enterprise/app/controllers/api/v1/accounts/inboxes/captain/reminder_settings_controller.rb b/enterprise/app/controllers/api/v1/accounts/inboxes/captain/reminder_settings_controller.rb index be3ef19..5a94c82 100644 --- a/enterprise/app/controllers/api/v1/accounts/inboxes/captain/reminder_settings_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/inboxes/captain/reminder_settings_controller.rb @@ -1,49 +1,39 @@ -module Api - module V1 - module Accounts - module Inboxes - module Captain - class ReminderSettingsController < Api::V1::Accounts::BaseController - before_action :fetch_inbox - before_action :fetch_or_initialize_settings +class Api::V1::Accounts::Inboxes::Captain::ReminderSettingsController < Api::V1::Accounts::BaseController + before_action :fetch_inbox + before_action :fetch_or_initialize_settings - def show - render json: @settings - end + def show + render json: @settings + end - def update - if @settings.update(settings_params) - render json: @settings - else - render json: { error: @settings.errors.full_messages.join(', ') }, status: :unprocessable_entity - end - end - - private - - def fetch_inbox - @inbox = Current.account.inboxes.find(params[:inbox_id]) - end - - def fetch_or_initialize_settings - @settings = ::Captain::InboxReminderSetting.find_or_initialize_by( - account: Current.account, - inbox: @inbox - ) - end - - def settings_params - params.permit( - :enabled, - :menu_message, - :menu_delay_minutes, - :feedback_message, - :feedback_delay_minutes - ) - end - end - end - end + def update + if @settings.update(settings_params) + render json: @settings + else + render json: { error: @settings.errors.full_messages.join(', ') }, status: :unprocessable_entity end end + + private + + def fetch_inbox + @inbox = Current.account.inboxes.find(params[:inbox_id]) + end + + def fetch_or_initialize_settings + @settings = ::Captain::InboxReminderSetting.find_or_initialize_by( + account: Current.account, + inbox: @inbox + ) + end + + def settings_params + params.permit( + :enabled, + :menu_message, + :menu_delay_minutes, + :feedback_message, + :feedback_delay_minutes + ) + end end diff --git a/enterprise/app/controllers/public/api/v1/captain/booking_app_controller.rb b/enterprise/app/controllers/public/api/v1/captain/booking_app_controller.rb index 6b0e941..140622c 100644 --- a/enterprise/app/controllers/public/api/v1/captain/booking_app_controller.rb +++ b/enterprise/app/controllers/public/api/v1/captain/booking_app_controller.rb @@ -1,16 +1,8 @@ -module Public - module Api - module V1 - module Captain - class BookingAppController < ActionController::Base - layout false +class Public::Api::V1::Captain::BookingAppController < ApplicationController + layout false - def index - # This action will serve the React App container - render :index - end - end - end - end + def index + # This action will serve the React App container + render :index end end diff --git a/enterprise/app/controllers/public/api/v1/captain/master_data_controller.rb b/enterprise/app/controllers/public/api/v1/captain/master_data_controller.rb index 73b1515..bd486cf 100644 --- a/enterprise/app/controllers/public/api/v1/captain/master_data_controller.rb +++ b/enterprise/app/controllers/public/api/v1/captain/master_data_controller.rb @@ -1,33 +1,25 @@ -module Public - module Api - module V1 - module Captain - class MasterDataController < ActionController::API - def show - # Assuming account_id is passed or derived from domain - account = Account.find(params[:account_id]) +class Public::Api::V1::Captain::MasterDataController < ActionController::API + def show + # Assuming account_id is passed or derived from domain + account = Account.find(params[:account_id]) - brands = account.captain_brands.includes(:units, :pricings) - extras = account.captain_extras.where(active: true).order(:order) - suites = account.captain_suites.all + brands = account.captain_brands.includes(:units, :pricings) + extras = account.captain_extras.where(active: true).order(:order) + suites = account.captain_suites.all - begin - config = account.captain_configuration || account.create_captain_configuration! - rescue StandardError => e - Rails.logger.error "Failed to create Captain Configuration: #{e.message}" - config = { title: 'Reserva Rápida', subtitle: 'Agende sua visita', primary_color: '#1E90FF', secondary_color: '#1B3B5F' } - end - - render json: { - app_config: config, - brands: brands.as_json(include: :units), - pricings: account.captain_pricings.as_json, - extras: extras.as_json, - suites: suites.as_json - } - end - end - end + begin + config = account.captain_configuration || account.create_captain_configuration! + rescue StandardError => e + Rails.logger.error "Failed to create Captain Configuration: #{e.message}" + config = { title: 'Reserva Rápida', subtitle: 'Agende sua visita', primary_color: '#1E90FF', secondary_color: '#1B3B5F' } end + + render json: { + app_config: config, + brands: brands.as_json(include: :units), + pricings: account.captain_pricings.as_json, + extras: extras.as_json, + suites: suites.as_json + } end end diff --git a/enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb b/enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb index 16be68a..b3fb016 100644 --- a/enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb +++ b/enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb @@ -1,172 +1,164 @@ -module Public - module Api - module V1 - module Captain - class ReservationsController < ActionController::API - # Use basic rescue_from for generic error handling to capture 'Inbox not found' or similar if they bubble up - rescue_from StandardError, with: :handle_standard_error +class Public::Api::V1::Captain::ReservationsController < ActionController::API + # Use basic rescue_from for generic error handling to capture 'Inbox not found' or similar if they bubble up + rescue_from StandardError, with: :handle_standard_error - def create - Rails.logger.info '[Captain::Booking] 🏁 Starting Reservation Creation' - Rails.logger.info "[Captain::Booking] Params: #{params.to_unsafe_h.except(:controller, :action)}" + def create + Rails.logger.info '[Captain::Booking] 🏁 Starting Reservation Creation' + Rails.logger.info "[Captain::Booking] Params: #{params.to_unsafe_h.except(:controller, :action)}" - # 1. Parse Params - # 3. Persistence Logic - ActiveRecord::Base.transaction do - # A. Find or Create Contact (Simple Logic for now) - contact = find_or_create_contact(reservation_params) + # 1. Parse Params + # 3. Persistence Logic + ActiveRecord::Base.transaction do + # A. Find or Create Contact (Simple Logic for now) + contact = find_or_create_contact(reservation_params) - # A.1 Find or Create Conversation - # We need an inbox to create a conversation. - inbox_id = unit.account.inboxes.first&.id - raise 'Inbox not found' unless inbox_id + # A.1 Find or Create Conversation + # We need an inbox to create a conversation. + inbox_id = unit.account.inboxes.first&.id + raise 'Inbox not found' unless inbox_id - conversation = ::Conversation.create!( - account_id: unit.account_id, - inbox_id: inbox_id, - contact_id: contact.id, - contact_inbox_id: ::ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox_id)&.id, - status: :open - ) + conversation = ::Conversation.create!( + account_id: unit.account_id, + inbox_id: inbox_id, + contact_id: contact.id, + contact_inbox_id: ::ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox_id)&.id, + status: :open + ) - # B. Create Reservation - @reservation = ::Captain::Reservation.create!( - account_id: unit.account_id, - inbox_id: conversation.inbox_id, - contact_id: contact.id, - contact_inbox_id: conversation.contact_inbox_id, - conversation_id: conversation.id, - captain_brand_id: unit.captain_brand_id, - captain_unit_id: unit.id, - suite_identifier: params[:selected_category] || 'Standard', - check_in_at: params[:check_in_at], - check_out_at: params[:check_out_at], - total_amount: params[:total_value] || params[:total_amount], - status: :pending_payment, - payment_status: :pending, - metadata: { - observacao: params[:observacao] - } - ) + # B. Create Reservation + @reservation = ::Captain::Reservation.create!( + account_id: unit.account_id, + inbox_id: conversation.inbox_id, + contact_id: contact.id, + contact_inbox_id: conversation.contact_inbox_id, + conversation_id: conversation.id, + captain_brand_id: unit.captain_brand_id, + captain_unit_id: unit.id, + suite_identifier: params[:selected_category] || 'Standard', + check_in_at: params[:check_in_at], + check_out_at: params[:check_out_at], + total_amount: params[:total_value] || params[:total_amount], + status: :pending_payment, + payment_status: :pending, + metadata: { + observacao: params[:observacao] + } + ) - # C. Generate PIX (Real or Mock) - pix_result = generate_pix(unit, reservation_params) + # C. Generate PIX (Real or Mock) + pix_result = generate_pix(unit, reservation_params) - raise "Pix Generation Failed: #{pix_result[:error]}" unless pix_result[:success] + raise "Pix Generation Failed: #{pix_result[:error]}" unless pix_result[:success] - # D. Save Pix Charge - ::Captain::PixCharge.create!( - reservation_id: @reservation.id, - unit_id: unit.id, - txid: pix_result[:txid], - pix_copia_e_cola: pix_result[:pix_copy_paste], - status: 'active', # Means created/waiting - raw_webhook_payload: {}, # Will be filled by webhook later - paid_at: nil - ) + # D. Save Pix Charge + ::Captain::PixCharge.create!( + reservation_id: @reservation.id, + unit_id: unit.id, + txid: pix_result[:txid], + pix_copia_e_cola: pix_result[:pix_copy_paste], + status: 'active', # Means created/waiting + raw_webhook_payload: {}, # Will be filled by webhook later + paid_at: nil + ) - Rails.logger.info "✅ Reservation ##{@reservation.id} Created & Persisted." - render_success(pix_result, @reservation.id) - end - rescue StandardError => e - Rails.logger.error "❌ Failed to create reservation: #{e.message}" - render json: { error: e.message }, status: :unprocessable_entity - end + Rails.logger.info "✅ Reservation ##{@reservation.id} Created & Persisted." + render_success(pix_result, @reservation.id) + end + rescue StandardError => e + Rails.logger.error "❌ Failed to create reservation: #{e.message}" + render json: { error: e.message }, status: :unprocessable_entity + end - def status - reservation = ::Captain::Reservation.find_by(id: params[:id]) - if reservation - render json: { - id: reservation.id, - status: reservation.status, - payment_status: reservation.payment_status - }, status: :ok - else - render json: { error: 'Not Found' }, status: :not_found - end - end - - private - - def find_or_create_contact(params) - # Logic to reuse contact by email or phone - # For this context, we assume an Account Context is known (unit.account_id) - # Simplified for brevity. In a real scenario, use ContactBuilder. - account_id = ::Captain::Unit.find(params[:unit_id]).account_id - contact = ::Contact.find_by(email: params[:email], account_id: account_id) - - contact ||= ::Contact.create!( - name: params[:contact_name] || params[:name], - email: params[:email], - phone_number: params[:phone], - account_id: account_id - ) - # Ensure ContactInbox exists (required for Reservation model validation) - inbox = ::Inbox.where(account_id: account_id).first - if inbox && !::ContactInbox.exists?(contact_id: contact.id, inbox_id: inbox.id) - ::ContactInbox.create!(contact: contact, inbox: inbox, source_id: contact.id) - end - contact - end - - def generate_pix(unit, params) - if unit.inter_client_id.present? - service = ::Captain::InterService.new( - client_id: unit.inter_client_id, - client_secret: unit.inter_client_secret, - cert_path: unit.inter_cert_path, - key_path: unit.inter_key_path, - pix_key: unit.inter_pix_key, - account_number: unit.inter_account_number - ) - service.create_pix_charge(params) - else - # Mock Return - { - success: true, - pix_copy_paste: 'MOCK_PIX_CODE_123', - qr_code_url: 'https://dummyimage.com/300x300/000/fff&text=MOCK+QR', - txid: "MOCK-#{SecureRandom.hex(10)}" - } - end - end - - def unit - @unit ||= ::Captain::Unit.find(params[:unit_id]) - end - - def reservation_params - params.permit( - :brand_id, :unit_id, :contact_name, :phone_number, :email, :cpf, - :check_in_at, :check_out_at, :total_amount, :duration_minutes, - metadata: {} - ) - end - - def handle_standard_error(e) - Rails.logger.error '[Captain::Booking::Error] 🔥🔥 CRITICAL ERROR in ReservationsController 🔥🔥' - Rails.logger.error "[Captain::Booking::Error] Message: #{e.message}" - Rails.logger.error "[Captain::Booking::Error] Backtrace:\n#{e.backtrace.first(15).join("\n")}" - - render json: { error: "Internal Server Error: #{e.message}", details: 'Check logs for Captain::Booking::Error' }, - status: :internal_server_error - end - - def render_success(data, reservation_id) - render json: { - success: true, - message: 'Reserva iniciada com sucesso.', - reservation_id: reservation_id, - metadata: { - pix: { - copyPasteCode: data[:pix_copy_paste], - qrCodeValue: data[:qr_code_url] || 'https://dummyimage.com/300x300/000/fff&text=QR+Code+Inter' - } - } - }, status: :ok - end - end - end + def status + reservation = ::Captain::Reservation.find_by(id: params[:id]) + if reservation + render json: { + id: reservation.id, + status: reservation.status, + payment_status: reservation.payment_status + }, status: :ok + else + render json: { error: 'Not Found' }, status: :not_found end end + + private + + def find_or_create_contact(params) + # Logic to reuse contact by email or phone + # For this context, we assume an Account Context is known (unit.account_id) + # Simplified for brevity. In a real scenario, use ContactBuilder. + account_id = ::Captain::Unit.find(params[:unit_id]).account_id + contact = ::Contact.find_by(email: params[:email], account_id: account_id) + + contact ||= ::Contact.create!( + name: params[:contact_name] || params[:name], + email: params[:email], + phone_number: params[:phone], + account_id: account_id + ) + # Ensure ContactInbox exists (required for Reservation model validation) + inbox = ::Inbox.where(account_id: account_id).first + if inbox && !::ContactInbox.exists?(contact_id: contact.id, inbox_id: inbox.id) + ::ContactInbox.create!(contact: contact, inbox: inbox, source_id: contact.id) + end + contact + end + + def generate_pix(unit, params) + if unit.inter_client_id.present? + service = ::Captain::InterService.new( + client_id: unit.inter_client_id, + client_secret: unit.inter_client_secret, + cert_path: unit.inter_cert_path, + key_path: unit.inter_key_path, + pix_key: unit.inter_pix_key, + account_number: unit.inter_account_number + ) + service.create_pix_charge(params) + else + # Mock Return + { + success: true, + pix_copy_paste: 'MOCK_PIX_CODE_123', + qr_code_url: 'https://dummyimage.com/300x300/000/fff&text=MOCK+QR', + txid: "MOCK-#{SecureRandom.hex(10)}" + } + end + end + + def unit + @unit ||= ::Captain::Unit.find(params[:unit_id]) + end + + def reservation_params + params.permit( + :brand_id, :unit_id, :contact_name, :phone_number, :email, :cpf, + :check_in_at, :check_out_at, :total_amount, :duration_minutes, + metadata: {} + ) + end + + def handle_standard_error(e) + Rails.logger.error '[Captain::Booking::Error] 🔥🔥 CRITICAL ERROR in ReservationsController 🔥🔥' + Rails.logger.error "[Captain::Booking::Error] Message: #{e.message}" + Rails.logger.error "[Captain::Booking::Error] Backtrace:\n#{e.backtrace.first(15).join("\n")}" + + render json: { error: "Internal Server Error: #{e.message}", details: 'Check logs for Captain::Booking::Error' }, + status: :internal_server_error + end + + def render_success(data, reservation_id) + render json: { + success: true, + message: 'Reserva iniciada com sucesso.', + reservation_id: reservation_id, + metadata: { + pix: { + copyPasteCode: data[:pix_copy_paste], + qrCodeValue: data[:qr_code_url] || 'https://dummyimage.com/300x300/000/fff&text=QR+Code+Inter' + } + } + }, status: :ok + end end diff --git a/enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb b/enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb index dc439ce..0fcaca7 100644 --- a/enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb +++ b/enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb @@ -1,54 +1,46 @@ -module Public - module Api - module V1 - module Captain - class WebhooksController < ActionController::API - def inter_pix - Rails.logger.info "[Captain::InterWebhook] 🔔 Received Webhook: #{params.to_unsafe_h}" +class Public::Api::V1::Captain::WebhooksController < ActionController::API + def inter_pix + Rails.logger.info "[Captain::InterWebhook] 🔔 Received Webhook: #{params.to_unsafe_h}" - # Inter sends an array of "pix" objects - pix_events = params[:pix] || [] + # Inter sends an array of "pix" objects + pix_events = params[:pix] || [] - pix_events.each do |event| - txid = event[:txid] - next unless txid + pix_events.each do |event| + txid = event[:txid] + next unless txid - charge = ::Captain::PixCharge.find_by(txid: txid) + charge = ::Captain::PixCharge.find_by(txid: txid) - if charge - Rails.logger.info "[Captain::InterWebhook] ✅ Found Charge for TXID: #{txid}" + if charge + Rails.logger.info "[Captain::InterWebhook] ✅ Found Charge for TXID: #{txid}" - # Update Charge - charge.update!( - status: 'paid', - paid_at: Time.current, - raw_webhook_payload: event.to_json - ) + # Update Charge + charge.update!( + status: 'paid', + paid_at: Time.current, + raw_webhook_payload: event.to_json + ) - # Update Reservation - reservation = charge.reservation - if reservation - reservation.update!( - status: :active, - payment_status: :paid - ) - Rails.logger.info "[Captain::InterWebhook] 🏨 Reservation ##{reservation.id} Confirmed!" + # Update Reservation + reservation = charge.reservation + if reservation + reservation.update!( + status: :active, + payment_status: :paid + ) + Rails.logger.info "[Captain::InterWebhook] 🏨 Reservation ##{reservation.id} Confirmed!" - # Trigger N8n Webhook - ::Captain::WebhookSenderService.new(reservation).perform + # Trigger N8n Webhook + ::Captain::WebhookSenderService.new(reservation).perform - # Send WhatsApp Confirmation - ::Captain::WhatsappNotificationService.new(reservation).perform - end - else - Rails.logger.warn "[Captain::InterWebhook] ⚠️ Charge NOT found for TXID: #{txid}" - end - end - - render json: { status: 'received' }, status: :ok - end + # Send WhatsApp Confirmation + ::Captain::WhatsappNotificationService.new(reservation).perform end + else + Rails.logger.warn "[Captain::InterWebhook] ⚠️ Charge NOT found for TXID: #{txid}" end end + + render json: { status: 'received' }, status: :ok end end diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index 3f8ba32..d4632da 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -98,7 +98,8 @@ module Captain::ChatHelper end def api_key - raw_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 diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index e589b44..c3adad3 100755 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -18,7 +18,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob trigger_media_analysis Rails.logger.info "[ResponseBuilderJob] Captain V2 Enabled? #{captain_v2_enabled?}" - File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] ResponseBuilderJob: V2 Enabled? #{captain_v2_enabled?}" } + File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.zone.now}] ResponseBuilderJob: V2 Enabled? #{captain_v2_enabled?}" } if captain_v2_enabled? generate_response_with_v2 @@ -209,7 +209,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob elapsed_time = Time.zone.now - @start_time remaining_delay = target_delay - elapsed_time - sleep(remaining_delay) if remaining_delay > 0 + sleep(remaining_delay) if remaining_delay.positive? end def fetch_new_incoming_messages @@ -217,11 +217,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob all_messages = @conversation.messages.order(:created_at) # Find the last message sent by the assistant (outgoing) - last_outgoing_index = all_messages.rindex { |m| m.outgoing? } + last_outgoing_index = all_messages.rindex(&:outgoing?) potential_messages = if last_outgoing_index # Get all messages after the last outgoing one - all_messages[(last_outgoing_index + 1)..-1] || [] + all_messages[(last_outgoing_index + 1)..] || [] else # If no outgoing messages, use all messages all_messages diff --git a/enterprise/app/jobs/captain/intent_classification_job.rb b/enterprise/app/jobs/captain/intent_classification_job.rb index 38e3eb0..94350dd 100644 --- a/enterprise/app/jobs/captain/intent_classification_job.rb +++ b/enterprise/app/jobs/captain/intent_classification_job.rb @@ -1,87 +1,85 @@ -module Captain - class IntentClassificationJob < ApplicationJob - queue_as :default +class Captain::IntentClassificationJob < ApplicationJob + queue_as :default - CATEGORIES = %w[ - valores - disponibilidade - localizacao - checkin_checkout - pet_friendly - cancelamento - cafe_da_manha - estacionamento - pagamento - outros - ].freeze + CATEGORIES = %w[ + valores + disponibilidade + localizacao + checkin_checkout + pet_friendly + cancelamento + cafe_da_manha + estacionamento + pagamento + outros + ].freeze - def perform(conversation_id, message_content) - conversation = Conversation.find_by(id: conversation_id) - return unless conversation + def perform(conversation_id, message_content) + conversation = Conversation.find_by(id: conversation_id) + return unless conversation - # Prevent labeling if already labeled recently (optional optimization, skipping for MVP to ensure accuracy) - # For MVP, we classify every user message to capture the flow, or we could limit to the first few. - # Let's classify every message that is substantial enough. - return if message_content.to_s.strip.length < 5 + # Prevent labeling if already labeled recently (optional optimization, skipping for MVP to ensure accuracy) + # For MVP, we classify every user message to capture the flow, or we could limit to the first few. + # Let's classify every message that is substantial enough. + return if message_content.to_s.strip.length < 5 - intent = classify_intent(message_content) - return unless intent.present? && CATEGORIES.include?(intent) + intent = classify_intent(message_content) + return unless intent.present? && CATEGORIES.include?(intent) - label_name = "duvida:#{intent}" + label_name = "duvida:#{intent}" - # Add label if not present - unless conversation.labels.exists?(name: label_name) - conversation.labels << Label.find_or_create_by(title: label_name, account_id: conversation.account_id) - Rails.logger.info "[IntentClassification] Applied label '#{label_name}' to conversation #{conversation.id}" - end - rescue StandardError => e - Rails.logger.error "[IntentClassification] Failed to classify: #{e.message}" + # Add label if not present + unless conversation.labels.exists?(name: label_name) + conversation.labels << Label.find_or_create_by(title: label_name, account_id: conversation.account_id) + Rails.logger.info "[IntentClassification] Applied label '#{label_name}' to conversation #{conversation.id}" end + rescue StandardError => e + Rails.logger.error "[IntentClassification] Failed to classify: #{e.message}" + end - private + private - def classify_intent(text) - # We use a simple prompt for the LLM - prompt = <<~PROMPT - Classifique a mensagem do usuário em UMA das seguintes categorias: - #{CATEGORIES.join(', ')} + def classify_intent(text) + # We use a simple prompt for the LLM + prompt = <<~PROMPT + Classifique a mensagem do usuário em UMA das seguintes categorias: + #{CATEGORIES.join(', ')} - Se não se encaixar claramente, responda 'outros'. - Responda APENAS com o nome da categoria. + Se não se encaixar claramente, responda 'outros'. + Responda APENAS com o nome da categoria. - Mensagem: "#{text}" - PROMPT + Mensagem: "#{text}" + PROMPT - # Using the existing LLM infrastructure - # We create a temporary safe agent config or just use direct LLM call if possible. - # Since we are inside Captain, we can try to use RubyLLM direct client if configured, - # or fallback to the conversation's assistant if available. + # Using the existing LLM infrastructure + # We create a temporary safe agent config or just use direct LLM call if possible. + # Since we are inside Captain, we can try to use RubyLLM direct client if configured, + # or fallback to the conversation's assistant if available. - # For simplicity and robustness in this specific codebase context, let's use the OpenAI client wrapper directly - # if available via the Agents gem or RubyLLM configuration already set up. + # For simplicity and robustness in this specific codebase context, let's use the OpenAI client wrapper directly + # if available via the Agents gem or RubyLLM configuration already set up. - messages = [{ role: 'user', content: prompt }] + messages = [{ role: 'user', content: prompt }] - # Robust API Key fetching for background jobs - api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value - api_key ||= ENV.fetch('OPENAI_API_KEY', nil) + # Robust API Key fetching for background jobs + api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value + api_key ||= ENV.fetch('OPENAI_API_KEY', nil) - # Strip eventual image suffixes if present (reuse sanitization logic) - api_key = api_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip + # Strip eventual image suffixes if present (reuse sanitization logic) + api_key = api_key.to_s.gsub(/\.(png|jpg|jpeg|gif|webp|svg|@2x|@3x).*$/i, '').strip - client = OpenAI::Client.new(access_token: api_key) + client = OpenAI::Client.new(access_token: api_key) - response = client.chat( - parameters: { - model: 'gpt-4o-mini', # Cost effective - messages: messages, - temperature: 0.0, - max_tokens: 10 - } - ) + response = client.chat( + parameters: { + model: 'gpt-4o-mini', # Cost effective + messages: messages, + temperature: 0.0, + max_tokens: 10 + } + ) - content = response.dig('choices', 0, 'message', 'content') - content&.strip&.downcase - end + content = response.dig('choices', 0, 'message', 'content') + content&.strip&.downcase end end diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index acdf5af..2ea9d79 100755 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -108,7 +108,7 @@ class Captain::Assistant < ApplicationRecord next unless tool_class # Avoid duplicates if tool is already added (e.g. hardcoded ones) - next if tools.any? { |t| t.is_a?(tool_class) } + next if tools.any?(tool_class) tools << tool_class.new(self, conversation: conversation, user: user) end diff --git a/enterprise/app/models/captain/configuration.rb b/enterprise/app/models/captain/configuration.rb index 20e9261..9e59659 100644 --- a/enterprise/app/models/captain/configuration.rb +++ b/enterprise/app/models/captain/configuration.rb @@ -1,10 +1,8 @@ -module Captain - class Configuration < ApplicationRecord - self.table_name = 'captain_configurations' +class Captain::Configuration < ApplicationRecord + self.table_name = 'captain_configurations' - belongs_to :account + belongs_to :account - validates :account_id, presence: true - validates :title, presence: true - end + validates :account_id, presence: true + validates :title, presence: true end diff --git a/enterprise/app/models/captain/reminder.rb b/enterprise/app/models/captain/reminder.rb index 392ec41..bec3c46 100644 --- a/enterprise/app/models/captain/reminder.rb +++ b/enterprise/app/models/captain/reminder.rb @@ -1,21 +1,19 @@ -module Captain - class Reminder < ApplicationRecord - self.table_name = 'captain_reminders' +class Captain::Reminder < ApplicationRecord + self.table_name = 'captain_reminders' - belongs_to :account - belongs_to :inbox - belongs_to :contact - belongs_to :contact_inbox - belongs_to :conversation, optional: true - belongs_to :source, polymorphic: true, optional: true + belongs_to :account + belongs_to :inbox + belongs_to :contact + belongs_to :contact_inbox + belongs_to :conversation, optional: true + belongs_to :source, polymorphic: true, optional: true - enum status: { pending: 0, sent: 1, failed: 2, cancelled: 3 } - enum reminder_type: { manual: 0, automation: 1 } + enum status: { pending: 0, sent: 1, failed: 2, cancelled: 3 } + enum reminder_type: { manual: 0, automation: 1 } - validates :scheduled_at, presence: true - validates :message, presence: true + validates :scheduled_at, presence: true + validates :message, presence: true - scope :pending, -> { where(status: :pending) } - scope :due, -> { pending.where('scheduled_at <= ?', Time.current) } - end + scope :pending, -> { where(status: :pending) } + scope :due, -> { pending.where('scheduled_at <= ?', Time.current) } end diff --git a/enterprise/app/models/captain/reservation.rb b/enterprise/app/models/captain/reservation.rb index 8a5e00e..de41f44 100644 --- a/enterprise/app/models/captain/reservation.rb +++ b/enterprise/app/models/captain/reservation.rb @@ -1,37 +1,35 @@ -module Captain - class Reservation < ApplicationRecord - self.table_name = 'captain_reservations' +class Captain::Reservation < ApplicationRecord + self.table_name = 'captain_reservations' - belongs_to :account - belongs_to :inbox - belongs_to :contact - belongs_to :contact_inbox - belongs_to :conversation, class_name: '::Conversation', optional: true - belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', optional: true - belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true - belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true + belongs_to :account + belongs_to :inbox + belongs_to :contact + belongs_to :contact_inbox + belongs_to :conversation, class_name: '::Conversation', optional: true + belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', optional: true + belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true + belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true - has_many :reminders, class_name: 'Captain::Reminder', as: :source, dependent: :destroy + has_many :reminders, class_name: 'Captain::Reminder', as: :source, dependent: :destroy - enum status: { scheduled: 0, active: 1, completed: 2, cancelled: 3, pending_payment: 4, draft: 5 } - enum payment_status: { pending: 'pending', paid: 'paid', failed: 'failed' }, _prefix: :payment + enum status: { scheduled: 0, active: 1, completed: 2, cancelled: 3, pending_payment: 4, draft: 5 } + enum payment_status: { pending: 'pending', paid: 'paid', failed: 'failed' }, _prefix: :payment - scope :filter_by_unit, ->(unit_id) { where(captain_unit_id: unit_id) if unit_id.present? } - scope :filter_by_status, ->(status) { where(status: status) if status.present? && status != 'all' } - scope :filter_by_date_range, lambda { |from, to| - if from.present? && to.present? - where(check_in_at: from..to) - elsif from.present? - where('check_in_at >= ?', from) - elsif to.present? - where('check_in_at <= ?', to) - end - } + scope :filter_by_unit, ->(unit_id) { where(captain_unit_id: unit_id) if unit_id.present? } + scope :filter_by_status, ->(status) { where(status: status) if status.present? && status != 'all' } + scope :filter_by_date_range, lambda { |from, to| + if from.present? && to.present? + where(check_in_at: from..to) + elsif from.present? + where('check_in_at >= ?', from) + elsif to.present? + where('check_in_at <= ?', to) + end + } - validates :suite_identifier, presence: true - validates :check_in_at, presence: true - validates :check_out_at, presence: true + validates :suite_identifier, presence: true + validates :check_in_at, presence: true + validates :check_out_at, presence: true - delegate :name, :email, :phone_number, to: :contact, prefix: true - end + delegate :name, :email, :phone_number, to: :contact, prefix: true end diff --git a/enterprise/app/models/captain/suite.rb b/enterprise/app/models/captain/suite.rb index 14c2dc2..be6ab0a 100644 --- a/enterprise/app/models/captain/suite.rb +++ b/enterprise/app/models/captain/suite.rb @@ -1,9 +1,7 @@ -module Captain - class Suite < ApplicationRecord - self.table_name = 'captain_suites' - belongs_to :account +class Captain::Suite < ApplicationRecord + self.table_name = 'captain_suites' + belongs_to :account - validates :name, presence: true - validates :category, presence: true - end + validates :name, presence: true + validates :category, presence: true end diff --git a/enterprise/app/models/captain/tool_config.rb b/enterprise/app/models/captain/tool_config.rb index a9183b5..202c852 100644 --- a/enterprise/app/models/captain/tool_config.rb +++ b/enterprise/app/models/captain/tool_config.rb @@ -1,15 +1,13 @@ -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 +class Captain::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 - validates :tool_key, presence: true + validates :tool_key, presence: true - # Ensure tool config is either linked to an inbox or an assistant - validates :captain_assistant_id, presence: true, unless: -> { inbox_id.present? } - validates :tool_key, uniqueness: { scope: [:account_id, :inbox_id] }, if: -> { inbox_id.present? } - validates :tool_key, uniqueness: { scope: [:account_id, :captain_assistant_id] }, if: -> { captain_assistant_id.present? } - end + # Ensure tool config is either linked to an inbox or an assistant + validates :captain_assistant_id, presence: true, unless: -> { inbox_id.present? } + validates :tool_key, uniqueness: { scope: [:account_id, :inbox_id] }, if: -> { inbox_id.present? } + validates :tool_key, uniqueness: { scope: [:account_id, :captain_assistant_id] }, if: -> { captain_assistant_id.present? } end diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 526cca9..b4bef6a 100755 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -32,7 +32,7 @@ class Captain::Assistant::AgentRunnerService # [FEATURE] Short-circuit for thank you/emoji messages to ensure reaction tool usage Rails.logger.info "[Captain V2] Checking for reaction. Message: #{message_to_process.inspect}" - File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] AgentRunnerService: checking reaction for #{message_to_process.inspect}" } + File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.zone.now}] AgentRunnerService: checking reaction for #{message_to_process.inspect}" } reaction_response = check_and_react_to_message(message_to_process) return reaction_response if reaction_response @@ -41,7 +41,7 @@ class Captain::Assistant::AgentRunnerService runner = Agents::Runner.with_agents(*agents) runner = add_callbacks_to_runner(runner) if @callbacks.any? - puts "[DEBUG V2] Running with agents: #{agents.map(&:name).join(', ')}" + Rails.logger.debug { "[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 @@ -89,7 +89,7 @@ class Captain::Assistant::AgentRunnerService end Rails.logger.info "[Captain V2] Reaction Pre-Check: Text='#{text}' Category=#{matched_category}" - File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.now}] AgentRunnerService: Text='#{text}' Category=#{matched_category}" } + File.open('/tmp/v2_debug.log', 'a') { |f| f.puts "[#{Time.zone.now}] AgentRunnerService: Text='#{text}' Category=#{matched_category}" } if matched_category Rails.logger.info "[Captain V2] Detected #{matched_category}. Executing ReactToMessageTool directly." @@ -140,7 +140,7 @@ class Captain::Assistant::AgentRunnerService # 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] + message_history[0...last_user_index] + message_history[(last_user_index + 1)..] else message_history end @@ -328,7 +328,7 @@ class Captain::Assistant::AgentRunnerService 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? + return if raw_key.blank? 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 } diff --git a/enterprise/app/services/captain/handoff_webhook_service.rb b/enterprise/app/services/captain/handoff_webhook_service.rb index a318a2c..6ea37bd 100644 --- a/enterprise/app/services/captain/handoff_webhook_service.rb +++ b/enterprise/app/services/captain/handoff_webhook_service.rb @@ -1,105 +1,103 @@ # frozen_string_literal: true -module Captain - class HandoffWebhookService - def initialize(conversation:, assistant:, handoff_context: {}) - @conversation = conversation - @assistant = assistant - @handoff_context = handoff_context +class Captain::HandoffWebhookService + def initialize(conversation:, assistant:, handoff_context: {}) + @conversation = conversation + @assistant = assistant + @handoff_context = handoff_context + end + + def deliver + return unless webhook_enabled? + + webhook_config = @assistant.handoff_webhook_config + url = webhook_config['url'] + headers = build_headers(webhook_config['headers'] || {}) + + payload = build_payload + + Rails.logger.info "[HandoffWebhook] Sending to #{url} for conversation #{@conversation.id}" + + response = Faraday.post(url, payload.to_json, headers) do |req| + req.options.timeout = webhook_config['timeout_seconds'] || 5 end - def deliver - return unless webhook_enabled? + log_success(response) + rescue StandardError => e + log_failure(e) + # Enqueue retry if configured + retry_if_needed(e) if should_retry? + end - webhook_config = @assistant.handoff_webhook_config - url = webhook_config['url'] - headers = build_headers(webhook_config['headers'] || {}) + private - payload = build_payload + def webhook_enabled? + config = @assistant.handoff_webhook_config + config.present? && config['enabled'] == true && config['url'].present? + end - Rails.logger.info "[HandoffWebhook] Sending to #{url} for conversation #{@conversation.id}" + def build_payload + { + event: 'conversation.handoff', + timestamp: Time.zone.now.iso8601, + conversation: conversation_data, + contact: contact_data, + handoff_context: @handoff_context, + assistant: assistant_data + } + end - response = Faraday.post(url, payload.to_json, headers) do |req| - req.options.timeout = webhook_config['timeout_seconds'] || 5 - end + def conversation_data + { + id: @conversation.id, + display_id: @conversation.display_id, + status: @conversation.status, + inbox_id: @conversation.inbox_id, + assignee_id: @conversation.assignee_id, + team_id: @conversation.team_id + } + end - log_success(response) - rescue StandardError => e - log_failure(e) - # Enqueue retry if configured - retry_if_needed(e) if should_retry? - end + def contact_data + contact = @conversation.contact + { + id: contact.id, + name: contact.name, + phone_number: contact.phone_number, + email: contact.email, + additional_attributes: contact.additional_attributes + } + end - private + def assistant_data + { + id: @assistant.id, + name: @assistant.name + } + end - def webhook_enabled? - config = @assistant.handoff_webhook_config - config.present? && config['enabled'] == true && config['url'].present? - end + def build_headers(custom_headers) + { + 'Content-Type' => 'application/json', + 'User-Agent' => 'Chatwoot-Captain/1.0' + }.merge(custom_headers) + end - def build_payload - { - event: 'conversation.handoff', - timestamp: Time.zone.now.iso8601, - conversation: conversation_data, - contact: contact_data, - handoff_context: @handoff_context, - assistant: assistant_data - } - end + def should_retry? + (@assistant.handoff_webhook_config['retry_attempts'] || 0).positive? + end - def conversation_data - { - id: @conversation.id, - display_id: @conversation.display_id, - status: @conversation.status, - inbox_id: @conversation.inbox_id, - assignee_id: @conversation.assignee_id, - team_id: @conversation.team_id - } - end + def retry_if_needed(error) + # TODO: Implement retry logic with Sidekiq in future PR + Rails.logger.warn "[HandoffWebhook] Retry needed but not implemented yet: #{error.message}" + end - def contact_data - contact = @conversation.contact - { - id: contact.id, - name: contact.name, - phone_number: contact.phone_number, - email: contact.email, - additional_attributes: contact.additional_attributes - } - end + def log_success(response) + Rails.logger.info "[HandoffWebhook] Success: #{response.status} for conversation #{@conversation.id}" + end - def assistant_data - { - id: @assistant.id, - name: @assistant.name - } - end - - def build_headers(custom_headers) - { - 'Content-Type' => 'application/json', - 'User-Agent' => 'Chatwoot-Captain/1.0' - }.merge(custom_headers) - end - - def should_retry? - (@assistant.handoff_webhook_config['retry_attempts'] || 0) > 0 - end - - def retry_if_needed(error) - # TODO: Implement retry logic with Sidekiq in future PR - Rails.logger.warn "[HandoffWebhook] Retry needed but not implemented yet: #{error.message}" - end - - def log_success(response) - Rails.logger.info "[HandoffWebhook] Success: #{response.status} for conversation #{@conversation.id}" - end - - def log_failure(error) - Rails.logger.error "[HandoffWebhook] Failed for conversation #{@conversation.id}: #{error.message}" - ChatwootExceptionTracker.new(error, account: @conversation.account).capture_exception - end + def log_failure(error) + Rails.logger.error "[HandoffWebhook] Failed for conversation #{@conversation.id}: #{error.message}" + ChatwootExceptionTracker.new(error, account: @conversation.account).capture_exception end end diff --git a/enterprise/app/services/captain/inter/auth_service.rb b/enterprise/app/services/captain/inter/auth_service.rb index f25f1ea..5e63bce 100644 --- a/enterprise/app/services/captain/inter/auth_service.rb +++ b/enterprise/app/services/captain/inter/auth_service.rb @@ -1,61 +1,57 @@ require 'faraday' -module Captain - module Inter - class AuthService - API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze - TOKEN_URL = '/oauth/v2/token'.freeze +class Captain::Inter::AuthService + API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze + TOKEN_URL = '/oauth/v2/token'.freeze - def initialize(unit) - @unit = unit - end + def initialize(unit) + @unit = unit + end - def token - cached_token = Redis::Alfred.get(cache_key) - return cached_token if cached_token.present? + def token + cached_token = Redis::Alfred.get(cache_key) + return cached_token if cached_token.present? - fetch_new_token - end + fetch_new_token + end - private + private - def cache_key - "inter_token:unit_#{@unit.id}" - end + def cache_key + "inter_token:unit_#{@unit.id}" + end - def fetch_new_token - raise "Unit #{@unit.name} is inactive" unless @unit.active? + def fetch_new_token + raise "Unit #{@unit.name} is inactive" unless @unit.active? - response = connection.post(TOKEN_URL) do |req| - req.headers['Content-Type'] = 'application/x-www-form-urlencoded' - # Inter requires Basic Auth with ClientID:ClientSecret - req.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{@unit.inter_client_id}:#{@unit.inter_client_secret}")}" + response = connection.post(TOKEN_URL) do |req| + req.headers['Content-Type'] = 'application/x-www-form-urlencoded' + # Inter requires Basic Auth with ClientID:ClientSecret + req.headers['Authorization'] = "Basic #{Base64.strict_encode64("#{@unit.inter_client_id}:#{@unit.inter_client_secret}")}" - req.body = URI.encode_www_form({ - grant_type: 'client_credentials', - scope: 'cob.write cob.read pix.write pix.read webhook.read webhook.write' - }) - end + req.body = URI.encode_www_form({ + grant_type: 'client_credentials', + scope: 'cob.write cob.read pix.write pix.read webhook.read webhook.write' + }) + end - raise "Auth Failed: #{response.body}" unless response.success? + raise "Auth Failed: #{response.body}" unless response.success? - data = JSON.parse(response.body) - access_token = data['access_token'] - expires_in = data['expires_in'].to_i + data = JSON.parse(response.body) + access_token = data['access_token'] + expires_in = data['expires_in'].to_i - # Cache with safety margin (5 min less than expiry) - Redis::Alfred.setex(cache_key, access_token, expires_in - 300) + # Cache with safety margin (5 min less than expiry) + Redis::Alfred.setex(cache_key, access_token, expires_in - 300) - access_token - end + access_token + end - def connection - @connection ||= Faraday.new(url: API_BASE_URL) do |conn| - conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path)) - conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path)) - conn.adapter Faraday.default_adapter - end - end + def connection + @connection ||= Faraday.new(url: API_BASE_URL) do |conn| + conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path)) + conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path)) + conn.adapter Faraday.default_adapter end end end diff --git a/enterprise/app/services/captain/inter/cob_service.rb b/enterprise/app/services/captain/inter/cob_service.rb index 18d241c..ec1fc94 100644 --- a/enterprise/app/services/captain/inter/cob_service.rb +++ b/enterprise/app/services/captain/inter/cob_service.rb @@ -1,90 +1,86 @@ -module Captain - module Inter - class CobService - API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze +class Captain::Inter::CobService + API_BASE_URL = 'https://cdpj.partners.bancointer.com.br'.freeze - def initialize(reservation) - @reservation = reservation - @unit = reservation.unit - end + def initialize(reservation) + @reservation = reservation + @unit = reservation.unit + end - def call - raise 'Unit not configured for Pix' unless @unit.inter_pix_key.present? + def call + raise 'Unit not configured for Pix' if @unit.inter_pix_key.blank? - token = AuthService.new(@unit).token + token = AuthService.new(@unit).token - # Determine TxId: Inter allows sending or they generate. - # Strategically, letting Inter generate is safer for avoidance of collision, - # but we need to store it. - # If we want to send, it must be 26-35 characters. + # Determine TxId: Inter allows sending or they generate. + # Strategically, letting Inter generate is safer for avoidance of collision, + # but we need to store it. + # If we want to send, it must be 26-35 characters. - payload = build_payload + payload = build_payload - response = connection(token).post('/pix/v2/cob', payload.to_json) + response = connection(token).post('/pix/v2/cob', payload.to_json) - raise "Pix Creation Failed: #{response.body}" unless response.success? + raise "Pix Creation Failed: #{response.body}" unless response.success? - # Ensure safe encoding for logging - safe_body = response.body.to_s.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') + # Ensure safe encoding for logging + safe_body = response.body.to_s.force_encoding('UTF-8').encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') - data = JSON.parse(safe_body) + data = JSON.parse(safe_body) - # [CRITICAL DEBUG] Log the ENTIRE response to see why it's being cut - Rails.logger.info "[BANCO INTER] FULL RESPONSE: #{safe_body}" - File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] BANCO INTER RAW BODY: #{safe_body}" - end + # [CRITICAL DEBUG] Log the ENTIRE response to see why it's being cut + Rails.logger.info "[BANCO INTER] FULL RESPONSE: #{safe_body}" + File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| + f.puts "[#{Time.zone.now}] BANCO INTER RAW BODY: #{safe_body}" + end - persist_charge(data) - end + persist_charge(data) + end - private + private - def build_payload - amount = @reservation.total_amount.to_f.round(2) + def build_payload + amount = @reservation.total_amount.to_f.round(2) - { - calendario: { expiracao: Captain::PixCharge::EXPIRATION_SECONDS }, # 1 hour - devedor: { - cpf: @reservation.contact.custom_attributes['cpf'] || '00000000000', # Fallback for dev/testing - nome: @reservation.contact.name || 'Cliente' - }, - valor: { original: format('%.2f', amount) }, - chave: @unit.inter_pix_key, - solicitacaoPagador: "Reserva #{@reservation.id}" - } - end + { + calendario: { expiracao: Captain::PixCharge::EXPIRATION_SECONDS }, # 1 hour + devedor: { + cpf: @reservation.contact.custom_attributes['cpf'] || '00000000000', # Fallback for dev/testing + nome: @reservation.contact.name || 'Cliente' + }, + valor: { original: format('%.2f', amount) }, + chave: @unit.inter_pix_key, + solicitacaoPagador: "Reserva #{@reservation.id}" + } + end - def persist_charge(data) - # Try every possible field where Inter might hide the EMV code - pix_code = data['pixCopiaECola'] || - data.dig('pix', 'copiaECola') || - data['qrcode'] || - data['textoImagemQRcode'] + def persist_charge(data) + # Try every possible field where Inter might hide the EMV code + pix_code = data['pixCopiaECola'] || + data.dig('pix', 'copiaECola') || + data['qrcode'] || + data['textoImagemQRcode'] - charge = @unit.pix_charges.create!( - reservation: @reservation, - txid: data['txid'], - pix_copia_e_cola: pix_code, - status: 'active', - e2eid: nil, - raw_webhook_payload: data.to_json - ) + charge = @unit.pix_charges.create!( + reservation: @reservation, + txid: data['txid'], + pix_copia_e_cola: pix_code, + status: 'active', + e2eid: nil, + raw_webhook_payload: data.to_json + ) - @reservation.update!(current_pix_charge_id: charge.id) - charge - end + @reservation.update!(current_pix_charge_id: charge.id) + charge + end - def connection(token) - Faraday.new(url: API_BASE_URL) do |conn| - conn.headers['Authorization'] = "Bearer #{token}" - conn.headers['Content-Type'] = 'application/json' - conn.headers['x-conta-corrente'] = @unit.inter_account_number - conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path)) - conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path)) - conn.adapter Faraday.default_adapter - end - end + def connection(token) + Faraday.new(url: API_BASE_URL) do |conn| + conn.headers['Authorization'] = "Bearer #{token}" + conn.headers['Content-Type'] = 'application/json' + conn.headers['x-conta-corrente'] = @unit.inter_account_number + conn.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@unit.inter_cert_path)) + conn.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read(@unit.inter_key_path)) + conn.adapter Faraday.default_adapter end end end diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 09ad2ed..2173f2f 100755 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -43,7 +43,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService # 3. Handle Tool Strategy if brain_decision.strategy == :execute_tool - File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.now}] BRAIN DECIDED: #{brain_decision.tool_key}" } + File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] BRAIN DECIDED: #{brain_decision.tool_key}" } inbox = @conversation.inbox @@ -57,7 +57,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService additional_data: { message: additional_message, tool_input: brain_decision.tool_input } ) - File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RUNNER RESULT: #{runner_result.inspect}" } + File.open(Rails.root.join('log/brain_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RUNNER RESULT: #{runner_result.inspect}" } return { 'response' => runner_result[:body][:message] } if runner_result[:fallback] && runner_result.dig(:body, :message).present? diff --git a/enterprise/app/services/captain/llm/contact_identity_service.rb b/enterprise/app/services/captain/llm/contact_identity_service.rb index 6a7bcc1..a1884f3 100644 --- a/enterprise/app/services/captain/llm/contact_identity_service.rb +++ b/enterprise/app/services/captain/llm/contact_identity_service.rb @@ -59,6 +59,6 @@ class Captain::Llm::ContactIdentityService def normalize_name(name) parts = name.split parts.shift if parts.first&.downcase.in?(%w[a o]) - parts.map { |part| part.capitalize }.join(' ') + parts.map(&:capitalize).join(' ') end end diff --git a/enterprise/app/services/captain/llm/jasmine_brain.rb b/enterprise/app/services/captain/llm/jasmine_brain.rb index 38cdf84..d1415aa 100644 --- a/enterprise/app/services/captain/llm/jasmine_brain.rb +++ b/enterprise/app/services/captain/llm/jasmine_brain.rb @@ -1,383 +1,379 @@ -module Captain - module Llm - class JasmineBrain - Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true) +class Captain::Llm::JasmineBrain + Decision = Struct.new(:strategy, :tool_key, :reasoning, :tool_input, keyword_init: true) - def self.decide(assistant:, conversation:, message:, history:) - new(assistant, conversation, message, history).decide + def self.decide(assistant:, conversation:, message:, history:) + new(assistant, conversation, message, history).decide + end + + def initialize(assistant, conversation, message, history) + @assistant = assistant + @conversation = conversation + @message = message.to_s + @history = history + @contact = conversation.contact + end + + def decide + Rails.logger.info "[JasmineBrain] DECIDING for message: '#{@message}' | Contact: #{@contact.id} | Scenario: #{@conversation.active_scenario_key}" + + # 1. Gate: Check if AI is disabled for this contact + return Decision.new(strategy: :skip_ai, reasoning: 'Contact has desligar_ia label') if contact_has_disabled_label? + + # [FEATURE] React to thank you messages and emojis FIRST + # This takes priority over sticky scenarios + reaction_decision = check_thank_you_or_emoji + if reaction_decision + Rails.logger.info "[JasmineBrain] Short-circuiting to REACTION: #{reaction_decision.inspect}" + return reaction_decision + end + + sticky_decision = sticky_decision_for_message + if sticky_decision + Rails.logger.info "[JasmineBrain] Sticky decision: #{sticky_decision.inspect}" + return sticky_decision + end + + # [FEATURE] Dynamic Routing (Self-Service) + # Verifica se algum Agente (Scenario) tem palavras-chave (Gatilhos) que batem com a mensagem. + # Se sim, faz o roteamento imediato (Short-Circuit) sem consultar o LLM. + dynamic_decision = check_dynamic_triggers + return dynamic_decision if dynamic_decision + + llm_decision = ask_brain_for_classification + if llm_decision + decision = Decision.new( + strategy: llm_decision['strategy'].to_s.downcase.to_sym, + tool_key: llm_decision['tool_key'], + reasoning: llm_decision['reasoning'], + tool_input: llm_decision['tool_input'] + ) + + log_decision(decision) + + return decision if decision.strategy == :execute_tool && decision.tool_key.present? + end + + if feature_faq_enabled? && question_like?(@message) && !reservation_like?(@message) + log_decision(Decision.new(strategy: :execute_tool, tool_key: 'faq_lookup', reasoning: 'FAQ fallback', tool_input: { query: @message })) + return Decision.new( + strategy: :execute_tool, + tool_key: 'faq_lookup', + reasoning: 'Fallback FAQ lookup for general question with feature_faq enabled.', + tool_input: { query: @message } + ) + end + + decision = Decision.new(strategy: :direct, reasoning: llm_decision ? 'Direct fallback' : 'LLM Classification Failed') + log_decision(decision) + decision + rescue StandardError => e + Rails.logger.error "[JasmineBrain] Error in decision: #{e.message}" + Decision.new(strategy: :direct, reasoning: "Error: #{e.message}") + end + + private + + def contact_has_disabled_label? + @conversation.labels.exists?(name: 'desligar_ia') + rescue StandardError + false + end + + def sticky_scenario_active? + return false unless @conversation.respond_to?(:active_scenario_key) + + expires_at = @conversation.active_scenario_expires_at + if expires_at.present? && expires_at < Time.current + clear_sticky_session + return false + end + + @conversation.active_scenario_key.present? + end + + def exit_keyword?(message) + text = message.to_s.downcase + return false if text.blank? + + exit_patterns = [ + /\b(cancelar|sair|parar|desistir|reiniciar|comecar de novo|resetar)\b/i, + /\b(falar com (humano|atendente|pessoa))\b/i, + /\b(tchau|adeus|ate logo)\b/i + ] + + exit_patterns.any? { |pattern| text.match?(pattern) } + end + + def clear_sticky_session + @conversation.update!( + active_scenario_key: nil, + active_scenario_expires_at: nil, + active_scenario_state: {} + ) + + # [DEEP CLEAN] Wipe any lingering Jasmine state or cached tool results + if @conversation.custom_attributes.present? + new_attrs = @conversation.custom_attributes.except('jasmine_state', 'last_availability') + @conversation.update!(custom_attributes: new_attrs) + end + + Rails.logger.info "[JasmineBrain] Session DEEP CLEANED for Conversation ##{@conversation.id}" + rescue StandardError => e + Rails.logger.warn "[JasmineBrain] Failed to clear sticky session: #{e.message}" + end + + def log_decision(decision) + payload = { + service: 'JasmineBrain', + conversation_id: @conversation&.id, + account_id: @conversation&.account_id, + decision_strategy: decision.strategy, + tool_key: decision.tool_key, + active_scenario_key: @conversation&.active_scenario_key, + scenario_stage: @conversation&.active_scenario_state&.dig('stage'), + message_length: @message.to_s.length, + timestamp: Time.current.iso8601 + } + + Rails.logger.info payload.to_json + rescue StandardError => e + Rails.logger.warn "[JasmineBrain] Failed to log decision: #{e.message}" + end + + def ask_brain_for_classification + system_prompt = build_classification_prompt + model = @assistant.try(:llm_model).presence || 'gpt-4o-mini' + + chat = RubyLLM.chat(model: model) + chat = chat.with_params( + response_format: { type: 'json_object' }, + temperature: 0.1 + ) + + chat.add_message({ role: 'system', content: system_prompt }) + + if @history.is_a?(Array) + @history.each do |msg| + content = msg[:content] + next if content.blank? + + chat.add_message({ role: msg[:role], content: content }) end + end - def initialize(assistant, conversation, message, history) - @assistant = assistant - @conversation = conversation - @message = message.to_s - @history = history - @contact = conversation.contact - end + raw_response = chat.ask(@message) + parse_json(raw_response) + end - def decide - Rails.logger.info "[JasmineBrain] DECIDING for message: '#{@message}' | Contact: #{@contact.id} | Scenario: #{@conversation.active_scenario_key}" + def build_classification_prompt + # Carregamos as ferramentas e cenários dinamicamente do assistente + # Incluímos as ferramentas básicas e os "Cenários" (que são ScenarioDelegatorTool) + available_tools = @assistant.agent_tools(conversation: @conversation, user: nil) - # 1. Gate: Check if AI is disabled for this contact - return Decision.new(strategy: :skip_ai, reasoning: 'Contact has desligar_ia label') if contact_has_disabled_label? + tools_list = available_tools.map do |tool| + tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name + tool_desc = tool.respond_to?(:description) ? tool.description : '' + tool_params = tool_parameters_schema_for_prompt(tool) + params_text = tool_params.present? ? "\n params_schema: #{tool_params.to_json}" : '' + "- #{tool_name}: #{tool_desc}#{params_text}" + end.join("\n") - # [FEATURE] React to thank you messages and emojis FIRST - # This takes priority over sticky scenarios - reaction_decision = check_thank_you_or_emoji - if reaction_decision - Rails.logger.info "[JasmineBrain] Short-circuiting to REACTION: #{reaction_decision.inspect}" - return reaction_decision - end + <<~PROMPT + You are Jasmine, the Brain of the operation. + Your goal is to classify the user's intent based on their latest message and decide the action strategy. - sticky_decision = sticky_decision_for_message - if sticky_decision - Rails.logger.info "[JasmineBrain] Sticky decision: #{sticky_decision.inspect}" - return sticky_decision - end + AVAILABLE INTENTS (TOOLS): + #{tools_list} + - direct: For general conversation, doubts unrelated to specific tools, or if unsure. - # [FEATURE] Dynamic Routing (Self-Service) - # Verifica se algum Agente (Scenario) tem palavras-chave (Gatilhos) que batem com a mensagem. - # Se sim, faz o roteamento imediato (Short-Circuit) sem consultar o LLM. - dynamic_decision = check_dynamic_triggers - return dynamic_decision if dynamic_decision + IMPORTANT: + - If the user greets (oi/ola/bom dia/boa tarde/boa noite) and no other request exists, ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "😀"}. + - If the user asks to keep an eye on something (acompanhar, ficar de olho, monitorar), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"}. + - If the user asks about reservations or availability (reserva, reservar, disponibilidade), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement. + - If the user asks about research/search (pesquisa, pesquisar, buscar, procura), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement. + - If the user sends a THANK YOU message ("obrigado", "obrigada", "valeu", "agradeço", "muito obrigado", "agradecido") -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}. + - If the user sends ONLY an emoji (🙏, 👍, ❤️, etc) -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}. + - If the user wants to check availability or make a reservation but did NOT mention a specific suite name (Stilo, Alexa, Hidro, Master), you MUST use "direct" strategy and ask: "Qual suíte você prefere?" or "Para qual suíte?". Do NOT guess the suite. + - If the user's request matches one of the specialized departments (scenarios) above, use that tool. + - Do NOT trigger "escalar_humano" for greeting messages or simple questions. + - Only use "escalar_humano" if the user is explicitly requesting a human or is angry. + - If the list of AVAILABLE INTENTS (TOOLS) above is empty, ALWAYS use "direct". + - Only choose "execute_tool" when you can fill the required tool_input fields from the user's message or recent context. + - If required fields are missing, use "direct" and ask for the missing info. + - For scenario tools (consultar_*), set tool_input to {"pergunta_interna": ""}. + - For faq_lookup, set tool_input to {"query": ""}. - llm_decision = ask_brain_for_classification - if llm_decision - decision = Decision.new( - strategy: llm_decision['strategy'].to_s.downcase.to_sym, - tool_key: llm_decision['tool_key'], - reasoning: llm_decision['reasoning'], - tool_input: llm_decision['tool_input'] - ) + Output MUST be a valid JSON object with: + { + "strategy": "execute_tool" OR "direct", + "tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL", + "tool_input": { "key": "value" } OR null, + "reasoning": "A brief explanation of why you chose this intent." + } - log_decision(decision) + Example: + User: "Tem vaga agora?" + JSON: {"strategy": "execute_tool", "tool_key": "status_suites", "tool_input": null, "reasoning": "User asked about vacancy."} - return decision if decision.strategy == :execute_tool && decision.tool_key.present? - end + Example: + User: "Muito obrigado!" + JSON: {"strategy": "execute_tool", "tool_key": "react_to_message", "tool_input": {"emoji": "❤️"}, "reasoning": "User sent a thank you message."} + PROMPT + end - if feature_faq_enabled? && question_like?(@message) && !reservation_like?(@message) - log_decision(Decision.new(strategy: :execute_tool, tool_key: 'faq_lookup', reasoning: 'FAQ fallback', tool_input: { query: @message })) - return Decision.new( - strategy: :execute_tool, - tool_key: 'faq_lookup', - reasoning: 'Fallback FAQ lookup for general question with feature_faq enabled.', - tool_input: { query: @message } - ) - end + def parse_json(response_obj) + content = response_obj.respond_to?(:content) ? response_obj.content : response_obj.to_s + # Attempt to clean code blocks if present (common with some LLMs) + clean_content = content.gsub(/^```json\s*|```$/, '').strip + JSON.parse(clean_content) + rescue JSON::ParserError + Rails.logger.warn "[JasmineBrain] Failed to parse JSON: #{content}" + nil + end - decision = Decision.new(strategy: :direct, reasoning: llm_decision ? 'Direct fallback' : 'LLM Classification Failed') - log_decision(decision) - decision - rescue StandardError => e - Rails.logger.error "[JasmineBrain] Error in decision: #{e.message}" - Decision.new(strategy: :direct, reasoning: "Error: #{e.message}") - end + def sticky_decision_for_message + return nil unless sticky_scenario_active? - private + if exit_keyword?(@message) + Rails.logger.info '[JasmineBrain] EXIT KEYWORD DETECTED. Clearing session.' + clear_sticky_session + return Decision.new( + strategy: :direct, + reasoning: 'User requested reset/exit', + tool_input: nil + ) + end - def contact_has_disabled_label? - @conversation.labels.exists?(name: 'desligar_ia') - rescue StandardError - false - end + Decision.new( + strategy: :execute_tool, + tool_key: @conversation.active_scenario_key, + reasoning: 'Sticky scenario active', + tool_input: { pergunta_interna: @message } + ) + end - def sticky_scenario_active? - return false unless @conversation.respond_to?(:active_scenario_key) + def feature_faq_enabled? + @assistant.config['feature_faq'].to_s == 'true' || @assistant.config['feature_faq'] == true + end - expires_at = @conversation.active_scenario_expires_at - if expires_at.present? && expires_at < Time.current - clear_sticky_session - return false - end + def check_thank_you_or_emoji + raw_msg = @message + raw_msg = raw_msg.content if raw_msg.respond_to?(:content) + text = raw_msg.to_s.strip.downcase - @conversation.active_scenario_key.present? - end + # Patterns for thank you messages + thank_you_patterns = [ + /\b(obrigad[oa]|obrigad[oa]s)\b/i, + /\b(valeu)\b/i, + /\b(agradeço|agradecid[oa])\b/i, + /\b(muito obrigad[oa])\b/i, + /\b(brigadão|brigadao|brigadinha)\b/i, + /\b(grat[oa]|gratidão|gratidao)\b/i + ] - def exit_keyword?(message) - text = message.to_s.downcase - return false if text.blank? + # Check if message is ONLY emoji(s) + only_emoji = text.gsub(/[\s\p{Emoji}]/u, '').empty? && text.match?(/\p{Emoji}/u) - exit_patterns = [ - /\b(cancelar|sair|parar|desistir|reiniciar|comecar de novo|resetar)\b/i, - /\b(falar com (humano|atendente|pessoa))\b/i, - /\b(tchau|adeus|ate logo)\b/i - ] + if thank_you_patterns.any? { |pattern| text.match?(pattern) } || only_emoji + Rails.logger.info '[JasmineBrain] Detected thank you or emoji, triggering react_to_message' + return Decision.new( + strategy: :execute_tool, + tool_key: 'react_to_message', + reasoning: 'Thank you or emoji detected - short-circuit to react_to_message', + tool_input: { emoji: '❤️' } + ) + end - exit_patterns.any? { |pattern| text.match?(pattern) } - end + nil + end - def clear_sticky_session - @conversation.update!( - active_scenario_key: nil, - active_scenario_expires_at: nil, - active_scenario_state: {} - ) + def question_like?(message) + text = message.to_s.strip.downcase + return false if text.empty? - # [DEEP CLEAN] Wipe any lingering Jasmine state or cached tool results - if @conversation.custom_attributes.present? - new_attrs = @conversation.custom_attributes.except('jasmine_state', 'last_availability') - @conversation.update!(custom_attributes: new_attrs) - end + text.end_with?('?') || + text.match?(/\A(qual|quanto|como|onde|quando|tem|possui|pode|faz|qual o|qual a)/) + end - Rails.logger.info "[JasmineBrain] Session DEEP CLEANED for Conversation ##{@conversation.id}" - rescue StandardError => e - Rails.logger.warn "[JasmineBrain] Failed to clear sticky session: #{e.message}" - end + def reservation_like?(message) + text = message.to_s.downcase + keywords = %w[ + reserv agendar agendamento suite pernoite diaria + check-in checkin entrada saida horario amanha hoje + ] - def log_decision(decision) - payload = { - service: 'JasmineBrain', - conversation_id: @conversation&.id, - account_id: @conversation&.account_id, - decision_strategy: decision.strategy, - tool_key: decision.tool_key, - active_scenario_key: @conversation&.active_scenario_key, - scenario_stage: @conversation&.active_scenario_state&.dig('stage'), - message_length: @message.to_s.length, - timestamp: Time.current.iso8601 - } + keywords.any? { |keyword| text.include?(keyword) } + end - Rails.logger.info payload.to_json - rescue StandardError => e - Rails.logger.warn "[JasmineBrain] Failed to log decision: #{e.message}" - end + def strong_reservation_intent?(message) + text = message.to_s.downcase + return false if text.blank? - def ask_brain_for_classification - system_prompt = build_classification_prompt - model = @assistant.try(:llm_model).presence || 'gpt-4o-mini' + # Padroes que indicam vontade explicita de reservar, nao apenas duvida + patterns = [ + /quero reservar/i, + /gostaria de reservar/i, + /fazer (uma )?reserva/i, + /para o dia \d+/i, + /pro dia \d+/i, + /reservar para/i, + /tem vaga (para|pra)/i + ] - chat = RubyLLM.chat(model: model) - chat = chat.with_params( - response_format: { type: 'json_object' }, - temperature: 0.1 - ) + patterns.any? { |pattern| text.match?(pattern) } + end - chat.add_message({ role: 'system', content: system_prompt }) + def check_dynamic_triggers + return nil if @message.blank? - if @history.is_a?(Array) - @history.each do |msg| - content = msg[:content] - next if content.blank? + # Carrega cenarios ativos que tenham palavras-chave definidas + scenarios = @assistant.scenarios.enabled.where.not(trigger_keywords: [nil, '']) - chat.add_message({ role: msg[:role], content: content }) - end - end + text = @message.downcase.strip - raw_response = chat.ask(@message) - parse_json(raw_response) - end + scenarios.each do |scenario| + # trigger_keywords eh text no banco, separado por virgula + keywords = scenario.trigger_keywords.to_s.split(',').map(&:strip).map(&:downcase).reject(&:blank?) - def build_classification_prompt - # Carregamos as ferramentas e cenários dinamicamente do assistente - # Incluímos as ferramentas básicas e os "Cenários" (que são ScenarioDelegatorTool) - available_tools = @assistant.agent_tools(conversation: @conversation, user: nil) + # Verifica se alguma palavra-chave esta contida na mensagem + match = keywords.find { |kw| text.include?(kw) } - tools_list = available_tools.map do |tool| - tool_name = tool.respond_to?(:name) ? tool.name : tool.class.name - tool_desc = tool.respond_to?(:description) ? tool.description : '' - tool_params = tool_parameters_schema_for_prompt(tool) - params_text = tool_params.present? ? "\n params_schema: #{tool_params.to_json}" : '' - "- #{tool_name}: #{tool_desc}#{params_text}" - end.join("\n") + next unless match - <<~PROMPT - You are Jasmine, the Brain of the operation. - Your goal is to classify the user's intent based on their latest message and decide the action strategy. + tool_key = "consultar_#{scenario.title.parameterize.underscore}" + Rails.logger.info "[JasmineBrain] Dynamic Trigger MATCH: '#{match}' -> Routing to #{scenario.title} (#{tool_key})" - AVAILABLE INTENTS (TOOLS): - #{tools_list} - - direct: For general conversation, doubts unrelated to specific tools, or if unsure. + return Decision.new( + strategy: :execute_tool, + tool_key: tool_key, + reasoning: "Dynamic Trigger matched keyword: '#{match}'", + tool_input: { pergunta_interna: @message } + ) + end - IMPORTANT: - - If the user greets (oi/ola/bom dia/boa tarde/boa noite) and no other request exists, ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "😀"}. - - If the user asks to keep an eye on something (acompanhar, ficar de olho, monitorar), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"}. - - If the user asks about reservations or availability (reserva, reservar, disponibilidade), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement. - - If the user asks about research/search (pesquisa, pesquisar, buscar, procura), ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "👀"} as a soft acknowledgement. - - If the user sends a THANK YOU message ("obrigado", "obrigada", "valeu", "agradeço", "muito obrigado", "agradecido") -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}. - - If the user sends ONLY an emoji (🙏, 👍, ❤️, etc) -> ALWAYS use "execute_tool" with tool_key "react_to_message" and tool_input {"emoji": "❤️"}. - - If the user wants to check availability or make a reservation but did NOT mention a specific suite name (Stilo, Alexa, Hidro, Master), you MUST use "direct" strategy and ask: "Qual suíte você prefere?" or "Para qual suíte?". Do NOT guess the suite. - - If the user's request matches one of the specialized departments (scenarios) above, use that tool. - - Do NOT trigger "escalar_humano" for greeting messages or simple questions. - - Only use "escalar_humano" if the user is explicitly requesting a human or is angry. - - If the list of AVAILABLE INTENTS (TOOLS) above is empty, ALWAYS use "direct". - - Only choose "execute_tool" when you can fill the required tool_input fields from the user's message or recent context. - - If required fields are missing, use "direct" and ask for the missing info. - - For scenario tools (consultar_*), set tool_input to {"pergunta_interna": ""}. - - For faq_lookup, set tool_input to {"query": ""}. + nil + end - Output MUST be a valid JSON object with: + def tool_parameters_schema_for_prompt(tool) + return tool.tool_parameters_schema if tool.respond_to?(:tool_parameters_schema) + return nil unless tool.respond_to?(:parameters) + + params = tool.parameters + return nil unless params.is_a?(Hash) + + if params.values.all? { |param| param.respond_to?(:type) } + { + type: 'object', + properties: params.transform_values do |param| { - "strategy": "execute_tool" OR "direct", - "tool_key": "THE_INTENT_KEY_IF_EXECUTE_TOOL_ELSE_NULL", - "tool_input": { "key": "value" } OR null, - "reasoning": "A brief explanation of why you chose this intent." - } - - Example: - User: "Tem vaga agora?" - JSON: {"strategy": "execute_tool", "tool_key": "status_suites", "tool_input": null, "reasoning": "User asked about vacancy."} - - Example: - User: "Muito obrigado!" - JSON: {"strategy": "execute_tool", "tool_key": "react_to_message", "tool_input": {"emoji": "❤️"}, "reasoning": "User sent a thank you message."} - PROMPT - end - - def parse_json(response_obj) - content = response_obj.respond_to?(:content) ? response_obj.content : response_obj.to_s - # Attempt to clean code blocks if present (common with some LLMs) - clean_content = content.gsub(/^```json\s*|```$/, '').strip - JSON.parse(clean_content) - rescue JSON::ParserError - Rails.logger.warn "[JasmineBrain] Failed to parse JSON: #{content}" - nil - end - - def sticky_decision_for_message - return nil unless sticky_scenario_active? - - if exit_keyword?(@message) - Rails.logger.info '[JasmineBrain] EXIT KEYWORD DETECTED. Clearing session.' - clear_sticky_session - return Decision.new( - strategy: :direct, - reasoning: 'User requested reset/exit', - tool_input: nil - ) - end - - Decision.new( - strategy: :execute_tool, - tool_key: @conversation.active_scenario_key, - reasoning: 'Sticky scenario active', - tool_input: { pergunta_interna: @message } - ) - end - - def feature_faq_enabled? - @assistant.config['feature_faq'].to_s == 'true' || @assistant.config['feature_faq'] == true - end - - def check_thank_you_or_emoji - raw_msg = @message - raw_msg = raw_msg.content if raw_msg.respond_to?(:content) - text = raw_msg.to_s.strip.downcase - - # Patterns for thank you messages - thank_you_patterns = [ - /\b(obrigad[oa]|obrigad[oa]s)\b/i, - /\b(valeu)\b/i, - /\b(agradeço|agradecid[oa])\b/i, - /\b(muito obrigad[oa])\b/i, - /\b(brigadão|brigadao|brigadinha)\b/i, - /\b(grat[oa]|gratidão|gratidao)\b/i - ] - - # Check if message is ONLY emoji(s) - only_emoji = text.gsub(/[\s\p{Emoji}]/u, '').empty? && text.match?(/\p{Emoji}/u) - - if thank_you_patterns.any? { |pattern| text.match?(pattern) } || only_emoji - Rails.logger.info '[JasmineBrain] Detected thank you or emoji, triggering react_to_message' - return Decision.new( - strategy: :execute_tool, - tool_key: 'react_to_message', - reasoning: 'Thank you or emoji detected - short-circuit to react_to_message', - tool_input: { emoji: '❤️' } - ) - end - - nil - end - - def question_like?(message) - text = message.to_s.strip.downcase - return false if text.empty? - - text.end_with?('?') || - text.match?(/\A(qual|quanto|como|onde|quando|tem|possui|pode|faz|qual o|qual a)/) - end - - def reservation_like?(message) - text = message.to_s.downcase - keywords = %w[ - reserv agendar agendamento suite pernoite diaria - check-in checkin entrada saida horario amanha hoje - ] - - keywords.any? { |keyword| text.include?(keyword) } - end - - def strong_reservation_intent?(message) - text = message.to_s.downcase - return false if text.blank? - - # Padroes que indicam vontade explicita de reservar, nao apenas duvida - patterns = [ - /quero reservar/i, - /gostaria de reservar/i, - /fazer (uma )?reserva/i, - /para o dia \d+/i, - /pro dia \d+/i, - /reservar para/i, - /tem vaga (para|pra)/i - ] - - patterns.any? { |pattern| text.match?(pattern) } - end - - def check_dynamic_triggers - return nil if @message.blank? - - # Carrega cenarios ativos que tenham palavras-chave definidas - scenarios = @assistant.scenarios.enabled.where.not(trigger_keywords: [nil, '']) - - text = @message.downcase.strip - - scenarios.each do |scenario| - # trigger_keywords eh text no banco, separado por virgula - keywords = scenario.trigger_keywords.to_s.split(',').map(&:strip).map(&:downcase).reject(&:blank?) - - # Verifica se alguma palavra-chave esta contida na mensagem - match = keywords.find { |kw| text.include?(kw) } - - next unless match - - tool_key = "consultar_#{scenario.title.parameterize.underscore}" - Rails.logger.info "[JasmineBrain] Dynamic Trigger MATCH: '#{match}' -> Routing to #{scenario.title} (#{tool_key})" - - return Decision.new( - strategy: :execute_tool, - tool_key: tool_key, - reasoning: "Dynamic Trigger matched keyword: '#{match}'", - tool_input: { pergunta_interna: @message } - ) - end - - nil - end - - def tool_parameters_schema_for_prompt(tool) - return tool.tool_parameters_schema if tool.respond_to?(:tool_parameters_schema) - return nil unless tool.respond_to?(:parameters) - - params = tool.parameters - return nil unless params.is_a?(Hash) - - if params.values.all? { |param| param.respond_to?(:type) } - { - type: 'object', - properties: params.transform_values do |param| - { - type: param.type, - description: param.description - }.compact - end, - required: params.select { |_, param| param.required }.keys - } - else - params - end - end + type: param.type, + description: param.description + }.compact + end, + required: params.select { |_, param| param.required }.keys + } + else + params end end end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index 0272283..3f5d4e8 100755 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -86,7 +86,6 @@ class Captain::Llm::SystemPromptsService SYSTEM_PROMPT_MESSAGE end - # rubocop:disable Metrics/MethodLength def copilot_response_generator(product_name, available_tools, config = {}) citation_guidelines = if config['feature_citation'] <<~CITATION_TEXT @@ -149,7 +148,6 @@ class Captain::Llm::SystemPromptsService #{available_tools} SYSTEM_PROMPT_MESSAGE end - # rubocop:enable Metrics/MethodLength def assistant_response_generator(assistant_name, product_name, config = {}) json_instruction = <<~JSON_INSTRUCTION @@ -258,7 +256,7 @@ class Captain::Llm::SystemPromptsService end def assistant_prompt_from_blocks(blocks) - Array(blocks).map do |block| + Array(blocks).filter_map do |block| title = block['title'] || block[:title] content = block['content'] || block[:content] next if title.to_s.strip.empty? && content.to_s.strip.empty? @@ -268,7 +266,7 @@ class Captain::Llm::SystemPromptsService else "[#{title}]\n#{content}".strip end - end.compact.join("\n\n") + end.join("\n\n") end def paginated_faq_generator(start_page, end_page, language = 'english') diff --git a/enterprise/app/services/captain/reminders/create_service.rb b/enterprise/app/services/captain/reminders/create_service.rb index cf1e5f1..f4ec516 100644 --- a/enterprise/app/services/captain/reminders/create_service.rb +++ b/enterprise/app/services/captain/reminders/create_service.rb @@ -19,8 +19,8 @@ class Captain::Reminders::CreateService inbox = conversation&.inbox || find_inbox contact_inbox = conversation&.contact_inbox || find_or_create_contact_inbox(inbox) - raise ArgumentError, 'Inbox not found' unless inbox.present? - raise ArgumentError, 'Contact not found' unless contact_inbox&.contact.present? + raise ArgumentError, 'Inbox not found' if inbox.blank? + raise ArgumentError, 'Contact not found' if contact_inbox&.contact.blank? schedule_time = parse_time(params[:scheduled_at]) raise ArgumentError, 'Scheduled time is required' unless schedule_time diff --git a/enterprise/app/services/captain/reminders/processor.rb b/enterprise/app/services/captain/reminders/processor.rb index 39c5e96..7c918b7 100644 --- a/enterprise/app/services/captain/reminders/processor.rb +++ b/enterprise/app/services/captain/reminders/processor.rb @@ -32,7 +32,7 @@ class Captain::Reminders::Processor def suite_available? assistant = inbox.captain_assistant - return false unless assistant.present? + return false if assistant.blank? tool = Captain::Tools::StatusSuitesTool.new(assistant, conversation: conversation) result = tool.execute @@ -49,8 +49,8 @@ class Captain::Reminders::Processor raise ArgumentError, 'Message content is required' if content.blank? target_conversation = conversation || find_or_create_conversation - raise ArgumentError, 'Conversation not found' unless target_conversation.present? - raise ArgumentError, 'Assistant not configured for inbox' unless assistant.present? + raise ArgumentError, 'Conversation not found' if target_conversation.blank? + raise ArgumentError, 'Assistant not configured for inbox' if assistant.blank? Current.executed_by = assistant Current.account = @reminder.account diff --git a/enterprise/app/services/captain/reservations/create_service.rb b/enterprise/app/services/captain/reservations/create_service.rb index 55f24a7..20e0239 100644 --- a/enterprise/app/services/captain/reservations/create_service.rb +++ b/enterprise/app/services/captain/reservations/create_service.rb @@ -32,8 +32,8 @@ class Captain::Reservations::CreateService inbox = conversation&.inbox || find_inbox contact_inbox = conversation&.contact_inbox || find_or_create_contact_inbox(inbox) - raise ArgumentError, 'Inbox not found' unless inbox.present? - raise ArgumentError, 'Contact not found' unless contact_inbox&.contact.present? + raise ArgumentError, 'Inbox not found' if inbox.blank? + raise ArgumentError, 'Contact not found' if contact_inbox&.contact.blank? check_in_at = parse_time(params[:check_in_at]) raise ArgumentError, 'Check-in time is required' unless check_in_at @@ -149,13 +149,13 @@ class Captain::Reservations::CreateService end def find_brand(brand_id) - return unless brand_id.present? + return if brand_id.blank? Captain::Brand.find_by(id: brand_id, account_id: account.id) end def find_unit(unit_id) - return unless unit_id.present? + return if unit_id.blank? Captain::Unit.find_by(id: unit_id, account_id: account.id) end diff --git a/enterprise/app/services/captain/tools/base_tool.rb b/enterprise/app/services/captain/tools/base_tool.rb index 5ea7474..89dabcf 100755 --- a/enterprise/app/services/captain/tools/base_tool.rb +++ b/enterprise/app/services/captain/tools/base_tool.rb @@ -16,7 +16,7 @@ class Captain::Tools::BaseTool < RubyLLM::Tool RubyLLM.logger.debug "Tool #{name} returned: #{result.inspect}" return result if result.is_a?(RubyLLM::Tool::Halt) - return result unless fallback_message.present? + return result if fallback_message.blank? return result unless errorish_result?(result) fallback_message diff --git a/enterprise/app/services/captain/tools/check_availability_tool.rb b/enterprise/app/services/captain/tools/check_availability_tool.rb index 5cc28bb..6e66a55 100644 --- a/enterprise/app/services/captain/tools/check_availability_tool.rb +++ b/enterprise/app/services/captain/tools/check_availability_tool.rb @@ -1,465 +1,459 @@ -module Captain - module Tools - class CheckAvailabilityTool < BaseTool - def name - 'check_availability' - end +class Captain::Tools::CheckAvailabilityTool < BaseTool + def name + 'check_availability' + end - def description - 'Checks availability and price for a hotel suite. Requires "suite" (e.g., Stilo, Master) and "duration" (default 1). Returns the calculated price.' - end + def description + 'Checks availability and price for a hotel suite. Requires "suite" (e.g., Stilo, Master) and "duration" (default 1). Returns the calculated price.' + end - def tool_parameters_schema - { - type: 'object', - properties: { - suite: { - type: 'string', - description: 'Nome da suíte/categoria (ex: Stilo, Master, Hidro ou Spa)' - }, - duration: { - type: 'integer', - description: 'Duração em horas (padrão: 1)' - }, - date: { - type: 'string', - description: 'Data desejada para a reserva (ex: 20/01/2026 ou 20 de janeiro)' - }, - check_in_at: { - type: 'string', - description: 'Data/hora desejada para o check-in (ISO ou texto livre)' - } - }, - required: %w[suite] + def tool_parameters_schema + { + type: 'object', + properties: { + suite: { + type: 'string', + description: 'Nome da suíte/categoria (ex: Stilo, Master, Hidro ou Spa)' + }, + duration: { + type: 'integer', + description: 'Duração em horas (padrão: 1)' + }, + date: { + type: 'string', + description: 'Data desejada para a reserva (ex: 20/01/2026 ou 20 de janeiro)' + }, + check_in_at: { + type: 'string', + description: 'Data/hora desejada para o check-in (ISO ou texto livre)' } + }, + required: %w[suite] + } + end + + def execute(*args, **params) + actual_params = resolve_params(args, params) + account_id = @conversation&.account_id || @assistant&.account_id + + Rails.logger.info "[CheckAvailabilityTool] STARTING with params: #{actual_params}" + Rails.logger.debug { "[CheckAvailabilityTool] PRICING COUNT: #{Captain::Pricing.where(account_id: account_id).count}" } + Rails.logger.debug { "[CheckAvailabilityTool] FIRST PRICING: #{Captain::Pricing.where(account_id: account_id).first.inspect}" } + + suite_category = actual_params[:suite] + requested_duration = actual_params[:duration].presence # Don't default yet + + if suite_category.blank? + return "Por favor, pergunte ao cliente: 'Qual suíte você prefere (Stilo, Alexa ou Hidro) e por quanto tempo gostaria de ficar?'." + end + + ensure_conversation_context! + + # ... (Anti-Hallucination logic remains same) ... + + # [DATE RESOLUTION] + target_date = resolve_target_date(actual_params) + Rails.logger.info "[CheckAvailabilityTool] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}" + + # [DEBUG] Log the context + current_inbox_id = @conversation&.inbox_id + # [KEYWORD SEARCH] + # 1. First, find if the term matches any Brand suite_keywords or suite_categories + account_brands = Captain::Brand.where(account_id: account_id) + + # Try to find a category that matches the input directly (case insensitive) + matched_category = nil + + normalized_input = suite_category.to_s.strip.downcase + + # Iterate over brands to find a match in categories or keywords + account_brands.find_each do |brand| + # Check direct category name match + found_cat = brand.suite_categories&.find { |cat| cat.to_s.downcase == normalized_input } + if found_cat + matched_category = found_cat + break end - def execute(*args, **params) - actual_params = resolve_params(args, params) - account_id = @conversation&.account_id || @assistant&.account_id + # Check keywords match + # suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" } + brand.suite_keywords&.each do |cat_name, keywords_str| + next if keywords_str.blank? - Rails.logger.info "[CheckAvailabilityTool] STARTING with params: #{actual_params}" - Rails.logger.debug { "[CheckAvailabilityTool] PRICING COUNT: #{Captain::Pricing.where(account_id: account_id).count}" } - Rails.logger.debug { "[CheckAvailabilityTool] FIRST PRICING: #{Captain::Pricing.where(account_id: account_id).first.inspect}" } - - suite_category = actual_params[:suite] - requested_duration = actual_params[:duration].presence # Don't default yet - - if suite_category.blank? - return "Por favor, pergunte ao cliente: 'Qual suíte você prefere (Stilo, Alexa ou Hidro) e por quanto tempo gostaria de ficar?'." + keywords_list = keywords_str.to_s.downcase.split(',').map(&:strip) + if keywords_list.any? { |kw| normalized_input.include?(kw) } + matched_category = cat_name + break end + end + break if matched_category + end - ensure_conversation_context! + # Use the matched category if found, otherwise stick to the original input (fallback) + final_suite_category = matched_category || suite_category - # ... (Anti-Hallucination logic remains same) ... + Rails.logger.info "[CheckAvailabilityTool] KEYWORD MATCH: Input='#{suite_category}' -> Resolved='#{final_suite_category}'" - # [DATE RESOLUTION] - target_date = resolve_target_date(actual_params) - Rails.logger.info "[CheckAvailabilityTool] RESOLVED DATE: #{target_date} | SUITE: #{suite_category}" + pricing_scope = Captain::Pricing.where(account_id: account_id) + .where('suite_category ILIKE ?', final_suite_category) - # [DEBUG] Log the context - current_inbox_id = @conversation&.inbox_id - # [KEYWORD SEARCH] - # 1. First, find if the term matches any Brand suite_keywords or suite_categories - account_brands = Captain::Brand.where(account_id: account_id) + # [INBOX PRIORITY] Filter by Current Inbox > Global + current_inbox_id = @conversation&.inbox_id + pricing_scope = if current_inbox_id.present? + # STRICT MODE: Only fetch prices for THIS specific inbox. + # Supports both legacy (inbox_id column) and new (has_many through join table) + pricing_scope.left_joins(:inboxes) + .where('captain_pricings.inbox_id = :id OR captain_pricing_inboxes.inbox_id = :id', id: current_inbox_id) + .distinct + else + # No Context (Playground/Test): Global Only + pricing_scope.where(inbox_id: nil) + end - # Try to find a category that matches the input directly (case insensitive) - matched_category = nil + # Sort in Ruby to ensure Specific Inbox (non-nil) comes before Global (nil) + # This implements the "Override" behavior. + pricing_scope = pricing_scope.sort_by { |p| p.inbox_id ? 0 : 1 } - normalized_input = suite_category.to_s.strip.downcase + pricing_scope = filter_pricings_by_day_range(pricing_scope, target_date) if target_date - # Iterate over brands to find a match in categories or keywords - account_brands.find_each do |brand| - # Check direct category name match - found_cat = brand.suite_categories&.find { |cat| cat.to_s.downcase == normalized_input } - if found_cat - matched_category = found_cat - break - end + # [AUTO-MENU MODE] If duration is missing, return all options + if requested_duration.blank? + available_options = pricing_scope.map do |p| + "#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}" + end.join(', ') - # Check keywords match - # suite_keywords is a Hash: { "Category Name" => "keyword1, keyword2" } - brand.suite_keywords&.each do |cat_name, keywords_str| - next if keywords_str.blank? + if available_options.present? + msg = "Disponível! Para a suíte #{final_suite_category} em #{target_date&.strftime('%d/%m')}, tenho estas opções: #{available_options}. Pergunte qual duração o cliente prefere." + Rails.logger.info "[CheckAvailabilityTool] MENU MODE: #{msg}" + else + msg = "Não encontrei tarifas para a suíte #{final_suite_category} nesta data. Confirme o nome da suíte." + end + return msg + end - keywords_list = keywords_str.to_s.downcase.split(',').map(&:strip) - if keywords_list.any? { |kw| normalized_input.include?(kw) } - matched_category = cat_name - break - end - end - break if matched_category - end + pricing = pick_pricing_for_duration(pricing_scope, requested_duration) - # Use the matched category if found, otherwise stick to the original input (fallback) - final_suite_category = matched_category || suite_category + if pricing + final_price = pricing.price.to_f + msg = "Disponível! A Suíte #{final_suite_category} para #{requested_duration}h em #{target_date&.strftime('%d/%m')} está saindo por #{ActiveSupport::NumberHelper.number_to_currency( + final_price, unit: 'R$ ', separator: ',', delimiter: '.' + )} (#{pricing.day_range})." + persist_last_availability(final_suite_category, requested_duration, pricing, target_date) + Rails.logger.info "[CheckAvailabilityTool] SUCCESS: #{msg}" + else + available_options = pricing_scope.map do |p| + "#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}" + end.join(', ') - Rails.logger.info "[CheckAvailabilityTool] KEYWORD MATCH: Input='#{suite_category}' -> Resolved='#{final_suite_category}'" - - pricing_scope = Captain::Pricing.where(account_id: account_id) - .where('suite_category ILIKE ?', final_suite_category) - - # [INBOX PRIORITY] Filter by Current Inbox > Global - current_inbox_id = @conversation&.inbox_id - pricing_scope = if current_inbox_id.present? - # STRICT MODE: Only fetch prices for THIS specific inbox. - # Supports both legacy (inbox_id column) and new (has_many through join table) - pricing_scope.left_joins(:inboxes) - .where('captain_pricings.inbox_id = :id OR captain_pricing_inboxes.inbox_id = :id', id: current_inbox_id) - .distinct - else - # No Context (Playground/Test): Global Only - pricing_scope.where(inbox_id: nil) - end - - # Sort in Ruby to ensure Specific Inbox (non-nil) comes before Global (nil) - # This implements the "Override" behavior. - pricing_scope = pricing_scope.sort_by { |p| p.inbox_id ? 0 : 1 } - - pricing_scope = filter_pricings_by_day_range(pricing_scope, target_date) if target_date - - # [AUTO-MENU MODE] If duration is missing, return all options - if requested_duration.blank? - available_options = pricing_scope.map do |p| - "#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}" - end.join(', ') - - if available_options.present? - msg = "Disponível! Para a suíte #{final_suite_category} em #{target_date&.strftime('%d/%m')}, tenho estas opções: #{available_options}. Pergunte qual duração o cliente prefere." - Rails.logger.info "[CheckAvailabilityTool] MENU MODE: #{msg}" - return msg - else - msg = "Não encontrei tarifas para a suíte #{final_suite_category} nesta data. Confirme o nome da suíte." - return msg - end - end - - pricing = pick_pricing_for_duration(pricing_scope, requested_duration) - - if pricing - final_price = pricing.price.to_f - msg = "Disponível! A Suíte #{final_suite_category} para #{requested_duration}h em #{target_date&.strftime('%d/%m')} está saindo por #{ActiveSupport::NumberHelper.number_to_currency( - final_price, unit: 'R$ ', separator: ',', delimiter: '.' - )} (#{pricing.day_range})." - persist_last_availability(final_suite_category, requested_duration, pricing, target_date) - Rails.logger.info "[CheckAvailabilityTool] SUCCESS: #{msg}" - return msg - else - available_options = pricing_scope.map do |p| - "#{p.duration}: #{ActiveSupport::NumberHelper.number_to_currency(p.price.to_f, unit: 'R$ ', separator: ',', delimiter: '.')}" - end.join(', ') - - if available_options.present? - msg = "Não encontrei tarifa exata para #{requested_duration}h. IMPORTANTE: Informe ao cliente que temos estas opções disponíveis para #{final_suite_category}: #{available_options}. Pergunte qual ele prefere." - else - msg = "Não encontrei tarifas cadastradas para a suíte #{final_suite_category} nesta data (#{target_date}). Por favor, confirme se o nome da suíte está correto." - end - - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" } - return msg - end - rescue StandardError => e - Rails.logger.error "[CheckAvailabilityTool] CRITICAL ERROR: #{e.message}\n#{e.backtrace.first(5).join("\n")}" - raise e + if available_options.present? + msg = "Não encontrei tarifa exata para #{requested_duration}h. IMPORTANTE: Informe ao cliente que temos estas opções disponíveis para #{final_suite_category}: #{available_options}. Pergunte qual ele prefere." + else + msg = "Não encontrei tarifas cadastradas para a suíte #{final_suite_category} nesta data (#{target_date}). Por favor, confirme se o nome da suíte está correto." end - private + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" } + end + return msg + rescue StandardError => e + Rails.logger.error "[CheckAvailabilityTool] CRITICAL ERROR: #{e.message}\n#{e.backtrace.first(5).join("\n")}" + raise e + end - # Helper to ensure we have a conversation object - def ensure_conversation_context! - return if @conversation.present? + private + + # Helper to ensure we have a conversation object + def ensure_conversation_context! + return if @conversation.present? + end + + def resolve_account_id(conversation, assistant) + conversation&.account_id || assistant&.account_id + end + + def infer_unit + @conversation&.inbox&.captain_inbox&.unit + end + + def persist_last_availability(suite_category, duration, pricing, target_date) + return unless @conversation + + @conversation.custom_attributes ||= {} + price_value = pricing&.price&.to_f + @conversation.custom_attributes['last_availability'] = { + suite: suite_category, + duration: duration, + price: price_value, + day_range: pricing.day_range, + date: target_date&.iso8601, + captured_at: Time.zone.now.iso8601 + } + @conversation.save! + + update_sticky_state(suite_category, duration, pricing, target_date) + rescue StandardError => e + Rails.logger.warn "[CheckAvailabilityTool] Failed to persist last availability: #{e.message}" + end + + def update_sticky_state(suite_category, duration, pricing, target_date) + return unless @conversation.respond_to?(:active_scenario_state) + + state = @conversation.active_scenario_state || {} + price_value = pricing&.price&.to_f + collected = (state['collected'] || {}).merge( + 'suite' => suite_category, + 'duration' => duration, + 'date' => target_date&.iso8601 + ).compact + + last_tool_results = (state['last_tool_results'] || {}).merge( + 'check_availability' => { + 'suite' => suite_category, + 'duration' => duration, + 'price' => price_value, + 'day_range' => pricing.day_range, + 'date' => target_date&.iso8601, + 'captured_at' => Time.zone.now.iso8601 + } + ) + + @conversation.update!( + active_scenario_state: state.merge( + 'stage' => 'availability_checked', + 'collected' => collected, + 'last_tool_results' => last_tool_results, + 'updated_at' => Time.current.iso8601 + ) + ) + rescue StandardError => e + Rails.logger.warn "[CheckAvailabilityTool] Failed to update sticky state: #{e.message}" + end + + def pick_pricing_for_duration(scope, requested_duration) + pricings = scope.to_a + return pricings.first if requested_duration.blank? # Se não pediu duração, qualquer uma serve + return nil if pricings.empty? + + # Normaliza a entrada do usuário (ex: "três horas" -> 3, "3h" -> 3) + normalized_request = normalize_duration_input(requested_duration) + + # 1. Tenta match exato pelo número normalizado + if normalized_request.is_a?(Integer) + matched = pricings.find do |pricing| + extract_duration_number(pricing.duration) == normalized_request end + return matched if matched + end - def resolve_account_id(conversation, assistant) - conversation&.account_id || assistant&.account_id - end + # 2. Tenta match pelo texto normalizado (ex: "pernoite") + requested_text = requested_duration.to_s.strip.downcase + matched = pricings.find do |pricing| + p_dur = pricing.duration.to_s.strip.downcase + p_dur == requested_text || p_dur.include?(requested_text) || requested_text.include?(p_dur) || normalize_duration_input(p_dur) == normalized_request + end - def infer_unit - @conversation&.inbox&.captain_inbox&.unit - end + return matched if matched - def persist_last_availability(suite_category, duration, pricing, target_date) - return unless @conversation + # [FIX] Strict Mode com Log: + Rails.logger.warn "[CheckAvailabilityTool] Nenhuma tarifa encontrada para '#{requested_duration}' (Normalizado: #{normalized_request}). Opcoes: #{pricings.map(&:duration)}" + nil + end - @conversation.custom_attributes ||= {} - price_value = pricing&.price&.to_f - @conversation.custom_attributes['last_availability'] = { - suite: suite_category, - duration: duration, - price: price_value, - day_range: pricing.day_range, - date: target_date&.iso8601, - captured_at: Time.zone.now.iso8601 - } - @conversation.save! + def normalize_duration_input(input) + text = input.to_s.downcase.strip - update_sticky_state(suite_category, duration, pricing, target_date) - rescue StandardError => e - Rails.logger.warn "[CheckAvailabilityTool] Failed to persist last availability: #{e.message}" - end + # Mapa de extenso para números + word_to_num = { + 'um' => 1, 'uma' => 1, + 'dois' => 2, 'duas' => 2, + 'tres' => 3, 'três' => 3, + 'quatro' => 4, + 'cinco' => 5, + 'seis' => 6, + 'doze' => 12 + } - def update_sticky_state(suite_category, duration, pricing, target_date) - return unless @conversation.respond_to?(:active_scenario_state) + # Verifica palavras por extenso + word_to_num.each do |word, num| + return num if text.include?(word) + end - state = @conversation.active_scenario_state || {} - price_value = pricing&.price&.to_f - collected = (state['collected'] || {}).merge( - 'suite' => suite_category, - 'duration' => duration, - 'date' => target_date&.iso8601 - ).compact + # Verifica dígitos + match = text.match(/(\d+)/) + return match[1].to_i if match - last_tool_results = (state['last_tool_results'] || {}).merge( - 'check_availability' => { - 'suite' => suite_category, - 'duration' => duration, - 'price' => price_value, - 'day_range' => pricing.day_range, - 'date' => target_date&.iso8601, - 'captured_at' => Time.zone.now.iso8601 - } - ) + text # Retorna o texto original se não for número (ex: "pernoite") + end - @conversation.update!( - active_scenario_state: state.merge( - 'stage' => 'availability_checked', - 'collected' => collected, - 'last_tool_results' => last_tool_results, - 'updated_at' => Time.current.iso8601 - ) - ) - rescue StandardError => e - Rails.logger.warn "[CheckAvailabilityTool] Failed to update sticky state: #{e.message}" - end + def extract_duration_number(value) + return nil if value.blank? - def pick_pricing_for_duration(scope, requested_duration) - pricings = scope.to_a - return pricings.first if requested_duration.blank? # Se não pediu duração, qualquer uma serve - return nil if pricings.empty? + text = value.to_s.downcase + match = text.match(/(\d+)/) + match ? match[1].to_i : nil + end - # Normaliza a entrada do usuário (ex: "três horas" -> 3, "3h" -> 3) - normalized_request = normalize_duration_input(requested_duration) + def resolve_target_date(actual_params) + date_text = actual_params[:date].presence || actual_params[:data].presence + check_in_at = actual_params[:check_in_at].presence - # 1. Tenta match exato pelo número normalizado - if normalized_request.is_a?(Integer) - matched = pricings.find do |pricing| - extract_duration_number(pricing.duration) == normalized_request - end - return matched if matched - end + # 1. Try to get date from param or history FIRST + base_date = parse_date_from_text(date_text) if date_text.present? + base_date ||= infer_date_from_history - # 2. Tenta match pelo texto normalizado (ex: "pernoite") - requested_text = requested_duration.to_s.strip.downcase - matched = pricings.find do |pricing| - p_dur = pricing.duration.to_s.strip.downcase - p_dur == requested_text || p_dur.include?(requested_text) || requested_text.include?(p_dur) || normalize_duration_input(p_dur) == normalized_request - end - - return matched if matched - - # [FIX] Strict Mode com Log: - Rails.logger.warn "[CheckAvailabilityTool] Nenhuma tarifa encontrada para '#{requested_duration}' (Normalizado: #{normalized_request}). Opcoes: #{pricings.map(&:duration)}" + # 2. If we have a check_in_at time + if check_in_at.present? + parsed_time = begin + Time.zone.parse(check_in_at.to_s) + rescue StandardError nil end + if parsed_time + # If check_in_at is just a time (e.g. "21:00"), combine it with base_date + return base_date if base_date && check_in_at.to_s.length <= 5 # Likely just HH:MM - def normalize_duration_input(input) - text = input.to_s.downcase.strip - - # Mapa de extenso para números - word_to_num = { - 'um' => 1, 'uma' => 1, - 'dois' => 2, 'duas' => 2, - 'tres' => 3, 'três' => 3, - 'quatro' => 4, - 'cinco' => 5, - 'seis' => 6, - 'doze' => 12 - } - - # Verifica palavras por extenso - word_to_num.each do |word, num| - return num if text.include?(word) - end - - # Verifica dígitos - match = text.match(/(\d+)/) - return match[1].to_i if match - - text # Retorna o texto original se não for número (ex: "pernoite") - end - - def extract_duration_number(value) - return nil if value.blank? - - text = value.to_s.downcase - match = text.match(/(\d+)/) - match ? match[1].to_i : nil - end - - def resolve_target_date(actual_params) - date_text = actual_params[:date].presence || actual_params[:data].presence - check_in_at = actual_params[:check_in_at].presence - - # 1. Try to get date from param or history FIRST - base_date = parse_date_from_text(date_text) if date_text.present? - base_date ||= infer_date_from_history - - # 2. If we have a check_in_at time - if check_in_at.present? - parsed_time = begin - Time.zone.parse(check_in_at.to_s) - rescue StandardError - nil - end - if parsed_time - # If check_in_at is just a time (e.g. "21:00"), combine it with base_date - return base_date if base_date && check_in_at.to_s.length <= 5 # Likely just HH:MM - - return parsed_time.to_date - end - end - - base_date || Time.zone.today - end - - def infer_date_from_history - return nil unless @conversation - - messages = @conversation.messages.incoming.order(created_at: :desc).limit(12).to_a - - # [CRITICAL RESET FIX] If there is a reset in history, stop looking further back - reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } - if reset_msg - # Keep only messages after reset - messages = messages.take_while { |m| m.id != reset_msg.id } - end - - messages.each do |message| - text = message.content.to_s - next if text.blank? - - date = parse_date_from_text(text) - return date if date.present? - end - - nil - end - - def filter_pricings_by_day_range(scope, target_date) - return scope if target_date.blank? - - target_wday = target_date.wday - pricings = scope.to_a - matched = pricings.select do |pricing| - day_range_matches_wday?(pricing.day_range, target_wday) - end - - matched.any? ? matched : scope - end - - def day_range_matches_wday?(day_range, wday) - return false if day_range.blank? - - days = normalize_day_range(day_range) - days.include?(wday) - end - - def normalize_day_range(day_range) - normalized = ActiveSupport::Inflector.transliterate(day_range.to_s).upcase - normalized = normalized.gsub(/\s+/, ' ').strip - - mapping = { - 'SEGUNDA' => 1, - 'TERCA' => 2, - 'QUARTA' => 3, - 'QUINTA' => 4, - 'SEXTA' => 5, - 'SABADO' => 6, - 'DOMINGO' => 0 - } - - if normalized.include?(' A ') - start_name, end_name = normalized.split(' A ').map(&:strip) - start_idx = mapping[start_name] - end_idx = mapping[end_name] - return [] if start_idx.nil? || end_idx.nil? - - return (start_idx..end_idx).to_a if start_idx <= end_idx - - return (start_idx..6).to_a + (0..end_idx).to_a - end - - normalized.split(',').map(&:strip).map { |name| mapping[name] }.compact - end - - def parse_date_from_text(text) - normalized = text.to_s.downcase - ascii = ActiveSupport::Inflector.transliterate(normalized) - - return Time.zone.today + 2.days if normalized.include?('depois de amanha') - return Time.zone.today + 1.day if normalized.include?('amanha') - return Time.zone.today if normalized.include?('hoje') - - if (match = normalized.match(%r{\b(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b})) - day = match[1].to_i - month = match[2].to_i - year = match[3].to_i - year += 2000 if year.positive? && year < 100 - year = Time.zone.today.year if year.zero? - return Date.new(year, month, day) - end - - months = { - 'jan' => 1, 'janeiro' => 1, - 'fev' => 2, 'fevereiro' => 2, - 'mar' => 3, 'marco' => 3, - 'abr' => 4, 'abril' => 4, - 'mai' => 5, 'maio' => 5, - 'jun' => 6, 'junho' => 6, - 'jul' => 7, 'julho' => 7, - 'ago' => 8, 'agosto' => 8, - 'set' => 9, 'setembro' => 9, - 'out' => 10, 'outubro' => 10, - 'nov' => 11, 'novembro' => 11, - 'dez' => 12, 'dezembro' => 12 - } - month_pattern = months.keys.join('|') - if (match = ascii.match(/\b(?:dia\s*)?(\d{1,2})\s*(?:de\s*)?(#{month_pattern})(?:\s*(?:de\s*)?(\d{2,4}))?\b/)) - day = match[1].to_i - month = months[match[2]] - year = match[3].to_i - year += 2000 if year.positive? && year < 100 - year = Time.zone.today.year if year.zero? - date = Date.new(year, month, day) - date = date.next_year if match[3].blank? && date < Time.zone.today - return date - end - - weekdays = { - 'segunda' => 1, - 'terca' => 2, - 'terça' => 2, - 'quarta' => 3, - 'quinta' => 4, - 'sexta' => 5, - 'sabado' => 6, - 'sábado' => 6, - 'domingo' => 0 - } - - weekdays.each do |name, wday| - next unless normalized.include?(name) - - today = Time.zone.today - days_ahead = (wday - today.wday) % 7 - days_ahead = 7 if days_ahead.zero? - date = today + days_ahead.days - date += 7.days if normalized.include?('que vem') - return date - end - - nil + return parsed_time.to_date end end + + base_date || Time.zone.today + end + + def infer_date_from_history + return nil unless @conversation + + messages = @conversation.messages.incoming.order(created_at: :desc).limit(12).to_a + + # [CRITICAL RESET FIX] If there is a reset in history, stop looking further back + reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } + if reset_msg + # Keep only messages after reset + messages = messages.take_while { |m| m.id != reset_msg.id } + end + + messages.each do |message| + text = message.content.to_s + next if text.blank? + + date = parse_date_from_text(text) + return date if date.present? + end + + nil + end + + def filter_pricings_by_day_range(scope, target_date) + return scope if target_date.blank? + + target_wday = target_date.wday + pricings = scope.to_a + matched = pricings.select do |pricing| + day_range_matches_wday?(pricing.day_range, target_wday) + end + + matched.any? ? matched : scope + end + + def day_range_matches_wday?(day_range, wday) + return false if day_range.blank? + + days = normalize_day_range(day_range) + days.include?(wday) + end + + def normalize_day_range(day_range) + normalized = ActiveSupport::Inflector.transliterate(day_range.to_s).upcase + normalized = normalized.gsub(/\s+/, ' ').strip + + mapping = { + 'SEGUNDA' => 1, + 'TERCA' => 2, + 'QUARTA' => 3, + 'QUINTA' => 4, + 'SEXTA' => 5, + 'SABADO' => 6, + 'DOMINGO' => 0 + } + + if normalized.include?(' A ') + start_name, end_name = normalized.split(' A ').map(&:strip) + start_idx = mapping[start_name] + end_idx = mapping[end_name] + return [] if start_idx.nil? || end_idx.nil? + + return (start_idx..end_idx).to_a if start_idx <= end_idx + + return (start_idx..6).to_a + (0..end_idx).to_a + end + + normalized.split(',').map(&:strip).filter_map { |name| mapping[name] } + end + + def parse_date_from_text(text) + normalized = text.to_s.downcase + ascii = ActiveSupport::Inflector.transliterate(normalized) + + return Time.zone.today + 2.days if normalized.include?('depois de amanha') + return Time.zone.today + 1.day if normalized.include?('amanha') + return Time.zone.today if normalized.include?('hoje') + + if (match = normalized.match(%r{\b(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b})) + day = match[1].to_i + month = match[2].to_i + year = match[3].to_i + year += 2000 if year.positive? && year < 100 + year = Time.zone.today.year if year.zero? + return Date.new(year, month, day) + end + + months = { + 'jan' => 1, 'janeiro' => 1, + 'fev' => 2, 'fevereiro' => 2, + 'mar' => 3, 'marco' => 3, + 'abr' => 4, 'abril' => 4, + 'mai' => 5, 'maio' => 5, + 'jun' => 6, 'junho' => 6, + 'jul' => 7, 'julho' => 7, + 'ago' => 8, 'agosto' => 8, + 'set' => 9, 'setembro' => 9, + 'out' => 10, 'outubro' => 10, + 'nov' => 11, 'novembro' => 11, + 'dez' => 12, 'dezembro' => 12 + } + month_pattern = months.keys.join('|') + if (match = ascii.match(/\b(?:dia\s*)?(\d{1,2})\s*(?:de\s*)?(#{month_pattern})(?:\s*(?:de\s*)?(\d{2,4}))?\b/)) + day = match[1].to_i + month = months[match[2]] + year = match[3].to_i + year += 2000 if year.positive? && year < 100 + year = Time.zone.today.year if year.zero? + date = Date.new(year, month, day) + date = date.next_year if match[3].blank? && date < Time.zone.today + return date + end + + weekdays = { + 'segunda' => 1, + 'terca' => 2, + 'terça' => 2, + 'quarta' => 3, + 'quinta' => 4, + 'sexta' => 5, + 'sabado' => 6, + 'sábado' => 6, + 'domingo' => 0 + } + + weekdays.each do |name, wday| + next unless normalized.include?(name) + + today = Time.zone.today + days_ahead = (wday - today.wday) % 7 + days_ahead = 7 if days_ahead.zero? + date = today + days_ahead.days + date += 7.days if normalized.include?('que vem') + return date + end + + nil end end diff --git a/enterprise/app/services/captain/tools/create_reservation_intent_tool.rb b/enterprise/app/services/captain/tools/create_reservation_intent_tool.rb index 2da60e9..dfb6311 100644 --- a/enterprise/app/services/captain/tools/create_reservation_intent_tool.rb +++ b/enterprise/app/services/captain/tools/create_reservation_intent_tool.rb @@ -1,364 +1,361 @@ -module Captain - module Tools - class CreateReservationIntentTool < BaseTool - def name - 'create_reservation_intent' - end +class Captain::Tools::CreateReservationIntentTool < BaseTool + def name + 'create_reservation_intent' + end - def description - 'Creates a draft reservation intent. Use this when the user agrees to a price/suite. Requires "suite" and "price" (decimal). Saves the intent so payment can be generated later.' - end + def description + 'Creates a draft reservation intent. Use this when the user agrees to a price/suite. Requires "suite" and "price" (decimal). Saves the intent so payment can be generated later.' + end - def tool_parameters_schema - { - type: 'object', - properties: { - suite: { - type: 'string', - description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)' - }, - price: { - type: 'number', - description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0' - }, - deposit_value: { - type: 'number', - description: 'Valor exato a ser cobrado no Pix agora (Sinal). Se informado, substitui o cálculo automático de 50%. Ex: 27.50' - } - }, - required: %w[suite price] + def tool_parameters_schema + { + type: 'object', + properties: { + suite: { + type: 'string', + description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)' + }, + price: { + type: 'number', + description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0' + }, + deposit_value: { + type: 'number', + description: 'Valor exato a ser cobrado no Pix agora (Sinal). Se informado, substitui o cálculo automático de 50%. Ex: 27.50' } + }, + required: %w[suite price] + } + end + + def execute(*args, **params) + actual_params = resolve_params(args, params) + File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| + f.puts "[#{Time.zone.now}] STARTING CreateReservationIntentTool with params: #{actual_params}" + end + + suite_category = actual_params[:suite] + # Remove currency symbols and parse + price_raw = actual_params[:price].to_s.gsub(/[^\d,.]/, '').tr(',', '.') + price = price_raw.to_f + + deposit_input = actual_params[:deposit_value].to_s.gsub(/[^\d,.]/, '').tr(',', '.') + deposit_override = deposit_input.to_f if deposit_input.present? + + last_availability = fetch_last_availability + + if suite_category.blank? || price <= 0 + inferred = infer_from_history + suite_category ||= inferred[:suite] + price = inferred[:price].to_f if price <= 0 && inferred[:price].present? + end + + if (suite_category.blank? || price <= 0) && last_availability.present? + suite_category ||= last_availability[:suite] + price = last_availability[:price].to_f if price <= 0 && last_availability[:price].present? + end + + # ... (Validation Logic kept effectively same, just moved down) ... + + # [ANTI-HALLUCINATION - REINICIAR BARRIER] + # Only scan messages AFTER the last 'reiniciar' command. + all_incoming = @conversation&.messages&.incoming&.order(created_at: :asc)&.last(10) || [] + last_reset_index = all_incoming.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } + + relevant_messages = last_reset_index ? all_incoming[(last_reset_index + 1)..] : all_incoming + user_text_post_reset = relevant_messages.map(&:content).join(' ').downcase + user_text_post_reset = ActiveSupport::Inflector.transliterate(user_text_post_reset).gsub(/[^\w\s]/, '') + + # [ALIASES MAP] + aliases = { + 'hidromassagem' => %w[hidro banheira jacuzzi hidromassagem], + 'stilo' => %w[stilo estilo], + 'master' => %w[master], + 'alexa' => %w[alexa] + } + + suite_key = suite_category.to_s.downcase.strip + suite_key = ActiveSupport::Inflector.transliterate(suite_key) + + valid_terms = aliases[suite_key] || [suite_key] + + # Check if ANY of the valid terms is in the user text + match_found = valid_terms.any? do |term| + term_clean = ActiveSupport::Inflector.transliterate(term) + user_text_post_reset.include?(term_clean) + end + + File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| + f.puts "[#{Time.zone.now}] INTENT BARRIER SCAN: Looking for #{valid_terms} in '#{user_text_post_reset}' -> Match: #{match_found}" + end + + if suite_category.present? && !match_found + File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| + f.puts "[#{Time.zone.now}] INTENT BLOCKED: Suite '#{suite_category}' (or aliases) not found after reset." end + return "Atenção: O usuário ainda não escolheu a suíte '#{suite_category}' nesta nova conversa. Pergunte: 'Qual suíte você gostaria de reservar?'." + end - def execute(*args, **params) - actual_params = resolve_params(args, params) - File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] STARTING CreateReservationIntentTool with params: #{actual_params}" - end - - suite_category = actual_params[:suite] - # Remove currency symbols and parse - price_raw = actual_params[:price].to_s.gsub(/[^\d,.]/, '').tr(',', '.') - price = price_raw.to_f - - deposit_input = actual_params[:deposit_value].to_s.gsub(/[^\d,.]/, '').tr(',', '.') - deposit_override = deposit_input.to_f if deposit_input.present? - - last_availability = fetch_last_availability - - if suite_category.blank? || price <= 0 - inferred = infer_from_history - suite_category ||= inferred[:suite] - price = inferred[:price].to_f if price <= 0 && inferred[:price].present? - end - - if (suite_category.blank? || price <= 0) && last_availability.present? - suite_category ||= last_availability[:suite] - price = last_availability[:price].to_f if price <= 0 && last_availability[:price].present? - end - - # ... (Validation Logic kept effectively same, just moved down) ... - - # [ANTI-HALLUCINATION - REINICIAR BARRIER] - # Only scan messages AFTER the last 'reiniciar' command. - all_incoming = @conversation&.messages&.incoming&.order(created_at: :asc)&.last(10) || [] - last_reset_index = all_incoming.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } - - relevant_messages = last_reset_index ? all_incoming[(last_reset_index + 1)..-1] : all_incoming - user_text_post_reset = relevant_messages.map(&:content).join(' ').downcase - user_text_post_reset = ActiveSupport::Inflector.transliterate(user_text_post_reset).gsub(/[^\w\s]/, '') - - # [ALIASES MAP] - aliases = { - 'hidromassagem' => %w[hidro banheira jacuzzi hidromassagem], - 'stilo' => %w[stilo estilo], - 'master' => %w[master], - 'alexa' => %w[alexa] - } - - suite_key = suite_category.to_s.downcase.strip - suite_key = ActiveSupport::Inflector.transliterate(suite_key) - - valid_terms = aliases[suite_key] || [suite_key] - - # Check if ANY of the valid terms is in the user text - match_found = valid_terms.any? do |term| - term_clean = ActiveSupport::Inflector.transliterate(term) - user_text_post_reset.include?(term_clean) - end - - File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] INTENT BARRIER SCAN: Looking for #{valid_terms} in '#{user_text_post_reset}' -> Match: #{match_found}" - end - - if suite_category.present? && !match_found - File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] INTENT BLOCKED: Suite '#{suite_category}' (or aliases) not found after reset." - end - return "Atenção: O usuário ainda não escolheu a suíte '#{suite_category}' nesta nova conversa. Pergunte: 'Qual suíte você gostaria de reservar?'." - end - - # Global Price Lock: If the price the agent wants to charge - # is different from the VERY LAST availability check, block it. - # EXCEPTION: If explicit deposit_value is provided, we trust the agent knows what it's doing (e.g. promo/custom time) - if price > 0 && last_availability.present? && !(deposit_override && deposit_override > 0) && price_mismatch?(price, last_availability[:price]) - msg = "ATENÇÃO: Preço (R$ #{format('%.2f', - price)}) diverge da última cotação (R$ #{format('%.2f', - last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar." - File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] PRICE BLOCK: Agent tried #{price} but last quote was #{last_availability[:price]}" - end - return msg - end - - if suite_category.blank? - msg = "SYSTEM INFO: Você esqueceu de informar a 'suite'. Pergunte ao cliente qual suíte e duração ele deseja." - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: #{msg}" } - return msg - end - - if price <= 0 - msg = 'SYSTEM INFO: Preço inválido. Use consultar_disponibilidade.' - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: #{msg}" } - return msg - end - - ensure_conversation_context! - - unless @conversation && @conversation.inbox - msg = "Erro Crítico: Contexto de conversa não disponível (Conversation/Inbox nil). Params: #{actual_params}" - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" } - return msg - end - - unit = infer_unit - unless unit - msg = 'Erro: Unidade não encontrada para esta conversa. Verifique se o Inbox está conectado a uma Unidade.' - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: #{msg}" } - return msg - end - - check_in_at, check_out_at = resolve_check_in_and_out(actual_params) - - # [IDEMPOTENCY CHECK] - # If a draft for this conversation, suite, and price was created < 5 mins ago, reuse it. - recent_draft = Captain::Reservation.where(conversation_id: @conversation.id, status: :draft) - .where('created_at > ?', 5.minutes.ago) - .where(suite_identifier: suite_category) - .order(created_at: :desc) - .first - - # Determine Final Charge Amount - deposit_amount = if deposit_override && deposit_override > 0 - deposit_override - else - price / 2.0 - end - - if recent_draft && (recent_draft.total_amount.to_f - deposit_amount).abs < 0.1 - msg = "ATENÇÃO: A reserva JÁ FOI CRIADA anteriormente (ID: #{recent_draft.id}). NÃO crie novamente. Apenas CHAME A FERRAMENTA 'generate_pix' AGORA para finalizar." - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] IDEMPOTENCY HIT: Reuse draft #{recent_draft.id}" } - return msg - end - - # Cancel previous drafts to keep history clean (optional, but good for robust state) - Captain::Reservation.where(conversation_id: @conversation.id, status: :draft).update_all(status: :cancelled) - - begin - Captain::Reservation.create!( - conversation_id: @conversation.id, - account: @conversation.account, - contact: @conversation.contact, - contact_inbox: @conversation.contact_inbox, - inbox: @conversation.inbox, - captain_unit_id: unit.id, - captain_brand_id: unit.captain_brand_id, - suite_identifier: suite_category, - status: :draft, - total_amount: deposit_amount, # Saving finalized amount - check_in_at: check_in_at, - check_out_at: check_out_at - ) - - update_sticky_state( - suite: suite_category, - price: deposit_amount, - check_in_at: check_in_at, - check_out_at: check_out_at - ) - - msg = "Reserva iniciada com sucesso! O valor do sinal (50%) é: #{ActiveSupport::NumberHelper.number_to_currency(deposit_amount, - unit: 'R$ ', separator: ',', delimiter: '.')}. INSTRUÇÃO: Como a reserva foi criada com sucesso, avise o cliente e CHAME IMEDIATAMENTE a ferramenta 'generate_pix' para entregar o código de pagamento." - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] SUCCESS: #{msg}" } - return msg - rescue StandardError => e - error_msg = "ERRO FATAL NA CRIACAO: #{e.message} | #{e.backtrace.first}" - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] EXCEPTION: #{error_msg}" } - return "Erro técnico ao criar reserva: #{e.message}" - end + # Global Price Lock: If the price the agent wants to charge + # is different from the VERY LAST availability check, block it. + # EXCEPTION: If explicit deposit_value is provided, we trust the agent knows what it's doing (e.g. promo/custom time) + if price.positive? && last_availability.present? && !(deposit_override && deposit_override.positive?) && price_mismatch?(price, + last_availability[:price]) + msg = "ATENÇÃO: Preço (R$ #{format('%.2f', + price)}) diverge da última cotação (R$ #{format('%.2f', + last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar." + File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| + f.puts "[#{Time.zone.now}] PRICE BLOCK: Agent tried #{price} but last quote was #{last_availability[:price]}" end + return msg + end - private + if suite_category.blank? + msg = "SYSTEM INFO: Você esqueceu de informar a 'suite'. Pergunte ao cliente qual suíte e duração ele deseja." + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" } + return msg + end - def same_suite?(s1, s2) - s1.to_s.downcase.strip == s2.to_s.downcase.strip - end + if price <= 0 + msg = 'SYSTEM INFO: Preço inválido. Use consultar_disponibilidade.' + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" } + return msg + end - def price_mismatch?(p1, p2) - (p1.to_f - p2.to_f).abs > 0.01 - end + ensure_conversation_context! - def resolve_check_in_and_out(params) - c_in = params[:check_in_at] || params[:date] || params[:day] - c_out = params[:check_out_at] + unless @conversation&.inbox + msg = "Erro Crítico: Contexto de conversa não disponível (Conversation/Inbox nil). Params: #{actual_params}" + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" } + return msg + end - # Try to parse check-in - begin - check_in = if c_in.present? - Time.zone.parse(c_in.to_s) + unit = infer_unit + unless unit + msg = 'Erro: Unidade não encontrada para esta conversa. Verifique se o Inbox está conectado a uma Unidade.' + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: #{msg}" } + return msg + end + + check_in_at, check_out_at = resolve_check_in_and_out(actual_params) + + # [IDEMPOTENCY CHECK] + # If a draft for this conversation, suite, and price was created < 5 mins ago, reuse it. + recent_draft = Captain::Reservation.where(conversation_id: @conversation.id, status: :draft) + .where('created_at > ?', 5.minutes.ago) + .where(suite_identifier: suite_category) + .order(created_at: :desc) + .first + + # Determine Final Charge Amount + deposit_amount = if deposit_override&.positive? + deposit_override else - # Default to tomorrow if not specified - Time.zone.now.tomorrow.change(hour: 14) + price / 2.0 end - rescue StandardError - check_in = Time.zone.now.tomorrow.change(hour: 14) - end - # Try to parse check-out - begin - check_out = if c_out.present? - Time.zone.parse(c_out.to_s) - else - # Default to 12 hours after check-in if not specified - check_in + 12.hours - end - rescue StandardError - check_out = check_in + 12.hours - end + if recent_draft && (recent_draft.total_amount.to_f - deposit_amount).abs < 0.1 + msg = "ATENÇÃO: A reserva JÁ FOI CRIADA anteriormente (ID: #{recent_draft.id}). NÃO crie novamente. Apenas CHAME A FERRAMENTA 'generate_pix' AGORA para finalizar." + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] IDEMPOTENCY HIT: Reuse draft #{recent_draft.id}" } + return msg + end - [check_in, check_out] - end + # Cancel previous drafts to keep history clean (optional, but good for robust state) + Captain::Reservation.where(conversation_id: @conversation.id, status: :draft).update_all(status: :cancelled) - def send_pix_message(pix_code) - return if pix_code.blank? + begin + Captain::Reservation.create!( + conversation_id: @conversation.id, + account: @conversation.account, + contact: @conversation.contact, + contact_inbox: @conversation.contact_inbox, + inbox: @conversation.inbox, + captain_unit_id: unit.id, + captain_brand_id: unit.captain_brand_id, + suite_identifier: suite_category, + status: :draft, + total_amount: deposit_amount, # Saving finalized amount + check_in_at: check_in_at, + check_out_at: check_out_at + ) - @conversation.messages.create!( - content: pix_code, - message_type: :outgoing, - account: @conversation.account, - inbox: @conversation.inbox, - sender: @assistant - ) - end + update_sticky_state( + suite: suite_category, + price: deposit_amount, + check_in_at: check_in_at, + check_out_at: check_out_at + ) - # Helper to ensure we have a conversation object, even if passed differently - def ensure_conversation_context! - # Se @conversation for nulo mas tivermos um ID nos params ou args, podemos tentar buscar - return if @conversation.present? - - # Tentativa de fallback (ex: se o runner passar conversation_id via params) - # Implementação futura se necessário. Por enquanto, focamos em validar o que temos. - end - - def infer_unit - @conversation&.inbox&.captain_inbox&.unit - end - - def update_sticky_state(suite:, price:, check_in_at:, check_out_at:) - return unless @conversation.respond_to?(:active_scenario_state) - - state = @conversation.active_scenario_state || {} - collected = (state['collected'] || {}).merge( - 'suite' => suite, - 'price' => price, - 'check_in_at' => check_in_at&.iso8601, - 'check_out_at' => check_out_at&.iso8601 - ).compact - - @conversation.update!( - active_scenario_state: state.merge( - 'stage' => 'reservation_intent_created', - 'collected' => collected, - 'updated_at' => Time.current.iso8601 - ) - ) - rescue StandardError => e - Rails.logger.warn "[CreateReservationIntentTool] Failed to update sticky state: #{e.message}" - end - - def fetch_last_availability - return nil unless @conversation - - data = @conversation.custom_attributes&.fetch('last_availability', nil) - return nil unless data.is_a?(Hash) - - # [FIX] Validade da Informação (TTL) - # Se a cotação tem mais de 4 horas, considere expirada. - # Isso força o agente a perguntar novamente em uma nova interação. - captured_at = data['captured_at'] - return nil if captured_at.blank? - - if Time.zone.parse(captured_at) < 4.hours.ago - Rails.logger.info '[CreateReservationIntent] Ignorando last_availability expirado (older than 4h)' - return nil - end - - data.with_indifferent_access - end - - def infer_from_history - return {} unless @conversation.present? - - suite_candidates = available_suite_categories - - # [FIX] Janela de Contexto Temporal - # Olha apenas as mensagens das últimas 4 horas. - # Se o cliente falou da suíte ontem, não assumimos que ele quer a mesma hoje. - messages = @conversation.messages - .where(private: false) - .where('created_at >= ?', 4.hours.ago) - .order(created_at: :desc) - .limit(20).to_a - - # [CRITICAL RESET FIX] If there is a reset in history, stop looking further back - reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } - if reset_msg - # Keep only messages after reset - messages = messages.take_while { |m| m.id != reset_msg.id } - end - - messages.each do |message| - content = message.content.to_s - suite = find_suite_in_text(content, suite_candidates) - price = extract_price_from_text(content) - - return { suite: suite, price: price } if suite.present? || price.present? - end - - {} - end - - def available_suite_categories - unit = infer_unit - return %w[Stilo Master Hidromassagem] unless unit - - Captain::Pricing.where(captain_brand_id: unit.captain_brand_id).pluck(:suite_category).compact.uniq - end - - def find_suite_in_text(content, suite_candidates) - return nil if content.blank? - - suite_candidates.find { |suite| content.downcase.include?(suite.to_s.downcase) } - end - - def extract_price_from_text(content) - return nil if content.blank? - - match = content.match(/R\$\s*([\d\.]+,\d{2})/) - return nil unless match - - match[1].tr('.', '').tr(',', '.').to_f - end + msg = "Reserva iniciada com sucesso! O valor do sinal (50%) é: #{ActiveSupport::NumberHelper.number_to_currency(deposit_amount, + unit: 'R$ ', separator: ',', delimiter: '.')}. INSTRUÇÃO: Como a reserva foi criada com sucesso, avise o cliente e CHAME IMEDIATAMENTE a ferramenta 'generate_pix' para entregar o código de pagamento." + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] SUCCESS: #{msg}" } + return msg + rescue StandardError => e + error_msg = "ERRO FATAL NA CRIACAO: #{e.message} | #{e.backtrace.first}" + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] EXCEPTION: #{error_msg}" } + return "Erro técnico ao criar reserva: #{e.message}" end end + + private + + def same_suite?(s1, s2) + s1.to_s.downcase.strip == s2.to_s.downcase.strip + end + + def price_mismatch?(p1, p2) + (p1.to_f - p2.to_f).abs > 0.01 + end + + def resolve_check_in_and_out(params) + c_in = params[:check_in_at] || params[:date] || params[:day] + c_out = params[:check_out_at] + + # Try to parse check-in + begin + check_in = if c_in.present? + Time.zone.parse(c_in.to_s) + else + # Default to tomorrow if not specified + Time.zone.now.tomorrow.change(hour: 14) + end + rescue StandardError + check_in = Time.zone.now.tomorrow.change(hour: 14) + end + + # Try to parse check-out + begin + check_out = if c_out.present? + Time.zone.parse(c_out.to_s) + else + # Default to 12 hours after check-in if not specified + check_in + 12.hours + end + rescue StandardError + check_out = check_in + 12.hours + end + + [check_in, check_out] + end + + def send_pix_message(pix_code) + return if pix_code.blank? + + @conversation.messages.create!( + content: pix_code, + message_type: :outgoing, + account: @conversation.account, + inbox: @conversation.inbox, + sender: @assistant + ) + end + + # Helper to ensure we have a conversation object, even if passed differently + def ensure_conversation_context! + # Se @conversation for nulo mas tivermos um ID nos params ou args, podemos tentar buscar + return if @conversation.present? + + # Tentativa de fallback (ex: se o runner passar conversation_id via params) + # Implementação futura se necessário. Por enquanto, focamos em validar o que temos. + end + + def infer_unit + @conversation&.inbox&.captain_inbox&.unit + end + + def update_sticky_state(suite:, price:, check_in_at:, check_out_at:) + return unless @conversation.respond_to?(:active_scenario_state) + + state = @conversation.active_scenario_state || {} + collected = (state['collected'] || {}).merge( + 'suite' => suite, + 'price' => price, + 'check_in_at' => check_in_at&.iso8601, + 'check_out_at' => check_out_at&.iso8601 + ).compact + + @conversation.update!( + active_scenario_state: state.merge( + 'stage' => 'reservation_intent_created', + 'collected' => collected, + 'updated_at' => Time.current.iso8601 + ) + ) + rescue StandardError => e + Rails.logger.warn "[CreateReservationIntentTool] Failed to update sticky state: #{e.message}" + end + + def fetch_last_availability + return nil unless @conversation + + data = @conversation.custom_attributes&.fetch('last_availability', nil) + return nil unless data.is_a?(Hash) + + # [FIX] Validade da Informação (TTL) + # Se a cotação tem mais de 4 horas, considere expirada. + # Isso força o agente a perguntar novamente em uma nova interação. + captured_at = data['captured_at'] + return nil if captured_at.blank? + + if Time.zone.parse(captured_at) < 4.hours.ago + Rails.logger.info '[CreateReservationIntent] Ignorando last_availability expirado (older than 4h)' + return nil + end + + data.with_indifferent_access + end + + def infer_from_history + return {} if @conversation.blank? + + suite_candidates = available_suite_categories + + # [FIX] Janela de Contexto Temporal + # Olha apenas as mensagens das últimas 4 horas. + # Se o cliente falou da suíte ontem, não assumimos que ele quer a mesma hoje. + messages = @conversation.messages + .where(private: false) + .where('created_at >= ?', 4.hours.ago) + .order(created_at: :desc) + .limit(20).to_a + + # [CRITICAL RESET FIX] If there is a reset in history, stop looking further back + reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } + if reset_msg + # Keep only messages after reset + messages = messages.take_while { |m| m.id != reset_msg.id } + end + + messages.each do |message| + content = message.content.to_s + suite = find_suite_in_text(content, suite_candidates) + price = extract_price_from_text(content) + + return { suite: suite, price: price } if suite.present? || price.present? + end + + {} + end + + def available_suite_categories + unit = infer_unit + return %w[Stilo Master Hidromassagem] unless unit + + Captain::Pricing.where(captain_brand_id: unit.captain_brand_id).pluck(:suite_category).compact.uniq + end + + def find_suite_in_text(content, suite_candidates) + return nil if content.blank? + + suite_candidates.find { |suite| content.downcase.include?(suite.to_s.downcase) } + end + + def extract_price_from_text(content) + return nil if content.blank? + + match = content.match(/R\$\s*([\d\.]+,\d{2})/) + return nil unless match + + match[1].tr('.', '').tr(',', '.').to_f + end end diff --git a/enterprise/app/services/captain/tools/definitions.rb b/enterprise/app/services/captain/tools/definitions.rb index 1a1d680..1848e6b 100644 --- a/enterprise/app/services/captain/tools/definitions.rb +++ b/enterprise/app/services/captain/tools/definitions.rb @@ -1,58 +1,54 @@ -module Captain - module Tools - class Definitions - ALL = { - 'status_suites' => { - type: :http, - method: :get, - url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus', - description: 'Check suite availability' - }, - 'maria_fotos' => { - type: :webhook, - description: 'Send photos via webhook' - }, - '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 (👍, ❤️, 😊)' - }, - 'update_contact' => { - type: :internal, - name: 'Atualizar Contato', - description: 'Atualiza nome e CPF do contato atual' - }, - 'check_availability' => { - type: :internal, - name: 'Consultar Disponibilidade', - description: 'Verifica preço e disponibilidade de suíte' - }, - 'create_reservation_intent' => { - type: :internal, - name: 'Criar Intenção de Reserva', - description: 'Salva rascunho da reserva com valor acordado' - }, - 'generate_pix' => { - type: :internal, - name: 'Gerar Pix (Finalizar)', - description: 'Gera Pix para a reserva em rascunho atual' - }, - 'list_reservations' => { - type: :internal, - name: 'Listar Reservas', - description: 'Lista as ultimas reservas do contato atual' - }, - 'faq_lookup' => { - type: :internal, - name: 'Pesquisar FAQ', - description: 'Busca respostas no FAQ por similaridade semantica', - always_on: true - } - }.freeze - end - end +class Captain::Tools::Definitions + ALL = { + 'status_suites' => { + type: :http, + method: :get, + url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus', + description: 'Check suite availability' + }, + 'maria_fotos' => { + type: :webhook, + description: 'Send photos via webhook' + }, + '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 (👍, ❤️, 😊)' + }, + 'update_contact' => { + type: :internal, + name: 'Atualizar Contato', + description: 'Atualiza nome e CPF do contato atual' + }, + 'check_availability' => { + type: :internal, + name: 'Consultar Disponibilidade', + description: 'Verifica preço e disponibilidade de suíte' + }, + 'create_reservation_intent' => { + type: :internal, + name: 'Criar Intenção de Reserva', + description: 'Salva rascunho da reserva com valor acordado' + }, + 'generate_pix' => { + type: :internal, + name: 'Gerar Pix (Finalizar)', + description: 'Gera Pix para a reserva em rascunho atual' + }, + 'list_reservations' => { + type: :internal, + name: 'Listar Reservas', + description: 'Lista as ultimas reservas do contato atual' + }, + 'faq_lookup' => { + type: :internal, + name: 'Pesquisar FAQ', + description: 'Busca respostas no FAQ por similaridade semantica', + always_on: true + } + }.freeze end diff --git a/enterprise/app/services/captain/tools/generate_pix_tool.rb b/enterprise/app/services/captain/tools/generate_pix_tool.rb index 2a8a2ed..fee6887 100644 --- a/enterprise/app/services/captain/tools/generate_pix_tool.rb +++ b/enterprise/app/services/captain/tools/generate_pix_tool.rb @@ -1,148 +1,144 @@ -module Captain - module Tools - class GeneratePixTool < BaseTool - def name - 'generate_pix' - end +class Captain::Tools::GeneratePixTool < BaseTool + def name + 'generate_pix' + end - def description - 'Generates a Pix payment for the ACTIVE DRAFT reservation. Returns a structured object with formatted_message and raw_payload.' - end + def description + 'Generates a Pix payment for the ACTIVE DRAFT reservation. Returns a structured object with formatted_message and raw_payload.' + end - def tool_parameters_schema - { - type: 'object', - properties: { - amount: { - type: 'number', - description: 'Opcional. Valor final exato para cobrar no Pix. Se informado, atualiza o valor da reserva antes de gerar.' - } - } + def tool_parameters_schema + { + type: 'object', + properties: { + amount: { + type: 'number', + description: 'Opcional. Valor final exato para cobrar no Pix. Se informado, atualiza o valor da reserva antes de gerar.' } + } + } + end + + def execute(*args, **params) + actual_params = resolve_params(args, params) + input_amount = actual_params[:amount].to_s.gsub(/[^\d,.]/, '').tr(',', '.') + override_amount = input_amount.to_f if input_amount.present? && input_amount.to_f.positive? + + # ... (Validation Logic) ... + # 1. Validate Contact Info + contact = @conversation.contact + return error_response('Erro: CPF não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.custom_attributes['cpf'].blank? + return error_response('Erro: Nome não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.name.blank? + + pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last + if pending + # [AMOUT CHECK] + # If an explicit amount was passed, but we found a pending charge with different amount, + # we should probably CANCEL/EXPIRE the old one and generate a new one. + if override_amount && (pending.total_amount.to_f - override_amount).abs > 0.1 + Rails.logger.info "[GeneratePixTool] Montante mudou (#{pending.total_amount} -> #{override_amount}). Forçando novo Pix." + pending.update!(total_amount: override_amount) + + # Expire old charges + Captain::PixCharge.where(reservation_id: pending.id).update_all(status: 'expired') + return generate_new_pix(pending, prefix: "Atualizei o valor para R$ #{format('%.2f', override_amount)}. Novo Pix abaixo:") end - def execute(*args, **params) - actual_params = resolve_params(args, params) - input_amount = actual_params[:amount].to_s.gsub(/[^\d,.]/, '').tr(',', '.') - override_amount = input_amount.to_f if input_amount.present? && input_amount.to_f > 0 - - # ... (Validation Logic) ... - # 1. Validate Contact Info - contact = @conversation.contact - return error_response('Erro: CPF não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.custom_attributes['cpf'].blank? - return error_response('Erro: Nome não cadastrado. Use a ferramenta de atualizar contato primeiro.') if contact.name.blank? - - pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last - if pending - # [AMOUT CHECK] - # If an explicit amount was passed, but we found a pending charge with different amount, - # we should probably CANCEL/EXPIRE the old one and generate a new one. - if override_amount && (pending.total_amount.to_f - override_amount).abs > 0.1 - Rails.logger.info "[GeneratePixTool] Montante mudou (#{pending.total_amount} -> #{override_amount}). Forçando novo Pix." - pending.update!(total_amount: override_amount) - - # Expire old charges - Captain::PixCharge.where(reservation_id: pending.id).update_all(status: 'expired') - return generate_new_pix(pending, prefix: "Atualizei o valor para R$ #{format('%.2f', override_amount)}. Novo Pix abaixo:") - end - - charge = current_pix_charge_for(pending) - if charge&.pix_copia_e_cola.present? - if charge.expired? || charge.expired_by_time? - charge.update!(status: 'expired') unless charge.expired? - return generate_new_pix(pending, prefix: 'Pix expirado. Gerando um novo agora.') - end - - return build_pix_response(charge, pending, - prefix: 'Pix ainda válido. Segue abaixo para pagamento:') - end - - return generate_new_pix(pending, prefix: 'Nenhuma cobrança ativa encontrada. Gerando um novo Pix.') + charge = current_pix_charge_for(pending) + if charge&.pix_copia_e_cola.present? + if charge.expired? || charge.expired_by_time? + charge.update!(status: 'expired') unless charge.expired? + return generate_new_pix(pending, prefix: 'Pix expirado. Gerando um novo agora.') end - reservation = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft') - .where('updated_at > ?', 2.hours.ago) - .order(created_at: :desc) - .first - - unless reservation - return error_response('Erro: Nenhuma intenção de reserva recente (últimas 2h) encontrada. Por favor, confirme a suíte e o valor novamente usando "Quero reservar".') - end - - # [AMOUNT OVERRIDE] - if override_amount - Rails.logger.info "[GeneratePixTool] Atualizando valor da reserva #{reservation.id} de #{reservation.total_amount} para #{override_amount} antes de gerar Pix." - reservation.update!(total_amount: override_amount) - end - - Rails.logger.info "[GeneratePixTool] Usando Reserva ID #{reservation.id} | Valor no Banco: #{reservation.total_amount}" - generate_new_pix(reservation) + return build_pix_response(charge, pending, + prefix: 'Pix ainda válido. Segue abaixo para pagamento:') end - private - - def error_response(msg) - { formatted_message: msg, success: false } - end - - def current_pix_charge_for(reservation) - return nil unless reservation - return reservation.current_pix_charge if reservation.respond_to?(:current_pix_charge) - - Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first - end - - def generate_new_pix(reservation, prefix: nil) - service = Captain::Inter::CobService.new(reservation) - charge = service.call - - # Force status update to make it visible in Admin Panel immediately - reservation.update!(status: 'pending_payment') - Rails.logger.info "[GeneratePixTool] Reserva #{reservation.id} movida para pending_payment" - - final_prefix = prefix || 'Cobrança Pix gerada com sucesso.' - build_pix_response(charge, reservation, prefix: final_prefix) - rescue StandardError => e - Rails.logger.error "[GeneratePixTool] Falha ao gerar Pix: #{e.message}" - error_response('Erro técnico ao gerar o Pix. Por favor, tente novamente em alguns instantes.') - end - - def build_pix_response(charge, reservation, prefix: nil) - intro = prefix.present? ? "#{prefix}\n\n" : '' - - # [Architecture Update] One-Tap Copy Link (SGID) - # Fix: Ensure host is reachable (localhost instead of 0.0.0.0 for local dev) and instruct LLM to NOT use Markdown. - base_url = ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com') - base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0') - - token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s - link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url) - - instructions = "Para facilitar, gerei um link de pagamento rápido com botão COPIAR.\n" \ - "1. Clique no link abaixo\n" \ - "2. Aperte 'COPIAR CÓDIGO PIX'\n" \ - "3. Cole no app do seu banco e pague.\n\n" \ - "Link Seguro: #{link}\n\n" \ - '(IMPORTANTE PARA A I.A.: Envie este link EXATAMENTE como está acima. NÃO use formatação Markdown [texto](url) NEM coloque entre colchetes [url]. O WhatsApp não reconhece. Envie APENAS a URL pura, solta no texto.)' - - # Fallback raw payload in case the user asks for it explicitly later (hidden from formatted msg by default now) - final_code = charge.pix_copia_e_cola.to_s.strip - if final_code.start_with?('/spi/') - header = '00020101021226930014BR.GOV.BCB.PIX2571spi-qrcode.bancointer.com.br' - final_code = "#{header}#{final_code}" - end - - full_message = "#{intro}#{instructions}" - - { - formatted_message: full_message, - raw_payload: final_code, # Kept for debugging/fallback - payment_link: link, - amount: reservation.total_amount.to_f, - reservation_id: reservation.id, - success: true - } - end + return generate_new_pix(pending, prefix: 'Nenhuma cobrança ativa encontrada. Gerando um novo Pix.') end + + reservation = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft') + .where('updated_at > ?', 2.hours.ago) + .order(created_at: :desc) + .first + + unless reservation + return error_response('Erro: Nenhuma intenção de reserva recente (últimas 2h) encontrada. Por favor, confirme a suíte e o valor novamente usando "Quero reservar".') + end + + # [AMOUNT OVERRIDE] + if override_amount + Rails.logger.info "[GeneratePixTool] Atualizando valor da reserva #{reservation.id} de #{reservation.total_amount} para #{override_amount} antes de gerar Pix." + reservation.update!(total_amount: override_amount) + end + + Rails.logger.info "[GeneratePixTool] Usando Reserva ID #{reservation.id} | Valor no Banco: #{reservation.total_amount}" + generate_new_pix(reservation) + end + + private + + def error_response(msg) + { formatted_message: msg, success: false } + end + + def current_pix_charge_for(reservation) + return nil unless reservation + return reservation.current_pix_charge if reservation.respond_to?(:current_pix_charge) + + Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first + end + + def generate_new_pix(reservation, prefix: nil) + service = Captain::Inter::CobService.new(reservation) + charge = service.call + + # Force status update to make it visible in Admin Panel immediately + reservation.update!(status: 'pending_payment') + Rails.logger.info "[GeneratePixTool] Reserva #{reservation.id} movida para pending_payment" + + final_prefix = prefix || 'Cobrança Pix gerada com sucesso.' + build_pix_response(charge, reservation, prefix: final_prefix) + rescue StandardError => e + Rails.logger.error "[GeneratePixTool] Falha ao gerar Pix: #{e.message}" + error_response('Erro técnico ao gerar o Pix. Por favor, tente novamente em alguns instantes.') + end + + def build_pix_response(charge, reservation, prefix: nil) + intro = prefix.present? ? "#{prefix}\n\n" : '' + + # [Architecture Update] One-Tap Copy Link (SGID) + # Fix: Ensure host is reachable (localhost instead of 0.0.0.0 for local dev) and instruct LLM to NOT use Markdown. + base_url = ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com') + base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0') + + token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s + link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url) + + instructions = "Para facilitar, gerei um link de pagamento rápido com botão COPIAR.\n" \ + "1. Clique no link abaixo\n" \ + "2. Aperte 'COPIAR CÓDIGO PIX'\n" \ + "3. Cole no app do seu banco e pague.\n\n" \ + "Link Seguro: #{link}\n\n" \ + '(IMPORTANTE PARA A I.A.: Envie este link EXATAMENTE como está acima. NÃO use formatação Markdown [texto](url) NEM coloque entre colchetes [url]. O WhatsApp não reconhece. Envie APENAS a URL pura, solta no texto.)' + + # Fallback raw payload in case the user asks for it explicitly later (hidden from formatted msg by default now) + final_code = charge.pix_copia_e_cola.to_s.strip + if final_code.start_with?('/spi/') + header = '00020101021226930014BR.GOV.BCB.PIX2571spi-qrcode.bancointer.com.br' + final_code = "#{header}#{final_code}" + end + + full_message = "#{intro}#{instructions}" + + { + formatted_message: full_message, + raw_payload: final_code, # Kept for debugging/fallback + payment_link: link, + amount: reservation.total_amount.to_f, + reservation_id: reservation.id, + success: true + } end end diff --git a/enterprise/app/services/captain/tools/parsers/status_suites_parser.rb b/enterprise/app/services/captain/tools/parsers/status_suites_parser.rb index d16420a..02e0ee3 100644 --- a/enterprise/app/services/captain/tools/parsers/status_suites_parser.rb +++ b/enterprise/app/services/captain/tools/parsers/status_suites_parser.rb @@ -1,29 +1,27 @@ -module Captain - module Tools - module Parsers - class StatusSuitesParser - def self.parse(response_body) - # Assuming response_body is already a Hash or needs parsing - data = response_body.is_a?(String) ? JSON.parse(response_body) : response_body rescue {} - - # Example normalization logic based on prompt - { - tool_key: 'status_suites', - free: normalize_items(data['free'] || []), - occupied: normalize_items(data['occupied'] || []), - cleaning: normalize_items(data['cleaning'] || []) - } - end +class Captain::Tools::Parsers::StatusSuitesParser + def self.parse(response_body) + # Assuming response_body is already a Hash or needs parsing + data = begin + response_body.is_a?(String) ? JSON.parse(response_body) : response_body + rescue StandardError + {} + end - def self.normalize_items(items) - items.map do |item| - { - suite: item['suite'] || item['id'], - category: item['category'] || item['categoria'] || 'Standard' - } - end - end - end + # Example normalization logic based on prompt + { + tool_key: 'status_suites', + free: normalize_items(data['free'] || []), + occupied: normalize_items(data['occupied'] || []), + cleaning: normalize_items(data['cleaning'] || []) + } + end + + def self.normalize_items(items) + items.map do |item| + { + suite: item['suite'] || item['id'], + category: item['category'] || item['categoria'] || 'Standard' + } end end 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 index be20742..b192570 100644 --- a/enterprise/app/services/captain/tools/react_to_message_tool.rb +++ b/enterprise/app/services/captain/tools/react_to_message_tool.rb @@ -1,80 +1,76 @@ -module Captain - module Tools - class ReactToMessageTool < BaseTool - def name - 'react_to_message' - end +class Captain::Tools::ReactToMessageTool < BaseTool + def name + 'react_to_message' + end - def description - 'Envia uma reação de emoji à última mensagem do cliente no WhatsApp. Use SEMPRE quando o cliente enviar agradecimentos, elogios ou emojis.' - end + def description + 'Envia uma reação de emoji à última mensagem do cliente no WhatsApp. Use SEMPRE quando o cliente enviar agradecimentos, elogios ou emojis.' + end - def tool_parameters_schema - { - type: 'object', - properties: { - emoji: { - type: 'string', - description: 'O emoji para reagir. Use ❤️ para agradecimentos, 👍 para confirmações, 😊 para saudações.' - } - }, - required: ['emoji'] + def tool_parameters_schema + { + type: 'object', + properties: { + emoji: { + type: 'string', + description: 'O emoji para reagir. Use ❤️ para agradecimentos, 👍 para confirmações, 😊 para saudações.' } - end + }, + required: ['emoji'] + } + end - def initialize(assistant, user: nil, conversation: nil) - super(assistant, user: user, conversation: conversation) - end + def initialize(assistant, user: nil, conversation: nil) + super(assistant, user: user, conversation: conversation) + end - def execute(*args, **params) - actual_params = resolve_params(args, params) - emoji = actual_params[:emoji] - return error_response('Conversation not found') unless @conversation.present? - return error_response('Emoji is required') if emoji.blank? + def execute(*args, **params) + actual_params = resolve_params(args, params) + emoji = actual_params[:emoji] + return error_response('Conversation not found') if @conversation.blank? + return error_response('Emoji is required') if emoji.blank? - # Get the last incoming message from the customer - last_customer_message = @conversation.messages.incoming.last - if last_customer_message.blank? - Rails.logger.warn "[ReactToMessageTool] Failure: No incoming message found for conversation #{@conversation.id}" - return error_response('No customer message to react to') - end - - # Get the external message ID (source_id) - required for WhatsApp reactions - message_external_id = last_customer_message.source_id - if message_external_id.blank? - Rails.logger.warn "[ReactToMessageTool] Failure: Message #{last_customer_message.id} has no source_id" - return error_response('Message has no external ID for reaction') - end - - 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' => external_id, - 'is_reaction' => true - } - ) - end - - def error_response(message) - { success: false, error: message }.to_json - end + # Get the last incoming message from the customer + last_customer_message = @conversation.messages.incoming.last + if last_customer_message.blank? + Rails.logger.warn "[ReactToMessageTool] Failure: No incoming message found for conversation #{@conversation.id}" + return error_response('No customer message to react to') end + + # Get the external message ID (source_id) - required for WhatsApp reactions + message_external_id = last_customer_message.source_id + if message_external_id.blank? + Rails.logger.warn "[ReactToMessageTool] Failure: Message #{last_customer_message.id} has no source_id" + return error_response('Message has no external ID for reaction') + end + + 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' => external_id, + 'is_reaction' => true + } + ) + end + + def error_response(message) + { success: false, error: message }.to_json end end diff --git a/enterprise/app/services/captain/tools/reminder_tool.rb b/enterprise/app/services/captain/tools/reminder_tool.rb index 49c6b65..dbd6f9d 100644 --- a/enterprise/app/services/captain/tools/reminder_tool.rb +++ b/enterprise/app/services/captain/tools/reminder_tool.rb @@ -1,61 +1,57 @@ -module Captain - module Tools - class ReminderTool < BaseTool - def self.name - 'reminder' - end +class Captain::Tools::ReminderTool < BaseTool + def self.name + 'reminder' + end - description 'Schedule a reminder to send a message to the customer at a specific time.' + description 'Schedule a reminder to send a message to the customer at a specific time.' - param :message, type: 'string', desc: 'Message to send to the customer' - param :scheduled_at, type: 'string', desc: 'ISO datetime for when to send the reminder' - param :minutes_from_now, type: 'integer', desc: 'Alternative to scheduled_at: minutes from now' + param :message, type: 'string', desc: 'Message to send to the customer' + param :scheduled_at, type: 'string', desc: 'ISO datetime for when to send the reminder' + param :minutes_from_now, type: 'integer', desc: 'Alternative to scheduled_at: minutes from now' - def initialize(assistant, user: nil, conversation: nil) - @conversation = conversation - super(assistant, user: user) - end + def initialize(assistant, user: nil, conversation: nil) + @conversation = conversation + super(assistant, user: user) + end - def execute(*args, **params) - actual_params = resolve_params(args, params) - message = actual_params[:message] - scheduled_at = actual_params[:scheduled_at] - minutes_from_now = actual_params[:minutes_from_now] - return error_response('Conversation not found') unless @conversation.present? - return error_response('Message is required') if message.blank? + def execute(*args, **params) + actual_params = resolve_params(args, params) + message = actual_params[:message] + scheduled_at = actual_params[:scheduled_at] + minutes_from_now = actual_params[:minutes_from_now] + return error_response('Conversation not found') if @conversation.blank? + return error_response('Message is required') if message.blank? - schedule_time = parse_schedule_time(scheduled_at, minutes_from_now) - return error_response('Scheduled time is required') unless schedule_time + schedule_time = parse_schedule_time(scheduled_at, minutes_from_now) + return error_response('Scheduled time is required') unless schedule_time - reminder = Captain::Reminders::CreateService.new( - account: @assistant.account, - params: { - conversation_id: @conversation.id, - message: message, - scheduled_at: schedule_time, - reminder_type: 'manual' - }, - created_by: @user - ).perform + reminder = Captain::Reminders::CreateService.new( + account: @assistant.account, + params: { + conversation_id: @conversation.id, + message: message, + scheduled_at: schedule_time, + reminder_type: 'manual' + }, + created_by: @user + ).perform - { success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json - rescue StandardError => e - Rails.logger.error "[ReminderTool] Failed: #{e.message}" - error_response(e.message) - end + { success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json + rescue StandardError => e + Rails.logger.error "[ReminderTool] Failed: #{e.message}" + error_response(e.message) + end - private + private - def parse_schedule_time(scheduled_at, minutes_from_now) - return Time.zone.parse(scheduled_at) if scheduled_at.present? - return Time.current + minutes_from_now.to_i.minutes if minutes_from_now.present? + def parse_schedule_time(scheduled_at, minutes_from_now) + return Time.zone.parse(scheduled_at) if scheduled_at.present? + return Time.current + minutes_from_now.to_i.minutes if minutes_from_now.present? - nil - end + nil + end - def error_response(message) - { success: false, error: message }.to_json - end - end + def error_response(message) + { success: false, error: message }.to_json end end diff --git a/enterprise/app/services/captain/tools/search_documentation_service.rb b/enterprise/app/services/captain/tools/search_documentation_service.rb index c246476..c44d491 100755 --- a/enterprise/app/services/captain/tools/search_documentation_service.rb +++ b/enterprise/app/services/captain/tools/search_documentation_service.rb @@ -45,7 +45,7 @@ class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool end def low_confidence?(response) - return false unless response&.respond_to?(:neighbor_distance) + return false unless response.respond_to?(:neighbor_distance) response.neighbor_distance.to_f > LOW_CONFIDENCE_DISTANCE end diff --git a/enterprise/app/services/captain/tools/status_suites_tool.rb b/enterprise/app/services/captain/tools/status_suites_tool.rb index 4873f6d..93a9037 100644 --- a/enterprise/app/services/captain/tools/status_suites_tool.rb +++ b/enterprise/app/services/captain/tools/status_suites_tool.rb @@ -1,61 +1,57 @@ -module Captain - module Tools - class StatusSuitesTool < BaseTool - def self.name - 'status_suites' - end +class Captain::Tools::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.' + 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 initialize(assistant, user: nil, conversation: nil) + @conversation = conversation + super(assistant, user: user) + end - def execute(*_args, **_params) - config = find_tool_config - return { success: false, error: 'Tool not configured' }.to_json unless config&.is_enabled + def execute(*_args, **_params) + 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 + 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 + 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) + 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 + 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 diff --git a/enterprise/app/services/captain/tools/suite_watchdog_tool.rb b/enterprise/app/services/captain/tools/suite_watchdog_tool.rb index f0d0f04..7c7a2cb 100644 --- a/enterprise/app/services/captain/tools/suite_watchdog_tool.rb +++ b/enterprise/app/services/captain/tools/suite_watchdog_tool.rb @@ -1,59 +1,55 @@ -module Captain - module Tools - class SuiteWatchdogTool < BaseTool - def self.name - 'suite_watchdog' - end +class Captain::Tools::SuiteWatchdogTool < BaseTool + def self.name + 'suite_watchdog' + end - description 'Monitor a suite availability and notify the customer when it becomes free.' + description 'Monitor a suite availability and notify the customer when it becomes free.' - param :suite_identifier, type: 'string', desc: 'Suite number or identifier (e.g. 102)' - param :interval_minutes, type: 'integer', desc: 'How often to check availability' - param :message, type: 'string', desc: 'Message to send when the suite becomes available' + param :suite_identifier, type: 'string', desc: 'Suite number or identifier (e.g. 102)' + param :interval_minutes, type: 'integer', desc: 'How often to check availability' + param :message, type: 'string', desc: 'Message to send when the suite becomes available' - def initialize(assistant, user: nil, conversation: nil) - @conversation = conversation - super(assistant, user: user) - end + def initialize(assistant, user: nil, conversation: nil) + @conversation = conversation + super(assistant, user: user) + end - def execute(*args, **params) - actual_params = resolve_params(args, params) - suite_identifier = actual_params[:suite_identifier] - interval_minutes = actual_params[:interval_minutes] || 10 - message = actual_params[:message] - return error_response('Conversation not found') unless @conversation.present? - return error_response('Suite identifier is required') if suite_identifier.blank? + def execute(*args, **params) + actual_params = resolve_params(args, params) + suite_identifier = actual_params[:suite_identifier] + interval_minutes = actual_params[:interval_minutes] || 10 + message = actual_params[:message] + return error_response('Conversation not found') if @conversation.blank? + return error_response('Suite identifier is required') if suite_identifier.blank? - reminder = Captain::Reminders::CreateService.new( - account: @assistant.account, - params: { - conversation_id: @conversation.id, - message: message.presence || default_message(suite_identifier), - scheduled_at: Time.current + interval_minutes.to_i.minutes, - reminder_type: 'suite_watchdog', - metadata: { - suite_identifier: suite_identifier, - interval_minutes: interval_minutes.to_i - } - }, - created_by: @user - ).perform + reminder = Captain::Reminders::CreateService.new( + account: @assistant.account, + params: { + conversation_id: @conversation.id, + message: message.presence || default_message(suite_identifier), + scheduled_at: Time.current + interval_minutes.to_i.minutes, + reminder_type: 'suite_watchdog', + metadata: { + suite_identifier: suite_identifier, + interval_minutes: interval_minutes.to_i + } + }, + created_by: @user + ).perform - { success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json - rescue StandardError => e - Rails.logger.error "[SuiteWatchdogTool] Failed: #{e.message}" - error_response(e.message) - end + { success: true, reminder_id: reminder.id, scheduled_at: reminder.scheduled_at.iso8601 }.to_json + rescue StandardError => e + Rails.logger.error "[SuiteWatchdogTool] Failed: #{e.message}" + error_response(e.message) + end - private + private - def default_message(suite_identifier) - I18n.t('captain.reminders.defaults.suite_available', suite: suite_identifier) - end + def default_message(suite_identifier) + I18n.t('captain.reminders.defaults.suite_available', suite: suite_identifier) + end - def error_response(message) - { success: false, error: message }.to_json - end - end + def error_response(message) + { success: false, error: message }.to_json end end diff --git a/enterprise/app/services/captain/tools/tool_runner.rb b/enterprise/app/services/captain/tools/tool_runner.rb index 30c4456..6a2cc48 100644 --- a/enterprise/app/services/captain/tools/tool_runner.rb +++ b/enterprise/app/services/captain/tools/tool_runner.rb @@ -1,316 +1,310 @@ require 'net/http' require 'uri' -module Captain - module Tools - class ToolRunner - def self.run(assistant:, tool_key:, inbox:, conversation:, additional_data: {}) - new(assistant, tool_key, inbox, conversation, additional_data).run - end +class Captain::Tools::ToolRunner + def self.run(assistant:, tool_key:, inbox:, conversation:, additional_data: {}) + new(assistant, tool_key, inbox, conversation, additional_data).run + end - def initialize(assistant, tool_key, inbox, conversation, additional_data) - @assistant = assistant - @tool_key = tool_key - @inbox = inbox - @conversation = conversation - @definition = resolve_definition - @config = resolve_config - @contact = conversation.contact - @additional_data = additional_data - end + def initialize(assistant, tool_key, inbox, conversation, additional_data) + @assistant = assistant + @tool_key = tool_key + @inbox = inbox + @conversation = conversation + @definition = resolve_definition + @config = resolve_config + @contact = conversation.contact + @additional_data = additional_data + end - def run - start_time = Time.current - result = if !tool_enabled? - failed_response('Tool not configured or disabled') - elsif !@definition - failed_response('Tool definition not found') - else - case @definition[:type] - when :http then execute_http - when :webhook then execute_webhook - when :internal then execute_internal - when :scenario then execute_scenario - else failed_response('Unknown tool type') - end - end + def run + start_time = Time.current + result = if !tool_enabled? + failed_response('Tool not configured or disabled') + elsif !@definition + failed_response('Tool definition not found') + else + case @definition[:type] + when :http then execute_http + when :webhook then execute_webhook + when :internal then execute_internal + when :scenario then execute_scenario + else failed_response('Unknown tool type') + end + end - duration = (Time.current - start_time) * 1000 - result = apply_fallback(result) - result.merge(duration_ms: duration) - end + duration = (Time.current - start_time) * 1000 + result = apply_fallback(result) + result.merge(duration_ms: duration) + end - private + private - def resolve_definition - return Captain::Tools::Definitions::ALL[@tool_key] if Captain::Tools::Definitions::ALL.key?(@tool_key) + def resolve_definition + return Captain::Tools::Definitions::ALL[@tool_key] if Captain::Tools::Definitions::ALL.key?(@tool_key) - scenario = find_scenario_by_tool_key - return { type: :scenario, scenario: scenario } if scenario + scenario = find_scenario_by_tool_key + return { type: :scenario, scenario: scenario } if scenario - Rails.logger.warn "[ToolRunner] Unknown tool_key: #{@tool_key}" - nil - end + Rails.logger.warn "[ToolRunner] Unknown tool_key: #{@tool_key}" + nil + end - def resolve_config - Captain::ToolConfig.find_by(captain_assistant_id: @assistant.id, tool_key: @tool_key) || - Captain::ToolConfig.find_by(inbox: @inbox, account: @inbox.account, tool_key: @tool_key) - end + def resolve_config + Captain::ToolConfig.find_by(captain_assistant_id: @assistant.id, tool_key: @tool_key) || + Captain::ToolConfig.find_by(inbox: @inbox, account: @inbox.account, tool_key: @tool_key) + end - def find_scenario_by_tool_key - @assistant.scenarios.enabled.find do |scenario| - "consultar_#{scenario.title.parameterize.underscore}" == @tool_key - end - end + def find_scenario_by_tool_key + @assistant.scenarios.enabled.find do |scenario| + "consultar_#{scenario.title.parameterize.underscore}" == @tool_key + end + end - def tool_enabled? - return true if @definition && @definition[:type] == :scenario - return true if @definition && @definition[:always_on] + def tool_enabled? + return true if @definition && @definition[:type] == :scenario + return true if @definition && @definition[:always_on] - @config&.is_enabled - end + @config&.is_enabled + end - def execute_scenario - scenario = @definition[:scenario] - # We pass the contact (user) and conversation context. - # We use the full user message as the 'pergunta_interna' for the sub-agent. - tool = Captain::Tools::ScenarioDelegatorTool.new(scenario, user: @contact, conversation: @conversation) + def execute_scenario + scenario = @definition[:scenario] + # We pass the contact (user) and conversation context. + # We use the full user message as the 'pergunta_interna' for the sub-agent. + tool = Captain::Tools::ScenarioDelegatorTool.new(scenario, user: @contact, conversation: @conversation) - # ScenarioDelegatorTool expects 'pergunta_interna' - params = tool_params - params[:pergunta_interna] ||= @additional_data[:message] - execution_result = tool.execute(params) + # ScenarioDelegatorTool expects 'pergunta_interna' + params = tool_params + params[:pergunta_interna] ||= @additional_data[:message] + execution_result = tool.execute(params) - if execution_result.is_a?(String) - { success: true, body: { message: execution_result } } - else - { success: true, body: execution_result } - end - rescue StandardError => e - { success: false, error: "Scenario Error: #{e.message}" } - end + if execution_result.is_a?(String) + { success: true, body: { message: execution_result } } + else + { success: true, body: execution_result } + end + rescue StandardError => e + { success: false, error: "Scenario Error: #{e.message}" } + end - def execute_http - uri = URI(@definition[:url]) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = uri.scheme == 'https' - http.read_timeout = 8 + def execute_http + uri = URI(@definition[:url]) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.read_timeout = 8 - request = Net::HTTP::Get.new(uri) - request['PLUG-PLAY-ID'] = @config.plug_play_id - request['PLUG-PLAY-TOKEN'] = @config.plug_play_token + request = Net::HTTP::Get.new(uri) + request['PLUG-PLAY-ID'] = @config.plug_play_id + request['PLUG-PLAY-TOKEN'] = @config.plug_play_token - response = http.request(request) + response = http.request(request) - if response.is_a?(Net::HTTPSuccess) - parsed_body = parse_response(response.body) - { success: true, body: parsed_body, status: response.code } - else - { success: false, status: response.code, error: 'HTTP Request Failed' } - end - rescue StandardError => e - { success: false, error: e.message } - end + if response.is_a?(Net::HTTPSuccess) + parsed_body = parse_response(response.body) + { success: true, body: parsed_body, status: response.code } + else + { success: false, status: response.code, error: 'HTTP Request Failed' } + end + rescue StandardError => e + { success: false, error: e.message } + end - def execute_internal - tool_class_name = "Captain::Tools::#{@tool_key.camelize}Tool" - # Safe constantize to avoid arbitrary code execution if key was untrusted (though it comes from Definitions) - klass = tool_class_name.safe_constantize + def execute_internal + tool_class_name = "Captain::Tools::#{@tool_key.camelize}Tool" + # Safe constantize to avoid arbitrary code execution if key was untrusted (though it comes from Definitions) + klass = tool_class_name.safe_constantize - return failed_response("Tool Class #{tool_class_name} not found") unless klass + return failed_response("Tool Class #{tool_class_name} not found") unless klass - tool_instance = klass.new(@assistant, user: @contact, conversation: @conversation) + tool_instance = klass.new(@assistant, user: @contact, conversation: @conversation) - # Merge additional data into params if the tool expects them - # Typically internal tools take a params hash. - # We assume tool_output from brain is passed as params or we build it here? - # ToolRunner.run signature: additional_data: { message: ... } - # The 'params' usuall come from the Brain's tool_input. - # But ToolRunner is called with `tool_key`... wait. - # AssistantChatService (line 48) calls ToolRunner with tool_key. - # But WHERE are the arguments (e.g. suite="Stilo")? - # A HA! JasmineBrain.decide returns `tool_key`. - # But does it return parameters? - # Looking at AssistantChatService again... - # brain_decision has .tool_key. - # It DOES NOT look like it passes parameters to ToolRunner! - # CHECK THIS before committing logic. + # Merge additional data into params if the tool expects them + # Typically internal tools take a params hash. + # We assume tool_output from brain is passed as params or we build it here? + # ToolRunner.run signature: additional_data: { message: ... } + # The 'params' usuall come from the Brain's tool_input. + # But ToolRunner is called with `tool_key`... wait. + # AssistantChatService (line 48) calls ToolRunner with tool_key. + # But WHERE are the arguments (e.g. suite="Stilo")? + # A HA! JasmineBrain.decide returns `tool_key`. + # But does it return parameters? + # Looking at AssistantChatService again... + # brain_decision has .tool_key. + # It DOES NOT look like it passes parameters to ToolRunner! + # CHECK THIS before committing logic. - # Assumption based on `CheckAvailabilityTool` code: `execute(params = {})`. - # Params need to come from somewhere. - # In current AssistantChatService, `runner_result` is called without `tool_input`. - # This implies tools must parse the message THEMSELVES or parameters are missing? - # Let's verify AssistantChatService brain decision struct. + # Assumption based on `CheckAvailabilityTool` code: `execute(params = {})`. + # Params need to come from somewhere. + # In current AssistantChatService, `runner_result` is called without `tool_input`. + # This implies tools must parse the message THEMSELVES or parameters are missing? + # Let's verify AssistantChatService brain decision struct. - # For now, implementing the basic invocation. `execute` in BaseTool often parses context if params are empty. - # But CheckAvailabilityTool explicitly does `suite_category = params['suite']`. - # If params are empty, it fails. + # For now, implementing the basic invocation. `execute` in BaseTool often parses context if params are empty. + # But CheckAvailabilityTool explicitly does `suite_category = params['suite']`. + # If params are empty, it fails. - # CRITICAL: Is JasmineBrain extracting parameters? - # If not, the tool must extract them from @conversation. - # But CheckAvailabilityTool uses `params`. + # CRITICAL: Is JasmineBrain extracting parameters? + # If not, the tool must extract them from @conversation. + # But CheckAvailabilityTool uses `params`. - # Let's verify `JasmineBrain` in a later step if needed. - # For now, we pass `additional_data` as params which usually contains `message`. - # But `CheckAvailabilityTool` expects 'suite'. + # Let's verify `JasmineBrain` in a later step if needed. + # For now, we pass `additional_data` as params which usually contains `message`. + # But `CheckAvailabilityTool` expects 'suite'. - # I will pass `additional_data` merged with any extracted parameters if available. - # But since I can't change the inputs to ToolRunner right here easily without finding the caller... - # I will assume the Tool executes with what it has. + # I will pass `additional_data` merged with any extracted parameters if available. + # But since I can't change the inputs to ToolRunner right here easily without finding the caller... + # I will assume the Tool executes with what it has. - # Wait, BaseTool has access to @conversation. - # CheckAvailabilityTool reads `params['suite']`. - # If JasmineBrain doesn't extract 'suite', this fails. + # Wait, BaseTool has access to @conversation. + # CheckAvailabilityTool reads `params['suite']`. + # If JasmineBrain doesn't extract 'suite', this fails. - # RE-READING `GeneratePixTool` (Step 8838 modified): - # "Refactored... to operate on an active draft reservation, removing direct parameter requirements". + # RE-READING `GeneratePixTool` (Step 8838 modified): + # "Refactored... to operate on an active draft reservation, removing direct parameter requirements". - # RE-READING `CreateReservationIntentTool` (Step 8868): - # `suite_category = params['suite']`. It NEEDS params! + # RE-READING `CreateReservationIntentTool` (Step 8868): + # `suite_category = params['suite']`. It NEEDS params! - # RE-READING `AssistantChatService` (Step 8898): - # `brain_decision = Captain::Llm::JasmineBrain.decide(...)`. - # `ToolRunner.run(..., additional_data: { message: additional_data })`. + # RE-READING `AssistantChatService` (Step 8898): + # `brain_decision = Captain::Llm::JasmineBrain.decide(...)`. + # `ToolRunner.run(..., additional_data: { message: additional_data })`. - # Does `JasmineBrain` decision contain parameters? - # `brain_decision.tool_key`. - # If `JasmineBrain` is an LLM call providing JSON, it usually provides arguments. - # But `AssistantChatService` doesn't seem to pass them to `ToolRunner`. + # Does `JasmineBrain` decision contain parameters? + # `brain_decision.tool_key`. + # If `JasmineBrain` is an LLM call providing JSON, it usually provides arguments. + # But `AssistantChatService` doesn't seem to pass them to `ToolRunner`. - # This might be ANOTHER bug. - # But first, let's enable the execution. CheckAvailabilityTool might parse the last message if params are empty? - # No, it checks `params['suite']`. + # This might be ANOTHER bug. + # But first, let's enable the execution. CheckAvailabilityTool might parse the last message if params are empty? + # No, it checks `params['suite']`. - # I will inject a "Smart Parameter Extraction" if params are missing? - # OR: I assume `additional_data` CONTAINS the params? - # `AssistantChatService` passes `additional_data: { message: additional_message }`. - # That's just the message string. + # I will inject a "Smart Parameter Extraction" if params are missing? + # OR: I assume `additional_data` CONTAINS the params? + # `AssistantChatService` passes `additional_data: { message: additional_message }`. + # That's just the message string. - # Warning: `CreateReservationIntentTool` will fail if it receives empty params. - # But `ToolRunner` must support it first. + # Warning: `CreateReservationIntentTool` will fail if it receives empty params. + # But `ToolRunner` must support it first. - # I will implement the execution. - # If I see "Missing suite" in the logs, I know `AssistantChatService` needs to pass parameters. + # I will implement the execution. + # If I see "Missing suite" in the logs, I know `AssistantChatService` needs to pass parameters. - execution_result = tool_instance.execute(tool_params) + execution_result = tool_instance.execute(tool_params) - # Normalize result. Tool execute usually returns a String or Hash. - if execution_result.is_a?(String) - { success: true, body: { message: execution_result } } - else - { success: true, body: execution_result } - end - rescue StandardError => e - { success: false, error: e.message } - end + # Normalize result. Tool execute usually returns a String or Hash. + if execution_result.is_a?(String) + { success: true, body: { message: execution_result } } + else + { success: true, body: execution_result } + end + rescue StandardError => e + { success: false, error: e.message } + end - def execute_webhook - url = @config.webhook_url - return failed_response('Webhook URL missing') if url.blank? + def execute_webhook + url = @config.webhook_url + return failed_response('Webhook URL missing') if url.blank? - payload = build_webhook_payload + payload = build_webhook_payload - # Specific logic for escalate_human - if @tool_key == 'escalar_humano' - # Label logic handles in brain/response service if success - end + # Specific logic for escalate_human + if @tool_key == 'escalar_humano' + # Label logic handles in brain/response service if success + end - response = RestClient::Request.execute( - method: :post, - url: url, - payload: payload.to_json, - headers: { content_type: :json, accept: :json }, - timeout: 8 - ) + response = RestClient::Request.execute( + method: :post, + url: url, + payload: payload.to_json, + headers: { content_type: :json, accept: :json }, + timeout: 8 + ) - { success: true, status: response.code, body: { message: 'Webhook sent' } } - rescue StandardError => e - { success: false, error: e.message } - end + { success: true, status: response.code, body: { message: 'Webhook sent' } } + rescue StandardError => e + { success: false, error: e.message } + end - def tool_params - params = @additional_data[:tool_input] - params = {} unless params.is_a?(Hash) - params.with_indifferent_access - end + def tool_params + params = @additional_data[:tool_input] + params = {} unless params.is_a?(Hash) + params.with_indifferent_access + end - def build_webhook_payload - base = { - conversation_id: @conversation.id, - contact_id: @contact.id, - event: @tool_key - } + def build_webhook_payload + base = { + conversation_id: @conversation.id, + contact_id: @contact.id, + event: @tool_key + } - # Specific payloads - if @tool_key == 'maria_fotos' - base.merge!( - suite_category: extract_suite_category || 'Indefinida', - message: 'User requested photos' - ) - elsif @tool_key == 'escalar_humano' - base.merge!(reason: 'User requested human agent') - end + # Specific payloads + if @tool_key == 'maria_fotos' + base[:suite_category] = extract_suite_category || 'Indefinida' + base[:message] = 'User requested photos' + elsif @tool_key == 'escalar_humano' + base[:reason] = 'User requested human agent' + end - base - end + base + end - def extract_suite_category - # Simple extraction or ask JasmineBrain to help later? - # For V1, we try to grab from message content or nil - msg = @additional_data[:message]&.downcase || '' - return 'Alexa' if msg.include?('alexa') - return 'Stilo' if msg.include?('stilo') - return 'Hidromassagem' if msg.include?('hidro') + def extract_suite_category + # Simple extraction or ask JasmineBrain to help later? + # For V1, we try to grab from message content or nil + msg = @additional_data[:message]&.downcase || '' + return 'Alexa' if msg.include?('alexa') + return 'Stilo' if msg.include?('stilo') + return 'Hidromassagem' if msg.include?('hidro') - nil - end + nil + end - def parse_response(body) - case @tool_key - when 'status_suites' - Captain::Tools::Parsers::StatusSuitesParser.parse(body) - else - begin - JSON.parse(body) - rescue StandardError - body - end - end - end - - def failed_response(msg) - { success: false, error: msg } - end - - def apply_fallback(result) - failed = !result[:success] - # Treat generate_pix business failures as fallback-worthy. - if !failed && @tool_key == 'generate_pix' - body = result[:body] - body_success = - if body.is_a?(Hash) - body[:success].nil? ? body['success'] : body[:success] - end - failed = body_success == false - end - - return result unless failed - return result unless fallback_configured? - - { - success: true, - body: { message: @config.fallback_message.to_s }, - fallback: true, - error: result[:error] || result.dig(:body, :error) - } - end - - def fallback_configured? - return false unless @config&.fallback_message.present? - return false if %w[faq_lookup react_to_message].include?(@tool_key) - - true + def parse_response(body) + case @tool_key + when 'status_suites' + Captain::Tools::Parsers::StatusSuitesParser.parse(body) + else + begin + JSON.parse(body) + rescue StandardError + body end end end + + def failed_response(msg) + { success: false, error: msg } + end + + def apply_fallback(result) + failed = !result[:success] + # Treat generate_pix business failures as fallback-worthy. + if !failed && @tool_key == 'generate_pix' + body = result[:body] + body_success = + if body.is_a?(Hash) + body[:success].nil? ? body['success'] : body[:success] + end + failed = body_success == false + end + + return result unless failed + return result unless fallback_configured? + + { + success: true, + body: { message: @config.fallback_message.to_s }, + fallback: true, + error: result[:error] || result.dig(:body, :error) + } + end + + def fallback_configured? + return false if @config&.fallback_message.blank? + return false if %w[faq_lookup react_to_message].include?(@tool_key) + + true + end end diff --git a/enterprise/app/services/captain/tools/update_contact_tool.rb b/enterprise/app/services/captain/tools/update_contact_tool.rb index f1f983c..33ef7aa 100644 --- a/enterprise/app/services/captain/tools/update_contact_tool.rb +++ b/enterprise/app/services/captain/tools/update_contact_tool.rb @@ -1,93 +1,88 @@ -module Captain - module Tools - class UpdateContactTool < BaseTool - def name - 'update_contact' - end +class Captain::Tools::UpdateContactTool < BaseTool + def name + 'update_contact' + end - def description - 'Updates the contact information (Name and CPF) for the current conversation customer. Use this when the user provides their details.' - end + def description + 'Updates the contact information (Name and CPF) for the current conversation customer. Use this when the user provides their details.' + end - def tool_parameters_schema - { - type: 'object', - properties: { - nome: { - type: 'string', - description: 'Nome completo do cliente para cadastro.' - }, - cpf: { - type: 'string', - description: 'CPF do cliente (apenas números ou formatado).' - } - }, - required: [] + def tool_parameters_schema + { + type: 'object', + properties: { + nome: { + type: 'string', + description: 'Nome completo do cliente para cadastro.' + }, + cpf: { + type: 'string', + description: 'CPF do cliente (apenas números ou formatado).' } - end + }, + required: [] + } + end - def execute(*args, **params) - actual_params = resolve_params(args, params) - File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] STARTING UpdateContactTool with params: #{actual_params}" - end - - name = actual_params[:nome] || actual_params[:name] - cpf = actual_params[:cpf] - - return 'Erro: Nenhum dado fornecido (nome ou cpf).' if name.blank? && cpf.blank? - - ensure_conversation_context! - - unless @conversation && @conversation.contact - msg = "Erro Crítico: Contexto de conversa ou contato não disponível. Params: #{actual_params}" - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" } - return msg - end - - contact = @conversation.contact - contact.name = name if name.present? - contact.custom_attributes ||= {} - contact.custom_attributes['cpf'] = cpf if cpf.present? - - if contact.save - update_sticky_state(name: contact.name, cpf: contact.custom_attributes['cpf']) - msg = "Dados atualizados com sucesso. Nome: #{contact.name}, CPF: #{contact.custom_attributes['cpf']}" - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] SUCCESS: #{msg}" } - msg - else - msg = "Erro ao salvar dados: #{contact.errors.full_messages.join(', ')}" - File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.now}] FAILURE: #{msg}" } - msg - end - end - - private - - # Helper to ensure we have a conversation object - def ensure_conversation_context! - return if @conversation.present? - end - - def update_sticky_state(name:, cpf:) - return unless @conversation.respond_to?(:active_scenario_state) - return if name.blank? && cpf.blank? - - state = @conversation.active_scenario_state || {} - collected = (state['collected'] || {}).merge( - 'name' => name.presence, - 'cpf' => cpf.presence - ).compact - - @conversation.update!( - active_scenario_state: state.merge( - 'collected' => collected, - 'updated_at' => Time.current.iso8601 - ) - ) - rescue StandardError => e - Rails.logger.warn "[UpdateContactTool] Failed to update sticky state: #{e.message}" - end + def execute(*args, **params) + actual_params = resolve_params(args, params) + File.open(Rails.root.join('log/tool_debug.log'), 'a') do |f| + f.puts "[#{Time.zone.now}] STARTING UpdateContactTool with params: #{actual_params}" end + + name = actual_params[:nome] || actual_params[:name] + cpf = actual_params[:cpf] + + return 'Erro: Nenhum dado fornecido (nome ou cpf).' if name.blank? && cpf.blank? + + ensure_conversation_context! + + unless @conversation&.contact + msg = "Erro Crítico: Contexto de conversa ou contato não disponível. Params: #{actual_params}" + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" } + return msg + end + + contact = @conversation.contact + contact.name = name if name.present? + contact.custom_attributes ||= {} + contact.custom_attributes['cpf'] = cpf if cpf.present? + + if contact.save + update_sticky_state(name: contact.name, cpf: contact.custom_attributes['cpf']) + msg = "Dados atualizados com sucesso. Nome: #{contact.name}, CPF: #{contact.custom_attributes['cpf']}" + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] SUCCESS: #{msg}" } + else + msg = "Erro ao salvar dados: #{contact.errors.full_messages.join(', ')}" + File.open(Rails.root.join('log/tool_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] FAILURE: #{msg}" } + end + msg + end + + private + + # Helper to ensure we have a conversation object + def ensure_conversation_context! + return if @conversation.present? + end + + def update_sticky_state(name:, cpf:) + return unless @conversation.respond_to?(:active_scenario_state) + return if name.blank? && cpf.blank? + + state = @conversation.active_scenario_state || {} + collected = (state['collected'] || {}).merge( + 'name' => name.presence, + 'cpf' => cpf.presence + ).compact + + @conversation.update!( + active_scenario_state: state.merge( + 'collected' => collected, + 'updated_at' => Time.current.iso8601 + ) + ) + rescue StandardError => e + Rails.logger.warn "[UpdateContactTool] Failed to update sticky state: #{e.message}" end end diff --git a/enterprise/app/services/captain/webhook_sender_service.rb b/enterprise/app/services/captain/webhook_sender_service.rb index abe5a14..6f29913 100644 --- a/enterprise/app/services/captain/webhook_sender_service.rb +++ b/enterprise/app/services/captain/webhook_sender_service.rb @@ -1,67 +1,65 @@ -module Captain - class WebhookSenderService - def initialize(reservation) - @reservation = reservation - @unit = reservation.unit - end +class Captain::WebhookSenderService + def initialize(reservation) + @reservation = reservation + @unit = reservation.unit + end - def perform - return unless @unit&.webhook_url.present? + def perform + return if @unit&.webhook_url.blank? - payload = build_payload - send_webhook(payload) - end + payload = build_payload + send_webhook(payload) + end - private + private - def build_payload - contact = @reservation.contact - brand = @reservation.brand + def build_payload + contact = @reservation.contact + brand = @reservation.brand - { - event: 'reservation_update', - timestamp: Time.current.iso8601, - reservation: { - id: @reservation.id, - suite: @reservation.suite_identifier, - status: @reservation.status, - payment_status: @reservation.payment_status, - check_in: @reservation.check_in_at&.iso8601, - check_out: @reservation.check_out_at&.iso8601, - created_at: @reservation.created_at&.iso8601 - }, - financial: { - total_amount: @reservation.total_amount, - paid_amount: @reservation.active? ? (@reservation.total_amount * 0.5) : 0.0, - currency: 'BRL' - }, - customer: { - name: contact&.name, - email: contact&.email, - phone: contact&.phone_number - }, - unit: { - name: @unit.name, - brand: brand&.name - }, - metadata: @reservation.metadata - } - end + { + event: 'reservation_update', + timestamp: Time.current.iso8601, + reservation: { + id: @reservation.id, + suite: @reservation.suite_identifier, + status: @reservation.status, + payment_status: @reservation.payment_status, + check_in: @reservation.check_in_at&.iso8601, + check_out: @reservation.check_out_at&.iso8601, + created_at: @reservation.created_at&.iso8601 + }, + financial: { + total_amount: @reservation.total_amount, + paid_amount: @reservation.active? ? (@reservation.total_amount * 0.5) : 0.0, + currency: 'BRL' + }, + customer: { + name: contact&.name, + email: contact&.email, + phone: contact&.phone_number + }, + unit: { + name: @unit.name, + brand: brand&.name + }, + metadata: @reservation.metadata + } + end - def send_webhook(payload) - uri = URI.parse(@unit.webhook_url) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = (uri.scheme == 'https') + def send_webhook(payload) + uri = URI.parse(@unit.webhook_url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == 'https') - request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/json' }) - request.body = payload.to_json + request = Net::HTTP::Post.new(uri.path, { 'Content-Type' => 'application/json' }) + request.body = payload.to_json - begin - response = http.request(request) - Rails.logger.info "[Captain::WebhookSender] Sent to #{@unit.webhook_url} | Status: #{response.code}" - rescue StandardError => e - Rails.logger.error "[Captain::WebhookSender] Failed to send: #{e.message}" - end + begin + response = http.request(request) + Rails.logger.info "[Captain::WebhookSender] Sent to #{@unit.webhook_url} | Status: #{response.code}" + rescue StandardError => e + Rails.logger.error "[Captain::WebhookSender] Failed to send: #{e.message}" end end end diff --git a/enterprise/app/services/captain/whatsapp_notification_service.rb b/enterprise/app/services/captain/whatsapp_notification_service.rb index 6ad926b..699105d 100644 --- a/enterprise/app/services/captain/whatsapp_notification_service.rb +++ b/enterprise/app/services/captain/whatsapp_notification_service.rb @@ -1,98 +1,96 @@ -module Captain - class WhatsappNotificationService - def initialize(reservation) - @reservation = reservation - @unit = reservation.unit - @account = reservation.account - @contact = reservation.contact - @conversation = reservation.conversation +class Captain::WhatsappNotificationService + def initialize(reservation) + @reservation = reservation + @unit = reservation.unit + @account = reservation.account + @contact = reservation.contact + @conversation = reservation.conversation + end + + def perform + return unless valid_for_sending? + + # Find sender (Captain Bot or First Admin) + sender = find_sender + + # Build Message Content + content = build_message_content + + # Send Message + ::Messages::MessageBuilder.new( + sender, + @conversation, + { + content: content, + private: false, + message_type: 'outgoing' + } + ).perform + + Rails.logger.info "[Captain::WhatsappNotification] 📨 Message sent to #{@contact.name} (Reserva ##{@reservation.id})" + rescue StandardError => e + Rails.logger.error "[Captain::WhatsappNotification] ❌ Error sending message: #{e.message}" + end + + private + + def valid_for_sending? + unless @unit&.inbox_id + Rails.logger.warn "[Captain::WhatsappNotification] ⚠️ No Inbox configured for Unit ##{@unit&.id}" + return false end - def perform - return unless valid_for_sending? + # Ensure conversation belongs to the correct inbox? + # If the reservation conversation is in a different inbox (e.g. Web Widget), we might need to create a new conversation in the WhatsApp Inbox. + # For now, let's assume the reservation conversation IS the one we want to reply to, OR we simply send to that conversation. + # BUT, if the user requested "Send using the Unit's specific inbox", we must respect that. - # Find sender (Captain Bot or First Admin) - sender = find_sender - - # Build Message Content - content = build_message_content - - # Send Message - ::Messages::MessageBuilder.new( - sender, - @conversation, - { - content: content, - private: false, - message_type: 'outgoing' - } - ).perform - - Rails.logger.info "[Captain::WhatsappNotification] 📨 Message sent to #{@contact.name} (Reserva ##{@reservation.id})" - rescue StandardError => e - Rails.logger.error "[Captain::WhatsappNotification] ❌ Error sending message: #{e.message}" - end - - private - - def valid_for_sending? - unless @unit&.inbox_id - Rails.logger.warn "[Captain::WhatsappNotification] ⚠️ No Inbox configured for Unit ##{@unit&.id}" + if @conversation.inbox_id != @unit.inbox_id + # We need to find or create a conversation in the Target Inbox + target_inbox = @account.inboxes.find_by(id: @unit.inbox_id) + unless target_inbox + Rails.logger.error "[Captain::WhatsappNotification] ❌ Target Inbox ##{@unit.inbox_id} not found" return false end - # Ensure conversation belongs to the correct inbox? - # If the reservation conversation is in a different inbox (e.g. Web Widget), we might need to create a new conversation in the WhatsApp Inbox. - # For now, let's assume the reservation conversation IS the one we want to reply to, OR we simply send to that conversation. - # BUT, if the user requested "Send using the Unit's specific inbox", we must respect that. + # Find contact inbox for the target inbox + contact_inbox = ::ContactInbox.find_by(contact_id: @contact.id, inbox_id: target_inbox.id) - if @conversation.inbox_id != @unit.inbox_id - # We need to find or create a conversation in the Target Inbox - target_inbox = @account.inboxes.find_by(id: @unit.inbox_id) - unless target_inbox - Rails.logger.error "[Captain::WhatsappNotification] ❌ Target Inbox ##{@unit.inbox_id} not found" - return false - end + # If contact doesn't exist in that inbox, we might need to create source_id (phone)? + # Actually, if it's a phone-based inbox (WhatsApp), the contact needs to have the phone number. + contact_inbox ||= ::ContactInbox.create!( + contact_id: @contact.id, + inbox_id: target_inbox.id, + source_id: @contact.phone_number # Assuming source_id for WA is phone + ) - # Find contact inbox for the target inbox - contact_inbox = ::ContactInbox.find_by(contact_id: @contact.id, inbox_id: target_inbox.id) - - # If contact doesn't exist in that inbox, we might need to create source_id (phone)? - # Actually, if it's a phone-based inbox (WhatsApp), the contact needs to have the phone number. - contact_inbox ||= ::ContactInbox.create!( - contact_id: @contact.id, - inbox_id: target_inbox.id, - source_id: @contact.phone_number # Assuming source_id for WA is phone - ) - - # Find or Create Conversation in Target Inbox - @conversation = ::Conversation.create!( - account_id: @account.id, - inbox_id: target_inbox.id, - contact_id: @contact.id, - contact_inbox_id: contact_inbox.id, - status: :open - ) - end - - true + # Find or Create Conversation in Target Inbox + @conversation = ::Conversation.create!( + account_id: @account.id, + inbox_id: target_inbox.id, + contact_id: @contact.id, + contact_inbox_id: contact_inbox.id, + status: :open + ) end - def find_sender - # Try to find a Captain Agent Bot first - # Valid logic: defaults to the account administrator - @account.administrators.first || @account.users.first - end + true + end - def build_message_content - # Format Dates - check_in = @reservation.check_in_at.strftime('%d/%m/%Y %H:%M') + def find_sender + # Try to find a Captain Agent Bot first + # Valid logic: defaults to the account administrator + @account.administrators.first || @account.users.first + end - "Olá #{@contact.name}, confirmamos o pagamento da sua reserva no #{@unit.name}! 🎉\n\n" \ - "🏨 *Reserva:* ##{@reservation.id}\n" \ - "📅 *Check-in:* #{check_in}\n" \ - "✅ *Status:* Confirmado\n\n" \ - 'Estamos ansiosos para recebê-lo!' - end + def build_message_content + # Format Dates + check_in = @reservation.check_in_at.strftime('%d/%m/%Y %H:%M') + + "Olá #{@contact.name}, confirmamos o pagamento da sua reserva no #{@unit.name}! 🎉\n\n" \ + "🏨 *Reserva:* ##{@reservation.id}\n" \ + "📅 *Check-in:* #{check_in}\n" \ + "✅ *Status:* Confirmado\n\n" \ + 'Estamos ansiosos para recebê-lo!' end end diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb index bbcab3c..b684534 100755 --- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb +++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb @@ -47,7 +47,7 @@ module Enterprise::MessageTemplates::HookExecutionService def send_typing_indicator # Access phone number safely via contact association phone = conversation.contact&.phone_number - return unless phone.present? + return if phone.blank? # Assuming Wuzapi is the provider for Channel::Whatsapp in this context # We need to find the Wuzapi client instance or create one. diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb index 991adb9..7396c18 100755 --- a/enterprise/app/services/llm/base_ai_service.rb +++ b/enterprise/app/services/llm/base_ai_service.rb @@ -14,7 +14,8 @@ class Llm::BaseAiService setup_temperature end - def chat(model: @model, temperature: @temperature, api_key: nil) # [INTENTIONAL] api_key reserved for per-request auth + # [INTENTIONAL] api_key reserved for per-request auth + def chat(model: @model, temperature: @temperature, api_key: nil) client = RubyLLM.chat(model: model) # client = client.with_api_key(api_key) if api_key.present? client.with_temperature(temperature) diff --git a/enterprise/app/services/llm/model_test_service.rb b/enterprise/app/services/llm/model_test_service.rb index 4e8fbdc..7a07663 100644 --- a/enterprise/app/services/llm/model_test_service.rb +++ b/enterprise/app/services/llm/model_test_service.rb @@ -1,5 +1,5 @@ class Llm::ModelTestService - TEST_PROMPT = 'Reply with the word ok.' + TEST_PROMPT = 'Reply with the word ok.'.freeze def initialize(provider:, model:, api_key:) @provider = provider diff --git a/enterprise/lib/captain/tools/faq_lookup_tool.rb b/enterprise/lib/captain/tools/faq_lookup_tool.rb index fe96213..fb3c0b5 100755 --- a/enterprise/lib/captain/tools/faq_lookup_tool.rb +++ b/enterprise/lib/captain/tools/faq_lookup_tool.rb @@ -8,7 +8,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool def perform(tool_context, **args) File.open(Rails.root.join('log/faq_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] FaqLookupTool CALLED with args: #{args.inspect}" + f.puts "[#{Time.zone.now}] FaqLookupTool CALLED with args: #{args.inspect}" end # Flexible argument handling: resolve if args is a hash or keywords @@ -20,7 +20,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool # Use existing vector search on approved responses if query.blank? - File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: No query provided" } + File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: No query provided" } return "No relevant FAQs found for: #{query}" end @@ -30,13 +30,13 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool if responses.empty? log_tool_usage('no_results', { query: query }) - File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.now}] RETURN: No results for '#{query}'" } + File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] RETURN: No results for '#{query}'" } "No relevant FAQs found for: #{query}" else log_tool_usage('found_results', { query: query, count: responses.size }) result = format_responses(responses) File.open(Rails.root.join('log/faq_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] SUCCESS: Found #{responses.size} results for '#{query}'. First: #{responses.first&.question}" + f.puts "[#{Time.zone.now}] SUCCESS: Found #{responses.size} results for '#{query}'. First: #{responses.first&.question}" end result end @@ -52,7 +52,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool end File.open(Rails.root.join('log/faq_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] distance_threshold: #{threshold}, before=#{responses.size}, after=#{filtered.size}" + f.puts "[#{Time.zone.now}] distance_threshold: #{threshold}, before=#{responses.size}, after=#{filtered.size}" end return responses if filtered.empty? @@ -77,7 +77,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool conversation = ::Conversation.find_by(id: conversation_id) File.open(Rails.root.join('log/faq_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] fallback_query: Fetching fresh conversation ID #{conversation_id}" + f.puts "[#{Time.zone.now}] fallback_query: Fetching fresh conversation ID #{conversation_id}" end latest_message = latest_non_greeting_message(conversation) @@ -94,20 +94,20 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool last_message = resolve_last_user_message(tool_context) if last_message.present? File.open(Rails.root.join('log/faq_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] resolve_query: Using state[:last_user_message] = '#{last_message}'" + f.puts "[#{Time.zone.now}] resolve_query: Using state[:last_user_message] = '#{last_message}'" end return last_message end # If query was passed explicitly and is not a greeting, use it if query.present? && !greeting_query?(query) - File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.now}] resolve_query: Using explicit query = '#{query}'" } + File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] resolve_query: Using explicit query = '#{query}'" } return query end # Fallback: get the most recent incoming message from conversation fallback = fallback_query(tool_context) - File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.now}] resolve_query: Using fallback = '#{fallback}'" } + File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] resolve_query: Using fallback = '#{fallback}'" } fallback end @@ -118,7 +118,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool candidate = Thread.current[:captain_last_user_message].to_s.strip if candidate.present? File.open(Rails.root.join('log/faq_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] resolve_last_user_message: Using thread-local last_user_message" + f.puts "[#{Time.zone.now}] resolve_last_user_message: Using thread-local last_user_message" end end end @@ -131,7 +131,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool def find_conversation_from_context(tool_context) state = resolve_context(tool_context) conversation_id = state.dig(:conversation, :id) - return nil unless conversation_id.present? + return nil if conversation_id.blank? ::Conversation.find_by(id: conversation_id) end @@ -147,12 +147,12 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool .map { |content| content.to_s.strip } File.open(Rails.root.join('log/faq_debug.log'), 'a') do |f| - f.puts "[#{Time.now}] latest_non_greeting_message: conv_id=#{conversation.id}, messages=#{messages.inspect}" + f.puts "[#{Time.zone.now}] latest_non_greeting_message: conv_id=#{conversation.id}, messages=#{messages.inspect}" end # Return the FIRST non-greeting message (which is the most recent due to desc order) result = messages.find { |content| content.present? && !greeting_query?(content) } - File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.now}] latest_non_greeting_message: selected='#{result}'" } + File.open(Rails.root.join('log/faq_debug.log'), 'a') { |f| f.puts "[#{Time.zone.now}] latest_non_greeting_message: selected='#{result}'" } result.to_s end diff --git a/enterprise/lib/captain/tools/list_reservations_tool.rb b/enterprise/lib/captain/tools/list_reservations_tool.rb index 5b04098..696ae84 100644 --- a/enterprise/lib/captain/tools/list_reservations_tool.rb +++ b/enterprise/lib/captain/tools/list_reservations_tool.rb @@ -28,7 +28,7 @@ class Captain::Tools::ListReservationsTool < Captain::Tools::BasePublicTool "ID #{reservation.id} - #{reservation.suite_identifier} - #{check_in} - #{unit_name} - status: #{status} - pagamento: #{payment}" end - "Reservas recentes:\n" + formatted.join("\n") + "Reservas recentes:\n#{formatted.join("\n")}" end private diff --git a/enterprise/lib/captain/tools/scenario_delegator_tool.rb b/enterprise/lib/captain/tools/scenario_delegator_tool.rb index 234ffcf..b882c75 100644 --- a/enterprise/lib/captain/tools/scenario_delegator_tool.rb +++ b/enterprise/lib/captain/tools/scenario_delegator_tool.rb @@ -1,375 +1,373 @@ # enterprise/lib/captain/tools/scenario_delegator_tool.rb -module Captain::Tools - class ScenarioDelegatorTool < Captain::Tools::BasePublicTool - attr_reader :scenario +class Captain::Tools::ScenarioDelegatorTool < Captain::Tools::BasePublicTool + attr_reader :scenario - def initialize(scenario, user: nil, conversation: nil) - @scenario = scenario - super(@scenario.assistant, user: user, conversation: conversation) + def initialize(scenario, user: nil, conversation: nil) + @scenario = scenario + super(@scenario.assistant, user: user, conversation: conversation) + 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: nil, **kwargs) + merged_args = kwargs.merge(pergunta_interna: pergunta_interna).with_indifferent_access + pergunta_interna = merged_args[:pergunta_interna] || merged_args['pergunta_interna'] + + if pergunta_interna.blank? + # Fallback: Se a IA chamou a ferramenta sem argumentos, tentamos pegar a última mensagem do usuário + last_message = @conversation&.messages&.incoming&.last&.content + pergunta_interna = last_message.presence || "Iniciar atendimento do cenário #{@scenario.title}" + Rails.logger.warn "[ScenarioDelegatorTool] Warning: 'pergunta_interna' is missing. Using fallback: '#{pergunta_interna}'" end - def name - "consultar_#{@scenario.title.parameterize.underscore}" - end + ensure_sticky_session! - def description - "Consulta o departamento especializado: #{@scenario.description}. Use esta ferramenta para obter informações ou realizar ações sobre este assunto." - end + # [FIX] Inject Context: The sub-agent needs previous messages to understand "Yes" or "I want that". + # We fetch the last 6 messages (excluding the very last valid user message if we just grabbed it) + reset_detected = false + if @conversation + recent_history = @conversation.messages.where(private: false).order(created_at: :desc).limit(10).reverse - 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: nil, **kwargs) - merged_args = kwargs.merge(pergunta_interna: pergunta_interna).with_indifferent_access - pergunta_interna = merged_args[:pergunta_interna] || merged_args['pergunta_interna'] - - if pergunta_interna.blank? - # Fallback: Se a IA chamou a ferramenta sem argumentos, tentamos pegar a última mensagem do usuário - last_message = @conversation&.messages&.incoming&.last&.content - pergunta_interna = last_message.presence || "Iniciar atendimento do cenário #{@scenario.title}" - Rails.logger.warn "[ScenarioDelegatorTool] Warning: 'pergunta_interna' is missing. Using fallback: '#{pergunta_interna}'" + # [RESET LOGIC] Truncate history if a reset command is found + reset_index = recent_history.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } + if reset_index + # Keep only messages AFTER the reset command + recent_history = recent_history[(reset_index + 1)..] || [] + reset_detected = true end - ensure_sticky_session! + # Format: "Rodrigo: Quero suite stilo\nJasmine: Disponivel, quer?\n..." + history_text = recent_history.map { |m| "#{m.sender&.name}: #{m.content}" }.join("\n") - # [FIX] Inject Context: The sub-agent needs previous messages to understand "Yes" or "I want that". - # We fetch the last 6 messages (excluding the very last valid user message if we just grabbed it) - reset_detected = false - if @conversation - recent_history = @conversation.messages.where(private: false).order(created_at: :desc).limit(10).reverse - - # [RESET LOGIC] Truncate history if a reset command is found - reset_index = recent_history.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) } - if reset_index - # Keep only messages AFTER the reset command - recent_history = recent_history[(reset_index + 1)..-1] || [] - reset_detected = true - end - - # Format: "Rodrigo: Quero suite stilo\nJasmine: Disponivel, quer?\n..." - history_text = recent_history.map { |m| "#{m.sender&.name}: #{m.content}" }.join("\n") - - # [CRITICAL] Always append the very last raw user message to ensure 'pergunta_interna' isn't just a vague summary. - last_user_msg = @conversation.messages.incoming.last&.content - if last_user_msg.present? && !history_text.include?(last_user_msg) - # Fallback mostly, as history_text likely has it. - history_text += "\nCliente (Última Mensagem): #{last_user_msg}" - end - - # [FORCE RESET] If reset detected in history, force clear session NOW to prevent ensure_sticky_session! from keeping old data - if reset_detected - Rails.logger.info '[ScenarioDelegatorTool] Reset detected in history. Forcing new session state.' - clear_sticky_session - @conversation.reload # Reload to ensure we have clean state - ensure_sticky_session! # Create fresh blank session - end - - contato_contexto = build_contact_context - # [CRITICAL] If reset detected, DO NOT inject previous sticky state like 'last_availability' - state_context = reset_detected ? '{}' : build_state_context(pergunta_interna) - - pergunta_interna = "Historico Recente:\n#{history_text}\n\n#{contato_contexto}\n\nEstado Atual (JSON):\n#{state_context}\n\nInstrucao/Pergunta da Jasmine: #{pergunta_interna}" - # Truncate to avoid context limit issues if massive - pergunta_interna = pergunta_interna.last(10_000) + # [CRITICAL] Always append the very last raw user message to ensure 'pergunta_interna' isn't just a vague summary. + last_user_msg = @conversation.messages.incoming.last&.content + if last_user_msg.present? && history_text.exclude?(last_user_msg) + # Fallback mostly, as history_text likely has it. + history_text += "\nCliente (Última Mensagem): #{last_user_msg}" end - # Instanciamos o agente do cenário, que já carrega suas próprias ferramentas (custom tools, etc) - agent = @scenario.agent(user: @user, conversation: @conversation) - - # 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] Contexto Injetado. Tamanho: #{pergunta_interna.length} chars" - 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. Success: #{!result.failed?}, Output: #{result.output.inspect}" - - if result.failed? || result.output.nil? - Rails.logger.info "[ScenarioDelegatorTool] Falha no sub-agente (#{@scenario.title}):" - # Agents::RunResult names: failed?, error, messages - Rails.logger.info " - Error Type: #{result.error&.class}" - Rails.logger.info " - Error Message: #{result.error}" - Rails.logger.info " - Last Messages: #{result.messages.last(3).map { |m| m.slice(:role, :content, :tool_calls) }.inspect}" - - fallback_response = attempt_fallback(pergunta_interna) - return fallback_response if fallback_response.present? - - return "O departamento #{@scenario.title} encontrou um erro: #{result.error || 'sem resposta clara'}." + # [FORCE RESET] If reset detected in history, force clear session NOW to prevent ensure_sticky_session! from keeping old data + if reset_detected + Rails.logger.info '[ScenarioDelegatorTool] Reset detected in history. Forcing new session state.' + clear_sticky_session + @conversation.reload # Reload to ensure we have clean state + ensure_sticky_session! # Create fresh blank session end - # Se o output for nulo ou vazio mas nao falhou (improvavel), garante resposta - final_output = result.output.is_a?(Hash) ? (result.output['response'] || result.output.to_s) : result.output.to_s - if final_output.blank? - Rails.logger.warn "[ScenarioDelegatorTool] Output em branco para #{@scenario.title}. Usando fallback de identificacao." - fallback_response = attempt_fallback(pergunta_interna) - return fallback_response if fallback_response.present? + contato_contexto = build_contact_context + # [CRITICAL] If reset detected, DO NOT inject previous sticky state like 'last_availability' + state_context = reset_detected ? '{}' : build_state_context(pergunta_interna) - return "O departamento #{@scenario.title} concluiu mas nao enviou resposta." + pergunta_interna = "Historico Recente:\n#{history_text}\n\n#{contato_contexto}\n\nEstado Atual (JSON):\n#{state_context}\n\nInstrucao/Pergunta da Jasmine: #{pergunta_interna}" + # Truncate to avoid context limit issues if massive + pergunta_interna = pergunta_interna.last(10_000) + end + + # Instanciamos o agente do cenário, que já carrega suas próprias ferramentas (custom tools, etc) + agent = @scenario.agent(user: @user, conversation: @conversation) + + # 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] Contexto Injetado. Tamanho: #{pergunta_interna.length} chars" + 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. Success: #{!result.failed?}, Output: #{result.output.inspect}" + + if result.failed? || result.output.nil? + Rails.logger.info "[ScenarioDelegatorTool] Falha no sub-agente (#{@scenario.title}):" + # Agents::RunResult names: failed?, error, messages + Rails.logger.info " - Error Type: #{result.error&.class}" + Rails.logger.info " - Error Message: #{result.error}" + Rails.logger.info " - Last Messages: #{result.messages.last(3).map { |m| m.slice(:role, :content, :tool_calls) }.inspect}" + + fallback_response = attempt_fallback(pergunta_interna) + return fallback_response if fallback_response.present? + + return "O departamento #{@scenario.title} encontrou um erro: #{result.error || 'sem resposta clara'}." + end + + # Se o output for nulo ou vazio mas nao falhou (improvavel), garante resposta + final_output = result.output.is_a?(Hash) ? (result.output['response'] || result.output.to_s) : result.output.to_s + if final_output.blank? + Rails.logger.warn "[ScenarioDelegatorTool] Output em branco para #{@scenario.title}. Usando fallback de identificacao." + fallback_response = attempt_fallback(pergunta_interna) + return fallback_response if fallback_response.present? + + return "O departamento #{@scenario.title} concluiu mas nao enviou resposta." + end + + update_sticky_session_state(final_output) + log_scenario_run(final_output) + clear_sticky_session if completion_signal?(final_output) + + final_output + + rescue StandardError => e + Rails.logger.error "[ScenarioDelegatorTool] ERRO CRÍTICO no sub-agente #{@scenario.title}: #{e.message}" + if e.respond_to?(:record) && e.record + Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Class: #{e.record.class.name}" + Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Errors: #{e.record.errors.full_messages.inspect}" + Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Attributes: #{e.record.attributes.inspect}" + end + Rails.logger.error "[ScenarioDelegatorTool] Backtrace:\n#{e.backtrace.first(15).join("\n")}" + "Erro técnico ao consultar o departamento #{@scenario.title}: #{e.message}" + end + + private + + def ensure_sticky_session! + return unless @conversation.respond_to?(:active_scenario_key) + + now = Time.current + expires_at = @conversation.active_scenario_expires_at + current_key = @conversation.active_scenario_key + scenario_key = name + + if current_key == scenario_key && expires_at.present? && expires_at >= now + @conversation.update!(active_scenario_expires_at: 15.minutes.from_now) + return + end + + @conversation.update!( + active_scenario_key: scenario_key, + active_scenario_expires_at: 15.minutes.from_now, + active_scenario_state: initial_sticky_state + ) + rescue StandardError => e + Rails.logger.warn "[ScenarioDelegatorTool] Failed to ensure sticky session: #{e.message}" + end + + def initial_sticky_state + contact = @conversation&.contact + { + 'stage' => 'initial', + 'collected' => { + 'name' => contact&.name.to_s.strip.presence, + 'cpf' => contact&.custom_attributes&.fetch('cpf', nil) + }.compact, + 'last_tool_results' => {}, + 'attempt_count' => 0, + 'started_at' => Time.current.iso8601 + } + end + + def build_state_context(pergunta_interna) + state = @conversation&.active_scenario_state || {} + contact = @conversation&.contact + + context_payload = { + 'pergunta_atual' => pergunta_interna.to_s, + 'stage_atual' => state['stage'] || 'initial', + 'dados_confirmados' => state['collected'] || {}, + 'contato' => { + 'nome' => contact&.name.to_s.strip.presence, + 'cpf' => contact&.custom_attributes&.fetch('cpf', nil), + 'telefone' => contact&.phone_number + }.compact + } + + JSON.generate(context_payload) + rescue StandardError + '{}' + end + + def update_sticky_session_state(final_output) + return unless @conversation.respond_to?(:active_scenario_state) + + state = @conversation.active_scenario_state || {} + updated = state.deep_merge( + 'last_output' => final_output.to_s.first(500), + 'updated_at' => Time.current.iso8601 + ) + + @conversation.update!( + active_scenario_state: updated, + active_scenario_expires_at: 15.minutes.from_now + ) + rescue StandardError => e + Rails.logger.warn "[ScenarioDelegatorTool] Failed to update sticky state: #{e.message}" + end + + def completion_signal?(text) + return false if text.blank? + + completion_patterns = [ + /pix (gerado|enviado)/i, + /reserva (confirmada|criada)/i, + /aguardo o pagamento/i, + /pagamento/i + ] + + completion_patterns.any? { |pattern| text.match?(pattern) } + end + + def clear_sticky_session + return unless @conversation.respond_to?(:active_scenario_key) + + @conversation.update!( + active_scenario_key: nil, + active_scenario_expires_at: nil, + active_scenario_state: {} + ) + rescue StandardError => e + Rails.logger.warn "[ScenarioDelegatorTool] Failed to clear sticky session: #{e.message}" + end + + def log_scenario_run(final_output) + payload = { + service: 'ScenarioDelegator', + scenario_key: name, + conversation_id: @conversation&.id, + account_id: @conversation&.account_id, + stage: @conversation&.active_scenario_state&.dig('stage'), + tools: @scenario&.tools, + output_length: final_output.to_s.length, + timestamp: Time.current.iso8601 + } + + Rails.logger.info payload.to_json + rescue StandardError => e + Rails.logger.warn "[ScenarioDelegatorTool] Failed to log scenario run: #{e.message}" + end + + def attempt_fallback(pergunta_interna) + return nil unless @conversation + + name, cpf = extract_name_and_cpf(pergunta_interna) + update_contact(name: name, cpf: cpf) if name.present? || cpf.present? + + pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last + charge = current_pix_charge_for(pending) if pending + + if charge&.pix_copia_e_cola.present? + if charge.expired? || charge.expired_by_time? + charge.update!(status: 'expired') unless charge.expired? + pix_msg = generate_pix + return pix_msg if pix_msg.present? + else + send_pix_message(charge.pix_copia_e_cola) + return 'Reenviei o Pix Copia e Cola para você concluir o pagamento.' end - - update_sticky_session_state(final_output) - log_scenario_run(final_output) - clear_sticky_session if completion_signal?(final_output) - - final_output - - rescue StandardError => e - Rails.logger.error "[ScenarioDelegatorTool] ERRO CRÍTICO no sub-agente #{@scenario.title}: #{e.message}" - if e.respond_to?(:record) && e.record - Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Class: #{e.record.class.name}" - Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Errors: #{e.record.errors.full_messages.inspect}" - Rails.logger.error "[ScenarioDelegatorTool] Invalid Record Attributes: #{e.record.attributes.inspect}" - end - Rails.logger.error "[ScenarioDelegatorTool] Backtrace:\n#{e.backtrace.first(15).join("\n")}" - "Erro técnico ao consultar o departamento #{@scenario.title}: #{e.message}" end - private - - def ensure_sticky_session! - return unless @conversation.respond_to?(:active_scenario_key) - - now = Time.current - expires_at = @conversation.active_scenario_expires_at - current_key = @conversation.active_scenario_key - scenario_key = name - - if current_key == scenario_key && expires_at.present? && expires_at >= now - @conversation.update!(active_scenario_expires_at: 15.minutes.from_now) - return - end - - @conversation.update!( - active_scenario_key: scenario_key, - active_scenario_expires_at: 15.minutes.from_now, - active_scenario_state: initial_sticky_state - ) - rescue StandardError => e - Rails.logger.warn "[ScenarioDelegatorTool] Failed to ensure sticky session: #{e.message}" - end - - def initial_sticky_state - contact = @conversation&.contact - { - 'stage' => 'initial', - 'collected' => { - 'name' => contact&.name.to_s.strip.presence, - 'cpf' => contact&.custom_attributes&.fetch('cpf', nil) - }.compact, - 'last_tool_results' => {}, - 'attempt_count' => 0, - 'started_at' => Time.current.iso8601 - } - end - - def build_state_context(pergunta_interna) - state = @conversation&.active_scenario_state || {} - contact = @conversation&.contact - - context_payload = { - 'pergunta_atual' => pergunta_interna.to_s, - 'stage_atual' => state['stage'] || 'initial', - 'dados_confirmados' => state['collected'] || {}, - 'contato' => { - 'nome' => contact&.name.to_s.strip.presence, - 'cpf' => contact&.custom_attributes&.fetch('cpf', nil), - 'telefone' => contact&.phone_number - }.compact - } - - JSON.generate(context_payload) - rescue StandardError - '{}' - end - - def update_sticky_session_state(final_output) - return unless @conversation.respond_to?(:active_scenario_state) - - state = @conversation.active_scenario_state || {} - updated = state.deep_merge( - 'last_output' => final_output.to_s.first(500), - 'updated_at' => Time.current.iso8601 - ) - - @conversation.update!( - active_scenario_state: updated, - active_scenario_expires_at: 15.minutes.from_now - ) - rescue StandardError => e - Rails.logger.warn "[ScenarioDelegatorTool] Failed to update sticky state: #{e.message}" - end - - def completion_signal?(text) - return false if text.blank? - - completion_patterns = [ - /pix (gerado|enviado)/i, - /reserva (confirmada|criada)/i, - /aguardo o pagamento/i, - /pagamento/i - ] - - completion_patterns.any? { |pattern| text.match?(pattern) } - end - - def clear_sticky_session - return unless @conversation.respond_to?(:active_scenario_key) - - @conversation.update!( - active_scenario_key: nil, - active_scenario_expires_at: nil, - active_scenario_state: {} - ) - rescue StandardError => e - Rails.logger.warn "[ScenarioDelegatorTool] Failed to clear sticky session: #{e.message}" - end - - def log_scenario_run(final_output) - payload = { - service: 'ScenarioDelegator', - scenario_key: name, - conversation_id: @conversation&.id, - account_id: @conversation&.account_id, - stage: @conversation&.active_scenario_state&.dig('stage'), - tools: @scenario&.tools, - output_length: final_output.to_s.length, - timestamp: Time.current.iso8601 - } - - Rails.logger.info payload.to_json - rescue StandardError => e - Rails.logger.warn "[ScenarioDelegatorTool] Failed to log scenario run: #{e.message}" - end - - def attempt_fallback(pergunta_interna) - return nil unless @conversation - - name, cpf = extract_name_and_cpf(pergunta_interna) - update_contact(name: name, cpf: cpf) if name.present? || cpf.present? - - pending = Captain::Reservation.where(conversation_id: @conversation.id, status: 'pending_payment').last - charge = current_pix_charge_for(pending) if pending - - if charge&.pix_copia_e_cola.present? - if charge.expired? || charge.expired_by_time? - charge.update!(status: 'expired') unless charge.expired? - pix_msg = generate_pix - return pix_msg if pix_msg.present? - else - send_pix_message(charge.pix_copia_e_cola) - return 'Reenviei o Pix Copia e Cola para você concluir o pagamento.' - end - end - + draft = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft').last + unless draft + create_msg = create_reservation_intent draft = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft').last - unless draft - create_msg = create_reservation_intent - draft = Captain::Reservation.where(conversation_id: @conversation.id, status: 'draft').last - return create_msg if draft.blank? && create_msg.present? - end - - return 'Para gerar o Pix, preciso do seu CPF.' if cpf.blank? - - pix_msg = generate_pix - return pix_msg[:formatted_message] if pix_msg.is_a?(Hash) && pix_msg[:formatted_message].present? - - pix_msg.is_a?(String) ? pix_msg : nil + return create_msg if draft.blank? && create_msg.present? end - def extract_name_and_cpf(text) - return [nil, nil] if text.blank? + return 'Para gerar o Pix, preciso do seu CPF.' if cpf.blank? - # 1. Extração de CPF (mantém a lógica atual que funciona) - cpf_match = text.match(/(\d{3}\.?\d{3}\.?\d{3}-?\d{2})/) - cpf = cpf_match ? cpf_match[1].gsub(/\D/, '') : nil + pix_msg = generate_pix + return pix_msg[:formatted_message] if pix_msg.is_a?(Hash) && pix_msg[:formatted_message].present? - # 2. Extração de Nome com Bloqueio de Lixo Técnico - lines = text.to_s.split("\n").map(&:strip).reject(&:blank?) + pix_msg.is_a?(String) ? pix_msg : nil + end - # Filtramos linhas que sabemos ser cabeçalhos injetados - blacklist_patterns = [ - 'Histórico da Conversa', - 'Instrução/Pergunta Atual', - 'Contexto do Contato', - 'Regra:', - 'DADO CONFIRMADO' - ] + def extract_name_and_cpf(text) + return [nil, nil] if text.blank? - # Busca a primeira linha que não seja um padrão técnico e não tenha ":" (que indica chave: valor ou cabeçalho) - candidate_line = lines.find do |line| - !blacklist_patterns.any? { |p| line.include?(p) } && !line.include?(':') - end + # 1. Extração de CPF (mantém a lógica atual que funciona) + cpf_match = text.match(/(\d{3}\.?\d{3}\.?\d{3}-?\d{2})/) + cpf = cpf_match ? cpf_match[1].gsub(/\D/, '') : nil - name = candidate_line&.strip + # 2. Extração de Nome com Bloqueio de Lixo Técnico + lines = text.to_s.split("\n").map(&:strip).reject(&:blank?) - # Validação final de sanidade para o nome - if name.present? && (name.length > 60 || name.match?(/[\[\]{}]/)) - # Um nome real não deve ser absurdamente longo e geralmente não tem caracteres especiais de sistema - name = nil - end + # Filtramos linhas que sabemos ser cabeçalhos injetados + blacklist_patterns = [ + 'Histórico da Conversa', + 'Instrução/Pergunta Atual', + 'Contexto do Contato', + 'Regra:', + 'DADO CONFIRMADO' + ] - [name.presence, cpf.presence] + # Busca a primeira linha que não seja um padrão técnico e não tenha ":" (que indica chave: valor ou cabeçalho) + candidate_line = lines.find do |line| + blacklist_patterns.none? { |p| line.include?(p) } && line.exclude?(':') end - def build_contact_context - return 'Contexto do Contato: sem dados.' unless @conversation&.contact + name = candidate_line&.strip - contact = @conversation.contact - name = contact.name.to_s.strip - cpf = contact.custom_attributes['cpf'].to_s.strip - - context_lines = ['Contexto do Contato:'] - context_lines << "nome: #{name.presence || 'desconhecido'}" - context_lines << "cpf: #{cpf.presence || 'nao informado'}" - context_lines << 'Regra: se nome e cpf ja existem, nao repetir a pergunta inicial de identificacao.' - context_lines.join("\n") + # Validação final de sanidade para o nome + if name.present? && (name.length > 60 || name.match?(/[\[\]{}]/)) + # Um nome real não deve ser absurdamente longo e geralmente não tem caracteres especiais de sistema + name = nil end - def update_contact(name:, cpf:) - tool = Captain::Tools::UpdateContactTool.new(@assistant, user: @user, conversation: @conversation) - tool.execute(nome: name, cpf: cpf) - rescue StandardError => e - Rails.logger.warn "[ScenarioDelegatorTool] Fallback update_contact failed: #{e.message}" - nil - end + [name.presence, cpf.presence] + end - def create_reservation_intent - tool = Captain::Tools::CreateReservationIntentTool.new(@assistant, user: @user, conversation: @conversation) - tool.execute({}) - rescue StandardError => e - Rails.logger.warn "[ScenarioDelegatorTool] Fallback create_reservation_intent failed: #{e.message}" - nil - end + def build_contact_context + return 'Contexto do Contato: sem dados.' unless @conversation&.contact - def generate_pix - tool = Captain::Tools::GeneratePixTool.new(@assistant, user: @user, conversation: @conversation) - tool.execute({}) - rescue StandardError => e - Rails.logger.warn "[ScenarioDelegatorTool] Fallback generate_pix failed: #{e.message}" - nil - end + contact = @conversation.contact + name = contact.name.to_s.strip + cpf = contact.custom_attributes['cpf'].to_s.strip - def send_pix_message(pix_code) - return if pix_code.blank? + context_lines = ['Contexto do Contato:'] + context_lines << "nome: #{name.presence || 'desconhecido'}" + context_lines << "cpf: #{cpf.presence || 'nao informado'}" + context_lines << 'Regra: se nome e cpf ja existem, nao repetir a pergunta inicial de identificacao.' + context_lines.join("\n") + end - @conversation.messages.create!( - content: pix_code, - message_type: :outgoing, - account: @conversation.account, - inbox: @conversation.inbox, - sender: @assistant - ) - end + def update_contact(name:, cpf:) + tool = Captain::Tools::UpdateContactTool.new(@assistant, user: @user, conversation: @conversation) + tool.execute(nome: name, cpf: cpf) + rescue StandardError => e + Rails.logger.warn "[ScenarioDelegatorTool] Fallback update_contact failed: #{e.message}" + nil + end - def current_pix_charge_for(reservation) - return nil unless reservation + def create_reservation_intent + tool = Captain::Tools::CreateReservationIntentTool.new(@assistant, user: @user, conversation: @conversation) + tool.execute({}) + rescue StandardError => e + Rails.logger.warn "[ScenarioDelegatorTool] Fallback create_reservation_intent failed: #{e.message}" + nil + end - return reservation.current_pix_charge if reservation.respond_to?(:current_pix_charge) + def generate_pix + tool = Captain::Tools::GeneratePixTool.new(@assistant, user: @user, conversation: @conversation) + tool.execute({}) + rescue StandardError => e + Rails.logger.warn "[ScenarioDelegatorTool] Fallback generate_pix failed: #{e.message}" + nil + end - Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first - end + def send_pix_message(pix_code) + return if pix_code.blank? + + @conversation.messages.create!( + content: pix_code, + message_type: :outgoing, + account: @conversation.account, + inbox: @conversation.inbox, + sender: @assistant + ) + end + + def current_pix_charge_for(reservation) + return nil unless reservation + + return reservation.current_pix_charge if reservation.respond_to?(:current_pix_charge) + + Captain::PixCharge.where(reservation_id: reservation.id).order(created_at: :desc).first end end diff --git a/interactive_jasmine.rb b/interactive_jasmine.rb index a025365..cdcdbca 100644 --- a/interactive_jasmine.rb +++ b/interactive_jasmine.rb @@ -2,13 +2,13 @@ assistant = Captain::Assistant.find_by(name: 'Jasmine (Hotel Prime)') unless assistant - puts "Erro: Jasmine não encontrada. Execute o seed primeiro." + puts 'Erro: Jasmine não encontrada. Execute o seed primeiro.' exit end -puts "==========================================================" -puts " JASMINE INTERATIVA - HOTEL 1001 NOITES PRIME " -puts "==========================================================" +puts '==========================================================' +puts ' JASMINE INTERATIVA - HOTEL 1001 NOITES PRIME ' +puts '==========================================================' puts "Digite sua mensagem (ou 'sair' para encerrar):" loop do @@ -16,15 +16,15 @@ loop do input = gets.chomp break if input.downcase == 'sair' - puts "..." - puts "(Jasmine está processando e digitando...)" - + 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 @@ -32,13 +32,13 @@ loop do target_delay = [target_delay, 7.0].min elapsed = Time.zone.now - start_time remaining = target_delay - elapsed - - sleep(remaining) if remaining > 0 + + sleep(remaining) if remaining.positive? 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 "----------------------------------------------------------" + puts '----------------------------------------------------------' end diff --git a/lib/tasks/captain_tool_keys.rake b/lib/tasks/captain_tool_keys.rake index 5479589..9d1bf6d 100644 --- a/lib/tasks/captain_tool_keys.rake +++ b/lib/tasks/captain_tool_keys.rake @@ -7,10 +7,10 @@ namespace :captain do only_in_code = code_tools - db_tools only_in_db = db_tools - code_tools - puts "Tools only in code (missing in DB configs):" + puts 'Tools only in code (missing in DB configs):' puts only_in_code.any? ? only_in_code.join("\n") : 'none' puts - puts "Tools only in DB configs (missing in code):" + puts 'Tools only in DB configs (missing in code):' puts only_in_db.any? ? only_in_db.join("\n") : 'none' end end diff --git a/lib/wuzapi/client.rb b/lib/wuzapi/client.rb index cb73382..36422c9 100644 --- a/lib/wuzapi/client.rb +++ b/lib/wuzapi/client.rb @@ -1,187 +1,185 @@ require 'net/http' require 'json' -module Wuzapi - class Client - class Error < StandardError; end - class AuthenticationError < Error; end - class ConnectionError < Error; end +class Wuzapi::Client + class Error < StandardError; end + class AuthenticationError < Error; end + class ConnectionError < Error; end - attr_reader :base_url + attr_reader :base_url - def initialize(base_url) - @base_url = normalize_url(base_url) + def initialize(base_url) + @base_url = normalize_url(base_url) + end + + # Admin Endpoints (Use Authorization header) + def create_user(admin_token, name, user_token) + payload = { name: name, token: user_token } + request(:post, '/admin/users', payload, admin_auth_headers(admin_token)) + end + + def delete_user(admin_token, user_id) + request(:delete, "/admin/users/#{user_id}", nil, admin_auth_headers(admin_token)) + end + + # User Endpoints (Use token header) + def send_text(user_token, phone_number, body, **options) + # Payload MUST be Case-Sensitive: Key 'Phone' and 'Body' + payload = { 'Phone' => phone_number, 'Body' => body }.merge(options) + request(:post, '/chat/send/text', payload, user_auth_headers(user_token)) + end + + def send_image(user_token, phone_number, base64_data, caption = nil) + payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Caption' => caption } + request(:post, '/chat/send/image', payload, user_auth_headers(user_token)) + end + + def send_file(user_token, phone_number, base64_data, filename) + payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Filename' => filename } + request(:post, '/chat/send/file', payload, user_auth_headers(user_token)) + end + + def send_reaction(user_token, phone_number, message_id, emoji) + payload = { 'Phone' => phone_number, 'Body' => emoji, 'Id' => message_id } + request(:post, '/chat/react', payload, user_auth_headers(user_token)) + end + + def send_chat_presence(user_token, phone_number, state, media = nil) + # State: "composing" or "paused" + # Media: "audio" (optional) + payload = { 'Phone' => phone_number, 'State' => state } + payload['Media'] = media if media + request(:post, '/chat/presence', payload, user_auth_headers(user_token)) + end + + def download_media(user_token, media_url) + # Some WuzAPI versions use a dedicated download endpoint to proxy Meta CDN + payload = { 'URL' => media_url } + request(:post, '/chat/downloadimage', payload, user_auth_headers(user_token)) + end + + def session_status(user_token) + request(:get, '/session/status', nil, user_auth_headers(user_token)) + end + + def get_qr_code(user_token) + request(:get, '/session/qr', nil, user_auth_headers(user_token)) + end + + def session_connect(user_token) + request(:post, '/session/connect', {}, user_auth_headers(user_token)) + end + + def session_disconnect(user_token) + request(:post, '/session/disconnect', nil, user_auth_headers(user_token)) + end + + def session_logout(user_token) + request(:get, '/session/logout', nil, user_auth_headers(user_token)) + end + + def set_webhook(user_token, webhook_url) + # Wuzapi expects PascalCase keys 'WebhookURL' and 'Events' with 'All' per user verification. + payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] } + request(:post, '/webhook', payload, user_auth_headers(user_token)) + end + + def update_webhook(user_token, webhook_url) + payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] } + request(:put, '/webhook', payload, user_auth_headers(user_token)) + end + + def get_webhook(user_token) + request(:get, '/webhook', nil, user_auth_headers(user_token)) + end + + private + + def normalize_url(url) + url.to_s.gsub(%r{/$}, '') + end + + def admin_auth_headers(token) + { 'Authorization' => token } + end + + def user_auth_headers(token) + { 'token' => token } + end + + def request(method, path, payload, headers) + uri = URI.parse("#{base_url}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + + if uri.scheme == 'https' + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE end - # Admin Endpoints (Use Authorization header) - def create_user(admin_token, name, user_token) - payload = { name: name, token: user_token } - request(:post, '/admin/users', payload, admin_auth_headers(admin_token)) + request_obj = case method + when :get + Net::HTTP::Get.new(uri.request_uri) + when :post + Net::HTTP::Post.new(uri.request_uri) + when :put + Net::HTTP::Put.new(uri.request_uri) + when :delete + Net::HTTP::Delete.new(uri.request_uri) + end + + # Common headers + request_obj['Content-Type'] = 'application/json' + request_obj['Accept'] = 'application/json' + + # Auth headers + headers.each { |k, v| request_obj[k] = v } + + request_obj.body = payload.to_json if payload + + begin + response = http.request(request_obj) + handle_response(response) + rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e + raise ConnectionError, "Could not connect to Wuzapi: #{e.message}" end + end - def delete_user(admin_token, user_id) - request(:delete, "/admin/users/#{user_id}", nil, admin_auth_headers(admin_token)) - end + def handle_response(response) + Rails.logger.info "WUZAPI RAW RESPONSE: status=#{response.code} ct=#{response['content-type']} body=#{response.body.to_s.truncate(1000)}" - # User Endpoints (Use token header) - def send_text(user_token, phone_number, body, **options) - # Payload MUST be Case-Sensitive: Key 'Phone' and 'Body' - payload = { 'Phone' => phone_number, 'Body' => body }.merge(options) - request(:post, '/chat/send/text', payload, user_auth_headers(user_token)) - end + if response.code.to_i >= 200 && response.code.to_i < 300 + content_type = response['content-type'] || '' - def send_image(user_token, phone_number, base64_data, caption = nil) - payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Caption' => caption } - request(:post, '/chat/send/image', payload, user_auth_headers(user_token)) - end - - def send_file(user_token, phone_number, base64_data, filename) - payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Filename' => filename } - request(:post, '/chat/send/file', payload, user_auth_headers(user_token)) - end - - def send_reaction(user_token, phone_number, message_id, emoji) - payload = { 'Phone' => phone_number, 'Body' => emoji, 'Id' => message_id } - request(:post, '/chat/react', payload, user_auth_headers(user_token)) - end - - def send_chat_presence(user_token, phone_number, state, media = nil) - # State: "composing" or "paused" - # Media: "audio" (optional) - payload = { 'Phone' => phone_number, 'State' => state } - payload['Media'] = media if media - request(:post, '/chat/presence', payload, user_auth_headers(user_token)) - end - - def download_media(user_token, media_url) - # Some WuzAPI versions use a dedicated download endpoint to proxy Meta CDN - payload = { 'URL' => media_url } - request(:post, '/chat/downloadimage', payload, user_auth_headers(user_token)) - end - - def session_status(user_token) - request(:get, '/session/status', nil, user_auth_headers(user_token)) - end - - def get_qr_code(user_token) - request(:get, '/session/qr', nil, user_auth_headers(user_token)) - end - - def session_connect(user_token) - request(:post, '/session/connect', {}, user_auth_headers(user_token)) - end - - def session_disconnect(user_token) - request(:post, '/session/disconnect', nil, user_auth_headers(user_token)) - end - - def session_logout(user_token) - request(:get, '/session/logout', nil, user_auth_headers(user_token)) - end - - def set_webhook(user_token, webhook_url) - # Wuzapi expects PascalCase keys 'WebhookURL' and 'Events' with 'All' per user verification. - payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] } - request(:post, '/webhook', payload, user_auth_headers(user_token)) - end - - def update_webhook(user_token, webhook_url) - payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] } - request(:put, '/webhook', payload, user_auth_headers(user_token)) - end - - def get_webhook(user_token) - request(:get, '/webhook', nil, user_auth_headers(user_token)) - end - - private - - def normalize_url(url) - url.to_s.gsub(%r{/$}, '') - end - - def admin_auth_headers(token) - { 'Authorization' => token } - end - - def user_auth_headers(token) - { 'token' => token } - end - - def request(method, path, payload, headers) - uri = URI.parse("#{base_url}#{path}") - http = Net::HTTP.new(uri.host, uri.port) - - if uri.scheme == 'https' - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_NONE + if content_type.include?('image/') + require 'base64' + base64_image = Base64.strict_encode64(response.body) + return { 'qrcode' => "data:#{content_type};base64,#{base64_image}" } end - request_obj = case method - when :get - Net::HTTP::Get.new(uri.request_uri) - when :post - Net::HTTP::Post.new(uri.request_uri) - when :put - Net::HTTP::Put.new(uri.request_uri) - when :delete - Net::HTTP::Delete.new(uri.request_uri) - end - - # Common headers - request_obj['Content-Type'] = 'application/json' - request_obj['Accept'] = 'application/json' - - # Auth headers - headers.each { |k, v| request_obj[k] = v } - - request_obj.body = payload.to_json if payload - begin - response = http.request(request_obj) - handle_response(response) - rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e - raise ConnectionError, "Could not connect to Wuzapi: #{e.message}" - end - end - - def handle_response(response) - Rails.logger.info "WUZAPI RAW RESPONSE: status=#{response.code} ct=#{response['content-type']} body=#{response.body.to_s.truncate(1000)}" - - if response.code.to_i >= 200 && response.code.to_i < 300 - content_type = response['content-type'] || '' - - if content_type.include?('image/') - require 'base64' - base64_image = Base64.strict_encode64(response.body) - return { 'qrcode' => "data:#{content_type};base64,#{base64_image}" } + body = JSON.parse(response.body) + # Normalize keys to 'qrcode' + # Check nested data object + if body['data'].is_a?(Hash) + found = body['data']['qrcode'] || body['data']['qr'] || body['data']['QRCode'] || body['data']['QR'] || body['data']['base64'] || body['data']['image'] + body['qrcode'] = found if found + # Check if data is the string itself + elsif body['data'].is_a?(String) && (body['data'].start_with?('data:') || body['data'].length > 50) + body['qrcode'] = body['data'] end - begin - body = JSON.parse(response.body) - # Normalize keys to 'qrcode' - # Check nested data object - if body['data'].is_a?(Hash) - found = body['data']['qrcode'] || body['data']['qr'] || body['data']['QRCode'] || body['data']['QR'] || body['data']['base64'] || body['data']['image'] - body['qrcode'] = found if found - # Check if data is the string itself - elsif body['data'].is_a?(String) && (body['data'].start_with?('data:') || body['data'].length > 50) - body['qrcode'] = body['data'] - end + # Check root keys if still not found + body['qrcode'] = body['qr'] || body['QRCode'] || body['QR'] || body['base64'] || body['image'] unless body['qrcode'] - # Check root keys if still not found - body['qrcode'] = body['qr'] || body['QRCode'] || body['QR'] || body['base64'] || body['image'] unless body['qrcode'] - - return body - rescue JSON::ParserError - Rails.logger.warn "Wuzapi response parse error or non-JSON: #{response.body}" - return { 'raw_body' => response.body } - end - elsif response.code.to_i == 401 || response.code.to_i == 403 - raise AuthenticationError, "Authentication failed: #{response.code} #{response.body}" - else - raise Error, "API Error: #{response.code} #{response.body}" + return body + rescue JSON::ParserError + Rails.logger.warn "Wuzapi response parse error or non-JSON: #{response.body}" + return { 'raw_body' => response.body } end + elsif response.code.to_i == 401 || response.code.to_i == 403 + raise AuthenticationError, "Authentication failed: #{response.code} #{response.body}" + else + raise Error, "API Error: #{response.code} #{response.body}" end end end diff --git a/local_test_ai.rb b/local_test_ai.rb index bd43741..87e398f 100644 --- a/local_test_ai.rb +++ b/local_test_ai.rb @@ -7,13 +7,13 @@ openai_key = ENV.fetch('OPENAI_API_KEY', nil) gemini_key = ENV.fetch('GEMINI_API_KEY', nil) if openai_key.present? - puts "✅ OPENAI_API_KEY found: #{openai_key[0..5]}...#{openai_key[-4..-1]}" + puts "✅ OPENAI_API_KEY found: #{openai_key[0..5]}...#{openai_key[-4..]}" else puts '❌ OPENAI_API_KEY NOT found' end if gemini_key.present? - puts "✅ GEMINI_API_KEY found: #{gemini_key[0..5]}...#{gemini_key[-4..-1]}" + puts "✅ GEMINI_API_KEY found: #{gemini_key[0..5]}...#{gemini_key[-4..]}" else puts '⚠️ GEMINI_API_KEY NOT found (Optional if using OpenAI)' end diff --git a/local_test_wuzapi.rb b/local_test_wuzapi.rb index d492419..77845a9 100644 --- a/local_test_wuzapi.rb +++ b/local_test_wuzapi.rb @@ -1,19 +1,17 @@ # local_test_wuzapi.rb # 1. Mock do Client para não fazer requisição real, apenas imprimir o que seria enviado -module Wuzapi - class Client - def request(method, path, payload, headers) - puts "\n--- [SIMULAÇÃO DE ENVIO] ---" - puts "Method: #{method.upcase}" - puts "Path: #{path}" - puts "Payload: #{JSON.pretty_generate(payload)}" - puts "Headers: #{headers}" - puts "--------------------------\n" +class Wuzapi::Client + def request(method, path, payload, headers) + puts "\n--- [SIMULAÇÃO DE ENVIO] ---" + puts "Method: #{method.upcase}" + puts "Path: #{path}" + puts "Payload: #{JSON.pretty_generate(payload)}" + puts "Headers: #{headers}" + puts "--------------------------\n" - # Retorna falso sucesso só para o script continuar - { 'success' => true } - end + # Retorna falso sucesso só para o script continuar + { 'success' => true } end end diff --git a/promote_super_admin.rb b/promote_super_admin.rb index 5401364..3072698 100644 --- a/promote_super_admin.rb +++ b/promote_super_admin.rb @@ -6,12 +6,12 @@ if user # Update to SuperAdmin # Using update_column to bypass validations if any, and direct SQL update is safer for type change sometimes user.update_column(:type, 'SuperAdmin') - puts "User promoted to SuperAdmin." - + puts 'User promoted to SuperAdmin.' + # Verify u_reload = User.find_by(email: email) puts "New type: #{u_reload.type}" puts "Is SuperAdmin class? #{u_reload.is_a?(SuperAdmin)}" else - puts "User not found!" + puts 'User not found!' end diff --git a/replay_job.rb b/replay_job.rb index baf28ae..51c236a 100644 --- a/replay_job.rb +++ b/replay_job.rb @@ -83,7 +83,7 @@ params = { } # Ensure logger prints to stdout -Rails.logger = Logger.new(STDOUT) +Rails.logger = Logger.new($stdout) puts '--- Execution Start ---' begin diff --git a/reproduction_search.rb b/reproduction_search.rb index 895c1f3..15ea028 100644 --- a/reproduction_search.rb +++ b/reproduction_search.rb @@ -8,7 +8,7 @@ puts "Assistant: #{assistant.name} (ID: #{assistant.id})" count = Captain::AssistantResponse.where(assistant_id: assistant.id).count puts "Total Responses for Assistant 1: #{count}" -if count > 0 +if count.positive? puts "\nSample Responses:" Captain::AssistantResponse.where(assistant_id: assistant.id).limit(5).each do |resp| puts "ID: #{resp.id}" @@ -23,7 +23,7 @@ else puts 'Checking ALL responses...' total_count = Captain::AssistantResponse.count puts "Total Responses in System: #{total_count}" - if total_count > 0 + if total_count.positive? first = Captain::AssistantResponse.first puts "First Response Assistant ID: #{first.assistant_id}" end diff --git a/script/generate_test_questions.rb b/script/generate_test_questions.rb index 6447309..f874745 100644 --- a/script/generate_test_questions.rb +++ b/script/generate_test_questions.rb @@ -5,7 +5,7 @@ unless account puts 'Nenhuma conta encontrada.' exit end -dates = [Date.today, Date.yesterday, 1.week.ago.to_date] +dates = [Time.zone.today, Date.yesterday, 1.week.ago.to_date] questions_data = [ { text: 'Aceita pagamento via PIX?', count: 45, label: 'duvida_valores' }, diff --git a/script/test_auto_resolve_inbox.rb b/script/test_auto_resolve_inbox.rb index 06699e9..42fa20c 100644 --- a/script/test_auto_resolve_inbox.rb +++ b/script/test_auto_resolve_inbox.rb @@ -2,7 +2,7 @@ require 'logger' # Setup logger -logger = Logger.new(STDOUT) +logger = Logger.new($stdout) logger.level = Logger::INFO logger.info '--- Testing Auto-Resolve by Inbox Logic ---' diff --git a/scripts/check_pending_embeddings.rb b/scripts/check_pending_embeddings.rb index 59452ac..3778c29 100644 --- a/scripts/check_pending_embeddings.rb +++ b/scripts/check_pending_embeddings.rb @@ -8,7 +8,7 @@ total_count = Captain::AssistantResponse.count puts "Total Assistant Responses: #{total_count}" puts "Pending Embeddings: #{pending_count}" -if pending_count > 0 +if pending_count.positive? puts "\n[!] Found #{pending_count} records without embeddings." puts ' This suggests Sidekiq might not be processing jobs.' diff --git a/scripts/test_real_pix_conversational.rb b/scripts/test_real_pix_conversational.rb index 4e440d9..72c6433 100644 --- a/scripts/test_real_pix_conversational.rb +++ b/scripts/test_real_pix_conversational.rb @@ -8,11 +8,11 @@ account = Account.first inbox = Inbox.find_by(name: 'Wuzapi') || Inbox.first assistant = Captain::Assistant.find_by(name: 'Jasmine') || Captain::Assistant.first # Force use of ENV key if assistant key is likely invalid/old -puts "ENV Key suffix: #{ENV['OPENAI_API_KEY'].to_s[-4..-1]}" +puts "ENV Key suffix: #{ENV['OPENAI_API_KEY'].to_s[-4..]}" assistant.api_key = ENV['OPENAI_API_KEY'] if ENV['OPENAI_API_KEY'].present? assistant.save(validate: false) -puts "Assistant Key (col) after: #{assistant.reload.api_key.to_s[-4..-1]}" +puts "Assistant Key (col) after: #{assistant.reload.api_key.to_s[-4..]}" unit = Captain::Unit.find_by(name: 'Unidade Ceilândia') || Captain::Unit.first @@ -74,7 +74,7 @@ rescue StandardError end service = Captain::Llm::AssistantChatService.new(assistant: assistant, conversation: conversation) -puts "Service API Key resolved to: #{service.send(:api_key).to_s[-4..-1]}" +puts "Service API Key resolved to: #{service.send(:api_key).to_s[-4..]}" response = service.generate_response(additional_message: msg1.content) diff --git a/scripts/test_whatsapp_notification.rb b/scripts/test_whatsapp_notification.rb index f9fe18a..8241605 100644 --- a/scripts/test_whatsapp_notification.rb +++ b/scripts/test_whatsapp_notification.rb @@ -61,7 +61,7 @@ puts '✅ Service Execution Completed.' puts '🔎 Checking for messages...' last_message = conversation.messages.last -if last_message && last_message.content.include?('confirmamos o pagamento') +if last_message&.content&.include?('confirmamos o pagamento') puts '✅ SUCCESS: Message created!' puts "📝 Content: #{last_message.content}" else diff --git a/seed_captain_tools.rb b/seed_captain_tools.rb index 647ac29..8fd18ee 100644 --- a/seed_captain_tools.rb +++ b/seed_captain_tools.rb @@ -38,9 +38,9 @@ inbox: inbox, tool_key: config[:tool_key] ) - + tool_config.assign_attributes(config) - + if tool_config.save puts "✅ Configured #{config[:tool_key]}" else @@ -49,4 +49,4 @@ end end -puts "Seed complete for all Inboxes!" +puts 'Seed complete for all Inboxes!' diff --git a/seed_jasmine_hotel.rb b/seed_jasmine_hotel.rb index df13000..260282a 100644 --- a/seed_jasmine_hotel.rb +++ b/seed_jasmine_hotel.rb @@ -2,7 +2,7 @@ # Objetivo: Configurar a Agente Jasmine e seus Sub-Agentes (Cenários) baseados no prompt do usuário. account = Account.first -user = account.users.first +account.users.first puts "Criando Assistente Jasmine para a conta: #{account.name}..." @@ -25,17 +25,17 @@ system_prompt_blocks = [ 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... @@ -81,13 +81,13 @@ Captain::Scenario.create!( 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 ) @@ -101,9 +101,9 @@ Captain::Scenario.create!( 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." @@ -118,9 +118,9 @@ Captain::Scenario.create!( 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:" @@ -128,5 +128,5 @@ Captain::Scenario.create!( TEXT ) -puts "Cenários (Daniela, Jamile, Maria) criados e vinculados à Jasmine." -puts "Configuração concluída. Teste no Console ou Playground!" +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 index 8b48543..ea40082 100644 --- a/seed_jasmine_hotel_v2.rb +++ b/seed_jasmine_hotel_v2.rb @@ -1,8 +1,8 @@ # 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 +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})..." @@ -25,17 +25,17 @@ Account.all.each do |account| 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... @@ -77,13 +77,13 @@ Account.all.each do |account| 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 ) @@ -97,9 +97,9 @@ Account.all.each do |account| 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." @@ -114,18 +114,18 @@ Account.all.each do |account| 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!" +puts 'Configuração concluída para todas as contas!' diff --git a/setup_docker_env.rb b/setup_docker_env.rb index 58a2f2a..fc8efdc 100644 --- a/setup_docker_env.rb +++ b/setup_docker_env.rb @@ -59,7 +59,7 @@ assistant ||= Captain::Assistant.create!( description: 'Gerente virtual', llm_provider: 'openai', llm_model: 'gpt-4o-mini', - api_key: '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', + api_key: ENV.fetch('OPENAI_API_KEY', 'sk-placeholder-key'), config: assistant_config )