Compare commits

...
7 Commits
Author SHA1 Message Date
Aakash BakhleandGitHub 38967fe75f Merge branch 'develop' into summaries 2026-02-04 17:57:25 +05:30
aakashb95 6e80f90357 Merge branch 'develop' into summaries 2026-02-03 18:52:01 +05:30
aakashb95 f0e9ffacd3 fix: summary language generation 2026-02-03 17:01:32 +05:30
aakashb95 45a1a01a96 fix: add context to prompt, get actionable 2026-02-03 16:40:25 +05:30
aakashb95 0e225d2f36 add: events for analytics 2026-01-30 14:57:35 +05:30
Pranav f45e82016d cache the summary 2026-01-29 11:45:36 -08:00
Pranav 137f0e726b Summaries in the ChatList 2026-01-29 09:56:29 -08:00
18 changed files with 599 additions and 43 deletions
@@ -37,14 +37,17 @@ class TasksAPI extends ApiClient {
/** /**
* Summarizes a conversation. * Summarizes a conversation.
* @param {string} conversationId - The conversation ID to summarize. * @param {string} conversationId - The conversation ID to summarize.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request. * @param {Object} [options] - Additional options.
* @param {boolean} [options.forceRegenerate] - Force regeneration of cached summary.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the summary. * @returns {Promise} A promise that resolves with the summary.
*/ */
summarize(conversationId, signal) { summarize(conversationId, { forceRegenerate = false, signal } = {}) {
return axios.post( return axios.post(
`${this.url}/summarize`, `${this.url}/summarize`,
{ {
conversation_display_id: conversationId, conversation_display_id: conversationId,
force_regenerate: forceRegenerate,
}, },
{ signal } { signal }
); );
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper'; import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
@@ -14,6 +15,8 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue'; import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue'; import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue'; import VoiceCallStatus from './VoiceCallStatus.vue';
import ConversationSummary from './ConversationSummary.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
activeLabel: { type: String, default: '' }, activeLabel: { type: String, default: '' },
@@ -46,8 +49,10 @@ const emit = defineEmits([
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const { t } = useI18n();
const hovered = ref(false); const hovered = ref(false);
const summaryRef = ref(null);
const showContextMenu = ref(false); const showContextMenu = ref(false);
const contextMenu = ref({ const contextMenu = ref({
x: null, x: null,
@@ -344,12 +349,15 @@ const deleteConversation = () => {
class="absolute flex flex-col ltr:right-3 rtl:left-3" class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'" :class="showMetaSection ? 'top-8' : 'top-4'"
> >
<span class="ml-auto font-normal leading-4 text-xxs"> <div class="flex items-center gap-1 ml-auto">
<TimeAgo <ConversationSummary ref="summaryRef" :chat="chat" />
:last-activity-timestamp="chat.timestamp" <span class="font-normal leading-4 text-xxs">
:created-at-timestamp="chat.created_at" <TimeAgo
/> :last-activity-timestamp="chat.timestamp"
</span> :created-at-timestamp="chat.created_at"
/>
</span>
</div>
<span <span
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9" class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'" :class="hasUnread ? 'block' : 'hidden'"
@@ -366,6 +374,26 @@ const deleteConversation = () => {
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" /> <SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template> </template>
</CardLabels> </CardLabels>
<!-- Expanded Summary Section -->
<div
v-if="summaryRef?.isExpanded"
class="mt-2 mx-2 mb-1 p-3 bg-n-alpha-1 dark:bg-n-alpha-2 rounded-lg"
>
<div v-if="summaryRef?.isLoading" class="flex items-center gap-2">
<Spinner :size="16" class="text-n-slate-10" />
<span class="text-xs text-n-slate-11">
{{ t('CHAT_LIST.SUMMARY.LOADING') }}
</span>
</div>
<div v-else-if="summaryRef?.error" class="text-xs text-n-ruby-11">
{{ summaryRef.error }}
</div>
<div
v-else-if="summaryRef?.formattedSummary"
class="text-xs text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
v-html="summaryRef.formattedSummary"
/>
</div>
</div> </div>
<ContextMenu <ContextMenu
v-if="showContextMenu" v-if="showContextMenu"
@@ -0,0 +1,116 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import Button from 'dashboard/components-next/button/Button.vue';
import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const props = defineProps({
chat: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const isLoading = ref(false);
const error = ref('');
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const cachedSummary = computed(() => props.chat?.cached_summary || '');
const cachedSummaryAt = computed(() => props.chat?.cached_summary_at || 0);
const lastActivityAt = computed(() => props.chat?.last_activity_at || 0);
const isStale = computed(() => {
if (!cachedSummaryAt.value) return true;
return lastActivityAt.value > cachedSummaryAt.value;
});
const formattedSummary = computed(() => {
return cachedSummary.value ? formatMessage(cachedSummary.value) : '';
});
const fetchSummary = async () => {
isLoading.value = true;
error.value = '';
try {
const result = await TasksAPI.summarize(props.chat.id, {
forceRegenerate: false,
});
const {
data: { message: generatedSummary },
} = result;
if (generatedSummary) {
store.dispatch('updateConversationCachedSummary', {
conversationId: props.chat.id,
cachedSummary: generatedSummary,
cachedSummaryAt: Math.floor(Date.now() / 1000),
});
}
} catch (e) {
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
error.value = e.response?.data?.error || t('CHAT_LIST.SUMMARY.ERROR');
}
} finally {
isLoading.value = false;
}
};
const toggleSummary = async () => {
if (isExpanded.value) {
isExpanded.value = false;
return;
}
isExpanded.value = true;
useTrack(CAPTAIN_EVENTS.SUMMARIZE_USED, {
conversationId: props.chat.id,
uiFrom: 'conversation_list',
});
// Only fetch if no cached summary
if (!cachedSummary.value && !error.value) {
await fetchSummary();
}
};
const onButtonClick = e => {
e.stopPropagation();
toggleSummary();
};
defineExpose({
isExpanded,
isLoading,
error,
formattedSummary,
captainTasksEnabled,
isStale,
});
</script>
<template>
<Button
v-if="captainTasksEnabled"
icon="i-material-symbols-auto-awesome"
slate
ghost
xs
:title="t('CHAT_LIST.SUMMARY.TITLE')"
class="opacity-0 group-hover:opacity-100 transition-opacity"
:class="{ '!opacity-100': isExpanded }"
@click="onButtonClick"
/>
</template>
@@ -34,11 +34,21 @@ function getEventPrefix(action) {
* @param {string} action - The action type * @param {string} action - The action type
* @param {number} conversationId - The conversation ID * @param {number} conversationId - The conversation ID
* @param {number} [followUpCount] - Optional follow-up count * @param {number} [followUpCount] - Optional follow-up count
* @param {string} [uiFrom] - Optional UI source identifier
* @returns {Object} The payload object * @returns {Object} The payload object
*/ */
function buildPayload(action, conversationId, followUpCount = undefined) { function buildPayload(
action,
conversationId,
followUpCount = undefined,
uiFrom = undefined
) {
const payload = { conversationId }; const payload = { conversationId };
if (uiFrom) {
payload.uiFrom = uiFrom;
}
// Add operation for rewrite actions // Add operation for rewrite actions
if (REWRITE_ACTIONS.includes(action)) { if (REWRITE_ACTIONS.includes(action)) {
payload.operation = action; payload.operation = action;
@@ -75,6 +85,7 @@ export function useCopilotReply() {
const trackedConversationId = ref(null); const trackedConversationId = ref(null);
const conversationId = computed(() => currentChat.value?.id); const conversationId = computed(() => currentChat.value?.id);
const uiFrom = 'editor';
const isActive = computed(() => showEditor.value || isGenerating.value); const isActive = computed(() => showEditor.value || isGenerating.value);
const isButtonDisabled = computed( const isButtonDisabled = computed(
@@ -97,7 +108,8 @@ export function useCopilotReply() {
buildPayload( buildPayload(
currentAction.value, currentAction.value,
trackedConversationId.value, trackedConversationId.value,
followUpCount.value followUpCount.value,
uiFrom
) )
); );
} }
@@ -168,7 +180,7 @@ export function useCopilotReply() {
const eventKey = `${getEventPrefix(action)}_USED`; const eventKey = `${getEventPrefix(action)}_USED`;
useTrack( useTrack(
CAPTAIN_EVENTS[eventKey], CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value) buildPayload(action, trackedConversationId.value, undefined, uiFrom)
); );
} }
isGenerating.value = false; isGenerating.value = false;
@@ -194,6 +206,7 @@ export function useCopilotReply() {
// Track follow-up sent event // Track follow-up sent event
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, { useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
conversationId: trackedConversationId.value, conversationId: trackedConversationId.value,
uiFrom,
}); });
followUpCount.value += 1; followUpCount.value += 1;
@@ -237,7 +250,8 @@ export function useCopilotReply() {
buildPayload( buildPayload(
currentAction.value, currentAction.value,
trackedConversationId.value, trackedConversationId.value,
followUpCount.value followUpCount.value,
uiFrom
) )
); );
} }
@@ -2,6 +2,7 @@ import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store'; import { useStore, useStoreGetters } from 'dashboard/composables/store';
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_summary' },
{ name: 'conversation_actions' }, { name: 'conversation_actions' },
{ name: 'macros' }, { name: 'macros' },
{ name: 'conversation_info' }, { name: 'conversation_info' },
@@ -137,6 +137,11 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text", "HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text", "SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read", "MESSAGE_READ": "Read",
"SENDING": "Sending" "SENDING": "Sending",
"SUMMARY": {
"TITLE": "Generate Summary",
"LOADING": "Generating summary...",
"ERROR": "Failed to generate summary"
}
} }
} }
@@ -349,6 +349,7 @@
"CONVERSATION_ACTIONS": "Conversation Actions", "CONVERSATION_ACTIONS": "Conversation Actions",
"CONVERSATION_LABELS": "Conversation Labels", "CONVERSATION_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information", "CONVERSATION_INFO": "Conversation Information",
"CONVERSATION_SUMMARY": "AI Summary",
"CONTACT_NOTES": "Contact Notes", "CONTACT_NOTES": "Contact Notes",
"CONTACT_ATTRIBUTES": "Contact Attributes", "CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations", "PREVIOUS_CONVERSATION": "Previous Conversations",
@@ -356,6 +357,16 @@
"LINEAR_ISSUES": "Linked Linear Issues", "LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders" "SHOPIFY_ORDERS": "Shopify Orders"
}, },
"SUMMARY": {
"DESCRIPTION": "Generate an AI-powered summary of this conversation",
"GENERATE": "Generate Summary",
"REGENERATE": "Regenerate",
"REFRESH": "Refresh",
"RETRY": "Retry",
"STALE": "Summary may be outdated",
"ERROR": "Failed to generate summary",
"EMPTY": "No summary available"
},
"SHOPIFY": { "SHOPIFY": {
"ORDER_ID": "Order #{id}", "ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders", "ERROR": "Error loading orders",
@@ -13,6 +13,7 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue'; import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue'; import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue'; import ConversationParticipant from './ConversationParticipant.vue';
import ConversationSummary from './ConversationSummary.vue';
import ContactInfo from './contact/ContactInfo.vue'; import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue'; import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue'; import ConversationInfo from './ConversationInfo.vue';
@@ -44,6 +45,7 @@ const {
const dragging = ref(false); const dragging = ref(false);
const conversationSidebarItems = ref([]); const conversationSidebarItems = ref([]);
const summaryRef = ref(null);
const shopifyIntegration = useFunctionGetter( const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration', 'integrations/getIntegration',
@@ -60,6 +62,10 @@ const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR) isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
); );
const isCaptainTasksEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS)
);
const linearIntegration = useFunctionGetter( const linearIntegration = useFunctionGetter(
'integrations/getIntegration', 'integrations/getIntegration',
'linear' 'linear'
@@ -150,7 +156,31 @@ onMounted(() => {
> >
<template #item="{ element }"> <template #item="{ element }">
<div <div
v-if="element.name === 'conversation_actions'" v-if="
element.name === 'conversation_summary' && isCaptainTasksEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_SUMMARY')"
:is-open="isContactSidebarItemOpen('is_conv_summary_open')"
compact
@toggle="
value => {
toggleSidebarUIState('is_conv_summary_open', value);
if (value && summaryRef) {
summaryRef.fetchSummary();
}
}
"
>
<ConversationSummary
ref="summaryRef"
:conversation-id="conversationId"
/>
</AccordionItem>
</div>
<div
v-else-if="element.name === 'conversation_actions'"
class="conversation--actions" class="conversation--actions"
> >
<AccordionItem <AccordionItem
@@ -0,0 +1,179 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const { formatMessage } = useMessageFormatter();
const currentChat = useMapGetter('getSelectedChat');
const isLoading = ref(false);
const error = ref('');
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const cachedSummary = computed(() => currentChat.value?.cached_summary || '');
const cachedSummaryAt = computed(
() => currentChat.value?.cached_summary_at || 0
);
const lastActivityAt = computed(() => currentChat.value?.last_activity_at || 0);
const uiFrom = 'conversation_sidebar';
const isStale = computed(() => {
if (!cachedSummaryAt.value) return true;
return lastActivityAt.value > cachedSummaryAt.value;
});
const hasSummary = computed(() => !!cachedSummary.value);
const formattedSummary = computed(() => {
return cachedSummary.value ? formatMessage(cachedSummary.value) : '';
});
const fetchSummary = async (forceRegenerate = false) => {
if (!captainTasksEnabled.value) return;
isLoading.value = true;
error.value = '';
try {
const result = await TasksAPI.summarize(props.conversationId, {
forceRegenerate,
});
const {
data: { message: generatedSummary },
} = result;
if (generatedSummary) {
store.dispatch('updateConversationCachedSummary', {
conversationId: currentChat.value.id,
cachedSummary: generatedSummary,
cachedSummaryAt: Math.floor(Date.now() / 1000),
});
}
} catch (e) {
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
error.value =
e.response?.data?.error || t('CONVERSATION_SIDEBAR.SUMMARY.ERROR');
}
} finally {
isLoading.value = false;
}
};
const trackSummary = action => {
useTrack(CAPTAIN_EVENTS.SUMMARIZE_USED, {
conversationId: props.conversationId,
uiFrom,
action,
});
};
const generateSummary = () => {
trackSummary('generate');
return fetchSummary(false);
};
const regenerate = () => {
trackSummary('regenerate');
return fetchSummary(true);
};
const retryGenerate = () => {
trackSummary('retry');
return fetchSummary(true);
};
watch(
() => props.conversationId,
() => {
error.value = '';
}
);
defineExpose({
fetchSummary,
captainTasksEnabled,
cachedSummary,
isStale,
});
</script>
<template>
<div v-if="captainTasksEnabled" class="p-3">
<div v-if="isLoading" class="flex items-center justify-center py-4">
<Spinner :size="20" class="text-n-slate-10" />
</div>
<div v-else-if="error" class="text-sm text-n-ruby-11">
{{ error }}
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.RETRY')"
size="sm"
variant="link"
class="ml-2"
@click="retryGenerate"
/>
</div>
<div v-else-if="!hasSummary" class="flex flex-col items-center gap-3 py-2">
<p class="text-sm text-n-slate-11 text-center mb-0">
{{ t('CONVERSATION_SIDEBAR.SUMMARY.DESCRIPTION') }}
</p>
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.GENERATE')"
icon="i-material-symbols-auto-awesome"
size="sm"
@click="generateSummary"
/>
</div>
<template v-else>
<div
v-if="isStale"
class="flex items-center gap-2 mb-2 text-xs text-n-amber-11"
>
<span>{{ t('CONVERSATION_SIDEBAR.SUMMARY.STALE') }}</span>
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REFRESH')"
size="xs"
variant="link"
@click="regenerate"
/>
</div>
<div
class="summary-content text-sm text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-1 [&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
v-html="formattedSummary"
/>
<div class="mt-3 pt-3 border-t border-n-weak">
<Button
:label="t('CONVERSATION_SIDEBAR.SUMMARY.REGENERATE')"
icon="i-lucide-refresh-cw"
size="sm"
variant="faded"
color="slate"
@click="regenerate"
/>
</div>
</template>
</div>
</template>
@@ -412,6 +412,17 @@ const actions = {
}); });
}, },
updateConversationCachedSummary(
{ commit },
{ conversationId, cachedSummary, cachedSummaryAt }
) {
commit(types.UPDATE_CONVERSATION_CACHED_SUMMARY, {
conversationId,
cachedSummary,
cachedSummaryAt,
});
},
setChatStatusFilter({ commit }, data) { setChatStatusFilter({ commit }, data) {
commit(types.CHANGE_CHAT_STATUS_FILTER, data); commit(types.CHANGE_CHAT_STATUS_FILTER, data);
}, },
@@ -116,6 +116,16 @@ export const mutations = {
chat.last_activity_at = lastActivityAt; chat.last_activity_at = lastActivityAt;
} }
}, },
[types.UPDATE_CONVERSATION_CACHED_SUMMARY](
_state,
{ conversationId, cachedSummary, cachedSummaryAt }
) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
if (chat) {
chat.cached_summary = cachedSummary;
chat.cached_summary_at = cachedSummaryAt;
}
},
[types.ASSIGN_PRIORITY](_state, { priority, conversationId }) { [types.ASSIGN_PRIORITY](_state, { priority, conversationId }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId); const [chat] = _state.allConversations.filter(c => c.id === conversationId);
chat.priority = priority; chat.priority = priority;
@@ -51,6 +51,7 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES: UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES', 'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY', UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
UPDATE_CONVERSATION_CACHED_SUMMARY: 'UPDATE_CONVERSATION_CACHED_SUMMARY',
UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS', UPDATE_CONVERSATION_CALL_STATUS: 'UPDATE_CONVERSATION_CALL_STATUS',
UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS', UPDATE_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES', SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
@@ -56,6 +56,8 @@ json.first_reply_created_at conversation.first_reply_created_at.to_i
json.unread_count conversation.unread_incoming_messages.count json.unread_count conversation.unread_incoming_messages.count
json.last_non_activity_message conversation.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data) json.last_non_activity_message conversation.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data)
json.last_activity_at conversation.last_activity_at.to_i json.last_activity_at conversation.last_activity_at.to_i
json.cached_summary conversation.cached_summary
json.cached_summary_at conversation.cached_summary_at.to_i
json.priority conversation.priority json.priority conversation.priority
json.waiting_since conversation.waiting_since.to_i.to_i json.waiting_since conversation.waiting_since.to_i.to_i
json.sla_policy_id conversation.sla_policy_id json.sla_policy_id conversation.sla_policy_id
@@ -0,0 +1,6 @@
class AddCachedSummaryToConversations < ActiveRecord::Migration[7.1]
def change
add_column :conversations, :cached_summary, :text
add_column :conversations, :cached_summary_at, :datetime
end
end
+2
View File
@@ -681,6 +681,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do
t.datetime "waiting_since" t.datetime "waiting_since"
t.text "cached_label_list" t.text "cached_label_list"
t.bigint "assignee_agent_bot_id" t.bigint "assignee_agent_bot_id"
t.text "cached_summary"
t.datetime "cached_summary_at"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
@@ -15,7 +15,8 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
def summarize def summarize
result = Captain::SummaryService.new( result = Captain::SummaryService.new(
account: Current.account, account: Current.account,
conversation_display_id: params[:conversation_display_id] conversation_display_id: params[:conversation_display_id],
force_regenerate: params[:force_regenerate].present?
).perform ).perform
render_result(result) render_result(result)
+73 -8
View File
@@ -1,14 +1,10 @@
class Captain::SummaryService < Captain::BaseTaskService class Captain::SummaryService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!] pattr_initialize [:account!, :conversation_display_id!, { force_regenerate: false }]
def perform def perform
make_api_call( return cached_response if use_cache?
model: GPT_MODEL,
messages: [ generate_and_cache_summary
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
]
)
end end
private private
@@ -16,4 +12,73 @@ class Captain::SummaryService < Captain::BaseTaskService
def event_name def event_name
'summarize' 'summarize'
end end
def use_cache?
return false if force_regenerate
return false if conversation.cached_summary.blank?
return false if conversation.cached_summary_at.blank?
conversation.cached_summary_at >= conversation.last_activity_at
end
def cached_response
{ message: conversation.cached_summary }
end
def generate_and_cache_summary
msg_count = conversation_message_count
result = make_api_call(
model: summary_model(msg_count),
messages: [
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: build_summary_content(msg_count) }
]
)
cache_summary(result[:message]) if result[:message].present? && result[:error].blank?
result
end
def conversation_message_count
conversation.messages.where(message_type: [:incoming, :outgoing]).count
end
def summary_model(msg_count)
msg_count < 7 ? GPT_MODEL : 'gpt-4.1'
end
def build_summary_content(msg_count)
llm_text = conversation.to_llm_text(include_contact_details: false)
context = build_conversation_context(msg_count)
context.present? ? "#{llm_text}\n\n#{context}" : llm_text
end
def build_conversation_context(msg_count)
['Conversation Stats:', *context_fields(msg_count).compact].join("\n")
end
def context_fields(msg_count)
[
"Message count: #{msg_count}",
("Status: #{conversation.status}" if conversation.status.present?),
("Priority: #{conversation.priority}" if conversation.priority.present?),
("Labels: #{conversation.cached_label_list}" if conversation.cached_label_list.present?),
*account_context_fields
]
end
def account_context_fields
[
("Account industry: #{account.custom_attributes['industry']}" if account.custom_attributes&.dig('industry').present?),
("Summary language: #{account.locale}" if account.locale.present?)
]
end
def cache_summary(summary)
conversation.update(
cached_summary: summary,
cached_summary_at: Time.current
)
end
end end
@@ -1,28 +1,99 @@
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. <prompt>
<role>AI support conversation summarizer</role>
Make sure you strongly adhere to the following rules when generating the summary <goal>
Produce a high-signal bullet summary that lets a new agent understand the CURRENT STATE and know what to do next — in under 10 seconds.
Earlier bullets convey what happened and what the current state is. The final bullet is always the actionable next step.
</goal>
1. Be brief and concise. The shorter the summary the better. <output_contract>
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read. <format>bullet_list</format>
3. Describe the customer intent in around 50 words. <only_output_bullets>true</only_output_bullets>
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. <bullet_count_rules>
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell. <rule>Count the messages in the conversation to determine bullet limits.</rule>
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible. <rule>1-3 messages → 1 bullet max</rule>
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. <rule>4-10 messages → 2-3 bullets max</rule>
8. The 'Action Items' should be brief and concise <rule>11-30 messages → 3-4 bullets max</rule>
9. Mark important words or parts of sentences as bold. <rule>31+ messages → 5-6 bullets max</rule>
10. Apply markdown syntax to format any included code, using backticks. </bullet_count_rules>
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions. <bullet_rules>
12. If any section does not have any content, remove that section and the heading from the response <single_sentence>true</single_sentence>
13. Do not insert your own opinions about the conversation. <concise>true</concise>
<no_redundancy>true</no_redundancy>
<no_prefixes>Do not prefix bullets with labels like "Next step:" or "Issue:" — ordering alone conveys structure.</no_prefixes>
</bullet_rules>
<language>
<rule>ALWAYS write the summary in the language specified by "Summary language" in the conversation context. This overrides the conversation language.</rule>
<rule>If no summary language is provided, match the primary language used in the conversation.</rule>
</language>
<markdown>
<bold_required>true</bold_required>
<code_in_backticks>true</code_in_backticks>
<no_headings>true</no_headings>
</markdown>
</output_contract>
<what_to_capture>
<priority_order>
<item>What is the customer trying to achieve right now?</item>
<item>What is the current blocker/problem?</item>
<item>What has already been done that changed the state?</item>
<item>What is the actionable next step (who does what)?</item>
</priority_order>
<skip_rule>If a priority item has no evidence in the conversation, skip it entirely — do not invent or guess.</skip_rule>
<last_bullet_rule>The final bullet must always be an actionable next step derived from the conversation.</last_bullet_rule>
</what_to_capture>
Reply in the user's language, as a markdown of the following format. <context_awareness>
<business_context>If account industry or business context is provided, adapt the summary focus accordingly (e.g., e-commerce → order/payment/shipping; SaaS → features/bugs/subscriptions; healthcare → appointments/records).</business_context>
<labels>If conversation labels are provided, use them to understand the issue category and focus the summary on what matters for that category.</labels>
<adapt>Only use context that is explicitly provided. Do not assume industry or category if not stated.</adapt>
</context_awareness>
**Customer Intent** <importance_filter>
<include_only_if_actionable_or_state_changing>
<rule>A bullet must describe a blocker, a confirmed action taken, a decision, or a required next step.</rule>
<rule>Would removing this bullet prevent the agent from acting correctly? If no, omit it.</rule>
</include_only_if_actionable_or_state_changing>
<drop_as_noise>
<item>Greetings, thanks, apologies, pleasantries, offers of help.</item>
<item>Repeated statements that do not add new information.</item>
<item>Meta commentary like "the current blocker is…" if it repeats an earlier bullet.</item>
</drop_as_noise>
</importance_filter>
**Conversation Summary** <anti_hallucination>
<hard_rules>
<item>Use ONLY information explicitly present in the conversation.</item>
<item>Do NOT compute or derive facts (no time math like "15 minutes from now", no inferred causes).</item>
<item>Do NOT invent resolution, closure, or "no further steps" statements.</item>
<item>Do NOT restate the same fact in multiple bullets; merge into one.</item>
</hard_rules>
</anti_hallucination>
**Action Items** <formatting_rules>
<bold_usage>
<rule>Bold the key nouns/verbs (problem, blocker, action, next step, key artifact).</rule>
<examples>
<example>**Meeting invite** not received.</example>
<example>Agent **sent calendar invite** for **4:45 PM GMT+1**.</example>
</examples>
</bold_usage>
<role_words>
<rule>Prefer **Customer** and **Agent** over "user" and "support agent".</rule>
</role_words>
</formatting_rules>
**Follow-up Items** <final_validation>
<checklist>
<item>Every bullet is one sentence.</item>
<item>Every bullet contains at least one **bold** phrase.</item>
<item>No bullet contains inferred/derived info.</item>
<item>No two bullets repeat the same meaning.</item>
<item>Last bullet is an actionable next step.</item>
<item>Summary language matches "Summary language" from context (if provided), otherwise matches conversation language.</item>
<item>Bullet count respects the scaling rules for the message count.</item>
</checklist>
<reject_if_fails>true</reject_if_fails>
</final_validation>
</prompt>