Files
moreminimore-chat/app/services/chatbot/decision_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

207 lines
8.8 KiB
Ruby

# Chatbot decision service for the self-contained OSS chatbot.
#
# Given an inbound user message (plus history and retrieved knowledge), decides what the
# bot should do:
# :answer — in-scope; `answer` is the reply (grounded in the knowledge base)
# :refuse — clearly off-topic (out of guardrail scope, e.g. fortune-telling); uses the
# account's out-of-scope reply template
# :handoff — related to scope but the bot can't answer (nothing in KB / undecidable);
# the caller should hand off to a human (conversation.bot_handoff!)
#
# Default: ONE LLM call returns a structured decision { decision, reason, answer }.
# Optional TWO-call mode: a guardrail call decides in/out of scope, then an answer call
# composes the reply from KB. Selected per account (config option; 2-call is for LLMs that
# handle the compound single-call poorly).
#
# Fail-closed: no LLM credential -> { disabled: true } (never sends chat content when
# disabled). Mirrors the Analytics::WeeklyPersonaEvaluator / Llm::AnalyticsClassifier pattern.
module Chatbot::DecisionService
DECISION_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
decision: {
type: 'string',
enum: %w[in_scope refuse handoff],
description: "in_scope = answer from the knowledge base; refuse = clearly off-topic and must not be answered; handoff = related to scope but bot cannot answer -> hand to a human."
},
reason: { type: 'string', description: 'One sentence justifying the decision.' },
answer: { type: 'string', description: 'The bot reply. Populated for in_scope; may be blank for refuse/handoff.' }
},
required: %w[decision reason answer]
}.freeze
Result = Struct.new(:action, :answer, :reason, :disabled, :error, keyword_init: true) do
def disabled? = disabled == true
def success? = error.nil?
def answer? = action == :answer
def refuse? = action == :refuse
def handoff? = action == :handoff
end
module_function
# @param account [Account]
# @param message [String] the inbound user text
# @param history [Array<Hash>] [{ role: 'user'|'assistant', content: String }]
# @param knowledge [Array<Hash>] [{ title:, content:, score: }] retrieved KB context
# @param call_mode [Integer] 1 (default) or 2
# @param system_prompt [String] optional per-account persona/system instructions
# @param guardrail_prompt [String] optional per-account allowed-scope instructions
# @return [Chatbot::DecisionService::Result]
def decide(account:, message:, history: [], knowledge: [], call_mode: 1, system_prompt: nil, guardrail_prompt: nil)
credential = Llm::Resolver.resolve(account)
return disabled_result if credential.nil?
# Prompts are threaded as explicit args (not module instance vars) so concurrent
# requests can never bleed one account's person/system prompt into another.
system = system_prompt.presence || SYSTEM_PROMPT
guardrail = guardrail_prompt.presence || GUARDRAIL_SCOPE
if call_mode == 2
decide_two_call(credential, message, history, knowledge, system, guardrail)
else
decide_one_call(credential, message, history, knowledge, system, guardrail)
end
rescue StandardError => e
Rails.logger.error("[ChatbotDecision] account=#{account&.id} #{e.class}: #{e.message}")
Result.new(error: e.message)
end
# -- 1-call mode ------------------------------------------------------------
def decide_one_call(credential, message, history, knowledge, system_prompt, guardrail_prompt)
response = call_llm(credential, build_one_call_prompt(message, history, knowledge, guardrail_prompt), system_prompt)
return Result.new(error: response[:error] || 'completion failed') if response[:error]
parsed = JSON.parse(sanitize_json(response[:content]))
action = normalize_action(parsed['decision'])
Result.new(
action: action,
reason: parsed['reason'].to_s,
answer: parsed['answer'].to_s,
disabled: false
)
rescue JSON::ParserError, TypeError
Result.new(error: 'LLM returned an unparsable decision')
end
# -- 2-call mode ------------------------------------------------------------
def decide_two_call(credential, message, history, knowledge, system_prompt, guardrail_prompt)
guardrail = call_llm(credential, build_guardrail_prompt(message, guardrail_prompt), system_prompt)
return Result.new(error: guardrail[:error] || 'guardrail failed') if guardrail[:error]
parsed = JSON.parse(sanitize_json(guardrail[:content]))
decision = parsed['decision']&.to_s
return Result.new(action: :refuse, reason: parsed['reason'].to_s, answer: '', disabled: false) if decision == 'refuse'
# refuse / handoff_unknown / anything-but-in_scope -> hand to a human
return Result.new(action: :handoff, reason: parsed['reason']&.to_s, answer: '', disabled: false) unless decision == 'in_scope'
answer_response = call_llm(credential, build_answer_prompt(message, history, knowledge), system_prompt)
return Result.new(error: answer_response[:error] || 'answer failed') if answer_response[:error]
Result.new(action: :answer, answer: answer_response[:content].to_s, reason: 'in_scope', disabled: false)
rescue JSON::ParserError, TypeError
Result.new(error: 'LLM returned an unparsable decision')
end
# -- LLM + prompt helpers ---------------------------------------------------
def call_llm(credential, prompt, system_prompt)
Llm::Config.with_api_key(credential[:api_key], api_base: credential[:api_base]) do |context|
chat = context.chat(model: MODEL).with_schema(DECISION_SCHEMA)
chat.with_instructions(system_prompt)
{ content: chat.ask(prompt).content }
end
rescue StandardError => e
Rails.logger.error("[ChatbotDecision] LLM call failed #{e.class}: #{e.message}")
{ error: e.message }
end
MODEL = Llm::Config::DEFAULT_MODEL
def build_one_call_prompt(message, history, knowledge, guardrail_prompt)
[
'Decide how the customer-service bot should respond to the customer message.',
'',
'## Guardrail scope',
guardrail_prompt,
'',
'## Knowledge base (retrieved, most relevant first)',
knowledge_text(knowledge).presence || '(no relevant knowledge found)',
'',
'## Conversation history',
history_text(history).presence || '(no prior messages)',
'',
"## Latest customer message\n#{message}",
'',
'If the message is in scope AND relevant knowledge exists, return decision=in_scope with the best answer grounded in the knowledge. If it is clearly outside the guardrail scope (e.g. fortune-telling, off-topic), return decision=refuse (answer may be blank). If it is related to scope but there is no knowledge to answer with, return decision=handoff.'
].join("\n")
end
def build_guardrail_prompt(message, guardrail_prompt)
[
'You are a safety guardrail. Decide whether this customer message is within the allowed scope.',
'',
guardrail_prompt,
'',
"## Customer message\n#{message}",
'',
'Return decision: refuse if clearly outside scope; in_scope if within scope but may need knowledge to answer; handoff_unknown if related but ambiguous.'
].join("\n")
end
def build_answer_prompt(message, history, knowledge)
[
'You are a helpful customer-service assistant for this business. Answer the customer using ONLY the provided knowledge base; do not invent facts.',
'',
'## Knowledge base',
knowledge_text(knowledge).presence || '(no relevant knowledge found)',
'',
'## Conversation history',
history_text(history).presence || '(no prior messages)',
'',
"## Customer message\n#{message}"
].join("\n")
end
def knowledge_text(knowledge)
Array(knowledge).map { |k| "- #{k[:title]}: #{k[:content]}".strip }.join("\n")
end
def history_text(history)
Array(history).map { |h| "#{h[:role].to_s.capitalize}: #{h[:content]}" }.join("\n")
end
def normalize_action(decision)
case decision&.to_sym
when :in_scope then :answer
when :refuse then :refuse
when :handoff then :handoff
else :handoff
end
end
def sanitize_json(content)
content.to_s.gsub('```json', '').gsub('```', '').strip
end
def disabled_result
Result.new(action: nil, answer: '', reason: '', disabled: true)
end
SYSTEM_PROMPT = <<~PROMPT.freeze
You decide and then answer for a customer-service chatbot. Stay within the allowed
guardrail scope and be truthful and helpful. Return only the JSON object described by
the schema no extra text.
PROMPT
GUARDRAIL_SCOPE = <<~SCOPE.freeze
The bot answers questions about this business's PRODUCTS, SERVICES, and related
support topics only. It must NOT answer unrelated or off-topic requests (e.g. personal
advice, fortune-telling, horoscopes, unrelated general knowledge, or any topic outside
the listed products/services/support).
SCOPE
end