diff --git a/app/javascript/dashboard/api/integrations/openapi.js b/app/javascript/dashboard/api/integrations/openapi.js new file mode 100644 index 000000000..9f075a9ef --- /dev/null +++ b/app/javascript/dashboard/api/integrations/openapi.js @@ -0,0 +1,83 @@ +/* global axios */ + +import ApiClient from '../ApiClient'; + +/** + * Represents the data object for a OpenAI hook. + * @typedef {Object} ConversationMessageData + * @property {string} [tone] - The tone of the message. + * @property {string} [content] - The content of the message. + * @property {string} [conversation_display_id] - The display ID of the conversation (optional). + */ + +/** + * A client for the OpenAI API. + * @extends ApiClient + */ +class OpenAIAPI extends ApiClient { + /** + * Creates a new OpenAIAPI instance. + */ + constructor() { + super('integrations', { accountScoped: true }); + + /** + * The conversation events supported by the API. + * @type {string[]} + */ + this.conversation_events = [ + 'summarize', + 'reply_suggestion', + 'label_suggestion', + ]; + } + + /** + * Processes an event using the OpenAI API. + * @param {Object} options - The options for the event. + * @param {string} [options.type='improve'] - The type of event to process. + * @param {string} [options.content] - The content of the event. + * @param {string} [options.tone] - The tone of the event. + * @param {string} [options.conversationId] - The ID of the conversation to process the event for. + * @param {string} options.hookId - The ID of the hook to use for processing the event. + * @param {AbortSignal} [signal] - AbortSignal to cancel the request. + * @returns {Promise} A promise that resolves with the result of the event processing. + */ + processEvent( + { type = 'improve', content, tone, conversationId, hookId }, + signal + ) { + /** + * @type {ConversationMessageData} + */ + let data = { + tone, + content, + }; + + // Always include conversation_display_id when available for session tracking + if (conversationId) { + data.conversation_display_id = conversationId; + } + + // For conversation-level events, only send conversation_display_id + if (this.conversation_events.includes(type)) { + data = { + conversation_display_id: conversationId, + }; + } + + return axios.post( + `${this.url}/hooks/${hookId}/process_event`, + { + event: { + name: type, + data, + }, + }, + { signal } + ); + } +} + +export default new OpenAIAPI(); diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue b/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue index 04bba8f59..3cd945208 100644 --- a/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue +++ b/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue @@ -48,7 +48,7 @@ export default { this.$emit('close'); }, - async generateAIContent(type = 'rephrase') { + async generateAIContent(type = 'improve') { this.isGenerating = true; this.generatedContent = await this.processEvent(type); this.isGenerating = false; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue index d91b5bd5c..22dc5d0d2 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue @@ -163,10 +163,7 @@ const handleMenuItemClick = item => { }; const handleSubMenuItemClick = (parentItem, subItem) => { - emit('executeCopilotAction', subItem.key, { - parentKey: parentItem.key, - tone: subItem.label.toLowerCase(), - }); + emit('executeCopilotAction', subItem.key); }; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index cde3630a3..2aa92fb5b 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -191,12 +191,12 @@ const imageUpload = useTemplateRef('imageUpload'); const editor = useTemplateRef('editor'); const handleCopilotAction = actionKey => { - if (actionKey === 'rephrase_selection' && editorView?.state) { + if (actionKey === 'improve_selection' && editorView?.state) { const { from, to } = editorView.state.selection; const selectedText = editorView.state.doc.textBetween(from, to).trim(); if (from !== to && selectedText) { - emit('executeCopilotAction', 'rephrase', selectedText); + emit('executeCopilotAction', 'improve', selectedText); } } else { emit('executeCopilotAction', actionKey); diff --git a/app/javascript/dashboard/composables/useAI.js b/app/javascript/dashboard/composables/useAI.js index 009aed6ac..d83066523 100644 --- a/app/javascript/dashboard/composables/useAI.js +++ b/app/javascript/dashboard/composables/useAI.js @@ -149,18 +149,14 @@ export function useAI() { }; /** - * Processes an AI event, such as rephrasing content. - * @param {string} [type='rephrase'] - The type of AI event to process. + * Processes an AI event, such as improving content. + * @param {string} [type='improve'] - The type of AI event to process. * @param {string} [content=''] - The content to process (for full message) or selected text (for selection-based). * @param {Object} [options={}] - Additional options. * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request. * @returns {Promise} The generated message or an empty string if an error occurs. */ - const processEvent = async ( - type = 'rephrase', - content = '', - options = {} - ) => { + const processEvent = async (type = 'improve', content = '', options = {}) => { try { const result = await EditorAPI.processEvent( { diff --git a/app/javascript/dashboard/composables/useCopilotReply.js b/app/javascript/dashboard/composables/useCopilotReply.js index cd992574c..729fe82fa 100644 --- a/app/javascript/dashboard/composables/useCopilotReply.js +++ b/app/javascript/dashboard/composables/useCopilotReply.js @@ -55,7 +55,7 @@ export function useCopilotReply() { } /** - * Executes a copilot action (e.g., rephrase, fix grammar). + * Executes a copilot action (e.g., improve, fix grammar). * @param {string} action - The action type * @param {string} data - The content to process */ diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 727316f1d..d5a98eb80 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -88,6 +88,7 @@ export const OPEN_AI_EVENTS = Object.freeze({ SUMMARIZE: 'OpenAI: Used summarize', REPLY_SUGGESTION: 'OpenAI: Used reply suggestion', REPHRASE: 'OpenAI: Used rephrase', + IMPROVE: 'OpenAI: Used improve', FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar', SHORTEN: 'OpenAI: Used shorten', EXPAND: 'OpenAI: Used expand', diff --git a/lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid b/lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid index 0ca5be000..520487357 100644 --- a/lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid +++ b/lib/integrations/openai/openai_prompts/fix_spelling_grammar.liquid @@ -8,6 +8,10 @@ Important guidelines: - Do not add or remove any information - Do not simplify, shorten, or expand the message - Ensure the output remains appropriate for customer support -- Ensure the reply is in the user's original language + +Super Important: +- If the message has some markdown formatting, keep the formatting as it is. +- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote. +- Ensure the output is in the user's original language Output only the corrected message, with no preamble, tags, or explanation. diff --git a/lib/integrations/openai/openai_prompts/improve.liquid b/lib/integrations/openai/openai_prompts/improve.liquid index a996ea8a4..7e3d35e82 100644 --- a/lib/integrations/openai/openai_prompts/improve.liquid +++ b/lib/integrations/openai/openai_prompts/improve.liquid @@ -1,25 +1,43 @@ -You are an expert customer support message optimizer. Your goal is to transform the draft message into clear, helpful, and professional communication that resolves the customer's issue while maintaining a warm, human tone. +You are a writing assistant for customer support agents. Your task is to improve a draft message by enhancing its language, clarity, and tone—not by adding new content. -CONTEXT: + {{ conversation_context }} + -CURRENT DRAFT: + {{ draft_message }} + -YOUR TASK: -Improve this draft message by: +## Your Task -1. **Clarity & Structure**: Reorganize for logical flow. Start with acknowledgment, then explanation, then action/resolution, then next steps if needed. -2. **Tone & Empathy**: Ensure the message is warm, professional, and acknowledges the customer's situation. Avoid robotic or overly formal language. -3. **Conciseness**: Remove redundancy and filler words while keeping necessary detail. Respect the customer's time. -4. **Action-Oriented**: Make next steps crystal clear. Use specific language ("Click the blue 'Reset Password' button" vs "reset your password"). -5. **Personalization**: Use the contact's name naturally and reference specific details from their situation when appropriate. +Rewrite the draft to be clearer, warmer, and more professional while preserving the agent's intent. -GUIDELINES: -- Maintain the core intent and information from the draft -- Fix any spelling, grammar, or punctuation errors -- Keep the response length appropriate (don't make short messages unnecessarily long) -- Preserve any technical accuracy or specific details -- If the draft has a problem (missing info, wrong tone, unclear), fix it +## What "Improve" Means + +Improve the **quality** of the message, not the **quantity** of information: + +| DO | DON'T | +|-----|--------| +| Fix grammar, spelling, punctuation | Add new information or steps | +| Improve sentence structure and flow | Expand scope beyond the draft | +| Make tone warmer and more professional | Add offers ("I can also...", "Would you like...") | +| Use contact's name naturally | Invent technical details, links, or examples | +| Make vague phrases more natural | Turn a brief answer into a long one | + +## Using the Context + +Use the conversation context to: +- Understand what's being discussed (so improvements make sense) +- Gauge appropriate tone (formal/casual, frustrated customer, etc.) +- Personalize with the contact's name when natural + +Do NOT use the context to fill in gaps or add information the agent didn't include. + +## Output Rules + +- Keep the improved message at a similar length to the draft (brief stays brief) +- Preserve any markdown formatting +- Block quotes (lines starting with `>`) contain quoted customer text—keep this unchanged, only improve the agent's reply +- Output in the same language as the draft +- Output only the improved message, no commentary -Output only the improved message, no explanations or meta-commentary. diff --git a/lib/integrations/openai/openai_prompts/tone_rewrite.liquid b/lib/integrations/openai/openai_prompts/tone_rewrite.liquid index 2f88da0bc..c140a93df 100644 --- a/lib/integrations/openai/openai_prompts/tone_rewrite.liquid +++ b/lib/integrations/openai/openai_prompts/tone_rewrite.liquid @@ -26,6 +26,10 @@ Important guidelines: - Maintain helpfulness and respect regardless of tone - Do not add information that wasn't in the original message - Do not remove critical details or instructions -- Ensure that the reply should be in user language. + +Super Important: +- If the message has some markdown formatting, keep the formatting as it is. +- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote. +- Ensure the output is in the user's original language Output only the rewritten message without any preamble, tags or explanation. diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb new file mode 100644 index 000000000..796c24be1 --- /dev/null +++ b/lib/integrations/openai/processor_service.rb @@ -0,0 +1,152 @@ +class Integrations::Openai::ProcessorService < Integrations::LlmBaseService + def reply_suggestion_message + make_api_call(reply_suggestion_body) + end + + def summarize_message + make_api_call(summarize_body) + end + + def fix_spelling_grammar_message + call_llm_with_prompt(fix_spelling_grammar_prompt) + end + + def confident_message + call_llm_with_prompt(tone_rewrite_prompt('confident')) + end + + def straightforward_message + call_llm_with_prompt(tone_rewrite_prompt('straightforward')) + end + + def casual_message + call_llm_with_prompt(tone_rewrite_prompt('casual')) + end + + def friendly_message + call_llm_with_prompt(tone_rewrite_prompt('friendly')) + end + + def professional_message + call_llm_with_prompt(tone_rewrite_prompt('professional')) + end + + def improve_message + template = prompt_from_file('improve') + + system_prompt = render_liquid_template(template, { + 'conversation_context' => conversation.to_llm_text(include_contact_details: true), + 'draft_message' => event['data']['content'] + }) + + call_llm_with_prompt(system_prompt, event['data']['content']) + end + + private + + def call_llm_with_prompt(system_content, user_content = event['data']['content']) + body = { + model: GPT_MODEL, + messages: [ + { role: 'system', content: system_content }, + { role: 'user', content: user_content } + ], + reasoning_effort: 'low' # TODO: make this configurable + }.to_json + make_api_call(body) + end + + def prompt_from_file(file_name, enterprise: false) + path = enterprise ? 'enterprise/lib/enterprise/integrations/openai_prompts' : 'lib/integrations/openai/openai_prompts' + Rails.root.join(path, "#{file_name}.liquid").read + end + + def render_liquid_template(template_content, variables = {}) + Liquid::Template.parse(template_content).render(variables) + end + + def tone_rewrite_prompt(tone) + template = prompt_from_file('tone_rewrite') + render_liquid_template(template, 'tone' => tone) + end + + def fix_spelling_grammar_prompt + prompt_from_file('fix_spelling_grammar') + end + + # TODO: Replace with LlmFormattable or enterprise/lib/captain/prompts/snippets/conversation.liquid + def conversation_messages(in_array_format: false) + messages = init_messages_body(in_array_format) + + add_messages_until_token_limit(conversation, messages, in_array_format) + end + + def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0) + character_count = start_from + conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message| + character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format) + break unless message_added + end + messages + end + + def add_message_if_within_limit(character_count, message, messages, in_array_format) + content = message.content_for_llm + if valid_message?(content, character_count) + add_message_to_list(message, messages, in_array_format, content) + character_count += content.length + [character_count, true] + else + [character_count, false] + end + end + + def valid_message?(content, character_count) + content.present? && character_count + content.length <= TOKEN_LIMIT + end + + def add_message_to_list(message, messages, in_array_format, content) + formatted_message = format_message(message, in_array_format, content) + messages.prepend(formatted_message) + end + + def init_messages_body(in_array_format) + in_array_format ? [] : '' + end + + def format_message(message, in_array_format, content) + in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content) + end + + def format_message_in_array(message, content) + { role: (message.incoming? ? 'user' : 'assistant'), content: content } + end + + def format_message_in_string(message, content) + sender_type = message.incoming? ? 'Customer' : 'Agent' + "#{sender_type} #{message.sender&.name} : #{content}\n" + end + + def summarize_body + { + model: GPT_MODEL, + messages: [ + { role: 'system', + content: prompt_from_file('summary', enterprise: false) }, + { role: 'user', content: conversation_messages } + ] + }.to_json + end + + def reply_suggestion_body + { + model: GPT_MODEL, + messages: [ + { role: 'system', + content: prompt_from_file('reply', enterprise: false) } + ].concat(conversation_messages(in_array_format: true)) + }.to_json + end +end + +Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService') diff --git a/lib/llm/config.rb b/lib/llm/config.rb index 94836d746..f983f5daf 100644 --- a/lib/llm/config.rb +++ b/lib/llm/config.rb @@ -1,7 +1,7 @@ require 'ruby_llm' module Llm::Config - DEFAULT_MODEL = 'gpt-4o-mini'.freeze + DEFAULT_MODEL = 'gpt-5-mini'.freeze class << self def initialized? @initialized ||= false