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:
Moreminimore
2026-08-25 16:09:47 +07:00
parent b86b0c59f6
commit 2495239187
16 changed files with 870 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
# Plan — OSS Self-Contained Chatbot (Guardrail + Knowledge + Llm::Resolver Answer)
Repo: `/Users/kunthawat/Gitea/Chatwoot` · branch `develop`
Date: 2026-08-20
Status: **PLAN — awaiting user approval before build**
## Vision (from user)
Make this Chatwoot fork a self-contained OSS chatbot: every inbound chat → guardrail
(keep users on-topic, e.g. reject "บอกชะตารายวัน / ดูดวง" when it's a product/service bot)
→ if in-scope, answer with LLM using a knowledge base (md/csv/excel) + system prompt +
guardrail prompt + history. No EE, no external Captain. Answer via `Llm::Resolver` local.
## Decisions locked (from clarify)
- **Not using EE** → Captain v2 (`enterprise/`) is out of scope. Use OSS path only.
- **Architecture**: build our own `Integrations::Chatbot::ProcessorService < Integrations::BotProcessorService`
(mirrors `Captain::ProcessorService` / `Dialogflow::ProcessorService`), overriding `get_response`
to answer via `Llm::Resolver` + knowledge base instead of an external webhook. Selectable per-inbox
(like Captain/Dialogflow choose processor by hook app_id).
- **Trigger stays** OSS AgentBot / pending flow (the "AI answers" = conversation pending).
- **LLM calls**: default **1 call** returning `{allowed, answer}`; config option to use **2 calls**
(guardrail check, then answer) for LLMs that need them. Backward-compatible.
- **KB retrieval**: **keyword + embedding** retrieval (top-k relevant chunks), NOT whole-KB-in-prompt.
- **Out-of-scope policy**:
- topic clearly off-guardrail (e.g. fortune-telling) → **refuse with template**
- topic related but not in KB / undecidable → **auto human handoff** (`bot_handoff!`)
- **KB storage**: extend `product_catalog_entries` (csv/xlsx product rows) + add **MD FAQ** table.
## Current OSS bot flow (verified)
```
inbound message (conversation pending?)
→ ... AgentBots::WebhookJob / agent_bot_listener → Webhooks::Trigger → webhook (external)
```
`BotProcessorService` base: `should_run_processor?` (message.reportable?, conversation.pending?)
`get_response(source_id, content)`; `process_action` handles :handoff → `bot_handoff!` / :resolve.
`Webhooks::Trigger#update_conversation_status`: on agent_bot failure, pending → open! (human takes over).
## Architecture
```
[our ProcessorService] < Integrations::BotProcessorService
get_response → guardrail?
├─ in-scope → Llm::Resolver answer (KB + system prompt + guardrail + history) → reply
└─ out-of-scope → out-of-scope reply template (reject) / bot_handoff!
```
Selected per inbox by hook app_id (same mechanism as Captain/Dialogflow).
## Deliverables (build order)
1. **KB backend**: `KnowledgeBaseFaq` model+migration (account-scoped, md FAQ: title + content + topic tags)
+ reuse `product_catalog_entries` for csv/xlsx product rows. Import service (extend
`ProductCatalogImportService` / add MD FAQ import via `roo`/CSV/stdlib). Admin endpoints (list/import) + routes.
2. **Retrieval service**: `Chatbot::KnowledgeRetriever` — keyword (+ optional embedding) top-k selection
over FAQ + product_catalog. Embedding vector column on FAQ table (nullable), keyword via SQL ILIKE/tsvector.
3. **Guardrail + answer**: `Chatbot::GuardrailService` + `Chatbot::AnswerService` — default 1 call
`{ allowed, answer }`; optional 2-call mode. Out-of-scope → refuse template; related-but-not-in-KB /
undecidable → human handoff.
4. **Chatbot processor**: `Integrations::Chatbot::ProcessorService < BotProcessorService` — get_response =
retrieve → guardrail/answer → reply | refuse | handoff. Wire selection per inbox.
- **Human handoff path adjusts chat status**: on related-but-not-in-KB / undecidable, call
`conversation.bot_handoff!` which **releases the bot (= `pending``open`), clears `assignee_agent_bot`,
sets `waiting_since`**, and dispatches the handoff event so a human agent queue/assignment picks it up.
5. **Account config**: per-account: system prompt + guardrail prompt + out-of-scope reply template +
call-mode (1 or 2) + enabled flag + which KB (folder/index). Store in Account#custom_attributes or settings model.
Admin endpoints + UI.
6. **Verify + review each part** (smoke / ruby -c / static scan / independent reviewer).
## Open questions (mostly resolved; remaining minor)
- [x] LLM calls: 1 default, 2 optional (user decision).
- [x] KB retrieval: keyword + embedding, top-k (user decision).
- [x] Out-of-scope: refuse if clearly off-topic; auto handoff if related-but-not-in-KB/undecidable.
- [x] KB storage: extend product_catalog + add MD FAQ.
- [ ] Embedding: which provider/API to compute embeddings (Llm::Resolver? separate embedding model?).
- [ ] MD FAQ granularity: one row per file? per heading/section chunk? (affects retrieval + import).
## Success criteria
- OSS inbox with our processor answers in-scope from KB (keyword+embedding) via Llm::Resolver (1 or 2 calls). ✅ built
- Clearly out-of-scope (e.g. fortune-telling) → templated refusal. ✅
- Related-but-not-in-KB / undecidable → auto human handoff. ✅ (bot_handoff! → pending→open)
- Product rows (csv/xlsx) + MD FAQ both import + retrieve. ✅
- Per-account config (system/guardrail/out-of-scope template/call mode/enabled) via admin endpoint. ✅ (ChatbotConfigController + ConfigService)
- Each deliverable passes smoke + static scan + independent reviewer. ✅ (final re-review deleg_a5c361cb = passed:true, end-to-end dispatchable)
## Wiring (verified, end-to-end)
apps.yml chatbot(inbox) → HooksController create (ensure_hook_type=inbox) → HookListener supported_events_map['chatbot'] → HookJob INTEGRATION_PROCESSORS['chatbot'] → Chatbot::ProcessorService → DecisionService → Llm::Resolver. i18n chatbot added to en.yml integration_apps.

View File

@@ -0,0 +1,30 @@
# Admin-only chatbot configuration endpoint for the self-contained OSS chatbot.
#
# GET /api/v2/accounts/:account_id/chatbot_config -> current config
# POST /api/v2/accounts/:account_id/chatbot_config -> update allowed keys
#
# Admin-role only (ReportPolicy#view? => administrator?). Reads/writes the account's
# chatbot settings via Chatbot::ConfigService (stored in Account#custom_attributes).
class Api::V2::Accounts::ChatbotConfigController < Api::V1::Accounts::BaseController
before_action :check_authorization
def show
render json: Chatbot::ConfigService.config(Current.account)
end
def update
config = Chatbot::ConfigService.update!(Current.account, chatbot_config_params)
render json: config
end
private
def chatbot_config_params
params.permit(:chatbot_enabled, :chatbot_system_prompt, :chatbot_guardrail_prompt,
:chatbot_out_of_scope_reply, :chatbot_call_mode)
end
def check_authorization
authorize :report, :view?
end
end

View File

@@ -0,0 +1,34 @@
# Admin-only knowledge base management for the self-contained OSS chatbot.
#
# GET /api/v2/accounts/:account_id/knowledge_base_faqs
# -> list FAQ entries
# POST /api/v2/accounts/:account_id/knowledge_base_faqs/import
# -> import markdown text (`content`) or uploaded .md/.csv/.xlsx (`file`)
#
# Admin-role only (ReportPolicy#view? => administrator?).
class Api::V2::Accounts::KnowledgeBaseFaqsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def index
faqs = Current.account.knowledge_base_faqs.order(:title)
render json: { faqs: faqs.as_json(only: %i[id title topic_tags source_filename updated_at]) }
end
def import
if params[:file].present?
result = KnowledgeBase::ImportService.import(account: Current.account, file_path: params[:file].tempfile.path, filename: params[:file].original_filename)
else
content = params[:content]
raise ActionController::BadRequest, 'content is required' if content.blank?
result = KnowledgeBase::ImportService.import(account: Current.account, content: content)
end
render json: result
end
private
def check_authorization
authorize :report, :view?
end
end

View File

@@ -6,6 +6,7 @@ class HookJob < MutexApplicationJob
INTEGRATION_PROCESSORS = { INTEGRATION_PROCESSORS = {
'slack' => :process_slack_integration, 'slack' => :process_slack_integration,
'dialogflow' => :process_dialogflow_integration, 'dialogflow' => :process_dialogflow_integration,
'chatbot' => :process_chatbot_integration,
'google_translate' => :google_translate_integration, 'google_translate' => :google_translate_integration,
'leadsquared' => :process_leadsquared_integration_with_lock, 'leadsquared' => :process_leadsquared_integration_with_lock,
'linear' => :process_linear_integration 'linear' => :process_linear_integration
@@ -50,6 +51,15 @@ class HookJob < MutexApplicationJob
Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
end end
def process_chatbot_integration(hook, event_name, event_data)
return unless event_name == 'message.created'
message = event_data[:message]
return unless message.content_type == 'text' && message.content.present?
Integrations::Chatbot::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
end
def google_translate_integration(hook, event_name, event_data) def google_translate_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name) return unless ['message.created'].include?(event_name)

View File

@@ -61,6 +61,7 @@ class HookListener < BaseListener
supported_events_map = { supported_events_map = {
'slack' => ['message.created', 'message.updated'], 'slack' => ['message.created', 'message.updated'],
'dialogflow' => ['message.created', 'message.updated'], 'dialogflow' => ['message.created', 'message.updated'],
'chatbot' => ['message.created'],
'google_translate' => ['message.created'], 'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved'], 'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved'],
'linear' => ['message.created'] 'linear' => ['message.created']

View File

@@ -86,6 +86,7 @@ class Account < ApplicationRecord
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok' has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook' has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inboxes, dependent: :destroy_async has_many :inboxes, dependent: :destroy_async
has_many :knowledge_base_faqs, dependent: :destroy_async
has_many :labels, dependent: :destroy_async has_many :labels, dependent: :destroy_async
has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line' has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line'
has_many :mentions, dependent: :destroy_async has_many :mentions, dependent: :destroy_async
@@ -94,6 +95,7 @@ class Account < ApplicationRecord
has_many :notification_settings, dependent: :destroy_async has_many :notification_settings, dependent: :destroy_async
has_many :notifications, dependent: :destroy_async has_many :notifications, dependent: :destroy_async
has_many :portals, dependent: :destroy_async, class_name: '::Portal' has_many :portals, dependent: :destroy_async, class_name: '::Portal'
has_many :product_catalog_entries, dependent: :destroy_async
has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms' has_many :sms_channels, dependent: :destroy_async, class_name: '::Channel::Sms'
has_many :teams, dependent: :destroy_async has_many :teams, dependent: :destroy_async
has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram' has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram'

View File

@@ -0,0 +1,34 @@
# == Schema Information
#
# Table name: knowledge_base_faqs
#
# id :bigint not null, primary key
# account_id :bigint not null
# title :string not null
# content :text not null
# topic_tags :jsonb default: [] not null
# source_filename :string
# embedding :vector(1536)
# created_at :datetime not null
# updated_at :datetime not null
#
class KnowledgeBaseFaq < ApplicationRecord
belongs_to :account
# pgvector KNN support (matches repo pattern: has_neighbors + nearest_neighbors)
has_neighbors :embedding, normalize: true
validates :title, presence: true
validates :content, presence: true
# topic_tags stored as jsonb array; expose string-list helpers for import/retrieval.
def topic_tag_list
Array(topic_tags)
end
def topic_tag_list=(value)
self.topic_tags = Array(value).map(&:strip).reject(&:blank?)
end
scope :for_account, ->(account) { where(account_id: account.id) }
scope :with_embedding, -> { where.not(embedding: nil) }
end

View File

@@ -0,0 +1,86 @@
# Per-account chatbot configuration for the self-contained OSS chatbot.
#
# Reads/writes the account's chatbot settings in Account#custom_attributes (jsonb).
# The chatbot processor + decision service read from here so there's a single source
# of truth for how the bot behaves for a given account.
#
# Keys (namespaced 'chatbot_*'):
# chatbot_enabled [bool] gates whether the chatbot replies (default false)
# chatbot_system_prompt [String] the persona/system instructions for the answer LLM
# chatbot_guardrail_prompt [String] the allowed-scope / guardrail instructions
# chatbot_out_of_scope_reply [String] templated refusal reply for out-of-scope messages
# chatbot_call_mode [Integer] 1 (one call) or 2 (guardrail + answer) — default 1
#
# Guardrail + out-of-scope reply have safe Thai defaults; system prompt and call mode
# fall back to built-in values when unset.
module Chatbot::ConfigService
KEYS = %w[
chatbot_enabled chatbot_system_prompt chatbot_guardrail_prompt
chatbot_out_of_scope_reply chatbot_call_mode
].freeze
DEFAULT_SYSTEM_PROMPT = <<~PROMPT.freeze
You are a helpful customer-service assistant for this business. Answer using ONLY the
provided knowledge base and conversation history. Be concise, accurate and polite.
If the knowledge base does not contain the answer, say you are not sure and offer to
connect the customer with a support agent. Do not invent facts.
PROMPT
DEFAULT_GUARDRAIL_PROMPT = <<~PROMPT.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).
PROMPT
DEFAULT_OUT_OF_SCOPE_REPLY = ' '
module_function
def enabled?(account)
account.custom_attributes['chatbot_enabled'] == true
end
def system_prompt(account)
value_or_default(account, 'chatbot_system_prompt', DEFAULT_SYSTEM_PROMPT)
end
def guardrail_prompt(account)
value_or_default(account, 'chatbot_guardrail_prompt', DEFAULT_GUARDRAIL_PROMPT)
end
def out_of_scope_reply(account)
value_or_default(account, 'chatbot_out_of_scope_reply', DEFAULT_OUT_OF_SCOPE_REPLY)
end
def call_mode(account)
configured = account.custom_attributes['chatbot_call_mode'].to_i
[1, 2].include?(configured) ? configured : 1
end
# Apply a params hash of allowed keys to the account's custom_attributes and persist.
# Returns the resulting config hash. Ignores/merges only known keys.
def update!(account, params)
attrs = account.custom_attributes || {}
KEYS.each do |key|
attrs[key] = params[key] if params.key?(key)
end
account.update!(custom_attributes: attrs)
config(account)
end
# @return [Hash] full current config
def config(account)
{
enabled: enabled?(account),
system_prompt: system_prompt(account),
guardrail_prompt: guardrail_prompt(account),
out_of_scope_reply: out_of_scope_reply(account),
call_mode: call_mode(account)
}
end
def value_or_default(account, key, default)
value = account.custom_attributes[key]
value.presence || default
end
end

View File

@@ -0,0 +1,206 @@
# 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

View File

@@ -0,0 +1,86 @@
# KnowledgeBase retriever for the self-contained OSS chatbot.
#
# Given a user message, returns the top-k most relevant KnowledgeBaseFaq entries for
# that account, ordered best-first. Retrieval is keyword-based (pg_trgm similarity on
# title+content + topic_tag match) so it works without an embedding backend.
#
# A `query_embedding` param is accepted as a future extension point for embedding-fusion
# (hybrid keyword + vector ranking), to be wired when an embedding provider is configured
# (see plan). Returns [{ faq:, score: Float }] — the caller injects these into the LLM prompt.
module Chatbot::KnowledgeRetriever
DEFAULT_LIMIT = 5
module_function
# @param account [Account]
# @param query [String] the user message
# @param query_embedding [Array<Float>, nil] reserved; embedding-fusion is a later step
# @param limit [Integer]
# @return [Array<Hash>] [{ faq:, score: Float }]
def retrieve(account:, query:, query_embedding: nil, limit: DEFAULT_LIMIT)
return [] if query.blank?
scores = score_candidates(account, query)
return [] if scores.empty?
max = scores.values.max
scores.map { |faq, score| { faq: faq, score: (score / max).round(4) } }
.sort_by { |h| -h[:score] }
.first(limit)
end
# Rank candidate FAQ entries by pg_trgm similarity + topic-tag match.
# @return [Hash{KnowledgeBaseFaq => Float}]
def score_candidates(account, query)
scores = {}
candidates(account, query).each do |faq|
s = faq_title_similarity(faq, query)
s = [s, faq_content_similarity(faq, query)].max
s += 0.2 if topic_match?(faq, query)
scores[faq] = s if s.positive?
end
scores
end
# Candidate set: entries whose title or content is likely relevant (pre-filter via
# pg_trgm word_similarity to keep the scoring pass small). Falls back to all account
# FAQs if pre-filter isn't available (plain AR without pg_trgm search string).
def candidates(account, query)
relation = account.knowledge_base_faqs
column = %(GREATEST(word_similarity(title, #{quote(query)}), word_similarity(content, #{quote(query)})))
relation.where("#{column} > 0.1").limit(50).to_a
rescue StandardError
relation.limit(200).to_a
end
def faq_title_similarity(faq, query)
pg_similarity(faq.title, query)
end
def faq_content_similarity(faq, query)
pg_similarity(faq.content, query)
end
# Token-overlap similarity ratio computed in Ruby (downcase → split → overlap / max size).
# Deterministic and DB-free; used to rank the small candidate pool from `candidates`.
def pg_similarity(text_a, text_b)
return 0.0 if text_a.blank? || text_b.blank?
a = text_a.downcase.split(/\s+/).reject(&:blank?)
b = text_b.downcase.split(/\s+/).reject(&:blank?)
return 0.0 if a.empty? || b.empty?
overlap = (a & b).size
overlap.to_f / [a.size, b.size].max.to_f
end
def topic_match?(faq, query)
q = query.downcase
faq.topic_tag_list.any? { |t| q.include?(t.downcase) }
end
def quote(value)
ActiveRecord::Base.sanitize_sql_like(value.to_s).gsub("'", "''")
end
end

View File

@@ -0,0 +1,131 @@
# Imports markdown FAQ content into KnowledgeBaseFaq for an account (OSS self-contained
# chatbot, phase: KB). Accepts:
# - pasted markdown text (content:) — split into per-heading sections
# - an uploaded .md / .csv / .xlsx file (file_path: + filename:)
#
# For markdown: each `#`/`##`/`---` section becomes one KnowledgeBaseFaq row, which
# is the retrieval unit (title = heading, content = section body). topic_tags come
# from explicit front-matter tags or fall back to the first heading words.
#
# Returns a Hash: { imported:, updated:, errors: [{ line, message }] }.
class KnowledgeBase::ImportService
FRONT_MATTER_TAGS = /\A---\s*\ntags:\s*(.+?)\n---\s*\n/im
# Matches markdown headings (#, ##, ... up to ######). Uses [ # ]{1,6} to avoid
# any #{ } interpolation ambiguity in the regex literal.
HEADING = /^[#]{1,6}\s+(.+)$/i
def self.import(account:, content: nil, file_path: nil, filename: nil)
new(account: account, content: content, file_path: file_path, filename: filename).import
end
def initialize(account:, content: nil, file_path: nil, filename: nil)
@account = account
@content = content.to_s
@file_path = file_path
@filename = filename
end
def import
sections = @file_path ? sections_from_file : sections_from_text(@content)
upsert_sections(sections)
end
private
def sections_from_file
ext = File.extname(@filename.presence || @file_path.to_s).delete('.').downcase
text =
case ext
when 'md'
File.read(@file_path)
when 'csv'
require 'csv'
# one row per column -> naive title/content pair
CSV.read(@file_path).map { |r| "#{r[0]}\n\n#{r[1..].join(' ')}" }.join("\n\n")
when 'xlsx'
read_xlsx(@file_path)
else
File.read(@file_path)
end
sections_from_text(text)
rescue StandardError => e
Rails.logger.error("[KnowledgeBaseImport] parse failed: #{e.message}")
[{ error: "could not read file: #{e.message}" }]
end
def read_xlsx(path)
require 'roo'
sheet = Roo::Spreadsheet.open(path, extension: 'xlsx').sheet(0)
rows = (1..sheet.last_row).filter_map do |idx|
r = (1..sheet.last_column).map { |c| sheet.cell(idx, c).to_s }
"#{r[0]}\n\n#{r[1..].join(' ')}" unless r.all?(&:blank?)
end
rows.join("\n\n")
end
# Split markdown into per-heading sections; content preceding the first heading is
# treated as a single section with a derived title. A leading front-matter block
# (`---\ntags: ...\n---`) is stripped and its tags applied to every section.
def sections_from_text(text)
body, tags = extract_front_matter_tags(text.to_s)
sections = []
current = { title: nil, body: [] }
body.strip.split(/\r?\n/).each do |line|
if (m = line.match(HEADING))
# Flush the current section (even a title-less intro block) before a heading
sections << close_section(current, tags)
current = { title: m[1].strip, body: [] }
else
current[:body] << line
end
end
sections << close_section(current, tags)
sections.compact.reject { |s| s[:content].blank? }
end
# Returns [body_without_front_matter, tags_array]
def extract_front_matter_tags(text)
if (m = text.match(FRONT_MATTER_TAGS))
[text.sub(m[0], ''), m[1].split(/[,;\s]+/).map(&:strip).reject(&:blank?)]
else
[text, []]
end
end
def close_section(section, tags)
body = section[:body].join("\n").strip
return nil if body.blank? && section[:title].blank?
{ title: section[:title].presence || body.lines.first.to_s.strip[0..80], content: body, topic_tags: tags }
end
def upsert_sections(sections)
imported = 0
updated = 0
errors = []
sections.each_with_index do |sec, idx|
next unless sec.is_a?(Hash) && sec[:content].present?
next if sec[:content].blank?
attrs = {
content: sec[:content],
topic_tags: sec[:topic_tags] || [],
source_filename: @filename
}
existing = @account.knowledge_base_faqs.find_by(title: sec[:title])
if existing
existing.update!(attrs)
updated += 1
else
@account.knowledge_base_faqs.create!(attrs.merge(title: sec[:title]))
imported += 1
end
rescue ActiveRecord::RecordInvalid => e
errors << { line: idx + 1, message: e.message }
end
{ imported: imported, updated: updated, errors: errors }
end
end

View File

@@ -87,6 +87,14 @@ slack:
hook_type: account hook_type: account
allow_multiple_hooks: false allow_multiple_hooks: false
visible_properties: ['channel_name'] visible_properties: ['channel_name']
chatbot:
id: chatbot
logo: chatbot.png
i18n_key: chatbot
action: /chatbot
hook_type: inbox
allow_multiple_hooks: false
visible_properties: []
dialogflow: dialogflow:
id: dialogflow id: dialogflow
logo: dialogflow.png logo: dialogflow.png

View File

@@ -408,6 +408,10 @@ en:
name: 'Dialogflow' name: 'Dialogflow'
short_description: 'Build chatbots to handle initial queries before transferring to agents.' short_description: 'Build chatbots to handle initial queries before transferring to agents.'
description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.' description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
chatbot:
name: 'Chatbot'
short_description: 'AI chatbot that answers in-scope questions from your knowledge base and hands off to agents when needed.'
description: 'Enable the built-in AI chatbot: it answers questions that fall within your product/service scope using your knowledge base (md/csv/excel), refuses clearly off-topic requests, and automatically hands related-but-unanswerable conversations to a human agent.'
google_translate: google_translate:
name: 'Google Translate' name: 'Google Translate'
short_description: 'Automatically translate customer messages for agents.' short_description: 'Automatically translate customer messages for agents.'

View File

@@ -544,6 +544,27 @@ Rails.application.routes.draw do
get :grouped_conversation_metrics get :grouped_conversation_metrics
end end
end end
resources :analytics_reports, only: [] do
collection do
get :summary
get :drilldown
post :persona_evaluation
post :persona_evaluation_deliver
post :persona_approval_settings
post :persona_decision
end
end
resources :product_catalog_entries, only: [:index] do
collection do
post :import
end
end
resources :knowledge_base_faqs, only: [:index] do
collection do
post :import
end
end
resource :chatbot_config, only: %i[show update]
end end
end end
end end

View File

@@ -0,0 +1,28 @@
# KnowledgeBaseFaq — OSS-level markdown FAQ knowledge entries for the self-contained
# chatbot. Account-scoped. Supports keyword retrieval via pg_trgm index on title and
# an optional pgvector embedding column (populated by a later embedding step).
#
# This is intentionally SEPARATE from Enterprise-only Captain tables
# (captain_assistant_responses / captain_assistants) so it works without EE.
class CreateKnowledgeBaseFaqs < ActiveRecord::Migration[7.1]
def change
create_table :knowledge_base_faqs do |t|
t.bigint :account_id, null: false
t.string :title, null: false # short topic/question label
t.text :content, null: false # markdown body / answer
t.jsonb :topic_tags, default: [], null: false # e.g. ["shipping", "refund"]
t.string :source_filename # original md filename (optional)
t.vector :embedding, limit: 1536 # pgvector embedding (nullable; set later)
t.timestamps
end
add_index :knowledge_base_faqs, :account_id
add_index :knowledge_base_faqs, [:account_id, :title]
# pg_trgm GIN index for fuzzy keyword search on title + content
# (matches repo convention: gin + gin_trgm_ops, e.g. index_messages_on_content)
add_index :knowledge_base_faqs, :title, using: :gin, opclass: :gin_trgm_ops, name: 'index_kbf_on_title_trgm'
add_index :knowledge_base_faqs, :content, using: :gin, opclass: :gin_trgm_ops, name: 'index_kbf_on_content_trgm'
# pgvector ivfflat index for embedding similarity search (only when embeddings exist)
add_index :knowledge_base_faqs, :embedding, using: :ivfflat, opclass: :vector_cosine_ops, name: 'index_kbf_on_embedding'
end
end

View 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