# Chatbot processor that answers inbound chats in-place using the OSS self-contained # chatbot (guardrail + knowledge base + Llm::Resolver), instead of an external bot. # # Selected per inbox via an Integrations::Hook with app_id == 'chatbot' (see HookJob). # Mirrors Integrations::Dialogflow::ProcessorService / Integrations::Captain::ProcessorService # by subclassing Integrations::BotProcessorService and overriding get_response. # # Flow (from BotProcessorService#process_content): # get_response(session_id, content) # -> retrieve knowledge (keyword top-k) # -> build history # -> Chatbot::DecisionService.decide (1 or 2 calls) # -> return an action marker consumed by process_response # process_response(message, decision) # -> :answer -> create outbound reply (knowledge-grounded) # -> :refuse -> create outbound reply using account's out-of-scope template # -> :handoff -> conversation.bot_handoff! (release bot, pending -> open, human takes over) # # Fail-closed: if the LLM is disabled or errors, we hand off to a human (safe default) # rather than silently not replying. Never sends chat content to the LLM when disabled. class Integrations::Chatbot::ProcessorService < Integrations::BotProcessorService pattr_initialize [:event_name!, :hook!, :event_data!] HANDOFF = 'chatbot_handoff'.freeze private # BotProcessorService calls get_response(source_id, content) then process_response. # We return a Chatbot::DecisionService::Result (or a String marker for handoff) and # handle rendering in process_response. def get_response(_session_id, message_content) return HANDOFF if message_content.blank? # Fail-closed gate: if the bot is not enabled for this account, hand to a human. return HANDOFF unless Chatbot::ConfigService.enabled?(conversation.account) result = Chatbot::DecisionService.decide( account: conversation.account, message: message_content, history: build_history, knowledge: knowledge_for(message_content), call_mode: chatbot_call_mode, system_prompt: Chatbot::ConfigService.system_prompt(conversation.account), guardrail_prompt: Chatbot::ConfigService.guardrail_prompt(conversation.account) ) # Fail-closed: disabled, or any error -> human handoff (safe default). return HANDOFF if result.disabled? || !result.success? result end def process_response(message, decision) return create_conversation(message, { content: out_of_scope_reply }) if decision.refuse? if decision.handoff? || decision == HANDOFF message.conversation.bot_handoff! return end return if decision.answer.blank? # nothing to say create_conversation(message, { content: decision.answer }) end # -- context helpers -------------------------------------------------------- def conversation @conversation ||= event_data[:message].conversation end def knowledge_for(content) Chatbot::KnowledgeRetriever.retrieve(account: conversation.account, query: content) end def build_history # Lightweight: the most recent few incoming/outgoing text messages (excluding this one). conversation.messages .where(message_type: %i[incoming outgoing], content_type: 'text') .where.not(id: event_data[:message].id) .order(created_at: :asc) .last(8) .map do |m| { role: m.message_type == 'outgoing' ? 'assistant' : 'user', content: m.content.to_s } end end def chatbot_call_mode Chatbot::ConfigService.call_mode(conversation.account) end def out_of_scope_reply Chatbot::ConfigService.out_of_scope_reply(conversation.account) end def create_conversation(message, content_params) return if content_params.blank? || content_params[:content].blank? conv = message.conversation conv.messages.create!( content_params.merge( message_type: :outgoing, account_id: conv.account_id, inbox_id: conv.inbox_id ) ) end end