feat: split editor actions to multiple services

This commit is contained in:
Shivam Mishra
2025-12-17 16:58:31 +05:30
parent 2f81fcee8c
commit 5b02b8e7c8
6 changed files with 234 additions and 116 deletions
@@ -1,8 +1,24 @@
class Api::V1::Accounts::Captain::EditorController < Api::V1::Accounts::BaseController
before_action :check_authorization
EVENT_SERVICE_MAP = {
'fix_spelling_grammar' => Captain::RewriteService,
'casual' => Captain::RewriteService,
'professional' => Captain::RewriteService,
'friendly' => Captain::RewriteService,
'confident' => Captain::RewriteService,
'straightforward' => Captain::RewriteService,
'improve' => Captain::RewriteService,
'summarize' => Captain::SummaryService,
'reply_suggestion' => Captain::ReplySuggestionService,
'label_suggestion' => Captain::LabelSuggestionService
}.freeze
def process_event
result = Captain::EditorService.new(
service_class = EVENT_SERVICE_MAP[params[:event]['name']]
return render json: { error: 'Unknown event' }, status: :unprocessable_entity unless service_class
result = service_class.new(
account: Current.account,
event: params[:event]
).perform
+14 -13
View File
@@ -7,15 +7,11 @@ class Captain::BaseEditorService
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
ALLOWED_EVENT_NAMES = %w[fix_spelling_grammar casual professional friendly confident
straightforward improve summarize reply_suggestion label_suggestion].freeze
CACHEABLE_EVENTS = %w[label_suggestion].freeze
CACHEABLE_EVENTS = [].freeze
pattr_initialize [:account!, :event!]
def perform
return nil unless valid_event_name?
return value_from_cache if value_from_cache.present?
response = send("#{event_name}_message")
@@ -70,12 +66,6 @@ class Captain::BaseEditorService
@conversation ||= account.conversations.find_by(display_id: event['data']['conversation_display_id'])
end
def valid_event_name?
# self.class::ALLOWED_EVENT_NAMES is way to access ALLOWED_EVENT_NAMES defined in the class hierarchy of the current object.
# This ensures that if ALLOWED_EVENT_NAMES is updated elsewhere in it's ancestors, we access the latest value.
self.class::ALLOWED_EVENT_NAMES.include?(event_name)
end
def event_is_cacheable?
# self.class::CACHEABLE_EVENTS is way to access CACHEABLE_EVENTS defined in the class hierarchy of the current object.
# This ensures that if CACHEABLE_EVENTS is updated elsewhere in it's ancestors, we access the latest value.
@@ -168,8 +158,19 @@ class Captain::BaseEditorService
{ error: error.message, request_messages: messages }
end
# To be overridden by child class
def api_key
raise NotImplementedError, 'Subclasses must implement api_key method'
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
end
def openai_hook
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
end
def system_api_key
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
def prompt_from_file(file_name)
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
end
end
@@ -1,46 +1,5 @@
class Captain::EditorService < Captain::BaseEditorService
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
def summarize_message
make_api_call(summarize_body)
end
def reply_suggestion_message
make_api_call(reply_suggestion_body)
end
class Captain::LabelSuggestionService < Captain::BaseEditorService
CACHEABLE_EVENTS = %w[label_suggestion].freeze
def label_suggestion_message
payload = label_suggestion_body
@@ -56,65 +15,6 @@ class Captain::EditorService < Captain::BaseEditorService
private
def api_key
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
end
def openai_hook
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
end
def system_api_key
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
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 }
]
}.to_json
make_api_call(body)
end
def prompt_from_file(file_name)
Rails.root.join('lib/integrations/openai/openai_prompts', "#{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
def summarize_body
{
model: GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: conversation_messages }
]
}.to_json
end
def reply_suggestion_body
{
model: GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('reply') }
].concat(conversation_messages(in_array_format: true))
}.to_json
end
def label_suggestion_body
# TODO: Enable based on separate model and settings source
# Future: Different model for label suggestion
+71
View File
@@ -0,0 +1,71 @@
class Captain::ReplySuggestionService < Captain::BaseEditorService
def reply_suggestion_message
make_api_call(reply_suggestion_body)
end
private
def reply_suggestion_body
{
model: GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('reply') }
].concat(conversation_messages(in_array_format: true))
}.to_json
end
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 init_messages_body(in_array_format)
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 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
end
+58
View File
@@ -0,0 +1,58 @@
class Captain::RewriteService < Captain::BaseEditorService
def fix_spelling_grammar_message
call_llm_with_prompt(prompt_from_file('fix_spelling_grammar'))
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 }
]
}.to_json
make_api_call(body)
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
end
+72
View File
@@ -0,0 +1,72 @@
class Captain::SummaryService < Captain::BaseEditorService
def summarize_message
make_api_call(summarize_body)
end
private
def summarize_body
{
model: GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: conversation_messages }
]
}.to_json
end
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 init_messages_body(in_array_format)
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 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
end