Compare commits

...
Author SHA1 Message Date
Shivam Mishra be68927a52 test: move captain assistant specs to oss 2025-10-29 19:38:17 +05:30
Shivam Mishra 1fd77fdec2 refactor: relocate captain assistant services to oss 2025-10-29 19:38:17 +05:30
Shivam Mishra 992cf6fd4d refactor: move Captain prompt renderer to oss 2025-10-29 19:38:17 +05:30
Shivam Mishra b3ac1dcd03 style: rubocop issues 2025-10-29 19:38:17 +05:30
Shivam Mishra 80caf25962 feat: conversation summary spec 2025-10-29 19:38:17 +05:30
Shivam Mishra bf035114fe refactor: rename to base assistant service 2025-10-29 19:38:17 +05:30
Shivam Mishra 990b58fe62 test: rewrite services 2025-10-29 19:38:17 +05:30
Shivam Mishra 52271dc984 fix: default context 2025-10-29 19:38:17 +05:30
Shivam Mishra 52d6f2a369 feat: add rewrite services 2025-10-29 19:38:17 +05:30
a35c3e4c06 feat: Template types components (#12714)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-5806/create-the-story-book-components-for-template-typestext-media-list

**Pending**
Need to standardize the structure to match the template/campaigns.


## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screenshots

<img width="669" height="179" alt="image"
src="https://github.com/user-attachments/assets/42efd292-8520-4b05-81ec-8bc526fc12db"
/>
<img width="646" height="304" alt="image"
src="https://github.com/user-attachments/assets/431dd964-006c-4877-a693-dae39b90df4c"
/>
<img width="646" height="380" alt="image"
src="https://github.com/user-attachments/assets/9052e31f-9292-4afb-8897-13931655fa00"
/>
<img width="646" height="272" alt="image"
src="https://github.com/user-attachments/assets/873d2488-e856-4a0d-8579-cc1bcc61cc8e"
/>
<img width="646" height="490" alt="image"
src="https://github.com/user-attachments/assets/14c2aa42-bf27-475f-aa70-fe59c1d00e9b"
/>
<img width="646" height="281" alt="image"
src="https://github.com/user-attachments/assets/1f42408e-03e8-4863-b4c7-715d13d67686"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-10-29 17:06:32 +05:30
Muhsin KelothandGitHub 344e8d5016 fix: Exclude authentication templates from WhatsApp template selection (#12753)
This PR add the changes for excluding the authentication templates from
the WhatsApp template selection in the frontend, as these templates are
not supported at the moment. Reference:
https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates#what-is-not-supported
2025-10-29 14:03:43 +05:30
3e27e28848 chore: Update captain pending FAQ interface (#12752)
# Pull Request Template

## Description

**This PR includes,**
- Added new pending FAQs view with approve/edit/delete actions for each
response.
- Implemented banner notification showing pending FAQ count on main
approved responses page.
- Created dedicated route for pending FAQs review at
/captain/responses/pending.
- Added automatic pending count updates when switching assistants or
routes.
- Modified ResponseCard component to show action buttons instead of
dropdown in pending view.

Fixes
https://linear.app/chatwoot/issue/CW-5833/pending-faqs-in-a-different-ux

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video
https://www.loom.com/share/5fe8f79b04cd4681b9360c48710b9373


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-10-28 20:47:42 -07:00
38af08534c fix: Captain response builder not getting triggered (#12729)
## Summary
- Fix captain response builder not getting triggered for cases where
responses are created as completed.

## Testing Instructions 
- Test articles with firecrawl
- Test articles without firecrawl
- Test PDF documents

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2025-10-28 18:31:08 -07:00
41 changed files with 2315 additions and 218 deletions
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
@@ -59,6 +60,10 @@ const props = defineProps({
type: Boolean,
default: true,
},
showActions: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
<span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }}
</span>
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
<div class="inline-flex items-center">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
>
<div
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="['administrator']">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
icon="i-lucide-circle-check-big"
sm
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'approve', value: 'approve' })
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
icon="i-lucide-pencil-line"
sm
slate
link
class="hover:!no-underline"
@click="
handleAssistantAction({
action: 'edit',
value: 'edit',
})
"
/>
<Button
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
icon="i-lucide-trash"
sm
ruby
link
class="hover:!no-underline"
@click="
handleAssistantAction({ action: 'delete', value: 'delete' })
"
/>
</div>
</Policy>
<div
class="flex items-center gap-3"
:class="{ 'justify-between w-full': !showActions }"
>
<div class="inline-flex items-center gap-3 min-w-0">
<span
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
v-if="status === 'approved'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
<Icon icon="i-woot-captain" class="size-3.5" />
{{ assistant?.name || '' }}
</span>
<div
v-if="documentable"
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
>
<Icon
v-if="documentable.type === 'Captain::Document'"
icon="i-ph-files-light"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'User'"
icon="i-ph-user-circle-plus"
class="size-3.5"
/>
<Icon
v-else-if="documentable.type === 'Conversation'"
icon="i-ph-chat-circle-dots"
class="size-3.5"
/>
<span
v-if="documentable.type === 'Captain::Document'"
class="truncate"
:title="documentable.name"
>
{{ documentable.name }}
</span>
</span>
<span
v-if="documentable.type === 'User'"
class="inline-flex items-center gap-1"
>
<i class="i-ph-user-circle-plus text-base" />
<span
class="max-w-96 truncate"
v-else-if="documentable.type === 'User'"
class="truncate"
:title="documentable.available_name"
>
{{ documentable.available_name }}
</span>
</span>
<span
v-else-if="documentable.type === 'Conversation'"
class="inline-flex items-center gap-1 group cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
<span
v-else-if="documentable.type === 'Conversation'"
class="hover:underline truncate cursor-pointer"
role="button"
@click="handleDocumentableClick"
>
{{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id,
})
}}
</span>
</span>
<span v-else />
</div>
</div>
<div
v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
>
<i
class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
{{ timestamp }}
</div>
</div>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</div>
</CardLayout>
</template>
@@ -0,0 +1,29 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="px-3 py-2.5">
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6>
<span
v-dompurify-html="message.content"
class="prose prose-bubble text-sm"
/>
</div>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" />
</div>
<div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,20 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -861,6 +861,7 @@
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
@@ -900,6 +901,10 @@
"APPROVED": "Approved",
"ALL": "All"
},
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": {
"TITLE": "Add an FAQ",
@@ -931,9 +936,9 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
},
"OPTIONS": {
"APPROVE": "Mark as approved",
"EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete FAQ"
"APPROVE": "Approve",
"EDIT_RESPONSE": "Edit",
"DELETE_RESPONSE": "Delete"
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
export const routes = [
@@ -125,6 +126,19 @@ export const routes = [
],
},
},
{
path: frontendURL('accounts/:accountId/captain/responses/pending'),
component: ResponsesPendingIndex,
name: 'captain_responses_pending',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/tools'),
component: CustomToolsIndex,
@@ -1,17 +1,15 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useRouter } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
@@ -37,7 +35,6 @@ const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedStatus = ref('all');
const selectedAssistant = ref('all');
const dialogType = ref('');
const searchQuery = ref('');
@@ -45,54 +42,17 @@ const { t } = useI18n();
const createDialog = ref(null);
const isStatusFilterOpen = ref(false);
const shouldShowDropdown = computed(() => {
if (assistants.value.length === 0) return false;
return !isFetching.value;
});
const statusOptions = computed(() =>
['all', 'pending', 'approved'].map(key => ({
label: t(`CAPTAIN.RESPONSES.STATUS.${key.toUpperCase()}`),
value: key,
action: 'filter',
}))
);
const filteredResponses = computed(() => {
return selectedStatus.value === 'pending'
? responses.value.filter(r => r.status === 'pending')
: responses.value;
});
const selectedStatusLabel = computed(() => {
const status = statusOptions.value.find(
option => option.value === selectedStatus.value
);
return t('CAPTAIN.RESPONSES.FILTER.STATUS', {
selected: status ? status.label : '',
});
});
const pendingCount = useMapGetter('captainResponses/getPendingCount');
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const handleAccept = async () => {
try {
await store.dispatch('captainResponses/update', {
id: selectedResponse.value.id,
status: 'approved',
});
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
} catch (error) {
const errorMessage =
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
useAlert(errorMessage);
} finally {
selectedResponse.value = null;
}
};
const handleCreate = () => {
dialogType.value = 'create';
@@ -105,9 +65,7 @@ const handleEdit = () => {
};
const handleAction = ({ action, id }) => {
selectedResponse.value = filteredResponses.value.find(
response => id === response.id
);
selectedResponse.value = responses.value.find(response => id === response.id);
nextTick(() => {
if (action === 'delete') {
handleDelete();
@@ -115,9 +73,6 @@ const handleAction = ({ action, id }) => {
if (action === 'edit') {
handleEdit();
}
if (action === 'approve') {
handleAccept();
}
});
};
@@ -136,10 +91,8 @@ const handleCreateClose = () => {
};
const fetchResponses = (page = 1) => {
const filterParams = { page };
if (selectedStatus.value !== 'all') {
filterParams.status = selectedStatus.value;
}
const filterParams = { page, status: 'approved' };
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
}
@@ -155,7 +108,7 @@ const hoveredCard = ref(null);
const bulkSelectionState = computed(() => {
const selectedCount = bulkSelectedIds.value.size;
const totalCount = filteredResponses.value?.length || 0;
const totalCount = responses.value?.length || 0;
return {
hasSelected: selectedCount > 0,
@@ -168,13 +121,13 @@ const bulkCheckbox = computed({
get: () => bulkSelectionState.value.allSelected,
set: value => {
bulkSelectedIds.value = value
? new Set(filteredResponses.value.map(r => r.id))
? new Set(responses.value.map(r => r.id))
: new Set();
},
});
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
const count = responses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
@@ -191,7 +144,7 @@ const handleCardSelect = id => {
};
const fetchResponseAfterBulkAction = () => {
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
const hasNoResponsesLeft = responses.value?.length === 0;
const currentPage = responseMeta.value?.page;
if (hasNoResponsesLeft) {
@@ -208,22 +161,6 @@ const fetchResponseAfterBulkAction = () => {
bulkSelectedIds.value = new Set();
};
const handleBulkApprove = async () => {
try {
await store.dispatch(
'captainBulkActions/handleBulkApprove',
Array.from(bulkSelectedIds.value)
);
fetchResponseAfterBulkAction();
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
);
}
};
const onPageChange = page => {
// Store current selection state before fetching new page
const wasAllPageSelected = bulkSelectionState.value.allSelected;
@@ -238,7 +175,7 @@ const onPageChange = page => {
};
const onDeleteSuccess = () => {
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
onPageChange(responseMeta.value.page - 1);
}
};
@@ -247,12 +184,6 @@ const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const handleStatusFilterChange = ({ value }) => {
selectedStatus.value = value;
isStatusFilterOpen.value = false;
fetchResponses();
};
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses();
@@ -262,9 +193,14 @@ const debouncedSearch = debounce(async () => {
fetchResponses();
}, 500);
const navigateToPendingFAQs = () => {
router.push({ name: 'captain_responses_pending' });
};
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
store.dispatch('captainResponses/fetchPendingCount');
});
</script>
@@ -276,8 +212,8 @@ onMounted(() => {
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
:is-empty="!filteredResponses.length"
:show-pagination-footer="!isFetching && !!filteredResponses.length"
:is-empty="!responses.length"
:show-pagination-footer="!isFetching && !!responses.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreate"
@@ -315,25 +251,7 @@ onMounted(() => {
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex gap-3">
<OnClickOutside @trigger="isStatusFilterOpen = false">
<Button
:label="selectedStatusLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isStatusFilterOpen = !isStatusFilterOpen"
/>
<DropdownMenu
v-if="isStatusFilterOpen"
:menu-items="statusOptions"
class="mt-2"
@action="handleStatusFilterChange"
/>
</OnClickOutside>
<div class="flex items-center gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
@@ -344,6 +262,7 @@ onMounted(() => {
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
@@ -380,15 +299,6 @@ onMounted(() => {
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
@@ -406,10 +316,19 @@ onMounted(() => {
<template #body>
<LimitBanner class="mb-5" />
<Banner
v-if="pendingCount > 0"
color="blue"
class="mb-4"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@action="navigateToPendingFAQs"
>
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
</Banner>
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in filteredResponses"
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
@@ -422,6 +341,7 @@ onMounted(() => {
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
:show-actions="false"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@@ -0,0 +1,412 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistant = ref('all');
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const shouldShowDropdown = computed(() => {
if (assistants.value.length === 0) return false;
return !isFetching.value;
});
const backUrl = computed(() =>
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
);
// Filter out approved responses in pending view
const filteredResponses = computed(() =>
responses.value.filter(response => response.status !== 'approved')
);
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const handleAccept = async () => {
try {
await store.dispatch('captainResponses/update', {
id: selectedResponse.value.id,
status: 'approved',
});
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
} catch (error) {
const errorMessage =
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
useAlert(errorMessage);
} finally {
selectedResponse.value = null;
}
};
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedResponse.value = filteredResponses.value.find(
response => id === response.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
if (action === 'approve') {
handleAccept();
}
});
};
const handleNavigationAction = ({ id, type }) => {
if (type === 'Conversation') {
router.push({
name: 'inbox_conversation',
params: { conversation_id: id },
});
}
};
const handleCreateClose = () => {
dialogType.value = '';
selectedResponse.value = null;
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
if (selectedAssistant.value !== 'all') {
filterParams.assistantId = selectedAssistant.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
store.dispatch('captainResponses/get', filterParams);
};
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const bulkSelectionState = computed(() => {
const selectedCount = bulkSelectedIds.value.size;
const totalCount = filteredResponses.value?.length || 0;
return {
hasSelected: selectedCount > 0,
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
allSelected: totalCount > 0 && selectedCount === totalCount,
};
});
const bulkCheckbox = computed({
get: () => bulkSelectionState.value.allSelected,
set: value => {
bulkSelectedIds.value = value
? new Set(filteredResponses.value.map(r => r.id))
: new Set();
},
});
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
return bulkSelectionState.value.allSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchResponseAfterBulkAction = () => {
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
const currentPage = responseMeta.value?.page;
if (hasNoResponsesLeft) {
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchResponses(pageToFetch);
} else {
fetchResponses(currentPage);
}
bulkSelectedIds.value = new Set();
};
const handleBulkApprove = async () => {
try {
await store.dispatch(
'captainBulkActions/handleBulkApprove',
Array.from(bulkSelectedIds.value)
);
fetchResponseAfterBulkAction();
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
);
}
};
const onPageChange = page => {
const wasAllPageSelected = bulkSelectionState.value.allSelected;
const hadPartialSelection = bulkSelectedIds.value.size > 0;
fetchResponses(page);
if (wasAllPageSelected || hadPartialSelection) {
bulkSelectedIds.value = new Set();
}
};
const onDeleteSuccess = () => {
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
onPageChange(responseMeta.value.page - 1);
}
};
const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const handleAssistantFilterChange = assistant => {
selectedAssistant.value = assistant;
fetchResponses();
};
const debouncedSearch = debounce(async () => {
fetchResponses();
}, 500);
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
});
</script>
<template>
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="['administrator']"
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
:is-empty="!filteredResponses.length"
:show-pagination-footer="!isFetching && !!filteredResponses.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:back-url="backUrl"
@update:current-page="onPageChange"
@click="handleCreate"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
/>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #controls>
<div
v-if="shouldShowDropdown"
class="mb-4 -mt-3 flex justify-between items-center py-1"
:class="{
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
bulkSelectionState.hasSelected,
}"
>
<div
v-if="!bulkSelectionState.hasSelected"
class="flex gap-3 justify-between w-full items-center"
>
<div class="flex items-center gap-3">
<AssistantSelector
:assistant-id="selectedAssistant"
@update="handleAssistantFilterChange"
/>
</div>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
<transition
name="slide-fade"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
enter-to-class="opacity-100 transform translate-x-0"
leave-active-class="hidden opacity-0"
>
<div
v-if="bulkSelectionState.hasSelected"
class="flex items-center gap-3"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5">
<Checkbox
v-model="bulkCheckbox"
:indeterminate="bulkSelectionState.isIndeterminate"
/>
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
{{ buildSelectedCountLabel }}
</span>
</div>
<span class="text-sm text-n-slate-10 tabular-nums">
{{
$t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.size,
})
}}
</span>
</div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex gap-3 items-center">
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
<div class="h-4 w-px bg-n-strong" />
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="bulkDeleteDialog.dialogRef.open()"
/>
</div>
</div>
</transition>
</div>
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in filteredResponses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="false"
:show-actions="!bulkSelectedIds.has(response.id)"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
/>
</div>
</template>
<DeleteDialog
v-if="selectedResponse"
ref="deleteDialog"
:entity="selectedResponse"
type="Responses"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
@delete-success="onBulkDeleteSuccess"
/>
<CreateResponseDialog
v-if="dialogType"
ref="createDialog"
:type="dialogType"
:selected-response="selectedResponse"
@close="handleCreateClose"
/>
</PageLayout>
</template>
@@ -1,9 +1,22 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from './storeFactory';
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
export default createStore({
name: 'CaptainResponse',
API: CaptainResponseAPI,
getters: {
getPendingCount: state => state.meta.pendingCount || 0,
},
mutations: {
[SET_PENDING_COUNT](state, count) {
state.meta = {
...state.meta,
pendingCount: Number(count),
};
},
},
actions: mutations => ({
removeBulkResponses: ({ commit, state }, ids) => {
const updatedRecords = state.records.filter(
@@ -28,5 +41,18 @@ export default createStore({
commit(mutations.SET, updatedRecords);
},
fetchPendingCount: async ({ commit }, assistantId) => {
try {
const response = await CaptainResponseAPI.get({
status: 'pending',
page: 1,
assistantId,
});
const count = response.data?.meta?.total_count || 0;
commit(SET_PENDING_COUNT, count);
} catch (error) {
commit(SET_PENDING_COUNT, 0);
}
},
}),
});
@@ -49,6 +49,7 @@ export const createMutations = mutationTypes => ({
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
@@ -69,7 +70,7 @@ export const createCrudActions = (API, mutationTypes) => ({
});
export const createStore = options => {
const { name, API, actions, getters } = options;
const { name, API, actions, getters, mutations } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
@@ -81,7 +82,10 @@ export const createStore = options => {
...createGetters(),
...(getters || {}),
},
mutations: createMutations(mutationTypes),
mutations: {
...createMutations(mutationTypes),
...(mutations || {}),
},
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
@@ -78,6 +78,11 @@ export const getters = {
return false;
}
// Filter out authentication templates
if (template.category === 'AUTHENTICATION') {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
@@ -265,6 +265,39 @@ describe('#getters', () => {
expect(result[0].name).toBe('regular_template');
});
it('filters out authentication templates', () => {
const authenticationTemplates = [
{
name: 'auth_template',
status: 'approved',
category: 'AUTHENTICATION',
components: [
{ type: 'BODY', text: 'Your verification code is {{1}}' },
],
},
{
name: 'regular_template',
status: 'approved',
category: 'MARKETING',
components: [{ type: 'BODY', text: 'Regular message' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: authenticationTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('returns valid templates from fixture data', () => {
const state = {
records: [
@@ -0,0 +1,89 @@
require 'agents'
class Captain::Assistant::BaseAssistantService
def initialize(text:)
@text = text
end
def execute
agent = build_agent
runner = Agents::Runner.with_agents(agent)
result = runner.run(@text, context: {})
return error_response(result.error) if result.respond_to?(:error) && result.error
process_result(result)
rescue StandardError => e
Rails.logger.error "[Captain V2] #{self.class.name} error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
error_response(e.message)
end
protected
def build_agent
Agents::Agent.new(
name: agent_name,
instructions: build_instructions,
model: agent_model,
response_schema: response_schema
)
end
def agent_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
end
def agent_name
raise NotImplementedError, "#{self.class} must implement agent_name"
end
def build_instructions
raise NotImplementedError, "#{self.class} must implement build_instructions"
end
def response_schema
raise NotImplementedError, "#{self.class} must implement response_schema"
end
def process_result(result)
output = result.output
return error_response(output[:error] || output['error']) if output.is_a?(Hash) && (output[:error] || output['error'])
build_success_response(output)
end
def build_success_response(output)
{
success: true,
result: extract_primary_field(output),
original_text: @text
}
end
def error_response(error_message)
{
success: false,
error: error_message,
original_text: @text
}
end
def extract_field(output, *field_names)
return output.to_s unless output.is_a?(Hash)
field_names.each do |field|
value = output[field.to_sym] || output[field.to_s]
return value if value
end
nil
end
def extract_primary_field(output)
output
end
end
@@ -0,0 +1,56 @@
class Captain::Assistant::ChangeToneService < Captain::Assistant::BaseAssistantService
SUPPORTED_TONES = %w[professional casual straightforward confident friendly].freeze
def initialize(text:, tone:)
super(text: text)
@tone = validate_tone(tone)
end
protected
def agent_name
'ToneChanger'
end
def build_instructions
context = { tone: @tone }
Captain::PromptRenderer.render('rewrite/tone', context.with_indifferent_access)
end
def response_schema
{
type: 'object',
properties: {
rewritten_text: {
type: 'string',
description: 'The rewritten text with the requested tone applied'
},
tone_applied: {
type: 'string',
description: 'The tone that was applied to the text'
}
},
required: %w[rewritten_text tone_applied],
additionalProperties: false
}
end
def build_success_response(output)
{
success: true,
rewritten_text: extract_field(output, 'rewritten_text'),
tone: @tone,
original_text: @text
}
end
private
def validate_tone(tone)
tone_str = tone.to_s.downcase
raise ArgumentError, "Unsupported tone: #{tone}. Supported tones: #{SUPPORTED_TONES.join(', ')}" unless SUPPORTED_TONES.include?(tone_str)
tone_str
end
end
@@ -0,0 +1,124 @@
class Captain::Assistant::ConversationSummaryService < Captain::Assistant::BaseAssistantService
TOKEN_LIMIT = 16_000
def initialize(conversation:)
@conversation = conversation
super(text: format_conversation_messages)
end
protected
def agent_name
'ConversationSummarizer'
end
def build_instructions
Captain::PromptRenderer.render('summary', {})
end
def response_schema
{
type: 'object',
properties: {
customer_intent: {
type: 'string',
description: 'Brief description of what the customer wants (around 50 words)'
},
conversation_summary: {
type: 'string',
description: 'Summary of the conversation in approximately 200 words'
},
action_items: {
type: 'array',
description: 'List of action items committed to by the agent or left incomplete',
items: {
type: 'string'
}
},
follow_up_items: {
type: 'array',
description: 'List of unresolved issues or outstanding questions',
items: {
type: 'string'
}
}
},
required: %w[customer_intent conversation_summary],
additionalProperties: false
}
end
def build_success_response(output)
{
success: true,
summary: build_summary_markdown(output),
structured_data: {
customer_intent: extract_field(output, 'customer_intent'),
conversation_summary: extract_field(output, 'conversation_summary'),
action_items: extract_array_field(output, 'action_items'),
follow_up_items: extract_array_field(output, 'follow_up_items')
}
}
end
private
def format_conversation_messages
messages = []
character_count = 0
@conversation.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.reorder('id desc')
.each do |message|
break if character_count + message.content.length > TOKEN_LIMIT
next if message.content.blank?
messages.prepend(format_message(message))
character_count += message.content.length
end
messages.join("\n")
end
def format_message(message)
sender_type = message.incoming? ? 'Customer' : 'Agent'
sender_name = message.sender&.name || 'Unknown'
"#{sender_type} #{sender_name}: #{message.content}"
end
def extract_array_field(output, field_name)
return [] unless output.is_a?(Hash)
output[field_name.to_sym] || output[field_name.to_s] || []
end
def build_summary_markdown(output)
return '' unless output.is_a?(Hash)
sections = []
if (intent = extract_field(output, 'customer_intent')).present?
sections << "**Customer Intent**\n\n#{intent}"
end
if (summary = extract_field(output, 'conversation_summary')).present?
sections << "**Conversation Summary**\n\n#{summary}"
end
action_items = extract_array_field(output, 'action_items')
if action_items&.any?
items_list = action_items.map { |item| "- #{item}" }.join("\n")
sections << "**Action Items**\n\n#{items_list}"
end
follow_up_items = extract_array_field(output, 'follow_up_items')
if follow_up_items&.any?
items_list = follow_up_items.map { |item| "- #{item}" }.join("\n")
sections << "**Follow-up Items**\n\n#{items_list}"
end
sections.join("\n\n")
end
end
@@ -0,0 +1,49 @@
class Captain::Assistant::FixGrammarService < Captain::Assistant::BaseAssistantService
protected
def agent_name
'GrammarFixer'
end
def build_instructions
Captain::PromptRenderer.render('rewrite/grammar', {})
end
def response_schema
{
type: 'object',
properties: {
corrected_text: {
type: 'string',
description: 'The text with corrected grammar, spelling, and punctuation'
},
corrections_made: {
type: 'array',
description: 'List of corrections that were made',
items: {
type: 'string'
}
}
},
required: ['corrected_text'],
additionalProperties: false
}
end
def build_success_response(output)
{
success: true,
corrected_text: extract_field(output, 'corrected_text'),
corrections_made: extract_corrections(output),
original_text: @text
}
end
private
def extract_corrections(output)
return [] unless output.is_a?(Hash)
output[:corrections_made] || output['corrections_made'] || []
end
end
@@ -5,11 +5,13 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
assistant = Captain::Assistant.find(assistant_id)
metadata = payload[:metadata]
canonical_url = normalize_link(metadata['url'])
document = assistant.documents.find_or_initialize_by(
external_link: metadata['url']
external_link: canonical_url
)
document.update!(
external_link: canonical_url,
content: payload[:markdown],
name: metadata['title'],
status: :available
@@ -17,4 +19,10 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
end
private
def normalize_link(raw_url)
raw_url.to_s.delete_suffix('/')
end
end
@@ -15,11 +15,11 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
document = assistant.documents.find_or_initialize_by(
external_link: page_link
)
normalized_link = normalize_link(page_link)
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
document.update!(
external_link: normalized_link,
name: page_title[0..254], content: content[0..14_999], status: :available
)
rescue StandardError => e
@@ -28,6 +28,10 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
private
def normalize_link(raw_link)
raw_link.to_s.delete_suffix('/')
end
def limit_exceeded?(account)
limits = account.usage_limits[:captain][:documents]
limits[:current_available].negative? || limits[:current_available].zero?
+16 -5
View File
@@ -37,6 +37,7 @@ class Captain::Document < ApplicationRecord
validate :validate_file_attachment, if: -> { pdf_file.attached? }
before_validation :ensure_account_id
before_validation :set_external_link_for_pdf
before_validation :normalize_external_link
enum status: {
in_progress: 0,
@@ -47,7 +48,7 @@ class Captain::Document < ApplicationRecord
after_create_commit :enqueue_crawl_job
after_create_commit :update_document_usage
after_destroy :update_document_usage
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
after_commit :enqueue_response_builder_job
scope :ordered, -> { order(created_at: :desc) }
scope :for_account, ->(account_id) { where(account_id: account_id) }
@@ -94,15 +95,18 @@ class Captain::Document < ApplicationRecord
end
def enqueue_response_builder_job
return if status != 'available'
return unless should_enqueue_response_builder?
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
def should_enqueue_response_builder?
# Only enqueue when status changes to available
# Avoid re-enqueueing when metadata is updated by the job itself
saved_change_to_status? && status == 'available'
return false if destroyed?
return false unless available?
return saved_change_to_status? if pdf_document?
(saved_change_to_status? || saved_change_to_content?) && content.present?
end
def update_document_usage
@@ -140,4 +144,11 @@ class Captain::Document < ApplicationRecord
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
end
def normalize_external_link
return if external_link.blank?
return if pdf_document?
self.external_link = external_link.delete_suffix('/')
end
end
-25
View File
@@ -1,25 +0,0 @@
require 'liquid'
class Captain::PromptRenderer
class << self
def render(template_name, context = {})
template = load_template(template_name)
liquid_template = Liquid::Template.parse(template)
liquid_template.render(stringify_keys(context))
end
private
def load_template(template_name)
template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
raise "Template not found: #{template_name}" unless File.exist?(template_path)
File.read(template_path)
end
def stringify_keys(hash)
hash.deep_stringify_keys
end
end
end
@@ -0,0 +1,9 @@
module Enterprise::Captain::PromptRenderer
private
def candidate_paths(template_name)
[
Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
] + super
end
end
+35
View File
@@ -0,0 +1,35 @@
require 'liquid'
class Captain::PromptRenderer
class << self
def render(template_name, context = {})
template = load_template(template_name)
liquid_template = Liquid::Template.parse(template)
liquid_template.render(stringify_keys(context))
end
private
def load_template(template_name)
candidate_paths(template_name).each do |template_path|
next unless File.exist?(template_path)
return File.read(template_path)
end
raise "Template not found: #{template_name}"
end
def candidate_paths(template_name)
[
Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid")
]
end
def stringify_keys(hash)
hash.deep_stringify_keys
end
end
end
Captain::PromptRenderer.singleton_class.prepend_mod_with('Captain::PromptRenderer')
@@ -0,0 +1,8 @@
You are an expert writing editor. Rewrite the provided text to correct grammar, spelling, and punctuation while fully preserving the original meaning and style.
Rules:
• Preserve tone, language, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
• Do not modify content inside code blocks or backticks.
• Do not add content or opinions. Keep length similar.
• Minimal edits if already correct.
• Do not alter legal, medical, or compliance meaning.
+17
View File
@@ -0,0 +1,17 @@
You are an expert writing editor. Rewrite the provided text according to the selected tone while keeping the original intent.
{% case tone %}
{% when "professional" %}Rewrite in a formal and precise tone.
{% when "casual" %}Rewrite in a relaxed and conversational tone.
{% when "straightforward" %}Rewrite concise and direct. Remove filler.
{% when "confident" %}Rewrite bold and assured.
{% when "friendly" %}Rewrite warm and positive.
{% else %}Rewrite to improve clarity and smoothness.
{% endcase %}
Rules:
• Preserve original language, meaning, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
• Do not modify content inside code blocks or backticks.
• Do not add content or opinions. Keep length similar.
• Minimal edits if already correct.
• Do not alter legal, medical, or compliance meaning.
+19
View File
@@ -0,0 +1,19 @@
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
Make sure you strongly adhere to the following rules when generating the summary:
1. Be brief and concise. The shorter the summary the better.
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
3. Describe the customer intent in around 50 words.
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
8. The 'Action Items' should be brief and concise.
9. Mark important words or parts of sentences as bold.
10. Apply markdown syntax to format any included code, using backticks.
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
12. If any section does not have any content, remove that section and the heading from the response.
13. Do not insert your own opinions about the conversation.
Reply in the user's language.
@@ -23,7 +23,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata]['title'],
external_link: payload[:metadata]['url'],
external_link: 'https://www.firecrawl.dev',
status: 'available'
)
end
@@ -32,7 +32,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
existing_document = create(:captain_document,
assistant: assistant,
account: assistant.account,
external_link: payload[:metadata]['url'],
external_link: 'https://www.firecrawl.dev',
content: 'old content',
name: 'old title',
status: :in_progress)
@@ -42,7 +42,9 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
end.not_to change(assistant.documents, :count)
existing_document.reload
# Payload URL ends with '/', but we persist the canonical URL without it.
expect(existing_document).to have_attributes(
external_link: 'https://www.firecrawl.dev',
content: payload[:markdown],
name: payload[:metadata]['title'],
status: 'available'
@@ -3,7 +3,7 @@ require 'rails_helper'
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
describe '#perform' do
let(:assistant) { create(:captain_assistant) }
let(:page_link) { 'https://example.com/page' }
let(:page_link) { 'https://example.com/page/' }
let(:page_title) { 'Example Page Title' }
let(:content) { 'Some page content here' }
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
@@ -24,7 +24,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document.external_link).to eq(page_link)
expect(document.external_link).to eq('https://example.com/page')
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document.status).to eq('available')
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
it 'updates existing document if one exists' do
existing_document = create(:captain_document,
assistant: assistant,
external_link: page_link,
external_link: 'https://example.com/page',
name: 'Old Title',
content: 'Old content')
@@ -4,6 +4,17 @@ RSpec.describe Captain::Document, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'URL normalization' do
it 'removes a trailing slash before validation' do
document = create(:captain_document,
assistant: assistant,
account: account,
external_link: 'https://example.com/path/')
expect(document.external_link).to eq('https://example.com/path')
end
end
describe 'PDF support' do
let(:pdf_document) do
doc = build(:captain_document, assistant: assistant, account: account)
@@ -82,4 +93,161 @@ RSpec.describe Captain::Document, type: :model do
end
end
end
describe 'response builder job callback' do
before { clear_enqueued_jobs }
describe 'non-PDF documents' do
it 'enqueues when created with available status and content' do
expect do
create(:captain_document, assistant: assistant, account: account, status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when created available without content' do
expect do
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when status transitions to available with existing content' do
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
document.update!(status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when status transitions to available without content' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :in_progress,
content: nil
)
expect do
document.update!(status: :available)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when content is populated on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: nil
)
clear_enqueued_jobs
expect do
document.update!(content: 'Fresh content from crawl')
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when content changes on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: 'Initial content'
)
clear_enqueued_jobs
expect do
document.update!(content: 'Updated crawl content')
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when content is cleared on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: 'Initial content'
)
clear_enqueued_jobs
expect do
document.update!(content: nil)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue for metadata-only updates' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect do
document.update!(metadata: { 'title' => 'Updated Again' })
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue while document remains in progress' do
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
document.update!(metadata: { 'title' => 'Updated' })
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
describe 'PDF documents' do
def build_pdf_document(status:, content:)
build(
:captain_document,
assistant: assistant,
account: account,
status: status,
content: content
).tap do |doc|
doc.pdf_file.attach(
io: StringIO.new('PDF content'),
filename: 'sample.pdf',
content_type: 'application/pdf'
)
end
end
it 'enqueues when created available without content' do
document = build_pdf_document(status: :available, content: nil)
expect do
document.save!
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when status transitions to available' do
document = build_pdf_document(status: :in_progress, content: nil)
document.save!
clear_enqueued_jobs
expect do
document.update!(status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when content updates without status change' do
document = build_pdf_document(status: :available, content: nil)
document.save!
clear_enqueued_jobs
expect do
document.update!(content: 'Extracted PDF text')
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
it 'does not enqueue when the document is destroyed' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect do
document.destroy!
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
end
@@ -1,17 +1,24 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Captain::PromptRenderer do
let(:template_name) { 'test_template' }
let(:template_content) { 'Hello {{name}}, your balance is {{balance}}' }
let(:template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
let(:enterprise_template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
let(:oss_template_path) { Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid") }
let(:context) { { name: 'John', balance: 100 } }
before do
if defined?(Enterprise::Captain::PromptRenderer) &&
!Captain::PromptRenderer.singleton_class.ancestors.include?(Enterprise::Captain::PromptRenderer)
Captain::PromptRenderer.singleton_class.prepend(Enterprise::Captain::PromptRenderer)
end
allow(File).to receive(:exist?).and_return(false)
allow(File).to receive(:exist?).with(template_path).and_return(true)
allow(File).to receive(:read).with(template_path).and_return(template_content)
allow(File).to receive(:read)
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(false)
allow(File).to receive(:exist?).with(oss_template_path).and_return(true)
allow(File).to receive(:read).with(oss_template_path).and_return(template_content)
end
describe '.render' do
@@ -39,7 +46,7 @@ RSpec.describe Captain::PromptRenderer do
nested_template = 'User: {{user.name}}, Account: {{user.account.type}}'
nested_context = { user: { name: 'Alice', account: { type: 'premium' } } }
allow(File).to receive(:read).with(template_path).and_return(nested_template)
allow(File).to receive(:read).with(oss_template_path).and_return(nested_template)
result = described_class.render(template_name, nested_context)
@@ -48,7 +55,7 @@ RSpec.describe Captain::PromptRenderer do
it 'handles empty context' do
simple_template = 'Hello World'
allow(File).to receive(:read).with(template_path).and_return(simple_template)
allow(File).to receive(:read).with(oss_template_path).and_return(simple_template)
result = described_class.render(template_name, {})
@@ -65,30 +72,41 @@ RSpec.describe Captain::PromptRenderer do
expect(result).to eq('rendered')
expect(Liquid::Template).to have_received(:parse).with(template_content)
end
it 'prefers enterprise template when available' do
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
allow(File).to receive(:read).with(enterprise_template_path).and_return('Enterprise {{name}}')
result = described_class.render(template_name, context)
expect(result).to eq('Enterprise John')
expect(File).to have_received(:read).with(enterprise_template_path)
expect(File).not_to have_received(:read).with(oss_template_path)
end
end
describe '.load_template' do
it 'reads template file from correct path' do
it 'reads template file from OSS path when enterprise file absent' do
described_class.send(:load_template, template_name)
expect(File).to have_received(:read).with(template_path)
expect(File).to have_received(:read).with(oss_template_path)
end
it 'raises error when template does not exist' do
allow(File).to receive(:exist?).with(template_path).and_return(false)
it 'raises error when template does not exist in either location' do
allow(File).to receive(:exist?).with(oss_template_path).and_return(false)
expect { described_class.send(:load_template, template_name) }
.to raise_error("Template not found: #{template_name}")
end
it 'constructs correct template path' do
expected_path = Rails.root.join('enterprise/lib/captain/prompts/my_template.liquid')
allow(File).to receive(:exist?).with(expected_path).and_return(true)
allow(File).to receive(:read).with(expected_path).and_return('test content')
it 'checks enterprise path before OSS path' do
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
allow(File).to receive(:read).with(enterprise_template_path).and_return('enterprise')
described_class.send(:load_template, 'my_template')
described_class.send(:load_template, template_name)
expect(File).to have_received(:exist?).with(expected_path)
expect(File).to have_received(:exist?).with(enterprise_template_path)
expect(File).not_to have_received(:read).with(oss_template_path)
end
end
@@ -0,0 +1,250 @@
require 'rails_helper'
RSpec.describe Captain::Assistant::ChangeToneService do
let(:text) { 'Hey! Just checking if you got my email about the project' }
let(:tone) { 'professional' }
let(:service) { described_class.new(text: text, tone: tone) }
let(:agent) { instance_double(Agents::Agent) }
let(:runner) { instance_double(Agents::Runner) }
let(:result) do
instance_double(Agents::RunResult,
output: {
'rewritten_text' => 'Good afternoon. I am following up regarding my previous email about the project.',
'tone_applied' => 'professional'
},
error: nil)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
allow(Agents::Agent).to receive(:new).and_return(agent)
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
allow(Captain::PromptRenderer).to receive(:render).and_return('tone prompt')
end
describe '#initialize' do
it 'initializes with text and tone' do
expect(service.instance_variable_get(:@text)).to eq(text)
expect(service.instance_variable_get(:@tone)).to eq('professional')
end
it 'converts tone to lowercase' do
service = described_class.new(text: text, tone: 'PROFESSIONAL')
expect(service.instance_variable_get(:@tone)).to eq('professional')
end
context 'with invalid tone' do
it 'raises ArgumentError' do
expect do
described_class.new(text: text, tone: 'invalid_tone')
end.to raise_error(ArgumentError, /Unsupported tone: invalid_tone/)
end
it 'includes supported tones in error message' do
expect do
described_class.new(text: text, tone: 'invalid')
end.to raise_error(ArgumentError, /professional, casual, straightforward, confident, friendly/)
end
end
end
describe '#execute' do
context 'when successful' do
it 'renders the tone prompt with correct context' do
expect(Captain::PromptRenderer).to receive(:render).with(
'rewrite/tone',
hash_including(tone: 'professional')
)
service.execute
end
it 'builds agent with correct parameters' do
expect(Agents::Agent).to receive(:new).with(
name: 'ToneChanger',
instructions: 'tone prompt',
model: 'gpt-4o-mini',
response_schema: service.send(:response_schema)
)
service.execute
end
it 'returns success response with rewritten text' do
response = service.execute
expect(response[:success]).to be true
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
expect(response[:tone]).to eq('professional')
expect(response[:original_text]).to eq(text)
end
end
context 'with different tones' do
%w[professional casual straightforward confident friendly].each do |test_tone|
it "works with #{test_tone} tone" do
service = described_class.new(text: text, tone: test_tone)
allow(Agents::Agent).to receive(:new).and_return(agent)
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
expect(Captain::PromptRenderer).to receive(:render).with(
'rewrite/tone',
hash_including(tone: test_tone)
)
service.execute
end
end
end
context 'when agent returns an error' do
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('Model error')
expect(response[:original_text]).to eq(text)
end
end
context 'when exception is raised' do
before do
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with(/ChangeToneService error: API timeout/)
expect(Rails.logger).to receive(:error).with(anything)
service.execute
end
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('API timeout')
end
end
end
describe '#agent_name' do
it 'returns ToneChanger' do
expect(service.send(:agent_name)).to eq('ToneChanger')
end
end
describe '#build_instructions' do
it 'renders the tone template with tone context' do
expect(Captain::PromptRenderer).to receive(:render).with(
'rewrite/tone',
hash_including(tone: 'professional')
)
service.send(:build_instructions)
end
end
describe '#response_schema' do
let(:schema) { service.send(:response_schema) }
it 'defines object type' do
expect(schema[:type]).to eq('object')
end
it 'includes rewritten_text property' do
expect(schema[:properties][:rewritten_text]).to include(
type: 'string',
description: 'The rewritten text with the requested tone applied'
)
end
it 'includes tone_applied property' do
expect(schema[:properties][:tone_applied]).to include(
type: 'string',
description: 'The tone that was applied to the text'
)
end
it 'marks both fields as required' do
expect(schema[:required]).to match_array(%w[rewritten_text tone_applied])
end
it 'disallows additional properties' do
expect(schema[:additionalProperties]).to be false
end
end
describe '#build_success_response' do
let(:output) do
{
'rewritten_text' => 'Rewritten content',
'tone_applied' => 'professional'
}
end
it 'extracts rewritten_text from output' do
response = service.send(:build_success_response, output)
expect(response[:rewritten_text]).to eq('Rewritten content')
end
it 'includes the requested tone' do
response = service.send(:build_success_response, output)
expect(response[:tone]).to eq('professional')
end
it 'includes original text' do
response = service.send(:build_success_response, output)
expect(response[:original_text]).to eq(text)
end
it 'marks response as successful' do
response = service.send(:build_success_response, output)
expect(response[:success]).to be true
end
end
describe 'SUPPORTED_TONES constant' do
it 'includes all valid tones' do
expect(described_class::SUPPORTED_TONES).to match_array(
%w[professional casual straightforward confident friendly]
)
end
end
describe 'End-to-End Tests' do
context 'when making real API call (stubbed at HTTP level)' do
let(:openai_response) do
{
id: 'chatcmpl-123',
object: 'chat.completion',
created: 1_677_652_288,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: JSON.generate({
rewritten_text: 'Good afternoon. I am following up regarding my previous email about the project.',
tone_applied: 'professional'
})
},
finish_reason: 'stop'
}
]
}
end
before do
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
end
it 'successfully changes tone through entire stack' do
response = service.execute
expect(response[:success]).to be true
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
expect(response[:tone]).to eq('professional')
expect(response[:original_text]).to eq(text)
end
end
end
end
@@ -0,0 +1,222 @@
require 'rails_helper'
RSpec.describe Captain::Assistant::ConversationSummaryService do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:service) { described_class.new(conversation: conversation) }
let(:agent) { instance_double(Agents::Agent) }
let(:runner) { instance_double(Agents::Runner) }
let(:result) do
instance_double(Agents::RunResult,
output: {
'customer_intent' => 'Customer wants to reset their password',
'conversation_summary' => 'The customer contacted support to reset their password. ' \
'Agent provided instructions and confirmed the reset was successful.',
'action_items' => ['Send password reset email', 'Verify account security'],
'follow_up_items' => ['Check if customer needs additional security settings']
},
error: nil)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
create(:message, conversation: conversation, message_type: :incoming, content: 'I need to reset my password')
create(:message, conversation: conversation, message_type: :outgoing, content: 'I can help you with that')
allow(Agents::Agent).to receive(:new).and_return(agent)
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
allow(Captain::PromptRenderer).to receive(:render).and_return('summary prompt')
end
describe '#initialize' do
it 'initializes with conversation' do
expect(service.instance_variable_get(:@conversation)).to eq(conversation)
end
it 'formats conversation messages as text' do
text = service.instance_variable_get(:@text)
expect(text).to include('I need to reset my password')
expect(text).to include('I can help you with that')
end
end
describe '#execute' do
context 'when successful' do
it 'renders the summary prompt' do
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
service.execute
end
it 'builds agent with correct parameters' do
expect(Agents::Agent).to receive(:new).with(
name: 'ConversationSummarizer',
instructions: 'summary prompt',
model: 'gpt-4o-mini',
response_schema: service.send(:response_schema)
)
service.execute
end
it 'returns success response with structured data' do
response = service.execute
expect(response[:success]).to be true
expect(response[:structured_data][:customer_intent]).to eq('Customer wants to reset their password')
expect(response[:structured_data][:conversation_summary]).to include('contacted support')
expect(response[:structured_data][:action_items]).to include('Send password reset email')
expect(response[:structured_data][:follow_up_items]).to include('Check if customer needs additional security settings')
end
it 'includes markdown formatted summary' do
response = service.execute
expect(response[:summary]).to include('**Customer Intent**')
expect(response[:summary]).to include('**Conversation Summary**')
expect(response[:summary]).to include('**Action Items**')
expect(response[:summary]).to include('**Follow-up Items**')
end
end
context 'when agent returns an error' do
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('Model error')
end
end
context 'when exception is raised' do
before do
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with(/ConversationSummaryService error: API timeout/)
expect(Rails.logger).to receive(:error).with(anything)
service.execute
end
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('API timeout')
end
end
end
describe '#agent_name' do
it 'returns ConversationSummarizer' do
expect(service.send(:agent_name)).to eq('ConversationSummarizer')
end
end
describe '#build_instructions' do
it 'renders the summary template' do
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
service.send(:build_instructions)
end
end
describe '#response_schema' do
let(:schema) { service.send(:response_schema) }
it 'defines object type' do
expect(schema[:type]).to eq('object')
end
it 'includes customer_intent property' do
expect(schema[:properties][:customer_intent]).to include(
type: 'string',
description: 'Brief description of what the customer wants (around 50 words)'
)
end
it 'includes conversation_summary property' do
expect(schema[:properties][:conversation_summary]).to include(
type: 'string',
description: 'Summary of the conversation in approximately 200 words'
)
end
it 'includes action_items property' do
expect(schema[:properties][:action_items][:type]).to eq('array')
expect(schema[:properties][:action_items][:items][:type]).to eq('string')
end
it 'includes follow_up_items property' do
expect(schema[:properties][:follow_up_items][:type]).to eq('array')
expect(schema[:properties][:follow_up_items][:items][:type]).to eq('string')
end
it 'marks required fields' do
expect(schema[:required]).to match_array(%w[customer_intent conversation_summary])
end
it 'disallows additional properties' do
expect(schema[:additionalProperties]).to be false
end
end
describe '#build_success_response' do
let(:output) do
{
'customer_intent' => 'Reset password',
'conversation_summary' => 'Password reset conversation',
'action_items' => ['Item 1', 'Item 2'],
'follow_up_items' => ['Follow-up 1']
}
end
it 'extracts customer_intent from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:customer_intent]).to eq('Reset password')
end
it 'extracts conversation_summary from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:conversation_summary]).to eq('Password reset conversation')
end
it 'extracts action_items from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:action_items]).to eq(['Item 1', 'Item 2'])
end
it 'extracts follow_up_items from output' do
response = service.send(:build_success_response, output)
expect(response[:structured_data][:follow_up_items]).to eq(['Follow-up 1'])
end
it 'builds markdown summary' do
response = service.send(:build_success_response, output)
expect(response[:summary]).to include('**Customer Intent**')
expect(response[:summary]).to include('Reset password')
end
it 'marks response as successful' do
response = service.send(:build_success_response, output)
expect(response[:success]).to be true
end
end
describe '#format_conversation_messages' do
it 'includes incoming and outgoing messages' do
text = service.send(:format_conversation_messages)
expect(text).to include('Customer')
expect(text).to include('Agent')
end
it 'excludes private messages' do
create(:message, conversation: conversation, message_type: :incoming, content: 'Private note', private: true)
text = service.send(:format_conversation_messages)
expect(text).not_to include('Private note')
end
it 'respects token limit' do
long_content = 'a' * 20_000
create(:message, conversation: conversation, message_type: :incoming, content: long_content)
text = service.send(:format_conversation_messages)
expect(text.length).to be <= described_class::TOKEN_LIMIT
end
end
end
@@ -0,0 +1,249 @@
require 'rails_helper'
RSpec.describe Captain::Assistant::FixGrammarService do
let(:text) { 'their going too the store tommorow and there bringing they\'re friends' }
let(:service) { described_class.new(text: text) }
let(:agent) { instance_double(Agents::Agent) }
let(:runner) { instance_double(Agents::Runner) }
let(:result) do
instance_double(Agents::RunResult,
output: {
'corrected_text' => 'They\'re going to the store tomorrow and they\'re bringing their friends.',
'corrections_made' => [
'Changed "their" to "They\'re"',
'Changed "too" to "to"',
'Fixed spelling: "tommorow" to "tomorrow"',
'Changed "there" to "they\'re"',
'Changed "they\'re" to "their"'
]
},
error: nil)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
allow(Agents::Agent).to receive(:new).and_return(agent)
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
allow(Captain::PromptRenderer).to receive(:render).and_return('grammar prompt')
end
describe '#initialize' do
it 'initializes with text only' do
expect(service.instance_variable_get(:@text)).to eq(text)
end
end
describe '#execute' do
context 'when successful' do
it 'renders the grammar prompt' do
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
service.execute
end
it 'builds agent with correct parameters' do
expect(Agents::Agent).to receive(:new).with(
name: 'GrammarFixer',
instructions: 'grammar prompt',
model: 'gpt-4o-mini',
response_schema: service.send(:response_schema)
)
service.execute
end
it 'returns success response with corrected text' do
response = service.execute
expect(response[:success]).to be true
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
expect(response[:original_text]).to eq(text)
end
it 'includes corrections made' do
response = service.execute
expect(response[:corrections_made]).to be_an(Array)
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
end
end
context 'when output does not include corrections_made' do
let(:result) do
instance_double(Agents::RunResult,
output: {
'corrected_text' => 'Corrected text without corrections list'
},
error: nil)
end
it 'returns empty array for corrections_made' do
response = service.execute
expect(response[:success]).to be true
expect(response[:corrections_made]).to eq([])
end
end
context 'when agent returns an error' do
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('Model error')
expect(response[:original_text]).to eq(text)
end
end
context 'when exception is raised' do
before do
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with(/FixGrammarService error: API timeout/)
expect(Rails.logger).to receive(:error).with(anything)
service.execute
end
it 'returns error response' do
response = service.execute
expect(response[:success]).to be false
expect(response[:error]).to eq('API timeout')
end
end
end
describe '#agent_name' do
it 'returns GrammarFixer' do
expect(service.send(:agent_name)).to eq('GrammarFixer')
end
end
describe '#build_instructions' do
it 'renders the grammar template with empty context' do
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
service.send(:build_instructions)
end
end
describe '#response_schema' do
let(:schema) { service.send(:response_schema) }
it 'defines object type' do
expect(schema[:type]).to eq('object')
end
it 'includes corrected_text property' do
expect(schema[:properties][:corrected_text]).to include(
type: 'string',
description: 'The text with corrected grammar, spelling, and punctuation'
)
end
it 'includes corrections_made property' do
expect(schema[:properties][:corrections_made]).to include(
type: 'array',
description: 'List of corrections that were made'
)
end
it 'marks corrected_text as required' do
expect(schema[:required]).to include('corrected_text')
end
it 'disallows additional properties' do
expect(schema[:additionalProperties]).to be false
end
end
describe '#build_success_response' do
let(:output) do
{
'corrected_text' => 'Corrected content',
'corrections_made' => ['Fix 1', 'Fix 2']
}
end
it 'extracts corrected_text from output' do
response = service.send(:build_success_response, output)
expect(response[:corrected_text]).to eq('Corrected content')
end
it 'includes corrections made' do
response = service.send(:build_success_response, output)
expect(response[:corrections_made]).to eq(['Fix 1', 'Fix 2'])
end
it 'includes original text' do
response = service.send(:build_success_response, output)
expect(response[:original_text]).to eq(text)
end
it 'marks response as successful' do
response = service.send(:build_success_response, output)
expect(response[:success]).to be true
end
end
describe '#extract_corrections' do
it 'returns array when output has corrections_made as symbol key' do
output = { corrections_made: ['Fix 1'] }
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
end
it 'returns array when output has corrections_made as string key' do
output = { 'corrections_made' => ['Fix 1'] }
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
end
it 'returns empty array when output is not a hash' do
expect(service.send(:extract_corrections, 'string')).to eq([])
end
it 'returns empty array when corrections_made is missing' do
expect(service.send(:extract_corrections, {})).to eq([])
end
end
describe 'End-to-End Tests' do
context 'when making real API call (stubbed at HTTP level)' do
let(:openai_response) do
{
id: 'chatcmpl-456',
object: 'chat.completion',
created: 1_677_652_300,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: JSON.generate({
corrected_text: 'They\'re going to the store tomorrow and they\'re bringing their friends.',
corrections_made: [
'Changed "their" to "They\'re"',
'Changed "too" to "to"',
'Fixed spelling: "tommorow" to "tomorrow"'
]
})
},
finish_reason: 'stop'
}
]
}
end
before do
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
end
it 'successfully fixes grammar through entire stack' do
response = service.execute
expect(response[:success]).to be true
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
expect(response[:original_text]).to eq(text)
end
end
end
end