diff --git a/.hermes/plans/2026-08-20-oss-chatbot-guardrail-knowledge.md b/.hermes/plans/2026-08-20-oss-chatbot-guardrail-knowledge.md new file mode 100644 index 000000000..bb87d942d --- /dev/null +++ b/.hermes/plans/2026-08-20-oss-chatbot-guardrail-knowledge.md @@ -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. diff --git a/app/controllers/api/v2/accounts/chatbot_config_controller.rb b/app/controllers/api/v2/accounts/chatbot_config_controller.rb new file mode 100644 index 000000000..951550e15 --- /dev/null +++ b/app/controllers/api/v2/accounts/chatbot_config_controller.rb @@ -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 diff --git a/app/controllers/api/v2/accounts/knowledge_base_faqs_controller.rb b/app/controllers/api/v2/accounts/knowledge_base_faqs_controller.rb new file mode 100644 index 000000000..535cff54f --- /dev/null +++ b/app/controllers/api/v2/accounts/knowledge_base_faqs_controller.rb @@ -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 diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb index eff80844f..c5cdcfc0d 100644 --- a/app/jobs/hook_job.rb +++ b/app/jobs/hook_job.rb @@ -6,6 +6,7 @@ class HookJob < MutexApplicationJob INTEGRATION_PROCESSORS = { 'slack' => :process_slack_integration, 'dialogflow' => :process_dialogflow_integration, + 'chatbot' => :process_chatbot_integration, 'google_translate' => :google_translate_integration, 'leadsquared' => :process_leadsquared_integration_with_lock, '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 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) return unless ['message.created'].include?(event_name) diff --git a/app/listeners/hook_listener.rb b/app/listeners/hook_listener.rb index 7c55b76d1..4387dacf0 100644 --- a/app/listeners/hook_listener.rb +++ b/app/listeners/hook_listener.rb @@ -61,6 +61,7 @@ class HookListener < BaseListener supported_events_map = { 'slack' => ['message.created', 'message.updated'], 'dialogflow' => ['message.created', 'message.updated'], + 'chatbot' => ['message.created'], 'google_translate' => ['message.created'], 'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved'], 'linear' => ['message.created'] diff --git a/app/models/account.rb b/app/models/account.rb index 74718a6c4..b38102980 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -86,6 +86,7 @@ class Account < ApplicationRecord has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok' has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook' has_many :inboxes, dependent: :destroy_async + has_many :knowledge_base_faqs, dependent: :destroy_async has_many :labels, dependent: :destroy_async has_many :line_channels, dependent: :destroy_async, class_name: '::Channel::Line' has_many :mentions, dependent: :destroy_async @@ -94,6 +95,7 @@ class Account < ApplicationRecord has_many :notification_settings, dependent: :destroy_async has_many :notifications, dependent: :destroy_async 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 :teams, dependent: :destroy_async has_many :telegram_channels, dependent: :destroy_async, class_name: '::Channel::Telegram' diff --git a/app/models/knowledge_base_faq.rb b/app/models/knowledge_base_faq.rb new file mode 100644 index 000000000..fcc5a86f4 --- /dev/null +++ b/app/models/knowledge_base_faq.rb @@ -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 diff --git a/app/services/chatbot/config_service.rb b/app/services/chatbot/config_service.rb new file mode 100644 index 000000000..81f32c535 --- /dev/null +++ b/app/services/chatbot/config_service.rb @@ -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 diff --git a/app/services/chatbot/decision_service.rb b/app/services/chatbot/decision_service.rb new file mode 100644 index 000000000..186217f82 --- /dev/null +++ b/app/services/chatbot/decision_service.rb @@ -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] [{ role: 'user'|'assistant', content: String }] + # @param knowledge [Array] [{ 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 diff --git a/app/services/chatbot/knowledge_retriever.rb b/app/services/chatbot/knowledge_retriever.rb new file mode 100644 index 000000000..2559d4afb --- /dev/null +++ b/app/services/chatbot/knowledge_retriever.rb @@ -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, nil] reserved; embedding-fusion is a later step + # @param limit [Integer] + # @return [Array] [{ 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 diff --git a/app/services/knowledge_base/import_service.rb b/app/services/knowledge_base/import_service.rb new file mode 100644 index 000000000..80a7ed88e --- /dev/null +++ b/app/services/knowledge_base/import_service.rb @@ -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 diff --git a/config/integration/apps.yml b/config/integration/apps.yml index b92076146..adc757545 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -87,6 +87,14 @@ slack: hook_type: account allow_multiple_hooks: false 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: id: dialogflow logo: dialogflow.png diff --git a/config/locales/en.yml b/config/locales/en.yml index fe5bfd8c3..2243afdba 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -408,6 +408,10 @@ en: name: 'Dialogflow' 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.' + 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: name: 'Google Translate' short_description: 'Automatically translate customer messages for agents.' diff --git a/config/routes.rb b/config/routes.rb index 93986a984..e5575577b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -544,6 +544,27 @@ Rails.application.routes.draw do get :grouped_conversation_metrics 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 diff --git a/db/migrate/20260820000000_create_knowledge_base_faqs.rb b/db/migrate/20260820000000_create_knowledge_base_faqs.rb new file mode 100644 index 000000000..5ee33ad66 --- /dev/null +++ b/db/migrate/20260820000000_create_knowledge_base_faqs.rb @@ -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 diff --git a/lib/integrations/chatbot/processor_service.rb b/lib/integrations/chatbot/processor_service.rb new file mode 100644 index 000000000..130f47724 --- /dev/null +++ b/lib/integrations/chatbot/processor_service.rb @@ -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