Compare 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.
* @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.
*/
summarize(conversationId, signal) {
summarize(conversationId, { forceRegenerate = false, signal } = {}) {
return axios.post(
`${this.url}/summarize`,
{
conversation_display_id: conversationId,
force_regenerate: forceRegenerate,
},
{ signal }
);
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
@@ -14,6 +15,8 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import ConversationSummary from './ConversationSummary.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@@ -46,8 +49,10 @@ const emit = defineEmits([
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const hovered = ref(false);
const summaryRef = ref(null);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
@@ -344,12 +349,15 @@ const deleteConversation = () => {
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
<div class="flex items-center gap-1 ml-auto">
<ConversationSummary ref="summaryRef" :chat="chat" />
<span class="font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
</div>
<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="hasUnread ? 'block' : 'hidden'"
@@ -366,6 +374,26 @@ const deleteConversation = () => {
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</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>
<ContextMenu
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 {number} conversationId - The conversation ID
* @param {number} [followUpCount] - Optional follow-up count
* @param {string} [uiFrom] - Optional UI source identifier
* @returns {Object} The payload object
*/
function buildPayload(action, conversationId, followUpCount = undefined) {
function buildPayload(
action,
conversationId,
followUpCount = undefined,
uiFrom = undefined
) {
const payload = { conversationId };
if (uiFrom) {
payload.uiFrom = uiFrom;
}
// Add operation for rewrite actions
if (REWRITE_ACTIONS.includes(action)) {
payload.operation = action;
@@ -75,6 +85,7 @@ export function useCopilotReply() {
const trackedConversationId = ref(null);
const conversationId = computed(() => currentChat.value?.id);
const uiFrom = 'editor';
const isActive = computed(() => showEditor.value || isGenerating.value);
const isButtonDisabled = computed(
@@ -97,7 +108,8 @@ export function useCopilotReply() {
buildPayload(
currentAction.value,
trackedConversationId.value,
followUpCount.value
followUpCount.value,
uiFrom
)
);
}
@@ -168,7 +180,7 @@ export function useCopilotReply() {
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
buildPayload(action, trackedConversationId.value, undefined, uiFrom)
);
}
isGenerating.value = false;
@@ -194,6 +206,7 @@ export function useCopilotReply() {
// Track follow-up sent event
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
conversationId: trackedConversationId.value,
uiFrom,
});
followUpCount.value += 1;
@@ -237,7 +250,8 @@ export function useCopilotReply() {
buildPayload(
currentAction.value,
trackedConversationId.value,
followUpCount.value
followUpCount.value,
uiFrom
)
);
}
@@ -2,6 +2,7 @@ import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_summary' },
{ name: 'conversation_actions' },
{ name: 'macros' },
{ name: 'conversation_info' },
@@ -137,6 +137,11 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"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_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information",
"CONVERSATION_SUMMARY": "AI Summary",
"CONTACT_NOTES": "Contact Notes",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
@@ -356,6 +357,16 @@
"LINEAR_ISSUES": "Linked Linear Issues",
"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": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
@@ -13,6 +13,7 @@ import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ConversationSummary from './ConversationSummary.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
@@ -44,6 +45,7 @@ const {
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const summaryRef = ref(null);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
@@ -60,6 +62,10 @@ const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const isCaptainTasksEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
@@ -150,7 +156,31 @@ onMounted(() => {
>
<template #item="{ element }">
<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"
>
<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) {
commit(types.CHANGE_CHAT_STATUS_FILTER, data);
},
@@ -116,6 +116,16 @@ export const mutations = {
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 }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
chat.priority = priority;
@@ -51,6 +51,7 @@ export default {
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
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_MESSAGE_CALL_STATUS: 'UPDATE_MESSAGE_CALL_STATUS',
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.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.cached_summary conversation.cached_summary
json.cached_summary_at conversation.cached_summary_at.to_i
json.priority conversation.priority
json.waiting_since conversation.waiting_since.to_i.to_i
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.text "cached_label_list"
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", "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"
@@ -15,7 +15,8 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
def summarize
result = Captain::SummaryService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
conversation_display_id: params[:conversation_display_id],
force_regenerate: params[:force_regenerate].present?
).perform
render_result(result)
+73 -8
View File
@@ -1,14 +1,10 @@
class Captain::SummaryService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
pattr_initialize [:account!, :conversation_display_id!, { force_regenerate: false }]
def perform
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
]
)
return cached_response if use_cache?
generate_and_cache_summary
end
private
@@ -16,4 +12,73 @@ class Captain::SummaryService < Captain::BaseTaskService
def event_name
'summarize'
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
@@ -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.
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.
<output_contract>
<format>bullet_list</format>
<only_output_bullets>true</only_output_bullets>
<bullet_count_rules>
<rule>Count the messages in the conversation to determine bullet limits.</rule>
<rule>1-3 messages → 1 bullet max</rule>
<rule>4-10 messages → 2-3 bullets max</rule>
<rule>11-30 messages → 3-4 bullets max</rule>
<rule>31+ messages → 5-6 bullets max</rule>
</bullet_count_rules>
<bullet_rules>
<single_sentence>true</single_sentence>
<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>