feat: reply editor follow-up (#13106)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
This commit is contained in:
co-authored by
aakashb95
parent
7f057127b0
commit
82f5dbe6c1
@@ -108,6 +108,27 @@ class TasksAPI extends ApiClient {
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to continue refining a previous task result.
|
||||
* @param {Object} options - The follow-up options.
|
||||
* @param {Object} options.followUpContext - The follow-up context from a previous task.
|
||||
* @param {string} options.message - The follow-up message/request from the user.
|
||||
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
|
||||
*/
|
||||
followUp({ followUpContext, message, conversationId }, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/follow_up`,
|
||||
{
|
||||
follow_up_context: followUpContext,
|
||||
message,
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new TasksAPI();
|
||||
|
||||
@@ -50,7 +50,8 @@ export default {
|
||||
|
||||
async generateAIContent(type = 'improve') {
|
||||
this.isGenerating = true;
|
||||
this.generatedContent = await this.processEvent(type);
|
||||
const { message } = await this.processEvent(type);
|
||||
this.generatedContent = message;
|
||||
this.isGenerating = false;
|
||||
},
|
||||
applyText() {
|
||||
|
||||
@@ -10,7 +10,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -28,7 +28,12 @@ const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
if (props.hasSelection) {
|
||||
// for now, we don't allow improving just aprt of the selection
|
||||
// we will add this feature later. Once we do, we can revert the change
|
||||
const hasSelection = false;
|
||||
// const hasSelection = props.hasSelection
|
||||
|
||||
if (hasSelection) {
|
||||
items.push({
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
|
||||
|
||||
+13
-11
@@ -1,35 +1,37 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
|
||||
defineProps({
|
||||
isGeneratingContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPrivate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
const acceptLabel = computed(() => {
|
||||
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center p-3 border-t"
|
||||
:class="{ 'border-n-weak': !isPrivate, 'border-n-amber-12/5': isPrivate }"
|
||||
>
|
||||
<div class="flex justify-between items-center p-3 pt-0">
|
||||
<NextButton
|
||||
label="Discard"
|
||||
:label="t('GENERAL.DISCARD')"
|
||||
slate
|
||||
link
|
||||
class="!px-1 hover:!no-underline"
|
||||
@@ -38,7 +40,7 @@ const handleSubmit = () => {
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<NextButton
|
||||
label="Accept"
|
||||
:label="acceptLabel"
|
||||
class="bg-n-iris-9 text-white"
|
||||
solid
|
||||
sm
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedFormatting,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
@@ -22,7 +22,13 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['focus', 'blur', 'clearSelection', 'contentReady']);
|
||||
const emit = defineEmits([
|
||||
'focus',
|
||||
'blur',
|
||||
'clearSelection',
|
||||
'contentReady',
|
||||
'send',
|
||||
]);
|
||||
|
||||
const copilotEditorContent = ref('');
|
||||
|
||||
@@ -37,6 +43,11 @@ const onBlur = () => {
|
||||
const clearEditorSelection = () => {
|
||||
emit('clearSelection');
|
||||
};
|
||||
|
||||
const onSend = () => {
|
||||
emit('send', copilotEditorContent.value);
|
||||
copilotEditorContent.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -62,6 +73,7 @@ const clearEditorSelection = () => {
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@send="onSend"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@@ -94,6 +95,7 @@ export default {
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
@@ -103,6 +105,7 @@ export default {
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -273,7 +276,7 @@ export default {
|
||||
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
|
||||
}
|
||||
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
|
||||
? '(⌘ + ↵)'
|
||||
? `(${this.shortcutKey})`
|
||||
: '(↵)';
|
||||
return `${sendMessageText} ${keyLabel}`;
|
||||
},
|
||||
@@ -624,7 +627,9 @@ export default {
|
||||
},
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (this.isAValidEvent('cmd_enter')) {
|
||||
if (this.copilot.isActive.value && this.isFocused) {
|
||||
this.onSubmitCopilotReply();
|
||||
} else if (this.isAValidEvent('cmd_enter')) {
|
||||
this.onSendReply();
|
||||
}
|
||||
},
|
||||
@@ -1171,6 +1176,7 @@ export default {
|
||||
@clear-selection="clearEditorSelection"
|
||||
@close="copilot.showEditor.value = false"
|
||||
@content-ready="copilot.setContentReady"
|
||||
@send="copilot.sendFollowUp"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
@@ -1240,7 +1246,6 @@ export default {
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="copilot.isActive.value"
|
||||
key="copilot-bottom-panel"
|
||||
:is-private="isOnPrivateNote"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.toggleEditor"
|
||||
|
||||
@@ -154,7 +154,7 @@ export function useAI() {
|
||||
* @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<string>} The generated message or an empty string if an error occurs.
|
||||
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
|
||||
*/
|
||||
const processEvent = async (type = 'improve', content = '', options = {}) => {
|
||||
try {
|
||||
@@ -167,20 +167,52 @@ export function useAI() {
|
||||
options.signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage },
|
||||
data: { message: generatedMessage, follow_up_context: followUpContext },
|
||||
} = result;
|
||||
return generatedMessage;
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
// Don't show error for aborted requests
|
||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
||||
return '';
|
||||
return { message: '' };
|
||||
}
|
||||
const errorData = error.response?.data?.error;
|
||||
const errorMessage =
|
||||
errorData?.error?.message ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
return '';
|
||||
return { message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to refine a previous AI task result.
|
||||
* @param {Object} options - The follow-up options.
|
||||
* @param {Object} options.followUpContext - The follow-up context from a previous task.
|
||||
* @param {string} options.message - The follow-up message/request from the user.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
|
||||
*/
|
||||
const followUp = async ({ followUpContext, message, signal }) => {
|
||||
try {
|
||||
const result = await TasksAPI.followUp(
|
||||
{ followUpContext, message, conversationId: conversationId.value },
|
||||
signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage, follow_up_context: updatedContext },
|
||||
} = result;
|
||||
return { message: generatedMessage, followUpContext: updatedContext };
|
||||
} catch (error) {
|
||||
// Don't show error for aborted requests
|
||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
||||
return { message: '', followUpContext };
|
||||
}
|
||||
const errorData = error.response?.data?.error;
|
||||
const errorMessage =
|
||||
errorData?.error?.message ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
return { message: '', followUpContext };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -201,5 +233,6 @@ export function useAI() {
|
||||
recordAnalytics,
|
||||
fetchLabelSuggestions,
|
||||
processEvent,
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
* @returns {Object} Copilot reply state and methods
|
||||
*/
|
||||
export function useCopilotReply() {
|
||||
const { processEvent } = useAI();
|
||||
const { processEvent, followUp } = useAI();
|
||||
const { updateUISettings } = useUISettings();
|
||||
|
||||
const showEditor = ref(false);
|
||||
const isGenerating = ref(false);
|
||||
const isContentReady = ref(false);
|
||||
const generatedContent = ref('');
|
||||
const followUpContext = ref(null);
|
||||
const abortController = ref(null);
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
@@ -38,6 +39,7 @@ export function useCopilotReply() {
|
||||
isGenerating.value = false;
|
||||
isContentReady.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,12 +77,14 @@ export function useCopilotReply() {
|
||||
isContentReady.value = false;
|
||||
|
||||
try {
|
||||
const content = await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
const { message: content, followUpContext: newContext } =
|
||||
await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) showEditor.value = true;
|
||||
isGenerating.value = false;
|
||||
}
|
||||
@@ -91,6 +95,40 @@ export function useCopilotReply() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to refine the current generated content.
|
||||
* @param {string} message - The follow-up message from the user
|
||||
*/
|
||||
async function sendFollowUp(message) {
|
||||
if (!followUpContext.value || !message.trim()) return;
|
||||
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: updatedContext } =
|
||||
await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts the generated content and returns it.
|
||||
* Note: Formatting is automatically stripped by the Editor component's
|
||||
@@ -108,6 +146,7 @@ export function useCopilotReply() {
|
||||
isGenerating,
|
||||
isContentReady,
|
||||
generatedContent,
|
||||
followUpContext,
|
||||
|
||||
isActive,
|
||||
isButtonDisabled,
|
||||
@@ -117,6 +156,7 @@ export function useCopilotReply() {
|
||||
toggleEditor,
|
||||
setContentReady,
|
||||
execute,
|
||||
sendFollowUp,
|
||||
accept,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
function isMacOS() {
|
||||
// Check modern userAgentData API first
|
||||
if (navigator.userAgentData?.platform) {
|
||||
return navigator.userAgentData.platform === 'macOS';
|
||||
}
|
||||
// Fallback to navigator.platform
|
||||
return (
|
||||
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
|
||||
);
|
||||
}
|
||||
|
||||
export function useKbd(keys) {
|
||||
const keySymbols = {
|
||||
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
|
||||
$mod: isMacOS() ? '⌘' : 'Ctrl',
|
||||
shift: '⇧',
|
||||
alt: '⌥',
|
||||
ctrl: 'Ctrl',
|
||||
cmd: '⌘',
|
||||
option: '⌥',
|
||||
enter: '↩',
|
||||
enter: '↵',
|
||||
tab: '⇥',
|
||||
esc: '⎋',
|
||||
};
|
||||
@@ -16,7 +27,11 @@ export function useKbd(keys) {
|
||||
return computed(() => {
|
||||
return keys
|
||||
.map(key => keySymbols[key.toLowerCase()] || key)
|
||||
.join('')
|
||||
.join(' ')
|
||||
.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
export function getModifierKey() {
|
||||
return isMacOS() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else...",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||
"ACCEPT": "Accept",
|
||||
"DISCARD": "Discard",
|
||||
"PREFERRED": "Preferred"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,8 @@ class Captain::TasksPolicy < ApplicationPolicy
|
||||
def label_suggestion?
|
||||
true
|
||||
end
|
||||
|
||||
def follow_up?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -78,6 +78,7 @@ Rails.application.routes.draw do
|
||||
post :summarize
|
||||
post :reply_suggestion
|
||||
post :label_suggestion
|
||||
post :follow_up
|
||||
end
|
||||
end
|
||||
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
||||
|
||||
@@ -39,6 +39,17 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
def follow_up
|
||||
result = Captain::FollowUpService.new(
|
||||
account: Current.account,
|
||||
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
|
||||
user_message: params[:message],
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_result(result)
|
||||
@@ -47,7 +58,9 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
|
||||
elsif result[:error]
|
||||
render json: { error: result[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: { message: result[:message] }
|
||||
response_data = { message: result[:message] }
|
||||
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
|
||||
render json: response_data
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -29,9 +29,16 @@ class Captain::BaseTaskService
|
||||
def make_api_call(model:, messages:)
|
||||
instrumentation_params = build_instrumentation_params(model, messages)
|
||||
|
||||
instrument_llm_call(instrumentation_params) do
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages)
|
||||
end
|
||||
|
||||
# Build follow-up context for client-side refinement, when applicable
|
||||
if build_follow_up_context? && response[:message].present?
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
else
|
||||
response
|
||||
end
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:)
|
||||
@@ -118,4 +125,25 @@ class Captain::BaseTaskService
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
# Follow-up context for client-side refinement
|
||||
def build_follow_up_context?
|
||||
# FollowUpService should return its own updated context
|
||||
!is_a?(Captain::FollowUpService)
|
||||
end
|
||||
|
||||
def build_follow_up_context(messages, response)
|
||||
{
|
||||
event_name: event_name,
|
||||
original_context: extract_original_context(messages),
|
||||
last_response: response[:message],
|
||||
conversation_history: []
|
||||
}
|
||||
end
|
||||
|
||||
def extract_original_context(messages)
|
||||
# Get the most recent user message for follow-up context
|
||||
user_msg = messages.reverse.find { |m| m[:role] == 'user' }
|
||||
user_msg ? user_msg[:content] : nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
class Captain::FollowUpService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
|
||||
|
||||
ALLOWED_EVENT_NAMES = %w[
|
||||
professional
|
||||
casual
|
||||
friendly
|
||||
confident
|
||||
straightforward
|
||||
fix_spelling_grammar
|
||||
improve
|
||||
summarize
|
||||
reply_suggestion
|
||||
label_suggestion
|
||||
].freeze
|
||||
|
||||
def perform
|
||||
return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
|
||||
|
||||
# Build context-aware system prompt
|
||||
system_prompt = build_follow_up_system_prompt(follow_up_context)
|
||||
|
||||
# Build full message array (convert history from string keys to symbol keys)
|
||||
history = follow_up_context['conversation_history'].to_a.map do |msg|
|
||||
{ role: msg['role'], content: msg['content'] }
|
||||
end
|
||||
|
||||
messages = [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: follow_up_context['original_context'] },
|
||||
{ role: 'assistant', content: follow_up_context['last_response'] },
|
||||
*history,
|
||||
{ role: 'user', content: user_message }
|
||||
]
|
||||
|
||||
response = make_api_call(model: GPT_MODEL, messages: messages)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_follow_up_system_prompt(session_data)
|
||||
action_context = describe_previous_action(session_data['event_name'])
|
||||
|
||||
<<~PROMPT
|
||||
You just performed a #{action_context} action for a customer support agent.
|
||||
Your job now is to help them refine the result based on their feedback.
|
||||
Be concise and focused on their specific request.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def describe_previous_action(event_name)
|
||||
case event_name
|
||||
when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
|
||||
"tone rewrite (#{event_name})"
|
||||
when 'fix_spelling_grammar'
|
||||
'spelling and grammar correction'
|
||||
when 'improve'
|
||||
'message improvement'
|
||||
when 'summarize'
|
||||
'conversation summary'
|
||||
when 'reply_suggestion'
|
||||
'reply suggestion'
|
||||
when 'label_suggestion'
|
||||
'label suggestion'
|
||||
else
|
||||
event_name
|
||||
end
|
||||
end
|
||||
|
||||
def valid_follow_up_context?
|
||||
return false unless follow_up_context.is_a?(Hash)
|
||||
return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
|
||||
|
||||
required_keys = %w[event_name original_context last_response]
|
||||
required_keys.all? { |key| follow_up_context[key].present? }
|
||||
end
|
||||
|
||||
def update_follow_up_context(user_msg, assistant_msg)
|
||||
updated_history = follow_up_context['conversation_history'].to_a + [
|
||||
{ 'role' => 'user', 'content' => user_msg },
|
||||
{ 'role' => 'assistant', 'content' => assistant_msg }
|
||||
]
|
||||
|
||||
{
|
||||
'event_name' => follow_up_context['event_name'],
|
||||
'original_context' => follow_up_context['original_context'],
|
||||
'last_response' => assistant_msg,
|
||||
'conversation_history' => updated_history
|
||||
}
|
||||
end
|
||||
|
||||
def event_name
|
||||
'follow_up'
|
||||
end
|
||||
end
|
||||
@@ -86,4 +86,8 @@ class Captain::LabelSuggestionService < Captain::BaseTaskService
|
||||
def event_name
|
||||
'label_suggestion'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
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')
|
||||
@@ -247,4 +247,37 @@ RSpec.describe Captain::BaseTaskService do
|
||||
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_original_context' do
|
||||
it 'returns the most recent user message' do
|
||||
messages = [
|
||||
{ role: 'user', content: 'First question' },
|
||||
{ role: 'assistant', content: 'First response' },
|
||||
{ role: 'user', content: 'Follow-up question' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to eq('Follow-up question')
|
||||
end
|
||||
|
||||
it 'returns nil when no user messages exist' do
|
||||
messages = [
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
{ role: 'assistant', content: 'Response' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns the only user message when there is just one' do
|
||||
messages = [
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
{ role: 'user', content: 'Single question' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to eq('Single question')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::FollowUpService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:user_message) { 'Make it more concise' }
|
||||
let(:follow_up_context) do
|
||||
{
|
||||
'event_name' => 'professional',
|
||||
'original_context' => 'Please help me with this issue',
|
||||
'last_response' => 'I would be happy to assist you with this matter.',
|
||||
'conversation_history' => [
|
||||
{ 'role' => 'user', 'content' => 'Make it shorter' },
|
||||
{ 'role' => 'assistant', 'content' => 'Happy to help with this.' }
|
||||
]
|
||||
}
|
||||
end
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
follow_up_context: follow_up_context,
|
||||
user_message: user_message,
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation_display_id is provided' do
|
||||
it 'resolves conversation for instrumentation' do
|
||||
expect(service.send(:conversation)).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when follow-up context exists' do
|
||||
it 'constructs messages array with full conversation history' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
messages = args[:messages]
|
||||
|
||||
expect(messages).to match(
|
||||
[
|
||||
a_hash_including(role: 'system', content: include('tone rewrite (professional)')),
|
||||
{ role: 'user', content: 'Please help me with this issue' },
|
||||
{ role: 'assistant', content: 'I would be happy to assist you with this matter.' },
|
||||
{ role: 'user', content: 'Make it shorter' },
|
||||
{ role: 'assistant', content: 'Happy to help with this.' },
|
||||
{ role: 'user', content: 'Make it more concise' }
|
||||
]
|
||||
)
|
||||
|
||||
{ message: 'Refined response' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns updated follow-up context' do
|
||||
allow(service).to receive(:make_api_call).and_return({ message: 'Refined response' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Refined response')
|
||||
expect(result[:follow_up_context]['last_response']).to eq('Refined response')
|
||||
expect(result[:follow_up_context]['conversation_history'].length).to eq(4)
|
||||
expect(result[:follow_up_context]['conversation_history'][-2]['content']).to eq('Make it more concise')
|
||||
expect(result[:follow_up_context]['conversation_history'][-1]['content']).to eq('Refined response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when follow-up context is missing' do
|
||||
let(:follow_up_context) { nil }
|
||||
|
||||
it 'returns error with 400 code' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:error]).to eq('Follow-up context missing')
|
||||
expect(result[:error_code]).to eq(400)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_follow_up_system_prompt' do
|
||||
it 'describes tone rewrite actions' do
|
||||
%w[professional casual friendly confident straightforward].each do |tone|
|
||||
session = { 'event_name' => tone }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include("tone rewrite (#{tone})")
|
||||
expect(prompt).to include('help them refine the result')
|
||||
end
|
||||
end
|
||||
|
||||
it 'describes fix_spelling_grammar action' do
|
||||
session = { 'event_name' => 'fix_spelling_grammar' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('spelling and grammar correction')
|
||||
end
|
||||
|
||||
it 'describes improve action' do
|
||||
session = { 'event_name' => 'improve' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('message improvement')
|
||||
end
|
||||
|
||||
it 'describes summarize action' do
|
||||
session = { 'event_name' => 'summarize' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('conversation summary')
|
||||
end
|
||||
|
||||
it 'describes reply_suggestion action' do
|
||||
session = { 'event_name' => 'reply_suggestion' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('reply suggestion')
|
||||
end
|
||||
|
||||
it 'describes label_suggestion action' do
|
||||
session = { 'event_name' => 'label_suggestion' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('label suggestion')
|
||||
end
|
||||
|
||||
it 'uses event_name directly for unknown actions' do
|
||||
session = { 'event_name' => 'custom_action' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('custom_action')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#describe_previous_action' do
|
||||
it 'returns tone description for tone operations' do
|
||||
expect(service.send(:describe_previous_action, 'professional')).to eq('tone rewrite (professional)')
|
||||
expect(service.send(:describe_previous_action, 'casual')).to eq('tone rewrite (casual)')
|
||||
expect(service.send(:describe_previous_action, 'friendly')).to eq('tone rewrite (friendly)')
|
||||
expect(service.send(:describe_previous_action, 'confident')).to eq('tone rewrite (confident)')
|
||||
expect(service.send(:describe_previous_action, 'straightforward')).to eq('tone rewrite (straightforward)')
|
||||
end
|
||||
|
||||
it 'returns specific descriptions for other operations' do
|
||||
expect(service.send(:describe_previous_action, 'fix_spelling_grammar')).to eq('spelling and grammar correction')
|
||||
expect(service.send(:describe_previous_action, 'improve')).to eq('message improvement')
|
||||
expect(service.send(:describe_previous_action, 'summarize')).to eq('conversation summary')
|
||||
expect(service.send(:describe_previous_action, 'reply_suggestion')).to eq('reply suggestion')
|
||||
expect(service.send(:describe_previous_action, 'label_suggestion')).to eq('label suggestion')
|
||||
end
|
||||
|
||||
it 'returns event name for unknown operations' do
|
||||
expect(service.send(:describe_previous_action, 'unknown')).to eq('unknown')
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user