diff --git a/app/javascript/dashboard/api/captain/tasks.js b/app/javascript/dashboard/api/captain/tasks.js index 1b5a38335..9c38c116b 100644 --- a/app/javascript/dashboard/api/captain/tasks.js +++ b/app/javascript/dashboard/api/captain/tasks.js @@ -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 } ); diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index cff40f2aa..9e0afac11 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -350,7 +350,7 @@ const deleteConversation = () => { :class="showMetaSection ? 'top-8' : 'top-4'" >
- + 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'; @@ -8,38 +9,56 @@ import TasksAPI from 'dashboard/api/captain/tasks'; import Button from 'dashboard/components-next/button/Button.vue'; const props = defineProps({ - conversationId: { - type: [Number, String], + 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 summary = ref(''); 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 summary.value ? formatMessage(summary.value) : ''; + return cachedSummary.value ? formatMessage(cachedSummary.value) : ''; }); const fetchSummary = async () => { isLoading.value = true; error.value = ''; try { - const result = await TasksAPI.summarize(props.conversationId); + const result = await TasksAPI.summarize(props.chat.id, { + forceRegenerate: false, + }); const { - data: { message: generatedMessage }, + data: { message: generatedSummary }, } = result; - summary.value = generatedMessage || ''; + + 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'); @@ -55,7 +74,8 @@ const toggleSummary = async () => { return; } isExpanded.value = true; - if (!summary.value && !error.value) { + // Only fetch if no cached summary + if (!cachedSummary.value && !error.value) { await fetchSummary(); } }; @@ -68,10 +88,10 @@ const onButtonClick = e => { defineExpose({ isExpanded, isLoading, - summary, error, formattedSummary, captainTasksEnabled, + isStale, }); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 67a17e989..896ccaab8 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -359,7 +359,9 @@ "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" }, diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ConversationSummary.vue b/app/javascript/dashboard/routes/dashboard/conversation/ConversationSummary.vue index cec253f1c..a25740426 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ConversationSummary.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ConversationSummary.vue @@ -1,6 +1,7 @@ @@ -90,11 +107,11 @@ defineExpose({ size="sm" variant="link" class="ml-2" - @click="refetch" + @click="() => fetchSummary(true)" />
-
+

{{ t('CONVERSATION_SIDEBAR.SUMMARY.DESCRIPTION') }}

@@ -102,49 +119,37 @@ defineExpose({ :label="t('CONVERSATION_SIDEBAR.SUMMARY.GENERATE')" icon="i-material-symbols-auto-awesome" size="sm" - @click="fetchSummary" + @click="() => fetchSummary(false)" />
-
- -
- {{ t('CONVERSATION_SIDEBAR.SUMMARY.EMPTY') }} -
- -
-
+
-
+
+
+
- - diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 559eaaad9..e3acc5f6f 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -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); }, diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index 8d3ef9a9a..190584060 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -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; diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 1ecafc493..cc3442f51 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -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', diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 4cb13f543..e05a1cda0 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -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 diff --git a/db/migrate/20260129180004_add_cached_summary_to_conversations.rb b/db/migrate/20260129180004_add_cached_summary_to_conversations.rb new file mode 100644 index 000000000..5f094e20b --- /dev/null +++ b/db/migrate/20260129180004_add_cached_summary_to_conversations.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 148e7769c..327110247 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do +ActiveRecord::Schema[7.1].define(version: 2026_01_29_180004) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -681,6 +681,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) 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" diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb index d7208d678..de27e5cac 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb @@ -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) diff --git a/lib/captain/summary_service.rb b/lib/captain/summary_service.rb index 16ee57b51..1e1e24f76 100644 --- a/lib/captain/summary_service.rb +++ b/lib/captain/summary_service.rb @@ -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,37 @@ 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 + result = 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) } + ] + ) + + cache_summary(result[:message]) if result[:message].present? && result[:error].blank? + + result + end + + def cache_summary(summary) + conversation.update( + cached_summary: summary, + cached_summary_at: Time.current + ) + end end diff --git a/lib/integrations/openai/openai_prompts/summary.liquid b/lib/integrations/openai/openai_prompts/summary.liquid index 3b73ca5ed..f14f73cc5 100644 --- a/lib/integrations/openai/openai_prompts/summary.liquid +++ b/lib/integrations/openai/openai_prompts/summary.liquid @@ -1,33 +1,78 @@ - Support analyst + AI support conversation summarizer - Extract the CURRENT STATE, not the conversation history. + + Produce a high-signal summary that lets a new agent understand the CURRENT STATE in under 10 seconds. + - - - What is the customer's problem or request? - - What is unresolved or pending? - - Key details needed to help (IDs, errors, configs) - + + bullet_list + true + 10 + + true + true + true + + same_as_user + + true + true + true + + - - - DO NOT narrate the conversation ("Customer said", "Agent replied", "User asked") - - DO NOT list sequence of events - - DO NOT describe what's missing or absent - - DO NOT include greetings, thanks, or pleasantries - - DO NOT add meta-commentary - + + + What is the customer trying to achieve right now? + What is the current blocker/problem? + What has already been done that changed the state? + What is the explicitly stated next step (who does what)? + + - - - 1 to 3 bullets maximum - - Direct statements about the situation - - Bold important details - - Same language as conversation - + + + A bullet must describe a blocker, a confirmed action taken, a decision, or a required next step. + If a detail does not help an agent act or understand the blocker, omit it. + + + Greetings, thanks, apologies, pleasantries, offers of help. + Repeated statements that do not add new information. + Meta commentary like “the current blocker is…” if it repeats an earlier bullet. + + - - **Billing issue**: Customer charged twice for order #12345, wants refund - **Password reset** not working - tried 3 times, no email received - Customer contacted support about billing. Agent asked for order number. Customer provided #12345. - No specific problem identified from the messages. - + + + Use ONLY information explicitly present in the conversation. + Do NOT compute or derive facts (no time math like “15 minutes from now”, no inferred causes). + Do NOT invent resolution, closure, or “no further steps” statements. + Do NOT restate the same fact in multiple bullets; merge into one. + + + + + + Bold the key nouns/verbs (problem, blocker, action, next step, key artifact). + + **Meeting invite** not received. + Agent **sent calendar invite** for **4:45 PM GMT+1**. + + + + Prefer **Customer** and **Agent** over “user” and “support agent”. + + + + + + Every bullet is one sentence. + Every bullet contains at least one **bold** phrase. + No bullet contains inferred/derived info. + No two bullets repeat the same meaning. + Bullets reflect current state and next action (if explicitly stated). + + true +