Skip to content

Commit

Permalink
Merge pull request #123 from merefield/collect_user_fields
Browse files Browse the repository at this point in the history
FEATURE: Use Chatbot to urge user to complete their User Fields.
  • Loading branch information
merefield authored Oct 12, 2024
2 parents befb9b2 + c6eaf59 commit 1a70869
Show file tree
Hide file tree
Showing 7 changed files with 212 additions and 22 deletions.
40 changes: 38 additions & 2 deletions app/controllers/discourse_chatbot/chatbot_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,42 @@ def start_bot_convo
evaluation = ::DiscourseChatbot::EventEvaluation.new
over_quota = evaluation.over_quota(current_user.id)

kick_off_statement = I18n.t("chatbot.quick_access_kick_off.announcement")

if SiteSetting.chatbot_user_fields_collection

trust_level = ::DiscourseChatbot::EventEvaluation.new.trust_level(current_user.id)
opts = { trust_level: trust_level, user_id: current_user.id }

start_bot = ::DiscourseChatbot::OpenAiBotRag.new(opts, false)

system_message = { "role": "system", "content": I18n.t("chatbot.prompt.system.rag.private", current_date_time: DateTime.current) }
assistant_message = { "role": "assistant", "content": I18n.t("chatbot.prmopt.quick_access_kick_off.announcement") }

system_message_suffix = start_bot.get_system_message_suffix(opts)
system_message[:content] += " " + system_message_suffix

messages = [system_message, assistant_message]

model = start_bot.model_name

parameters = {
model: model,
messages: messages,
max_completion_tokens: SiteSetting.chatbot_max_response_tokens,
temperature: SiteSetting.chatbot_request_temperature / 100.0,
top_p: SiteSetting.chatbot_request_top_p / 100.0,
frequency_penalty: SiteSetting.chatbot_request_frequency_penalty / 100.0,
presence_penalty: SiteSetting.chatbot_request_presence_penalty / 100.0
}

res = start_bot.client.chat(
parameters: parameters
)

kick_off_statement = res.dig("choices", 0, "message", "content")
end

if channel_type == "chat"

bot_author = ::User.find_by(username: SiteSetting.chatbot_bot_user)
Expand All @@ -44,7 +80,7 @@ def start_bot_convo
Chat::CreateMessage.call(
chat_channel_id: chat_channel_id,
guardian: guardian,
message: over_quota ? I18n.t('chatbot.errors.overquota') : I18n.t("chatbot.quick_access_kick_off.announcement"),
message: over_quota ? I18n.t('chatbot.errors.overquota') : kick_off_statement,
)
end

Expand All @@ -53,7 +89,7 @@ def start_bot_convo
elsif channel_type == "personal message"
default_opts = {
post_alert_options: { skip_send_email: true },
raw: over_quota ? I18n.t('chatbot.errors.overquota') : I18n.t("chatbot.quick_access_kick_off.announcement"),
raw: over_quota ? I18n.t('chatbot.errors.overquota') : kick_off_statement,
skip_validations: true,
title: I18n.t("chatbot.pm_prefix"),
archetype: Archetype.private_message,
Expand Down
30 changes: 25 additions & 5 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ en:
chatbot_forum_search_function_results_topic_max_posts_count: "The maximum number of Posts to be returned in the search results if content_type is 'topics'"
chatbot_tool_choice_first_iteration: "Choose if a tool must be used for the first iteration of the bots response thinking cycle. If you choose 'force_local_forum_search' the bot will search the forum for information, if you choose 'force_a_function' the bot will use a function of its choice, if you choose 'not_forced' (default) the bot will not be forced to use a tool for its first cycle."
chatbot_forum_search_function_hybrid_search: "(EXPERIMENTAL): Enable hybrid search mode. This will cause the bot to search using native keyword search in addition to embedding based semantic search and it will attempt to blend the results"
chatbot_url_integrity_check: "(EXPERIMENTAL): Enable URL integrity check. If the LLM has returned an invalid forum URL, this will be highlighted back to the LLM"
chatbot_locations_plugin_support: "(EXPERIMENTAL currently user locations only) Natural language querying capability for <a target='_blank' rel='noopener' href='https://github.com/paviliondev/discourse-locations'>Locations Plugin</a> (when using RAG mode, requires Locations Plugin to be installed)"
chatbot_escalate_to_staff_function: "(EXPERIMENTAL, Chat only) if user requests human assistance or gets irritated, escalate to staff via PM (requires staff group to be populated)"
chatbot_escalate_to_staff_groups: "(EXPERIMENTAL, Chat only) groups added to escalation PM, e.g. support team"
chatbot_escalate_to_staff_max_history: "(EXPERIMENTAL, Chat only) number of chat messages included in transcript added to escalation PM"
chatbot_url_integrity_check: "Enable URL integrity check. If the LLM has returned an invalid forum URL, this will be highlighted back to the LLM"
chatbot_locations_plugin_support: "(currently user locations only) Natural language querying capability for <a target='_blank' rel='noopener' href='https://github.com/paviliondev/discourse-locations'>Locations Plugin</a> (when using RAG mode, requires Locations Plugin to be installed)"
chatbot_escalate_to_staff_function: "(Chat only) if user requests human assistance or gets irritated, escalate to staff via PM (requires staff group to be populated)"
chatbot_escalate_to_staff_groups: "(Chat only) groups added to escalation PM, e.g. support team"
chatbot_escalate_to_staff_max_history: "(Chat only) number of chat messages included in transcript added to escalation PM"
chatbot_user_fields_collection: "(EXPERIMENTAL) Collect empty user fields from the user as part of the conversation"
chatbot_news_api_token: "News API token for news (if left blank, news will never be searched)<a target='_blank' rel='noopener' href='https://newsapi.org/'>Get one at NewsAPI.org</a>"
chatbot_firecrawl_api_token: "Firecrawl API token for crawling remote websites. If left blank, crawling will not be available. <a target='_blank' rel='noopener' href='https://www.firecrawl.dev/'>Get one at https://www.firecrawl.dev/</a>"
chatbot_jina_api_token: "Jina API token for web crawl and search. <a target='_blank' rel='noopener' href='https://jina.ai'>Get one at https://jina.ai</a>. Alternative to Firecrawl and Serp. If Firecrawl API token is populated, it will be used in preference to Jina for crawling. Ditto Serp API for Searching."
Expand Down Expand Up @@ -109,7 +110,26 @@ en:
post: "%{username} said %{raw}"
private_message:
title_creation: "Create a short Topic title from summarising the prior messages"
quick_access_kick_off:
announcement: "I must greet the user warmly and kick off any questions I want answered"
function:
user_information:
name: "user_information_for_%{user_field}"
system_message:
general: "You must be proactive and ask the user to tell you what their %{name} is. Do not wait to be prompted!"
confirmation: "You must be proactive and ask the user to tell you what response is to the declaration: %{name}. Do not wait to be prompted!"
dropdown: "You must be proactive and ask the user to tell you what their selection of: %{name} is. The choices are %{options}. Do not wait to be prompted!"
closing_statement: "However, ONLY ask ONE question at a time. Do not ask multiple questions in one go. Ask last question first."
description:
general: get information about %{user_field} for the user
confirmation: confirm if the user accepts the following %{user_field}
parameters:
answer:
text: the answer to the question about what user's %{user_field} is
confirmation: "user's confirmation or otherwise (true or false) of: %{user_field}. If user does not confirm it is false. If user does confirm, it is true."
dropdown: "the user's selection of: %{user_field} which can be one of: %{options}"
answer: "Confirm with the user the infomration they gave about %{user_field} has been saved."
error: "I couldn't save any information about %{user_field} for answer '%{answer}'"
calculator:
description: |
Useful for getting the result of a math expression. It is a general purpose calculator. It works with Ruby expressions.
Expand Down
3 changes: 3 additions & 0 deletions config/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ plugins:
chatbot_escalate_to_staff_max_history:
client: false
default: 10
chatbot_user_fields_collection:
client: false
default: false
chatbot_news_api_token:
client: false
default: ''
Expand Down
24 changes: 14 additions & 10 deletions lib/discourse_chatbot/bots/open_ai_bot_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
module ::DiscourseChatbot

class OpenAIBotBase < Bot
attr_reader :client, :model_name

def initialize(opts)
::OpenAI.configure do |config|
Expand Down Expand Up @@ -39,21 +40,24 @@ def initialize(opts)
end
end

@model_name =
SiteSetting.chatbot_support_vision == "directly" ? SiteSetting.chatbot_open_ai_vision_model :
case opts[:trust_level]
when TRUST_LEVELS[0], TRUST_LEVELS[1], TRUST_LEVELS[2]
SiteSetting.send("chatbot_open_ai_model_custom_" + opts[:trust_level] + "_trust") ?
SiteSetting.send("chatbot_open_ai_model_custom_name_" + opts[:trust_level] + "_trust") :
SiteSetting.send("chatbot_open_ai_model_" + opts[:trust_level] + "_trust")
else
SiteSetting.chatbot_open_ai_model_custom_low_trust ? SiteSetting.chatbot_open_ai_model_custom_name_low_trust : SiteSetting.chatbot_open_ai_model_low_trust
end
@model_name = get_model(opts)
end

def get_response(prompt, opts)
raise "Overwrite me!"
end

def get_model(opts)
SiteSetting.chatbot_support_vision == "directly" ? SiteSetting.chatbot_open_ai_vision_model :
case opts[:trust_level]
when TRUST_LEVELS[0], TRUST_LEVELS[1], TRUST_LEVELS[2]
SiteSetting.send("chatbot_open_ai_model_custom_" + opts[:trust_level] + "_trust") ?
SiteSetting.send("chatbot_open_ai_model_custom_name_" + opts[:trust_level] + "_trust") :
SiteSetting.send("chatbot_open_ai_model_" + opts[:trust_level] + "_trust")
else
SiteSetting.chatbot_open_ai_model_custom_low_trust ? SiteSetting.chatbot_open_ai_model_custom_name_low_trust : SiteSetting.chatbot_open_ai_model_low_trust
end
end

end
end
57 changes: 53 additions & 4 deletions lib/discourse_chatbot/bots/open_ai_bot_rag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"DiscourseChatbot::WebSearchFunction",
"DiscourseChatbot::WebCrawlerFunction",
"DiscourseChatbot::NewsFunction",
"DiscourseChatbot::UserFieldFunction",
"DiscourseChatbot::EscalateToStaffFunction",
"DiscourseChatbot::CalculatorFunction"]

Expand All @@ -30,21 +31,29 @@ class OpenAiBotRag < OpenAIBotBase
FORCE_A_FUNCTION = "force_a_function"
FORCE_LOCAL_SEARCH_FUNCTION = "force_local_forum_search"

def initialize(opts)
super
merge_functions(opts)
def initialize(opts, tools = true)
super(opts)
merge_functions(opts) if tools
end

def get_response(prompt, opts)
private_discussion = opts[:private] || false

if private_discussion
system_message = { "role": "system", "content": I18n.t("chatbot.prompt.system.rag.private", current_date_time: DateTime.current) }

if SiteSetting.chatbot_user_fields_collection
system_message[:content] += " " + get_system_message_suffix(opts)
end
else
system_message = { "role": "system", "content": I18n.t("chatbot.prompt.system.rag.open", current_date_time: DateTime.current) }
end

prompt.unshift(system_message)
if SiteSetting.chatbot_user_fields_collection
prompt << system_message
else
prompt.unshift(system_message)
end

@inner_thoughts = []
@posts_ids_found = []
Expand All @@ -61,6 +70,35 @@ def get_response(prompt, opts)
}
end

def get_system_message_suffix(opts)
system_message_suffix = ""
system_message_suffix_array = []
UserField.where(editable: true).order(:id).each do |user_field|
user_field_options = []
user_field_id = user_field.id
user_field_type = user_field.field_type_enum
if user_field_type == "dropdown"
UserFieldOption.where(user_field_id: user_field_id).each do |option|
user_field_options << option.value
end
end
if !::UserCustomField.where(user_id: opts[:user_id], name: "user_field_#{UserField.find_by(name: user_field.name).id}" ).exists? ||
::UserCustomField.where(user_id: opts[:user_id], name: "user_field_#{UserField.find_by(name: user_field.name).id}" ).first.value.blank?
system_message_suffix_array << case user_field_type
when "confirm"
I18n.t("chatbot.prompt.function.user_information.system_message.confirmation", name: user_field.name, description: user_field.description)
when "dropdown"
I18n.t("chatbot.prompt.function.user_information.system_message.dropdown", name: user_field.name, options: user_field_options.to_sentence)
else
I18n.t("chatbot.prompt.function.user_information.system_message.general", name: user_field.name, description: user_field.description)
end
end
break if system_message_suffix_array.length > 1
end
system_message_suffix = system_message_suffix_array.reverse.join(" ")
system_message_suffix += " " + I18n.t("chatbot.prompt.function.user_information.system_message.closing_statement")
end

def merge_functions(opts)
calculator_function = ::DiscourseChatbot::CalculatorFunction.new
wikipedia_function = ::DiscourseChatbot::WikipediaFunction.new
Expand Down Expand Up @@ -98,6 +136,17 @@ def merge_functions(opts)

functions = [calculator_function, wikipedia_function]

if opts[:private] && SiteSetting.chatbot_user_fields_collection
start_length = functions.length
UserField.where(editable: true).order(:id).each do |user_field|
if !::UserCustomField.where(user_id: opts[:user_id], name: "user_field_#{UserField.find_by(name: user_field.name).id}" ).exists? ||
::UserCustomField.where(user_id: opts[:user_id], name: "user_field_#{UserField.find_by(name: user_field.name).id}" ).first.value.blank?
functions << ::DiscourseChatbot::UserFieldFunction.new(user_field.name, opts[:user_id])
end
break if functions.length > start_length + 1
end
end

functions << forum_search_function if forum_search_function
functions << vision_function if vision_function
functions << paint_function if SiteSetting.chatbot_support_picture_creation
Expand Down
77 changes: 77 additions & 0 deletions lib/discourse_chatbot/functions/user_field_function.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

require_relative '../function'

module DiscourseChatbot
class UserFieldFunction < Function
def initialize(user_field, user_id)
@user_field_options = []
@user_field = user_field
@user_field_object = UserField.find_by(name: user_field)
@user_field_id = @user_field_object.id
@user_field_type = @user_field_object.field_type_enum
if @user_field_type == "dropdown"
UserFieldOption.where(user_field_id: @user_field_id).each do |option|
@user_field_options << option.value
end
end
@function_name = user_field.downcase.gsub(" ", "_")
@user_custom_field_name = "user_field_#{@user_field_id}"
@user_id = user_id
super()
end

def name
I18n.t("chatbot.prompt.function.user_information.name", user_field: @function_name)
end

def description
case @user_field_type
when "confirm"
I18n.t("chatbot.prompt.function.user_information.description.confirmation", user_field: @user_field)
else
I18n.t("chatbot.prompt.function.user_information.description.general", user_field: @user_field)
end
end

def parameters
case @user_field_type
when "text"
[
{ name: "answer", type: String, description: I18n.t("chatbot.prompt.function.user_information.parameters.answer.text", user_field: @user_field) } ,
]
when "confirm"
[
{ name: "answer", type: String, enum: ["true", "false"], description: I18n.t("chatbot.prompt.function.user_information.parameters.answer.confirmation", user_field: @user_field) } ,
]
when "dropdown"
[
{ name: "answer", type: String, enum: @user_field_options, description: I18n.t("chatbot.prompt.function.user_information.parameters.answer.dropdown", user_field: @user_field, options: @user_field_options) } ,
]
end
end

def required
['answer']
end

def process(args)
begin
super(args)
ucf = ::UserCustomField.where(user_id: @user_id, name: @user_custom_field_name).first

if ucf
ucf.value = args[parameters[0][:name]]
ucf.save!
else
::UserCustomField.create!(user_id: @user_id, name: @user_custom_field_name, value: args[parameters[0][:name]])
end

I18n.t("chatbot.prompt.function.user_information.answer", user_field: @user_field)
rescue StandardError => e
Rails.logger.error("Chatbot: Error occurred while attempting to store answer in a User Custom Field: #{e.message}")
I18n.t("chatbot.prompt.function.user_information.error", user_field: @user_field, answer: args[parameters[0][:name]])
end
end
end
end
3 changes: 2 additions & 1 deletion plugin.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# name: discourse-chatbot
# about: a plugin that allows you to have a conversation with a configurable chatbot in Discourse Chat, Topics and Private Messages
# version: 1.1.2
# version: 1.2.0
# authors: merefield
# url: https://github.com/merefield/discourse-chatbot

Expand Down Expand Up @@ -95,6 +95,7 @@ def progress_debug_message(message)
../lib/discourse_chatbot/bots/open_ai_bot_rag.rb
../lib/discourse_chatbot/safe_ruby/lib/safe_ruby.rb
../lib/discourse_chatbot/function.rb
../lib/discourse_chatbot/functions/user_field_function.rb
../lib/discourse_chatbot/functions/calculator_function.rb
../lib/discourse_chatbot/functions/escalate_to_staff_function.rb
../lib/discourse_chatbot/functions/news_function.rb
Expand Down

0 comments on commit 1a70869

Please sign in to comment.