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