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:
82
.hermes/plans/2026-08-20-oss-chatbot-guardrail-knowledge.md
Normal file
82
.hermes/plans/2026-08-20-oss-chatbot-guardrail-knowledge.md
Normal 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.
|
||||||
30
app/controllers/api/v2/accounts/chatbot_config_controller.rb
Normal file
30
app/controllers/api/v2/accounts/chatbot_config_controller.rb
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
34
app/models/knowledge_base_faq.rb
Normal file
34
app/models/knowledge_base_faq.rb
Normal 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
|
||||||
86
app/services/chatbot/config_service.rb
Normal file
86
app/services/chatbot/config_service.rb
Normal 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
|
||||||
206
app/services/chatbot/decision_service.rb
Normal file
206
app/services/chatbot/decision_service.rb
Normal 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
|
||||||
86
app/services/chatbot/knowledge_retriever.rb
Normal file
86
app/services/chatbot/knowledge_retriever.rb
Normal 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
|
||||||
131
app/services/knowledge_base/import_service.rb
Normal file
131
app/services/knowledge_base/import_service.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
28
db/migrate/20260820000000_create_knowledge_base_faqs.rb
Normal file
28
db/migrate/20260820000000_create_knowledge_base_faqs.rb
Normal 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
|
||||||
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