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)
207 lines
8.8 KiB
Ruby
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
|