Compare commits
5 Commits
chore/sync
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0105785b | ||
|
|
cbbfccaf42 | ||
|
|
572b9ccd10 | ||
|
|
358114d04d | ||
|
|
e94cadbdf6 |
@ -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,
|
||||
|
||||
@ -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'"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
|
||||
19
db/migrate/20260506183716_add_manual_pix_to_captain_units.rb
Normal file
19
db/migrate/20260506183716_add_manual_pix_to_captain_units.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
111
enterprise/app/services/captain/hermes/reply_context_builder.rb
Normal file
111
enterprise/app/services/captain/hermes/reply_context_builder.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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 só 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. SÓ 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
134
spec/enterprise/services/captain/hermes/client_spec.rb
Normal file
134
spec/enterprise/services/captain/hermes/client_spec.rb
Normal 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
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user