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)
This commit is contained in:
107
lib/integrations/chatbot/processor_service.rb
Normal file
107
lib/integrations/chatbot/processor_service.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user