Compare commits

...

5 Commits

Author SHA1 Message Date
Rodribm10
fc0105785b
feat: allow FAQ management via knowledge-base role
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
Allow Captain FAQ management through the existing knowledge_base_manage custom role while keeping plain agents read-only for FAQ actions.
2026-06-10 13:45:20 -03:00
Rodribm10
cbbfccaf42 fix(captain): resolve Hermes quoted replies by internal id
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
2026-05-24 17:22:05 -03:00
Rodribm10
572b9ccd10 fix(captain): send WhatsApp reply context to Hermes 2026-05-24 16:51:28 -03:00
Rodribm10
358114d04d fix(captain): respect report date filters 2026-05-17 14:00:22 -03:00
Rodribm10
e94cadbdf6 feat(captain): pix_mode manual_static pra Padova e Express
Adiciona caminho paralelo de PIX manual estático pra unidades sem
integração Inter (Padova, Express AL). Mudança 100% aditiva — todas as
outras unidades continuam no fluxo Inter inalterado (default
pix_mode=inter_dynamic aplicado pela migration).

Backend (sem SOUL/SKILL ainda — Frente 7 vem depois):
- Migration concurrent: pix_mode + 4 campos manual_pix_* em captain_units;
  provider + manual_proof_payload + manual_review_reason em captain_pix_charges
- Captain::Unit: enum pix_mode (prefix), validação condicional manual_*
- Captain::PixCharge: status estendido (awaiting_proof, pending_review),
  scope manual/inter, nota interna ramificada por modo
- GeneratePixTool MCP: branch manual_static (chave fixa, mensagem direta
  sem QR/Inter), preserva fluxo Inter intacto
- 4 tools MCP novas: verificar_comprovante_pix (vision gpt-5.3-codex),
  criar_nota_interna (genérica), confirmar_reserva_pix_manual (wrapper
  do ConfirmationService), marcar_reserva_pendente
- ConfirmationService: source_label cobre 'manual_pix_proof'

Próximos passos manuais (não inclusos neste commit):
1. Rodar migration em prod (entrypoint não roda no boot)
2. Seed Padova/Express com pix_mode=manual_static + chaves Stone
3. Deploy nova imagem via docker service update
4. Editar SOUL/SKILL Padova/Express na VPS Hermes + kill+boot

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:01:01 -03:00
28 changed files with 1235 additions and 79 deletions

View File

@ -1,13 +1,6 @@
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
def index
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
scope = scope.where(captain_unit_id: unit_id) if unit_id
scope = scope.where(inbox_id: inbox_id) if inbox_id
insights = scope.order(period_start: :desc).limit(12)
insights = filtered_insights.order(period_start: :desc).limit(12)
render json: insights.map { |i| format_insight(i) }
end
@ -39,6 +32,22 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
private
def filtered_insights
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
scope = scope.where(captain_unit_id: filter_unit_id) if filter_unit_id
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope = scope.for_period(*requested_period) if requested_period
scope
end
def filter_unit_id
params[:unit_id].presence&.to_i
end
def filter_inbox_id
params[:inbox_id].presence&.to_i
end
def enqueue_insight(unit_id, inbox_id, period_start, period_end)
insight = find_or_init_insight(unit_id, inbox_id, period_start, period_end)
return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing?
@ -77,6 +86,14 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
default
end
def requested_period
return nil if params[:period_start].blank? || params[:period_end].blank?
[Date.parse(params[:period_start].to_s), Date.parse(params[:period_end].to_s)]
rescue ArgumentError, TypeError
nil
end
def format_insight(insight)
{
id: insight.id,

View File

@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
const props = defineProps({
id: {
@ -71,6 +72,7 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const modelValue = computed({
get: () => props.isSelected,
@ -142,7 +144,7 @@ const handleDocumentableClick = () => {
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
:permissions="responseManagePermissions"
class="relative flex items-center group"
>
<Button
@ -168,7 +170,7 @@ const handleDocumentableClick = () => {
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="['administrator']">
<Policy v-if="showActions" :permissions="responseManagePermissions">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"

View File

@ -6,6 +6,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { computed } from 'vue';
@ -28,6 +29,7 @@ const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const onClick = () => {
emit('click');
@ -56,7 +58,7 @@ const onClearFilters = () => {
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:action-perms="responseManagePermissions"
:show-backdrop="isApproved"
>
<template v-if="isApproved" #empty-state-item>

View File

@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Policy from 'dashboard/components/policy.vue';
const emit = defineEmits(['close', 'createAssistant']);
@ -105,14 +106,16 @@ const openCreateAssistantDialog = () => {
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
<Policy :permissions="['administrator']">
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</Policy>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button

View File

@ -1,5 +1,6 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
@ -31,6 +32,11 @@ const meta = {
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const knowledgeBaseMeta = {
...meta,
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
};
const metaV2 = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
@ -42,13 +48,13 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
name: 'captain_assistants_responses_index',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
component: DocumentsIndex,
name: 'captain_assistants_documents_index',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
@ -78,7 +84,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
@ -119,7 +125,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
component: AssistantsIndexPage,
name: 'captain_assistants_index',
meta,
meta: knowledgeBaseMeta,
},
];

View File

@ -6,6 +6,8 @@ import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@ -24,6 +26,7 @@ const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const { checkPermissions } = usePolicy();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
@ -38,6 +41,10 @@ const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const canManageResponses = computed(() =>
checkPermissions(responseManagePermissions)
);
const selectedAssistantId = computed(() => Number(route.params.assistantId));
@ -206,7 +213,7 @@ onMounted(() => {
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="['administrator']"
:button-policy="responseManagePermissions"
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
@ -247,6 +254,7 @@ onMounted(() => {
<template #subHeader>
<BulkSelectBar
v-if="canManageResponses"
v-model="bulkSelectedIds"
:all-items="responses"
:select-all-label="buildSelectedCountLabel"
@ -293,8 +301,11 @@ onMounted(() => {
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
:selectable="
canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:show-menu="canManageResponses && !bulkSelectedIds.has(response.id)"
:show-actions="false"
@action="handleAction"
@navigate="handleNavigationAction"

View File

@ -7,6 +7,8 @@ import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@ -25,6 +27,7 @@ const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const { checkPermissions } = usePolicy();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
@ -40,6 +43,10 @@ const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const canManageResponses = computed(() =>
checkPermissions(responseManagePermissions)
);
const backUrl = computed(() => ({
name: 'captain_assistants_responses_index',
@ -286,6 +293,7 @@ onMounted(() => {
<template #subHeader>
<BulkSelectBar
v-if="canManageResponses"
v-model="bulkSelectedIds"
:all-items="filteredResponses"
:select-all-label="buildSelectedCountLabel"
@ -338,9 +346,14 @@ onMounted(() => {
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:selectable="
canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:show-menu="false"
:show-actions="!bulkSelectedIds.has(response.id)"
:show-actions="
canManageResponses && !bulkSelectedIds.has(response.id)
"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"

View File

@ -253,12 +253,22 @@ const fetchLpStats = async () => {
let pollInterval = null;
const insightFilterParams = () => {
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
return {
...(selectedInboxId.value && { inbox_id: selectedInboxId.value }),
...(period_start && period_end && { period_start, period_end }),
};
};
const fetchInsightsForSelectedFilters = async () => {
await store.dispatch('captainReports/fetchInsights', insightFilterParams());
};
const startPolling = () => {
if (pollInterval) return;
pollInterval = setInterval(async () => {
await store.dispatch('captainReports/fetchInsights', {
inbox_id: selectedInboxId.value,
});
await fetchInsightsForSelectedFilters();
}, 10000);
};
@ -289,6 +299,7 @@ watch(activeTab, async tab => {
watch([customStartDate, customEndDate], async () => {
if (selectedPeriod.value !== 'custom') return;
if (!customStartDate.value || !customEndDate.value) return;
await fetchInsightsForSelectedFilters();
if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();
@ -308,7 +319,7 @@ watch(
onMounted(async () => {
await store.dispatch('inboxes/get');
await store.dispatch('captainAssistants/get');
await store.dispatch('captainReports/fetchInsights', {});
await fetchInsightsForSelectedFilters();
if (hasProcessingInsights.value) startPolling();
await fetchLpStats();
});
@ -320,9 +331,7 @@ onUnmounted(() => {
const onFilterChange = async event => {
const value = event.target.value;
selectedInboxId.value = value ? Number(value) : null;
await store.dispatch('captainReports/fetchInsights', {
inbox_id: selectedInboxId.value,
});
await fetchInsightsForSelectedFilters();
if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();
@ -330,6 +339,7 @@ const onFilterChange = async event => {
const onPeriodChange = async event => {
selectedPeriod.value = event.target.value;
await fetchInsightsForSelectedFilters();
if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();

View File

@ -0,0 +1,19 @@
class AddManualPixToCaptainUnits < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :captain_units, :pix_mode, :string, default: 'inter_dynamic', null: false
add_column :captain_units, :manual_pix_key, :string
add_column :captain_units, :manual_pix_key_type, :string
add_column :captain_units, :manual_pix_owner_name, :string
add_column :captain_units, :manual_pix_bank_name, :string
add_index :captain_units, :pix_mode, algorithm: :concurrently
add_column :captain_pix_charges, :provider, :string, default: 'inter', null: false
add_column :captain_pix_charges, :manual_proof_payload, :jsonb
add_column :captain_pix_charges, :manual_review_reason, :string
add_index :captain_pix_charges, :provider, algorithm: :concurrently
end
end

View File

@ -46,17 +46,21 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
account: account,
unit: unit,
inbox: inbox,
conversations: conversations
conversations: conversations,
period_start: period_start,
period_end: period_end
).analyze
insight.update!(messages_count: conversations.sum { |conv| conv.messages.count })
insight.update!(messages_count: messages_in_period(conversations, period_start, period_end).count)
insight.mark_done!(payload)
end
def fetch_conversations(account, unit, inbox, period_start, period_end)
scope = account.conversations
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
.includes(:messages)
.joins(:messages)
.where(messages: { created_at: period_start.beginning_of_day..period_end.end_of_day })
.where.not(messages: { message_type: [Message.message_types[:activity], Message.message_types[:template]] })
.where(messages: { private: false })
if inbox
scope = scope.where(inbox_id: inbox.id)
@ -65,6 +69,19 @@ class Captain::Reports::GenerateInsightsJob < ApplicationJob
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
end
scope.to_a
account.conversations
.where(id: scope.select(:id).distinct)
.includes(:inbox, :contact, :messages)
.to_a
end
def messages_in_period(conversations, period_start, period_end)
conversation_ids = conversations.map(&:id)
return Message.none if conversation_ids.empty?
Message.where(conversation_id: conversation_ids)
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
.where.not(message_type: %i[activity template])
.where(private: false)
end
end

View File

@ -36,11 +36,29 @@ class Captain::PixCharge < ApplicationRecord
belongs_to :reservation, class_name: 'Captain::Reservation'
belongs_to :unit, class_name: 'Captain::Unit'
enum status: { active: 'active', paid: 'paid', expired: 'expired', failed: 'failed' }
enum status: {
active: 'active',
paid: 'paid',
expired: 'expired',
failed: 'failed',
awaiting_proof: 'awaiting_proof',
pending_review: 'pending_review'
}
validates :txid, presence: true, uniqueness: true
validates :unit_id, presence: true
scope :manual, -> { where(provider: 'manual') }
scope :inter, -> { where(provider: 'inter') }
def manual?
provider.to_s == 'manual'
end
def inter?
provider.to_s == 'inter'
end
after_create_commit :post_internal_pix_sent_note
after_create_commit :enqueue_retention_recalc
after_update_commit :enqueue_retention_recalc_on_status_change
@ -64,16 +82,7 @@ class Captain::PixCharge < ApplicationRecord
conversation = reservation&.conversation
return if conversation.blank?
value = original_value.to_f
expires_fmt = expires_at&.strftime('%d/%m/%Y %H:%M') || '—'
content = [
'💸 *PIX enviado ao cliente* — aguardando pagamento',
"Valor: R$ #{format('%.2f', value)}",
"Txid: #{txid}",
"Expira em: #{expires_fmt}",
"Reserva ##{reservation_id}"
].join("\n")
content = manual? ? manual_pix_note_content : inter_pix_note_content
Messages::MessageBuilder.new(
nil,
@ -110,6 +119,34 @@ class Captain::PixCharge < ApplicationRecord
return val.to_f if val.present?
end
if manual?
deposit = reservation&.metadata.to_h['deposit_amount']
return deposit.to_f if deposit.present?
end
reservation&.total_amount
end
private
def manual_pix_note_content
[
'💸 *PIX MANUAL enviado ao cliente* — aguardando comprovante',
"Valor: R$ #{format('%.2f', original_value.to_f)}",
"Chave: #{unit&.manual_pix_key} (#{unit&.manual_pix_bank_name})",
"Beneficiário esperado: #{unit&.manual_pix_owner_name}",
"Reserva ##{reservation_id}"
].join("\n")
end
def inter_pix_note_content
expires_fmt = expires_at&.strftime('%d/%m/%Y %H:%M') || '—'
[
'💸 *PIX enviado ao cliente* — aguardando pagamento',
"Valor: R$ #{format('%.2f', original_value.to_f)}",
"Txid: #{txid}",
"Expira em: #{expires_fmt}",
"Reserva ##{reservation_id}"
].join("\n")
end
end

View File

@ -74,9 +74,14 @@ class Captain::Unit < ApplicationRecord
encrypts :inter_key_content
enum status: { active: 'active', inactive: 'inactive' }, _default: 'active'
enum pix_mode: { inter_dynamic: 'inter_dynamic', manual_static: 'manual_static' }, _default: 'inter_dynamic', _prefix: true
MANUAL_PIX_KEY_TYPES = %w[cpf cnpj email phone random].freeze
validates :name, presence: true
validates :manual_pix_key_type, inclusion: { in: MANUAL_PIX_KEY_TYPES }, allow_nil: true
validate :proactive_pix_polling_requires_inter_credentials
validate :manual_static_requires_manual_pix_fields
after_commit :enqueue_supabase_provisioning, on: :create
@ -104,6 +109,13 @@ class Captain::Unit < ApplicationRecord
(inter_key_content.present? || resolved_inter_key_path.present?)
end
def manual_pix_configured?
pix_mode_manual_static? &&
manual_pix_key.present? &&
manual_pix_owner_name.present? &&
manual_pix_bank_name.present?
end
def resolved_inter_cert_path
resolve_certificate_path(inter_cert_path)
end
@ -128,6 +140,14 @@ class Captain::Unit < ApplicationRecord
)
end
def manual_static_requires_manual_pix_fields
return unless pix_mode_manual_static?
%i[manual_pix_key manual_pix_owner_name manual_pix_bank_name].each do |field|
errors.add(field, 'é obrigatório quando pix_mode = manual_static') if public_send(field).blank?
end
end
# Resolve o path do certificado — suporta caminho absoluto, relativo ao Rails.root
# ou nome de arquivo simples dentro de storage/certs/.
def resolve_certificate_path(path)

View File

@ -24,10 +24,6 @@ class Captain::AssistantResponsePolicy < ApplicationPolicy
def manage?
return true if @account_user.administrator?
if @account_user.custom_role.present?
return @account_user.custom_role.permissions.include?('knowledge_base_manage')
end
@account_user.agent?
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || false
end
end

View File

@ -71,10 +71,13 @@ class Captain::Hermes::Client
contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access
cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '')
history = contact_history_snapshot(contact, conversation)
reply_context_builder = Captain::Hermes::ReplyContextBuilder.new(message: message, conversation: conversation)
reply_context = reply_context_builder.perform
{
message: content_override.presence || text_for_hermes(message),
message: reply_context_builder.wrap_message(content_override.presence || text_for_hermes(message)),
image_urls: image_urls_for_hermes(message),
reply_context: reply_context,
contact_name: contact&.name,
contact_first_name: contact&.name.to_s.split.first,
contact_id: conversation.contact_id,

View File

@ -0,0 +1,111 @@
class Captain::Hermes::ReplyContextBuilder
def initialize(message:, conversation:)
@message = message
@conversation = conversation
end
def perform
return nil if reply_reference.blank?
{
external_id: reply_to_external_id,
message_id: reply_to_message_id,
found: quoted_message.present?,
quoted_message: quoted_message_snapshot
}.compact
end
def wrap_message(current_text)
return current_text if reply_context.blank?
"#{formatted_reply_context}\n\n[RESPOSTA ATUAL DO CLIENTE]\n#{current_text}"
end
private
attr_reader :message, :conversation
def reply_context
@reply_context ||= perform
end
def formatted_reply_context
return missing_reply_context unless reply_context[:found]
quoted = reply_context[:quoted_message]
quoted_content = quoted[:content].presence || quoted[:attachment_summary].presence || '[mensagem sem texto]'
<<~TEXT.strip
[CONTEXTO DE RESPOSTA DO WHATSAPP]
O cliente respondeu citando uma mensagem anterior.
Interprete a resposta atual como referência direta a essa mensagem citada.
Se a resposta atual usar termos como "esse valor", "desse valor", "essa", "esse" ou "isso",
resolva a referência usando a mensagem citada antes do restante do histórico.
Mensagem citada (#{quoted[:sender_label]}, #{quoted[:created_at]}): #{quoted_content}
TEXT
end
def missing_reply_context
<<~TEXT.strip
[CONTEXTO DE RESPOSTA DO WHATSAPP]
O cliente respondeu citando uma mensagem anterior, mas o Chatwoot não encontrou o conteúdo da mensagem citada.
Referência citada: #{reply_reference}
TEXT
end
def reply_reference
reply_to_external_id.presence || reply_to_message_id.presence
end
def reply_to_external_id
@reply_to_external_id ||= message.in_reply_to_external_id.presence ||
message.content_attributes.to_h['in_reply_to_external_id'].presence ||
message.content_attributes.to_h[:in_reply_to_external_id].presence
end
def reply_to_message_id
@reply_to_message_id ||= message.in_reply_to_id.presence ||
message.content_attributes.to_h['in_reply_to'].presence ||
message.content_attributes.to_h[:in_reply_to].presence
end
def quoted_message
@quoted_message ||= begin
found_by_id = conversation.messages.find_by(id: reply_to_message_id) if reply_to_message_id.present?
found_by_id || conversation.messages.find_by(source_id: reply_to_external_id)
end
end
def quoted_message_snapshot
return nil if quoted_message.blank?
{
id: quoted_message.id,
external_id: quoted_message.source_id,
message_type: quoted_message.message_type,
sender_label: sender_label,
sender_name: quoted_message.sender&.available_name,
content: quoted_message_content,
attachment_summary: attachment_summary,
created_at: quoted_message.created_at&.iso8601
}.compact
end
def sender_label
return 'cliente' if quoted_message.incoming?
return 'atendente/Hermes' if quoted_message.outgoing?
'sistema'
end
def quoted_message_content
quoted_message.content.to_s.truncate(1200)
end
def attachment_summary
return nil if quoted_message.attachments.blank?
types = quoted_message.attachments.filter_map(&:file_type)
"anexos: #{types.join(', ')}"
end
end

View File

@ -3,12 +3,14 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
MAX_CHARS_PER_CHUNK = 40_000
def initialize(account:, conversations:, unit: nil, inbox: nil)
def initialize(account:, conversations:, unit: nil, inbox: nil, period_start: nil, period_end: nil)
super()
@account = account
@unit = unit
@inbox = inbox
@conversations = conversations
@period_start = period_start
@period_end = period_end
end
# Analisa as conversas e retorna o payload de insights
@ -24,10 +26,10 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
private
attr_reader :account, :unit, :inbox, :conversations
attr_reader :account, :unit, :inbox, :conversations, :period_start, :period_end
def build_chunks
texts = conversations.map(&:to_llm_text).reject(&:blank?)
texts = conversations.map { |conversation| conversation_text(conversation) }.reject(&:blank?)
return [] if texts.empty?
chunks = []
@ -48,6 +50,38 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
chunks
end
def conversation_text(conversation)
return conversation.to_llm_text unless period_start && period_end
messages = conversation.messages
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
.where.not(message_type: %i[activity template])
.where(private: false)
.order(created_at: :asc)
return nil if messages.empty?
[
"Conversation ID: ##{conversation.display_id}",
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
messages.map { |message| format_message(message) }.join
].join("\n")
end
def format_message(message)
sender = case message.sender_type
when 'User'
'Support Agent'
when 'Contact'
'User'
else
'Bot'
end
"#{sender}: #{message.content_for_llm}\n"
end
def analyze_chunk(chunk)
response = instrument_llm_call(instrumentation_params) do
chat

View File

@ -19,6 +19,11 @@ class Captain::Mcp::ToolRegistry
Captain::Mcp::Tools::RescheduleReservationTool,
Captain::Mcp::Tools::ReactToMessageTool,
Captain::Mcp::Tools::CheckSuiteAvailabilityTool,
# PIX manual estático (Padova, Express AL) — fluxo paralelo ao Inter
Captain::Mcp::Tools::VerifyPixProofTool,
Captain::Mcp::Tools::CreateInternalNoteTool,
Captain::Mcp::Tools::ConfirmPixManualTool,
Captain::Mcp::Tools::MarkReservationPendingTool,
# Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes
Captain::Mcp::Tools::ListAssistantsTool,
Captain::Mcp::Tools::GetAssistantPricingTool,

View File

@ -0,0 +1,90 @@
# Tool MCP: confirma reserva via PIX manual (após validação de comprovante).
#
# Caso de uso: fluxo PIX manual (Padova, Express AL). Comprovante já foi
# validado pela tool verificar_comprovante_pix com verdict='ok'. Esta tool
# marca a charge como paga, persiste o payload extraído e dispara
# Captain::Payments::ConfirmationService — que cuida de marcar reserva
# paid+active, atualizar labels (pagamento_confirmado/reserva_feita),
# postar nota interna automática, disparar oferta de roleta e notificar
# Hermes proativamente. Mesmo trânsito da confirmação Inter.
#
# Pré-requisito: charge.provider='manual' E charge.manual_proof_payload
# com verdict='ok'. Tool é idempotente — chamada repetida em charge já
# paga retorna sucesso sem efeito colateral.
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
class Captain::Mcp::Tools::ConfirmPixManualTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'confirmar_reserva_pix_manual'
end
def description
'Confirma reserva PIX manual após comprovante validado (verdict=ok). Use SOMENTE depois de ' \
'verificar_comprovante_pix retornar ok. Marca PIX como pago e dispara o trânsito padrão de ' \
'confirmação (mensagem ao cliente, labels, roleta). NÃO use sem ter validado comprovante antes.'
end
def input_schema
{
type: 'object',
properties: {
pix_charge_id: {
type: 'integer',
description: 'ID da Captain::PixCharge (provider=manual). Obrigatório.'
}
},
required: ['pix_charge_id']
}
end
end
def call(args, _context:)
charge = Captain::PixCharge.find_by(id: args['pix_charge_id'])
return error_response('PixCharge não encontrada.') if charge.blank?
return error_response("PixCharge ##{charge.id} não é manual (provider=#{charge.provider}). Use o fluxo Inter normal.") unless charge.manual?
return text_response("PIX manual ##{charge.id} já estava confirmado (idempotente). Reserva ##{charge.reservation_id} ativa.") if charge.paid?
payload = charge.manual_proof_payload || {}
return error_response("PixCharge ##{charge.id} não tem comprovante validado. Chame verificar_comprovante_pix antes.") if payload.blank?
unless payload['verdict'] == 'ok'
return error_response("Comprovante não passou na validação (verdict=#{payload['verdict']}). Use marcar_reserva_pendente.")
end
reservation = charge.reservation
return error_response('PixCharge sem reserva vinculada — não consigo confirmar.') if reservation.blank?
mark_charge_paid!(charge, payload)
fire_confirmation!(reservation, payload)
text_response(
"Reserva ##{reservation.id} confirmada via PIX manual. PIX ##{charge.id} marcado como pago. " \
'Mensagem de confirmação será enviada ao cliente automaticamente.'
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::ConfirmPixManualTool] error: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
error_response("Erro ao confirmar reserva manual: #{e.message}")
end
private
def mark_charge_paid!(charge, payload)
extracted = payload['extracted'].to_h
charge.update!(
status: 'paid',
paid_at: Time.current,
e2eid: extracted['id_transacao'].presence || charge.e2eid
)
end
def fire_confirmation!(reservation, payload)
Captain::Payments::ConfirmationService.new(
reservation: reservation,
source: 'manual_pix_proof',
payload: payload,
actor: nil
).perform
end
end
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize

View File

@ -0,0 +1,68 @@
# Tool MCP: cria nota interna (privada) numa conversa.
#
# Caso de uso primário: fluxo PIX manual — após verificar_comprovante_pix,
# Hermes registra análise pra humano via nota interna antes de
# confirmar/marcar pendente. Genérica e reaproveitável: qualquer fluxo
# Hermes pode publicar nota interna pra deixar trilha pro time humano.
#
# Visibilidade: a nota é private=true (só atendentes veem; cliente não).
class Captain::Mcp::Tools::CreateInternalNoteTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'criar_nota_interna'
end
def description
'Cria nota interna (privada) na conversa. Use pra registrar análise/contexto pro time humano ' \
'sem mandar mensagem visível pro cliente. Use sempre antes de handoffs importantes ou pra logar ' \
'verificações automáticas (ex: validação de comprovante PIX manual).'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
content: {
type: 'string',
description: 'Conteúdo da nota. Pode ter markdown simples (negrito, listas, quebras de linha).'
}
},
required: %w[conversation_id content]
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
content = args['content'].to_s.strip
return error_response('Conteúdo da nota vazio.') if content.blank?
Messages::MessageBuilder.new(
nil,
conversation,
{ content: content, message_type: 'outgoing', private: true }
).perform
text_response("Nota interna criada na conversa ##{conversation.display_id}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::CreateInternalNoteTool] error: #{e.class}: #{e.message}")
error_response("Erro ao criar nota interna: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
end

View File

@ -80,6 +80,12 @@ class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
unit = resolve_unit(conversation, context)
return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank?
# Modo PIX manual estático (Padova, Express AL): sem integração Inter,
# sem QR/copia-cola dinâmico, sem fallback de página de reserva.
# Hermes apresenta a chave PIX fixa da unidade e o cliente envia
# comprovante pra validação por vision.
return dispatch_manual_pix_flow!(conversation, unit, args) if unit.pix_mode_manual_static?
# Sem credencial Inter: vai DIRETO pro fallback de página de reserva ao
# invés de retornar erro pro LLM (que ele ia transformar em "vou
# verificar" e travar). Cliente recebe link da página oficial pra
@ -322,6 +328,81 @@ class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
conversation.update_labels(merged)
end
# Fluxo PIX manual: unidade tem chave PIX estática (Padova, Express AL).
# Sem Inter, sem QR, sem fallback. Apresenta chave + nome do beneficiário
# pro cliente; aguarda comprovante (que será validado via vision pela
# tool verificar_comprovante_pix).
def dispatch_manual_pix_flow!(conversation, unit, args)
contact = conversation.contact
hydrate_contact_from_recent_messages!(contact, conversation)
missing = identity_missing_fields(contact)
return error_response("Faltam dados do cliente pra gerar Pix: #{missing.join(', ')}. Peça ao cliente antes de chamar esta tool.") if missing.any?
pricing = Captain::Mcp::PricingTables.calculate(
unit_id: unit.id,
suite_category: args['suite_category'],
period: args['period'],
total_guests: (args['total_guests'] || 2).to_i
)
if pricing[:error].present?
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] manual pricing inválido: #{pricing[:error]}")
return error_response("Não consegui calcular o valor: #{pricing[:error]}. Confirma a categoria/permanência com o cliente.")
end
total_amount = pricing[:amount]
deposit = (total_amount * DEFAULT_DEPOSIT_RATIO).round(2)
reservation = build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit)
charge = Captain::PixCharge.create!(
reservation: reservation,
unit: unit,
provider: 'manual',
status: 'awaiting_proof',
txid: "manual_#{SecureRandom.uuid}"
)
reservation.update!(status: :pending_payment)
dispatch_manual_pix_message(conversation, unit, deposit)
mark_awaiting_payment(conversation)
label_manual_pix(conversation)
deposit_str = format('%.2f', deposit)
total_str = format('%.2f', total_amount)
breakdown = "#{pricing[:breakdown][:suite_category]} / #{pricing[:breakdown][:period]}"
text_response(
"Pix MANUAL enviado: chave #{unit.manual_pix_key} (#{unit.manual_pix_bank_name}) — " \
"sinal R$ #{deposit_str} (50% de R$ #{total_str}#{breakdown}). " \
"Charge ##{charge.id}. Cliente vai mandar comprovante por imagem — quando chegar, " \
"chame verificar_comprovante_pix(image_url, pix_charge_id=#{charge.id})."
)
end
def dispatch_manual_pix_message(conversation, unit, deposit)
body = [
'Pode fazer o Pix:',
'',
"🔑 Chave: #{unit.manual_pix_key}",
"🏦 Banco: #{unit.manual_pix_bank_name}",
"💰 Valor: R$ #{format('%.2f', deposit)}",
"👤 Nome que aparece: *#{unit.manual_pix_owner_name}*",
'',
'Quando pagar, me manda o comprovante por aqui que eu confirmo.'
].join("\n")
Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to dispatch manual pix message: #{e.class} - #{e.message}")
end
def label_manual_pix(conversation)
current = conversation.label_list
conversation.update_labels((current + ['pix_manual']).uniq)
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to label manual pix: #{e.class} - #{e.message}")
end
# Fallback "leve" pra cenários onde Pix nem foi tentado (categoria não
# existe na unit, período inválido, sem credencial Inter cadastrada).
# Sem reservation/pricing/valores — só monta link com o que tem do

View File

@ -0,0 +1,83 @@
# Tool MCP: marca reserva PIX manual como PENDENTE de revisão humana.
#
# Caso de uso: fluxo PIX manual (Padova, Express AL). Comprovante foi
# validado pela tool verificar_comprovante_pix com verdict='duvida' OU
# Hermes/atendente julgou necessário escalar mesmo com ok. NÃO confirma
# a reserva — humano precisa olhar o comprovante e decidir.
#
# Efeitos:
# - PixCharge.status='pending_review' + persiste motivo
# - Conversa ganha labels: revisao_humana_pix + comprovante_recebido
# - NÃO chama ConfirmationService (cliente NÃO recebe mensagem de
# confirmação automática até humano resolver)
class Captain::Mcp::Tools::MarkReservationPendingTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'marcar_reserva_pendente'
end
def description
'Marca PIX manual como PENDENTE de revisão humana. Use quando verificar_comprovante_pix ' \
"retornar verdict='duvida' (valor não bate, data antiga, beneficiário diferente, suspeitas " \
'na imagem). NÃO confirma a reserva — humano precisa olhar antes. Cliente NÃO recebe ' \
'mensagem automática de confirmação.'
end
def input_schema
{
type: 'object',
properties: {
pix_charge_id: {
type: 'integer',
description: 'ID da Captain::PixCharge (provider=manual). Obrigatório.'
},
motivo: {
type: 'string',
description: 'Motivo curto (uma linha) pra deixar claro pro humano o que deu errado. ' \
'Ex: "valor R$ 10 a menos", "comprovante de ontem", "beneficiário não bate".'
}
},
required: %w[pix_charge_id motivo]
}
end
end
def call(args, _context:)
charge = Captain::PixCharge.find_by(id: args['pix_charge_id'])
return error_response('PixCharge não encontrada.') if charge.blank?
return error_response("PixCharge ##{charge.id} não é manual.") unless charge.manual?
motivo = args['motivo'].to_s.strip
return error_response('Motivo é obrigatório.') if motivo.blank?
mark_charge_pending!(charge, motivo)
label_pending_review(charge.reservation&.conversation)
text_response(
"PIX manual ##{charge.id} marcado como pendente de revisão. Motivo: #{motivo}. " \
"Reserva ##{charge.reservation_id} aguarda humano. Cliente NÃO foi notificado automaticamente."
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::MarkReservationPendingTool] error: #{e.class}: #{e.message}")
error_response("Erro ao marcar reserva pendente: #{e.message}")
end
private
def mark_charge_pending!(charge, motivo)
charge.update!(
status: 'pending_review',
manual_review_reason: motivo
)
end
def label_pending_review(conversation)
return if conversation.blank?
current = conversation.label_list
merged = (current + %w[revisao_humana_pix comprovante_recebido]).uniq - %w[pagamento_confirmado reserva_feita]
conversation.update_labels(merged)
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::MarkReservationPendingTool] label failed: #{e.class} - #{e.message}")
end
end

View File

@ -0,0 +1,291 @@
# Tool MCP: valida comprovante PIX (modo manual estático).
#
# Caso de uso: unidade opera em pix_mode='manual_static' (Padova, Express).
# Cliente recebeu chave PIX fixa, pagou, e enviou comprovante (imagem).
# Esta tool extrai dados via vision (gpt-5.3-codex multimodal), compara com
# o esperado (valor exato, data ≤24h, beneficiário/chave/banco fuzzy match
# com Captain::Unit.manual_pix_*) e retorna verdict pro Hermes.
#
# Verdicts:
# - ok → tudo bate, chamar confirmar_reserva_pix_manual
# - duvida → algo não bate, chamar marcar_reserva_pendente
# - nao_eh_comprovante → imagem não é comprovante PIX, pedir reenvio
#
# Hermes, ANTES de chamar esta tool, deve responder ao cliente:
# "⏳ Só um momento, vou verificar."
# Essa frase aciona handoff humano automaticamente (label triagem_humana),
# de modo que humano sempre acompanhe o resultado da validação.
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
class Captain::Mcp::Tools::VerifyPixProofTool < Captain::Mcp::Tools::BaseTool
PROOF_FRESHNESS_HOURS = 24
VALUE_TOLERANCE = 0.0 # zero — valor exato
class << self
def name
'verificar_comprovante_pix'
end
def description
'Valida comprovante PIX (modo manual). Use SOMENTE quando cliente enviar IMAGEM ' \
'de comprovante numa conversa que tem PIX manual ativo (provider=manual). Extrai dados ' \
'via vision e compara com a cobrança esperada. Retorna ok / duvida / nao_eh_comprovante. ' \
'ANTES de chamar, RESPONDA ao cliente "⏳ Só um momento, vou verificar." pra acionar handoff humano.'
end
def input_schema
{
type: 'object',
properties: {
image_url: {
type: 'string',
description: 'URL pública da imagem do comprovante (vinda do anexo da mensagem incoming).'
},
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
pix_charge_id: {
type: 'integer',
description: 'Opcional. ID da Captain::PixCharge associada. Se vazio, usa a charge manual mais recente da conversa.'
}
},
required: %w[image_url conversation_id]
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
charge = resolve_charge(conversation, args['pix_charge_id'])
return error_response('Não há PIX manual aguardando comprovante nesta conversa. Confirme com o cliente o que foi enviado.') if charge.blank?
unit = charge.unit
return error_response('PixCharge sem unidade vinculada — não consigo validar o beneficiário esperado.') if unit.blank?
image_url = args['image_url'].to_s.strip
return error_response('image_url vazio — passe a URL da imagem do comprovante.') if image_url.blank?
extracted = extract_proof_via_vision(image_url)
return text_response_for_verdict(charge, 'nao_eh_comprovante', extracted: extracted, mismatches: ['eh_comprovante_pix=false']) unless extracted['eh_comprovante_pix']
mismatches = compare_proof(extracted, charge, unit)
verdict = mismatches.empty? ? 'ok' : 'duvida'
text_response_for_verdict(charge, verdict, extracted: extracted, mismatches: mismatches)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::VerifyPixProofTool] error: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
error_response("Erro ao validar comprovante: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def resolve_charge(conversation, charge_id)
if charge_id.present?
charge = Captain::PixCharge.find_by(id: charge_id)
return charge if charge&.manual?
end
Captain::PixCharge.manual.joins(:reservation)
.where(captain_reservations: { conversation_id: conversation.id })
.where(status: %w[awaiting_proof active])
.order(created_at: :desc).first
end
def extract_proof_via_vision(image_url)
parts = [
{ type: 'text', text: vision_prompt },
{ type: 'image_url', image_url: { url: image_url } }
]
content = RubyLLM::Content::Raw.new(parts)
raw = RubyLLM.chat(model: vision_model)
.with_temperature(0)
.with_params(response_format: { type: 'json_object' })
.ask(content)
.content.to_s
JSON.parse(raw)
rescue JSON::ParserError => e
Rails.logger.warn("[Captain::Mcp::VerifyPixProofTool] JSON parse falhou: #{e.message} — raw=#{raw&.first(200)}")
{ 'eh_comprovante_pix' => false, 'parse_error' => true }
end
def vision_model
InstallationConfig.find_by(name: 'CAPTAIN_VISION_MODEL')&.value.presence ||
ENV.fetch('CAPTAIN_VISION_MODEL', 'gpt-5.3-codex')
end
def vision_prompt
<<~PROMPT
Você analisa comprovantes de PIX. Receba a imagem e extraia os dados em JSON ESTRITO (sem markdown, sem texto fora do JSON).
Schema obrigatório:
{
"eh_comprovante_pix": boolean, // true se a imagem é claramente um comprovante de PIX (transferência), false se é qualquer outra coisa (selfie, foto de chave, screenshot de chat, comprovante de outro tipo, etc).
"valor": number | null, // valor transferido em reais (ex: 90.00). Sem cifrão, sem espaços, com ponto decimal.
"data_hora_iso": string | null, // data e hora da transação em ISO 8601 com timezone Brasília (-03:00) ou UTC. Ex: "2026-05-06T14:30:00-03:00". Se a imagem mostrar data sem hora, use 12:00:00.
"beneficiario_nome": string | null, // nome do destinatário (quem recebeu). Pode ser nome de empresa, CPF/CNPJ formatado, etc. Texto literal extraído.
"beneficiario_chave": string | null, // chave PIX do destinatário se aparecer (CPF, CNPJ, email, telefone, ou chave aleatória UUID). Pode estar em qualquer formato. Texto literal.
"banco_destino": string | null, // banco destinatário (ex: "Stone", "Itaú", "Inter"). Texto literal.
"id_transacao": string | null, // ID/E2E/autenticação da transação (qualquer identificador único da transação que aparecer).
"remetente_nome": string | null, // nome de quem PAGOU (origem). Pode ajudar humano a auditar.
"suspeitas": [string] // lista vazia ou avisos: "imagem_borrada", "edicao_aparente", "fonte_inconsistente", "screenshot_de_screenshot", "valor_ilegivel", etc. liste suspeitas REAIS, não invente.
}
Regras:
- Retorne APENAS o JSON. Sem prefixo, sem sufixo, sem ```json.
- Se algum campo não estiver na imagem, use null (não invente).
- Para valor: sempre número (90.00, não "R$ 90,00").
- Se a imagem não for claramente um comprovante PIX, eh_comprovante_pix=false e os outros campos podem ser null.
PROMPT
end
def compare_proof(extracted, charge, unit)
mismatches = []
expected_value = charge.original_value.to_f
actual_value = extracted['valor'].to_f
if expected_value <= 0
mismatches << 'valor_esperado_indisponivel'
elsif (actual_value - expected_value).abs > VALUE_TOLERANCE
mismatches << "valor_divergente (esperado=R$ #{format('%.2f', expected_value)}, comprovante=R$ #{format('%.2f', actual_value)})"
end
if extracted['data_hora_iso'].blank?
mismatches << 'data_ausente_no_comprovante'
else
parsed = parse_proof_time(extracted['data_hora_iso'])
if parsed.nil?
mismatches << "data_invalida (#{extracted['data_hora_iso']})"
elsif parsed > 1.hour.from_now
mismatches << "data_no_futuro (#{extracted['data_hora_iso']})"
elsif parsed < PROOF_FRESHNESS_HOURS.hours.ago
mismatches << "data_antiga (#{extracted['data_hora_iso']}, > #{PROOF_FRESHNESS_HOURS}h)"
end
end
expected_owner = unit.manual_pix_owner_name.to_s
actual_owner = extracted['beneficiario_nome'].to_s
mismatches << "beneficiario_divergente (esperado='#{expected_owner}', comprovante='#{actual_owner}')" unless name_matches?(expected_owner, actual_owner)
expected_key = normalize_pix_key(unit.manual_pix_key)
actual_key = normalize_pix_key(extracted['beneficiario_chave'])
if expected_key.present? && actual_key.present? && !key_matches?(expected_key, actual_key)
mismatches << "chave_divergente (esperada=#{unit.manual_pix_key}, comprovante=#{extracted['beneficiario_chave']})"
end
expected_bank = unit.manual_pix_bank_name.to_s.downcase
actual_bank = extracted['banco_destino'].to_s.downcase
if expected_bank.present? && actual_bank.present? && !bank_matches?(expected_bank, actual_bank)
mismatches << "banco_divergente (esperado='#{unit.manual_pix_bank_name}', comprovante='#{extracted['banco_destino']}')"
end
suspeitas = Array(extracted['suspeitas']).reject(&:blank?)
mismatches << "suspeitas_vision: #{suspeitas.join(', ')}" if suspeitas.any?
mismatches
end
# Match flexível pra nome do beneficiário: case-insensitive, sem
# acentos, ignora pontuação e múltiplos espaços. Considera match se
# uma string contém a outra OU se compartilham >= 70% das palavras
# significativas (>2 chars).
def name_matches?(expected, actual)
return false if expected.blank? || actual.blank?
e = normalize_text(expected)
a = normalize_text(actual)
return true if e == a
return true if a.include?(e) || e.include?(a)
e_words = e.split.select { |w| w.length > 2 }
a_words = a.split.select { |w| w.length > 2 }
return false if e_words.empty?
matched = e_words.count { |w| a_words.any? { |aw| aw.include?(w) || w.include?(aw) } }
(matched.to_f / e_words.size) >= 0.7
end
def key_matches?(expected, actual)
return true if expected == actual
expected.include?(actual) || actual.include?(expected)
end
def bank_matches?(expected, actual)
actual.include?(expected) || expected.include?(actual)
end
def normalize_pix_key(key)
key.to_s.downcase.gsub(/[^\w@.+-]/, '')
end
def normalize_text(text)
text.to_s.unicode_normalize(:nfd).gsub(/\p{Mn}/, '')
.downcase.gsub(/[^a-z0-9\s]/, ' ').squish
end
def parse_proof_time(raw)
Time.iso8601(raw)
rescue ArgumentError, TypeError
begin
Time.zone.parse(raw)
rescue ArgumentError
nil
end
end
def text_response_for_verdict(charge, verdict, extracted:, mismatches:)
payload = {
verdict: verdict,
charge_id: charge.id,
reservation_id: charge.reservation_id,
expected: {
valor: charge.original_value.to_f,
beneficiario: charge.unit.manual_pix_owner_name,
chave: charge.unit.manual_pix_key,
banco: charge.unit.manual_pix_bank_name
},
extracted: extracted.slice('valor', 'data_hora_iso', 'beneficiario_nome', 'beneficiario_chave', 'banco_destino', 'id_transacao', 'remetente_nome'),
mismatches: mismatches
}
persist_extraction!(charge, extracted, mismatches, verdict)
text_response("VERIFICACAO_COMPROVANTE\n#{JSON.pretty_generate(payload)}\n\n" \
"Próximo passo:\n" \
"- ok → criar_nota_interna(...) + confirmar_reserva_pix_manual(pix_charge_id=#{charge.id})\n" \
"- duvida → criar_nota_interna(...) + marcar_reserva_pendente(pix_charge_id=#{charge.id}, motivo=...)\n" \
'- nao_eh_comprovante → peça novamente o comprovante real (sem handoff, sem nota interna).')
end
def persist_extraction!(charge, extracted, mismatches, verdict)
payload = {
'verdict' => verdict,
'extracted' => extracted,
'mismatches' => mismatches,
'verified_at' => Time.current.iso8601
}
# rubocop:disable Rails/SkipsModelValidations
# Skip de validação proposital — payload é JSON livre, não tem
# validação no model. Update direto evita disparar callbacks (ex:
# post_internal_pix_sent_note iria postar nota duplicada).
charge.update_columns(manual_proof_payload: payload)
# rubocop:enable Rails/SkipsModelValidations
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::VerifyPixProofTool] persist failed: #{e.class} - #{e.message}")
end
end
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength

View File

@ -66,6 +66,7 @@ class Captain::Payments::ConfirmationService
when 'payment_callback' then 'callback de pagamento'
when 'inter_cob_query_polling' then 'consulta periódica no Inter'
when 'inter_cob_query' then 'consulta manual no Inter'
when 'manual_pix_proof' then 'comprovante PIX manual validado'
else
'integração de pagamento'
end

View File

@ -180,16 +180,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:answer]).to eq('Test answer')
end
it 'creates a new response if the user is an agent' do
it 'does not create a new response if the user is an agent without knowledge base permission' do
expect do
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(1)
end.not_to change(Captain::AssistantResponse, :count)
expect(response).to have_http_status(:success)
expect(json_response[:question]).to eq('Test question?')
expect(response).to have_http_status(:forbidden)
end
it 'creates a new response if the user has a custom role with knowledge base permission' do
@ -254,7 +253,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
@ -281,12 +279,25 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
expect(json_response[:answer]).to eq('Updated answer')
end
it 'updates the response if the user is an agent' do
it 'does not update the response if the user is an agent without knowledge base permission' do
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
params: update_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:forbidden)
expect(response_record.reload.question).not_to eq('Updated question?')
end
it 'updates the response if the user has a custom role with knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['knowledge_base_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
params: update_params,
headers: agent_with_custom_role.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:question]).to eq('Updated question?')
end
@ -315,11 +326,24 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
describe 'DELETE /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
let!(:response_record) { create(:captain_assistant_response, assistant: assistant) }
it 'deletes the response' do
it 'does not delete the response if the user is an agent without knowledge base permission' do
expect do
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: agent.create_new_auth_token,
as: :json
end.not_to change(Captain::AssistantResponse, :count)
expect(response).to have_http_status(:forbidden)
end
it 'deletes the response if the user has a custom role with knowledge base permission' do
custom_role = create(:custom_role, account: account, permissions: ['knowledge_base_manage'])
AccountUser.find_by!(account: account, user: agent_with_custom_role).update!(custom_role: custom_role)
expect do
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: agent_with_custom_role.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(-1)
expect(response).to have_http_status(:no_content)

View File

@ -30,19 +30,16 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
}
end
it 'approves the responses and returns the updated records' do
it 'does not approve the responses if the user is an agent without knowledge base permission' do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response).to be_an(Array)
expect(json_response.length).to eq(2)
expect(response).to have_http_status(:forbidden)
# Verify responses were approved
pending_responses.each do |response|
expect(response.reload.status).to eq('approved')
expect(response.reload.status).to eq('pending')
end
end
end
@ -56,20 +53,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
}
end
it 'deletes the responses and returns an empty array' do
it 'does not delete the responses if the user is an agent without knowledge base permission' do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: delete_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(-2)
end.not_to change(Captain::AssistantResponse, :count)
expect(response).to have_http_status(:ok)
expect(json_response).to eq([])
expect(response).to have_http_status(:forbidden)
# Verify responses were deleted
pending_responses.each do |response|
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(response.reload.status).to eq('pending')
end
end
end

View File

@ -0,0 +1,53 @@
require 'rails_helper'
RSpec.describe Captain::Reports::GenerateInsightsJob do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:period_start) { Date.new(2026, 5, 17) }
let(:period_end) { Date.new(2026, 5, 17) }
let(:payload) do
{
'top_topics' => [],
'ai_failures' => [],
'faq_gaps' => [],
'sentiment' => { 'positive_count' => 0, 'negative_count' => 0, 'neutral_count' => 0, 'summary' => '' },
'highlights' => { 'praises' => [], 'complaints' => [] },
'most_requested_suites' => [],
'price_reactions' => { 'summary' => '', 'objections_count' => 0 },
'customer_opportunities' => [],
'recommendations' => [],
'period_summary' => 'Resumo do dia.'
}
end
it 'analyzes conversations with messages in the requested period and counts only period messages' do
conversation_with_today_messages = create(:conversation, account: account, inbox: inbox, created_at: 3.days.ago)
create(:message, account: account, inbox: inbox, conversation: conversation_with_today_messages, content: 'mensagem antiga',
created_at: period_start.prev_day.noon)
create(:message, account: account, inbox: inbox, conversation: conversation_with_today_messages, content: 'mensagem hoje',
created_at: period_start.noon)
create(:message, account: account, inbox: inbox, conversation: conversation_with_today_messages, content: 'resposta hoje',
message_type: 'outgoing', created_at: period_start.noon + 5.minutes)
conversation_without_today_messages = create(:conversation, account: account, inbox: inbox, created_at: period_start.noon)
create(:message, account: account, inbox: inbox, conversation: conversation_without_today_messages, content: 'fora do periodo',
created_at: period_start.prev_day.noon)
service = instance_double(Captain::Llm::ConversationInsightService, analyze: payload)
expect(Captain::Llm::ConversationInsightService).to receive(:new)
.with(hash_including(conversations: [conversation_with_today_messages], period_start: period_start, period_end: period_end))
.and_return(service)
described_class.perform_now(account.id, nil, period_start, period_end, inbox.id)
insight = Captain::ConversationInsight.find_by!(
account_id: account.id,
inbox_id: inbox.id,
period_start: period_start,
period_end: period_end
)
expect(insight.conversations_count).to eq(1)
expect(insight.messages_count).to eq(2)
expect(insight).to be_done
end
end

View File

@ -0,0 +1,134 @@
require 'rails_helper'
RSpec.describe Captain::Hermes::Client do
describe '#build_payload' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account, name: 'Cliente Teste') }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
let(:conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
end
let(:client) { described_class.new(inbox) }
it 'includes quoted WhatsApp message context in the Hermes-visible message' do
quoted_message = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :outgoing,
content: 'A suite Alexa esta disponivel por R$ 199.',
source_id: 'wamid.quoted-message'
)
reply = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
content: 'Pode reservar essa',
source_id: 'wamid.reply-message',
content_attributes: { in_reply_to_external_id: quoted_message.source_id }
)
payload = client.send(:build_payload, message: reply, conversation: conversation)
expect(payload[:reply_context]).to include(
external_id: quoted_message.source_id,
found: true
)
expect(payload[:reply_context][:quoted_message]).to include(
id: quoted_message.id,
message_type: 'outgoing',
sender_label: 'atendente/Hermes',
content: 'A suite Alexa esta disponivel por R$ 199.'
)
expect(payload[:message]).to include('[CONTEXTO DE RESPOSTA DO WHATSAPP]')
expect(payload[:message]).to include('A suite Alexa esta disponivel por R$ 199.')
expect(payload[:message]).to include('[RESPOSTA ATUAL DO CLIENTE]')
expect(payload[:message]).to include('Pode reservar essa')
end
it 'includes quoted context when Chatwoot stores the reply as an internal message id' do
quoted_message = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :outgoing,
content: "Stilo hoje fica assim:\n1h R$ 50\nValor pra ate 2 pessoas.",
source_id: 'WAID:quoted-stilo'
)
reply = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
content: 'Quero uma 1 hora na suite desse valor',
source_id: 'WAID:reply-stilo',
in_reply_to_id: quoted_message.id
)
payload = client.send(:build_payload, message: reply, conversation: conversation)
expect(payload[:reply_context]).to include(
message_id: quoted_message.id,
external_id: quoted_message.source_id,
found: true
)
expect(payload[:message]).to include('Stilo hoje fica assim')
expect(payload[:message]).to include('1h R$ 50')
expect(payload[:message]).to include('resolva a referência usando a mensagem citada')
expect(payload[:message]).to include('Quero uma 1 hora na suite desse valor')
end
it 'keeps the combined incoming text while adding quote context' do
quoted_message = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :outgoing,
content: 'Temos opcoes com hidro e garagem privativa.',
source_id: 'wamid.quoted-options'
)
reply = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
content: 'Essa',
content_attributes: { in_reply_to_external_id: quoted_message.source_id }
)
payload = client.send(
:build_payload,
message: reply,
conversation: conversation,
content_override: "Quero ver as suites\nEssa"
)
expect(payload[:message]).to include('Temos opcoes com hidro e garagem privativa.')
expect(payload[:message]).to include("Quero ver as suites\nEssa")
end
it 'does not add reply context when the message is not a WhatsApp reply' do
message = create(
:message,
account: account,
inbox: inbox,
conversation: conversation,
message_type: :incoming,
content: 'Oi, tem suite disponivel?'
)
payload = client.send(:build_payload, message: message, conversation: conversation)
expect(payload[:reply_context]).to be_nil
expect(payload[:message]).to eq('Oi, tem suite disponivel?')
end
end
end

View File

@ -0,0 +1,30 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ConversationInsightService do
describe '#build_chunks' do
it 'formats only messages inside the requested period' do
account = create(:account)
inbox = create(:inbox, account: account)
period_start = Date.new(2026, 5, 17)
conversation = create(:conversation, account: account, inbox: inbox)
create(:message, account: account, inbox: inbox, conversation: conversation, content: 'mensagem antiga',
created_at: period_start.prev_day.noon)
create(:message, account: account, inbox: inbox, conversation: conversation, content: 'mensagem do periodo',
created_at: period_start.noon)
service = described_class.new(
account: account,
inbox: inbox,
conversations: [conversation],
period_start: period_start,
period_end: period_start
)
text = service.send(:build_chunks).join("\n")
expect(text).to include('mensagem do periodo')
expect(text).not_to include('mensagem antiga')
end
end
end