Files
moreminimore-chat/lib/integrations/chatbot/processor_service.rb
Moreminimore 2495239187 feat(chatbot): OSS self-contained guardrail + knowledge-base chatbot
Built-in AI chatbot (no EE, uses Llm::Resolver) that answers in-scope chats
from a knowledge base and hands off to a human when needed. Selected per
inbox via an Integrations::Hook with app_id 'chatbot'.

- Integrations::Chatbot::ProcessorService (mirrors Dialogflow/Captain)
  wired via HookListener + HookJob + apps.yml(chatbot, inbox)
- Chatbot::DecisionService: 1-call default ({in_scope/refuse/handoff}),
  2-call option; thread-safe prompt threading
- Chatbot::KnowledgeRetriever: keyword top-k over KB (+ embedding reserved)
- KnowledgeBaseFaq + import service (md per-heading + front-matter tags)
- Chatbot::ConfigService + admin chatbot_config endpoint
- refs off-topic (e.g. fortune-telling); handoff = bot_handoff! (pending->open)
2026-08-25 16:09:47 +07:00

108 lines
4.0 KiB
Ruby

# 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