Compare commits
7 Commits
feat/capta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0105785b | ||
|
|
cbbfccaf42 | ||
|
|
572b9ccd10 | ||
|
|
358114d04d | ||
|
|
e94cadbdf6 | ||
|
|
c7d5bbff99 | ||
|
|
f6a96f9d4e |
@ -1,13 +1,6 @@
|
|||||||
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
|
||||||
def index
|
def index
|
||||||
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
|
insights = filtered_insights.order(period_start: :desc).limit(12)
|
||||||
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)
|
|
||||||
|
|
||||||
render json: insights.map { |i| format_insight(i) }
|
render json: insights.map { |i| format_insight(i) }
|
||||||
end
|
end
|
||||||
@ -39,6 +32,22 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
|
|||||||
|
|
||||||
private
|
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)
|
def enqueue_insight(unit_id, inbox_id, period_start, period_end)
|
||||||
insight = find_or_init_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?
|
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
|
default
|
||||||
end
|
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)
|
def format_insight(insight)
|
||||||
{
|
{
|
||||||
id: insight.id,
|
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 Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
@ -71,6 +72,7 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||||
|
|
||||||
const modelValue = computed({
|
const modelValue = computed({
|
||||||
get: () => props.isSelected,
|
get: () => props.isSelected,
|
||||||
@ -142,7 +144,7 @@ const handleDocumentableClick = () => {
|
|||||||
<div v-if="!compact && showMenu" class="flex items-center gap-2">
|
<div v-if="!compact && showMenu" class="flex items-center gap-2">
|
||||||
<Policy
|
<Policy
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
:permissions="['administrator']"
|
:permissions="responseManagePermissions"
|
||||||
class="relative flex items-center group"
|
class="relative flex items-center group"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@ -168,7 +170,7 @@ const handleDocumentableClick = () => {
|
|||||||
v-if="!compact"
|
v-if="!compact"
|
||||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
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">
|
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||||
<Button
|
<Button
|
||||||
v-if="status === 'pending'"
|
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 ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||||
|
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||||
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ const isPending = computed(() => props.variant === 'pending');
|
|||||||
|
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
const { replaceInstallationName } = useBranding();
|
const { replaceInstallationName } = useBranding();
|
||||||
|
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
emit('click');
|
emit('click');
|
||||||
@ -56,7 +58,7 @@ const onClearFilters = () => {
|
|||||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||||
"
|
"
|
||||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||||
:action-perms="['administrator']"
|
:action-perms="responseManagePermissions"
|
||||||
:show-backdrop="isApproved"
|
:show-backdrop="isApproved"
|
||||||
>
|
>
|
||||||
<template v-if="isApproved" #empty-state-item>
|
<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 Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'createAssistant']);
|
const emit = defineEmits(['close', 'createAssistant']);
|
||||||
|
|
||||||
@ -105,14 +106,16 @@ const openCreateAssistantDialog = () => {
|
|||||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Policy :permissions="['administrator']">
|
||||||
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
<Button
|
||||||
color="slate"
|
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
||||||
icon="i-lucide-plus"
|
color="slate"
|
||||||
size="sm"
|
icon="i-lucide-plus"
|
||||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
size="sm"
|
||||||
@click="openCreateAssistantDialog"
|
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||||
/>
|
@click="openCreateAssistantDialog"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||||
|
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
|
||||||
import { frontendURL } from '../../../helper/URLHelper';
|
import { frontendURL } from '../../../helper/URLHelper';
|
||||||
|
|
||||||
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||||
@ -31,6 +32,11 @@ const meta = {
|
|||||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const knowledgeBaseMeta = {
|
||||||
|
...meta,
|
||||||
|
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
|
||||||
|
};
|
||||||
|
|
||||||
const metaV2 = {
|
const metaV2 = {
|
||||||
permissions: ['administrator', 'agent'],
|
permissions: ['administrator', 'agent'],
|
||||||
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
|
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
|
||||||
@ -42,13 +48,13 @@ const assistantRoutes = [
|
|||||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
|
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
|
||||||
component: ResponsesIndex,
|
component: ResponsesIndex,
|
||||||
name: 'captain_assistants_responses_index',
|
name: 'captain_assistants_responses_index',
|
||||||
meta,
|
meta: knowledgeBaseMeta,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
|
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
|
||||||
component: DocumentsIndex,
|
component: DocumentsIndex,
|
||||||
name: 'captain_assistants_documents_index',
|
name: 'captain_assistants_documents_index',
|
||||||
meta,
|
meta: knowledgeBaseMeta,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||||
@ -78,7 +84,7 @@ const assistantRoutes = [
|
|||||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
|
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
|
||||||
component: ResponsesPendingIndex,
|
component: ResponsesPendingIndex,
|
||||||
name: 'captain_assistants_responses_pending',
|
name: 'captain_assistants_responses_pending',
|
||||||
meta,
|
meta: knowledgeBaseMeta,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
||||||
@ -119,7 +125,7 @@ const assistantRoutes = [
|
|||||||
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
|
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
|
||||||
component: AssistantsIndexPage,
|
component: AssistantsIndexPage,
|
||||||
name: 'captain_assistants_index',
|
name: 'captain_assistants_index',
|
||||||
meta,
|
meta: knowledgeBaseMeta,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
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 Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
@ -24,6 +26,7 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
const { checkPermissions } = usePolicy();
|
||||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||||
const responses = useMapGetter('captainResponses/getRecords');
|
const responses = useMapGetter('captainResponses/getRecords');
|
||||||
@ -38,6 +41,10 @@ const searchQuery = ref('');
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const createDialog = ref(null);
|
const createDialog = ref(null);
|
||||||
|
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||||
|
const canManageResponses = computed(() =>
|
||||||
|
checkPermissions(responseManagePermissions)
|
||||||
|
);
|
||||||
|
|
||||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||||
|
|
||||||
@ -206,7 +213,7 @@ onMounted(() => {
|
|||||||
<PageLayout
|
<PageLayout
|
||||||
:total-count="responseMeta.totalCount"
|
:total-count="responseMeta.totalCount"
|
||||||
:current-page="responseMeta.page"
|
:current-page="responseMeta.page"
|
||||||
:button-policy="['administrator']"
|
:button-policy="responseManagePermissions"
|
||||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||||
:is-fetching="isFetching"
|
:is-fetching="isFetching"
|
||||||
@ -247,6 +254,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template #subHeader>
|
<template #subHeader>
|
||||||
<BulkSelectBar
|
<BulkSelectBar
|
||||||
|
v-if="canManageResponses"
|
||||||
v-model="bulkSelectedIds"
|
v-model="bulkSelectedIds"
|
||||||
:all-items="responses"
|
:all-items="responses"
|
||||||
:select-all-label="buildSelectedCountLabel"
|
:select-all-label="buildSelectedCountLabel"
|
||||||
@ -293,8 +301,11 @@ onMounted(() => {
|
|||||||
:created-at="response.created_at"
|
:created-at="response.created_at"
|
||||||
:updated-at="response.updated_at"
|
:updated-at="response.updated_at"
|
||||||
:is-selected="bulkSelectedIds.has(response.id)"
|
:is-selected="bulkSelectedIds.has(response.id)"
|
||||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
:selectable="
|
||||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
canManageResponses &&
|
||||||
|
(hoveredCard === response.id || bulkSelectedIds.size > 0)
|
||||||
|
"
|
||||||
|
:show-menu="canManageResponses && !bulkSelectedIds.has(response.id)"
|
||||||
:show-actions="false"
|
:show-actions="false"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
@navigate="handleNavigationAction"
|
@navigate="handleNavigationAction"
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
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 Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
@ -25,6 +27,7 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { isOnChatwootCloud } = useAccount();
|
const { isOnChatwootCloud } = useAccount();
|
||||||
|
const { checkPermissions } = usePolicy();
|
||||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||||
const responses = useMapGetter('captainResponses/getRecords');
|
const responses = useMapGetter('captainResponses/getRecords');
|
||||||
@ -40,6 +43,10 @@ const searchQuery = ref('');
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const createDialog = ref(null);
|
const createDialog = ref(null);
|
||||||
|
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
|
||||||
|
const canManageResponses = computed(() =>
|
||||||
|
checkPermissions(responseManagePermissions)
|
||||||
|
);
|
||||||
|
|
||||||
const backUrl = computed(() => ({
|
const backUrl = computed(() => ({
|
||||||
name: 'captain_assistants_responses_index',
|
name: 'captain_assistants_responses_index',
|
||||||
@ -286,6 +293,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template #subHeader>
|
<template #subHeader>
|
||||||
<BulkSelectBar
|
<BulkSelectBar
|
||||||
|
v-if="canManageResponses"
|
||||||
v-model="bulkSelectedIds"
|
v-model="bulkSelectedIds"
|
||||||
:all-items="filteredResponses"
|
:all-items="filteredResponses"
|
||||||
:select-all-label="buildSelectedCountLabel"
|
:select-all-label="buildSelectedCountLabel"
|
||||||
@ -338,9 +346,14 @@ onMounted(() => {
|
|||||||
:created-at="response.created_at"
|
:created-at="response.created_at"
|
||||||
:updated-at="response.updated_at"
|
:updated-at="response.updated_at"
|
||||||
:is-selected="bulkSelectedIds.has(response.id)"
|
: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-menu="false"
|
||||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
:show-actions="
|
||||||
|
canManageResponses && !bulkSelectedIds.has(response.id)
|
||||||
|
"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
@navigate="handleNavigationAction"
|
@navigate="handleNavigationAction"
|
||||||
@select="handleCardSelect"
|
@select="handleCardSelect"
|
||||||
|
|||||||
@ -253,12 +253,22 @@ const fetchLpStats = async () => {
|
|||||||
|
|
||||||
let pollInterval = null;
|
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 = () => {
|
const startPolling = () => {
|
||||||
if (pollInterval) return;
|
if (pollInterval) return;
|
||||||
pollInterval = setInterval(async () => {
|
pollInterval = setInterval(async () => {
|
||||||
await store.dispatch('captainReports/fetchInsights', {
|
await fetchInsightsForSelectedFilters();
|
||||||
inbox_id: selectedInboxId.value,
|
|
||||||
});
|
|
||||||
}, 10000);
|
}, 10000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -289,6 +299,7 @@ watch(activeTab, async tab => {
|
|||||||
watch([customStartDate, customEndDate], async () => {
|
watch([customStartDate, customEndDate], async () => {
|
||||||
if (selectedPeriod.value !== 'custom') return;
|
if (selectedPeriod.value !== 'custom') return;
|
||||||
if (!customStartDate.value || !customEndDate.value) return;
|
if (!customStartDate.value || !customEndDate.value) return;
|
||||||
|
await fetchInsightsForSelectedFilters();
|
||||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||||
if (activeTab.value === 'operational') await fetchOperational();
|
if (activeTab.value === 'operational') await fetchOperational();
|
||||||
if (activeTab.value === 'executive') await fetchExecutive();
|
if (activeTab.value === 'executive') await fetchExecutive();
|
||||||
@ -308,7 +319,7 @@ watch(
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.dispatch('inboxes/get');
|
await store.dispatch('inboxes/get');
|
||||||
await store.dispatch('captainAssistants/get');
|
await store.dispatch('captainAssistants/get');
|
||||||
await store.dispatch('captainReports/fetchInsights', {});
|
await fetchInsightsForSelectedFilters();
|
||||||
if (hasProcessingInsights.value) startPolling();
|
if (hasProcessingInsights.value) startPolling();
|
||||||
await fetchLpStats();
|
await fetchLpStats();
|
||||||
});
|
});
|
||||||
@ -320,9 +331,7 @@ onUnmounted(() => {
|
|||||||
const onFilterChange = async event => {
|
const onFilterChange = async event => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
selectedInboxId.value = value ? Number(value) : null;
|
selectedInboxId.value = value ? Number(value) : null;
|
||||||
await store.dispatch('captainReports/fetchInsights', {
|
await fetchInsightsForSelectedFilters();
|
||||||
inbox_id: selectedInboxId.value,
|
|
||||||
});
|
|
||||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||||
if (activeTab.value === 'operational') await fetchOperational();
|
if (activeTab.value === 'operational') await fetchOperational();
|
||||||
if (activeTab.value === 'executive') await fetchExecutive();
|
if (activeTab.value === 'executive') await fetchExecutive();
|
||||||
@ -330,6 +339,7 @@ const onFilterChange = async event => {
|
|||||||
|
|
||||||
const onPeriodChange = async event => {
|
const onPeriodChange = async event => {
|
||||||
selectedPeriod.value = event.target.value;
|
selectedPeriod.value = event.target.value;
|
||||||
|
await fetchInsightsForSelectedFilters();
|
||||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||||
if (activeTab.value === 'operational') await fetchOperational();
|
if (activeTab.value === 'operational') await fetchOperational();
|
||||||
if (activeTab.value === 'executive') await fetchExecutive();
|
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,
|
account: account,
|
||||||
unit: unit,
|
unit: unit,
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
conversations: conversations
|
conversations: conversations,
|
||||||
|
period_start: period_start,
|
||||||
|
period_end: period_end
|
||||||
).analyze
|
).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)
|
insight.mark_done!(payload)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_conversations(account, unit, inbox, period_start, period_end)
|
def fetch_conversations(account, unit, inbox, period_start, period_end)
|
||||||
scope = account.conversations
|
scope = account.conversations
|
||||||
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
|
.joins(:messages)
|
||||||
.includes(: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
|
if inbox
|
||||||
scope = scope.where(inbox_id: inbox.id)
|
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?
|
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@ -36,11 +36,29 @@ class Captain::PixCharge < ApplicationRecord
|
|||||||
belongs_to :reservation, class_name: 'Captain::Reservation'
|
belongs_to :reservation, class_name: 'Captain::Reservation'
|
||||||
belongs_to :unit, class_name: 'Captain::Unit'
|
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 :txid, presence: true, uniqueness: true
|
||||||
validates :unit_id, presence: 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 :post_internal_pix_sent_note
|
||||||
after_create_commit :enqueue_retention_recalc
|
after_create_commit :enqueue_retention_recalc
|
||||||
after_update_commit :enqueue_retention_recalc_on_status_change
|
after_update_commit :enqueue_retention_recalc_on_status_change
|
||||||
@ -64,16 +82,7 @@ class Captain::PixCharge < ApplicationRecord
|
|||||||
conversation = reservation&.conversation
|
conversation = reservation&.conversation
|
||||||
return if conversation.blank?
|
return if conversation.blank?
|
||||||
|
|
||||||
value = original_value.to_f
|
content = manual? ? manual_pix_note_content : inter_pix_note_content
|
||||||
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")
|
|
||||||
|
|
||||||
Messages::MessageBuilder.new(
|
Messages::MessageBuilder.new(
|
||||||
nil,
|
nil,
|
||||||
@ -110,6 +119,34 @@ class Captain::PixCharge < ApplicationRecord
|
|||||||
return val.to_f if val.present?
|
return val.to_f if val.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if manual?
|
||||||
|
deposit = reservation&.metadata.to_h['deposit_amount']
|
||||||
|
return deposit.to_f if deposit.present?
|
||||||
|
end
|
||||||
|
|
||||||
reservation&.total_amount
|
reservation&.total_amount
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -74,9 +74,14 @@ class Captain::Unit < ApplicationRecord
|
|||||||
encrypts :inter_key_content
|
encrypts :inter_key_content
|
||||||
|
|
||||||
enum status: { active: 'active', inactive: 'inactive' }, _default: 'active'
|
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 :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 :proactive_pix_polling_requires_inter_credentials
|
||||||
|
validate :manual_static_requires_manual_pix_fields
|
||||||
|
|
||||||
after_commit :enqueue_supabase_provisioning, on: :create
|
after_commit :enqueue_supabase_provisioning, on: :create
|
||||||
|
|
||||||
@ -104,6 +109,13 @@ class Captain::Unit < ApplicationRecord
|
|||||||
(inter_key_content.present? || resolved_inter_key_path.present?)
|
(inter_key_content.present? || resolved_inter_key_path.present?)
|
||||||
end
|
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
|
def resolved_inter_cert_path
|
||||||
resolve_certificate_path(inter_cert_path)
|
resolve_certificate_path(inter_cert_path)
|
||||||
end
|
end
|
||||||
@ -128,6 +140,14 @@ class Captain::Unit < ApplicationRecord
|
|||||||
)
|
)
|
||||||
end
|
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
|
# Resolve o path do certificado — suporta caminho absoluto, relativo ao Rails.root
|
||||||
# ou nome de arquivo simples dentro de storage/certs/.
|
# ou nome de arquivo simples dentro de storage/certs/.
|
||||||
def resolve_certificate_path(path)
|
def resolve_certificate_path(path)
|
||||||
|
|||||||
@ -24,10 +24,6 @@ class Captain::AssistantResponsePolicy < ApplicationPolicy
|
|||||||
def manage?
|
def manage?
|
||||||
return true if @account_user.administrator?
|
return true if @account_user.administrator?
|
||||||
|
|
||||||
if @account_user.custom_role.present?
|
@account_user.custom_role&.permissions&.include?('knowledge_base_manage') || false
|
||||||
return @account_user.custom_role.permissions.include?('knowledge_base_manage')
|
|
||||||
end
|
|
||||||
|
|
||||||
@account_user.agent?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -71,10 +71,13 @@ class Captain::Hermes::Client
|
|||||||
contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access
|
contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access
|
||||||
cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '')
|
cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '')
|
||||||
history = contact_history_snapshot(contact, conversation)
|
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),
|
image_urls: image_urls_for_hermes(message),
|
||||||
|
reply_context: reply_context,
|
||||||
contact_name: contact&.name,
|
contact_name: contact&.name,
|
||||||
contact_first_name: contact&.name.to_s.split.first,
|
contact_first_name: contact&.name.to_s.split.first,
|
||||||
contact_id: conversation.contact_id,
|
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
|
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()
|
super()
|
||||||
@account = account
|
@account = account
|
||||||
@unit = unit
|
@unit = unit
|
||||||
@inbox = inbox
|
@inbox = inbox
|
||||||
@conversations = conversations
|
@conversations = conversations
|
||||||
|
@period_start = period_start
|
||||||
|
@period_end = period_end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Analisa as conversas e retorna o payload de insights
|
# Analisa as conversas e retorna o payload de insights
|
||||||
@ -24,10 +26,10 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :account, :unit, :inbox, :conversations
|
attr_reader :account, :unit, :inbox, :conversations, :period_start, :period_end
|
||||||
|
|
||||||
def build_chunks
|
def build_chunks
|
||||||
texts = conversations.map(&:to_llm_text).reject(&:blank?)
|
texts = conversations.map { |conversation| conversation_text(conversation) }.reject(&:blank?)
|
||||||
return [] if texts.empty?
|
return [] if texts.empty?
|
||||||
|
|
||||||
chunks = []
|
chunks = []
|
||||||
@ -48,6 +50,38 @@ class Captain::Llm::ConversationInsightService < Llm::BaseAiService
|
|||||||
chunks
|
chunks
|
||||||
end
|
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)
|
def analyze_chunk(chunk)
|
||||||
response = instrument_llm_call(instrumentation_params) do
|
response = instrument_llm_call(instrumentation_params) do
|
||||||
chat
|
chat
|
||||||
|
|||||||
@ -19,6 +19,11 @@ class Captain::Mcp::ToolRegistry
|
|||||||
Captain::Mcp::Tools::RescheduleReservationTool,
|
Captain::Mcp::Tools::RescheduleReservationTool,
|
||||||
Captain::Mcp::Tools::ReactToMessageTool,
|
Captain::Mcp::Tools::ReactToMessageTool,
|
||||||
Captain::Mcp::Tools::CheckSuiteAvailabilityTool,
|
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
|
# Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes
|
||||||
Captain::Mcp::Tools::ListAssistantsTool,
|
Captain::Mcp::Tools::ListAssistantsTool,
|
||||||
Captain::Mcp::Tools::GetAssistantPricingTool,
|
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)
|
unit = resolve_unit(conversation, context)
|
||||||
return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank?
|
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
|
# 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
|
# invés de retornar erro pro LLM (que ele ia transformar em "vou
|
||||||
# verificar" e travar). Cliente recebe link da página oficial pra
|
# 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)
|
conversation.update_labels(merged)
|
||||||
end
|
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
|
# Fallback "leve" pra cenários onde Pix nem foi tentado (categoria não
|
||||||
# existe na unit, período inválido, sem credencial Inter cadastrada).
|
# existe na unit, período inválido, sem credencial Inter cadastrada).
|
||||||
# Sem reservation/pricing/valores — só monta link com o que tem do
|
# 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 'payment_callback' then 'callback de pagamento'
|
||||||
when 'inter_cob_query_polling' then 'consulta periódica no Inter'
|
when 'inter_cob_query_polling' then 'consulta periódica no Inter'
|
||||||
when 'inter_cob_query' then 'consulta manual no Inter'
|
when 'inter_cob_query' then 'consulta manual no Inter'
|
||||||
|
when 'manual_pix_proof' then 'comprovante PIX manual validado'
|
||||||
else
|
else
|
||||||
'integração de pagamento'
|
'integração de pagamento'
|
||||||
end
|
end
|
||||||
|
|||||||
@ -180,16 +180,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
|
|||||||
expect(json_response[:answer]).to eq('Test answer')
|
expect(json_response[:answer]).to eq('Test answer')
|
||||||
end
|
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
|
expect do
|
||||||
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||||
params: valid_params,
|
params: valid_params,
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
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(response).to have_http_status(:forbidden)
|
||||||
expect(json_response[:question]).to eq('Test question?')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new response if the user has a custom role with knowledge base permission' do
|
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)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -281,12 +279,25 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
|
|||||||
expect(json_response[:answer]).to eq('Updated answer')
|
expect(json_response[:answer]).to eq('Updated answer')
|
||||||
end
|
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}",
|
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
|
||||||
params: update_params,
|
params: update_params,
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
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(response).to have_http_status(:ok)
|
||||||
expect(json_response[:question]).to eq('Updated question?')
|
expect(json_response[:question]).to eq('Updated question?')
|
||||||
end
|
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
|
describe 'DELETE /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
|
||||||
let!(:response_record) { create(:captain_assistant_response, assistant: assistant) }
|
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
|
expect do
|
||||||
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
|
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
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)
|
end.to change(Captain::AssistantResponse, :count).by(-1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
|
|||||||
@ -30,19 +30,16 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
|||||||
}
|
}
|
||||||
end
|
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",
|
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||||
params: valid_params,
|
params: valid_params,
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:forbidden)
|
||||||
expect(json_response).to be_an(Array)
|
|
||||||
expect(json_response.length).to eq(2)
|
|
||||||
|
|
||||||
# Verify responses were approved
|
|
||||||
pending_responses.each do |response|
|
pending_responses.each do |response|
|
||||||
expect(response.reload.status).to eq('approved')
|
expect(response.reload.status).to eq('pending')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -56,20 +53,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
|||||||
}
|
}
|
||||||
end
|
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
|
expect do
|
||||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||||
params: delete_params,
|
params: delete_params,
|
||||||
headers: agent.create_new_auth_token,
|
headers: agent.create_new_auth_token,
|
||||||
as: :json
|
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(response).to have_http_status(:forbidden)
|
||||||
expect(json_response).to eq([])
|
|
||||||
|
|
||||||
# Verify responses were deleted
|
|
||||||
pending_responses.each do |response|
|
pending_responses.each do |response|
|
||||||
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
expect(response.reload.status).to eq('pending')
|
||||||
end
|
end
|
||||||
end
|
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