239 lines
8.0 KiB
Ruby
Executable File
239 lines
8.0 KiB
Ruby
Executable File
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
|
include Captain::ChatHelper
|
|
include Captain::ChatResponseHelper
|
|
|
|
attr_reader :assistant, :conversation, :messages
|
|
|
|
def initialize(assistant:, conversation: nil)
|
|
super()
|
|
Rails.logger.info "AssistantChatService: Initialized for Assistant #{assistant.id} / Conv #{conversation&.id}"
|
|
|
|
@assistant = assistant
|
|
@conversation = conversation
|
|
|
|
@tools = build_tools
|
|
@messages = [system_message]
|
|
@response = ''
|
|
|
|
# Prefer assistant model when set; otherwise keep configured default.
|
|
@model = @assistant.llm_model.presence || @model
|
|
end
|
|
|
|
def generate_response(additional_message: nil, message_history: [], role: 'user')
|
|
tool_output = nil
|
|
|
|
# Skip brain decision layer if no conversation (playground mode)
|
|
# Skip brain decision layer if no conversation (playground mode)
|
|
# USER REQUEST: Bypass JasmineBrain temporarily for Live Chat too to match Playground behavior (Direct + Docs).
|
|
# TODO: Re-enable JasmineBrain when tool configurations are ready.
|
|
if @conversation.present? && false # Disabled temporarily
|
|
# 1. Brain Decision Layer (Jasmine)
|
|
brain_decision = Captain::Llm::JasmineBrain.decide(
|
|
assistant: @assistant,
|
|
conversation: @conversation,
|
|
message: additional_message,
|
|
history: message_history
|
|
)
|
|
|
|
# 2. Handle Skip Strategy (Stop AI)
|
|
if brain_decision.strategy == :skip_ai
|
|
Rails.logger.info "[Captain] Skipped AI response: #{brain_decision.reasoning}"
|
|
return nil
|
|
end
|
|
|
|
# 3. Handle Tool Strategy
|
|
if brain_decision.strategy == :execute_tool
|
|
inbox = @conversation.inbox
|
|
|
|
runner_result = Captain::Tools::ToolRunner.run(
|
|
assistant: @assistant,
|
|
tool_key: brain_decision.tool_key,
|
|
inbox: inbox,
|
|
conversation: @conversation,
|
|
additional_data: { message: additional_message }
|
|
)
|
|
|
|
if runner_result[:success]
|
|
# Handle side-effects (e.g., labels for escalate_human)
|
|
handle_tool_side_effects(brain_decision.tool_key, @conversation)
|
|
|
|
# Normalize output for LLM
|
|
tool_output = runner_result[:body]
|
|
|
|
# Stop if tool was just a fire-and-forget webhook that suggests stopping
|
|
return { 'response' => 'conversation_handoff' } if brain_decision.tool_key == 'escalar_humano'
|
|
end
|
|
end
|
|
|
|
# Inject Tool Output into System Context if available
|
|
if tool_output
|
|
@messages << {
|
|
role: 'system',
|
|
content: "DADO CONFIRMADO (FERRAMENTA): #{tool_output.to_json}"
|
|
}
|
|
end
|
|
end
|
|
|
|
context_pack = context_pack_message
|
|
@messages << context_pack if context_pack.present?
|
|
@messages += message_history
|
|
|
|
# Inject Tool Output into System Context if available (for playground or post-tool)
|
|
if tool_output
|
|
@messages << {
|
|
role: 'system',
|
|
content: "DADO CONFIRMADO (FERRAMENTA): #{tool_output.to_json}"
|
|
}
|
|
end
|
|
|
|
@messages << { role: role, content: additional_message } if additional_message.present?
|
|
|
|
request_chat_completion
|
|
end
|
|
|
|
private
|
|
|
|
def handle_tool_side_effects(tool_key, conversation)
|
|
return unless tool_key == 'escalar_humano'
|
|
|
|
conversation.contact.add_labels(['desligar_ia'])
|
|
conversation.add_labels(['desligar_ia'])
|
|
conversation.custom_attributes['ai_disabled'] = true
|
|
conversation.save!
|
|
end
|
|
|
|
def build_tools
|
|
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
|
end
|
|
|
|
def system_message
|
|
{
|
|
role: 'system',
|
|
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
|
|
}
|
|
end
|
|
|
|
def context_pack_message
|
|
return nil if @conversation.blank?
|
|
|
|
insight = @conversation.latest_crm_insight
|
|
insight_data = insight&.structured_data || {}
|
|
contact = @conversation.contact
|
|
contact_profile = contact&.additional_attributes || {}
|
|
|
|
preferred_name = contact_profile['preferred_name']
|
|
name_confidence = contact_profile['name_confidence']
|
|
name_source = contact_profile['name_source']
|
|
|
|
summary_text = insight&.summary_text.to_s.strip
|
|
summary_text = summary_text[0, 400] if summary_text.length > 400
|
|
|
|
content = build_context_pack(
|
|
preferred_name: preferred_name,
|
|
name_confidence: name_confidence,
|
|
name_source: name_source,
|
|
contact_profile: contact_profile,
|
|
insight: insight,
|
|
insight_data: insight_data,
|
|
summary_text: summary_text
|
|
)
|
|
|
|
Rails.logger.info("[Captain] Context pack size=#{content.length} conv_id=#{@conversation.id} insight_id=#{insight&.id || 'none'}")
|
|
|
|
{
|
|
role: 'system',
|
|
content: content
|
|
}
|
|
end
|
|
|
|
def build_context_pack(preferred_name:, name_confidence:, name_source:, contact_profile:, insight:, insight_data:, summary_text:)
|
|
header = "[CONTEXT PACK]\n"
|
|
guardrails = <<~GUARDRAILS
|
|
GUARDRAILS:
|
|
- Use o nome apenas se name_confidence >= 0.8.
|
|
- Se nao houver nome confiavel, pergunte uma vez e siga sem nome.
|
|
- Nao invente nome, preferencias ou dados que nao estejam no perfil/insights.
|
|
GUARDRAILS
|
|
|
|
contact_block = <<~CONTACT
|
|
CONTACT_PROFILE:
|
|
preferred_name: #{preferred_name.presence || 'desconhecido'}
|
|
name_confidence: #{name_confidence.presence || '0'}
|
|
name_source: #{name_source.presence || 'unknown'}
|
|
preferences: #{format_list(contact_profile['preferences'])}
|
|
frictions: #{format_list(contact_profile['frictions'])}
|
|
contact_pattern: #{format_hash(contact_profile['contact_pattern'])}
|
|
CONTACT
|
|
|
|
insights_block = <<~INSIGHTS
|
|
CONVERSATION_INSIGHTS (latest success):
|
|
#{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'}
|
|
summary_text: #{summary_text.presence || 'sem resumo valido'}
|
|
intent: #{format_value(insight_data['intent'])}
|
|
urgency: #{format_value(insight_data['urgency'])}
|
|
nba: #{format_hash(insight_data['nba'])}
|
|
suggested_labels: #{format_list(insight_data['suggested_labels'])}
|
|
INSIGHTS
|
|
|
|
max_length = 1500
|
|
parts = [header, contact_block, insights_block, guardrails]
|
|
combined = parts.join("\n").strip
|
|
return combined if combined.length <= max_length
|
|
|
|
trimmed_summary = summary_text[0, 200]
|
|
insights_block = <<~INSIGHTS
|
|
CONVERSATION_INSIGHTS (latest success):
|
|
#{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'}
|
|
summary_text: #{trimmed_summary.presence || 'sem resumo valido'}
|
|
intent: #{format_value(insight_data['intent'])}
|
|
urgency: #{format_value(insight_data['urgency'])}
|
|
nba: #{format_hash(insight_data['nba'])}
|
|
suggested_labels: #{format_list(insight_data['suggested_labels'])}
|
|
INSIGHTS
|
|
|
|
combined = [header, contact_block, insights_block, guardrails].join("\n").strip
|
|
return combined if combined.length <= max_length
|
|
|
|
insights_block = <<~INSIGHTS
|
|
CONVERSATION_INSIGHTS (latest success):
|
|
#{insight.present? ? "generated_at: #{insight.generated_at&.iso8601}" : 'sem insights ainda'}
|
|
intent: #{format_value(insight_data['intent'])}
|
|
urgency: #{format_value(insight_data['urgency'])}
|
|
nba: #{format_hash(insight_data['nba'])}
|
|
suggested_labels: #{format_list(insight_data['suggested_labels'])}
|
|
INSIGHTS
|
|
|
|
combined = [header, contact_block, insights_block, guardrails].join("\n").strip
|
|
return combined if combined.length <= max_length
|
|
|
|
combined = [header, contact_block, guardrails].join("\n").strip
|
|
combined[0, max_length]
|
|
end
|
|
|
|
def format_list(value)
|
|
return 'nenhum' if value.blank?
|
|
return value.join(', ') if value.is_a?(Array)
|
|
|
|
value.to_s
|
|
end
|
|
|
|
def format_hash(value)
|
|
return 'nenhum' if value.blank?
|
|
return value.to_json if value.is_a?(Hash)
|
|
|
|
value.to_s
|
|
end
|
|
|
|
def format_value(value)
|
|
value.present? ? value.to_s : 'desconhecido'
|
|
end
|
|
|
|
def persist_message(message, message_type = 'assistant')
|
|
# No need to implement
|
|
end
|
|
|
|
def feature_name
|
|
'assistant'
|
|
end
|
|
end
|