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)
87 lines
3.0 KiB
Ruby
87 lines
3.0 KiB
Ruby
# 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
|