├── .discourse-compatibility
├── .eslintrc.cjs
├── .github
├── FUNDING.yml
└── workflows
│ └── discourse-plugin.yml
├── .gitignore
├── .prettierrc.cjs
├── .rubocop.yml
├── COPYRIGHT.txt
├── Gemfile
├── Gemfile.lock
├── LICENSE
├── README.md
├── app
├── controllers
│ └── discourse_chatbot
│ │ └── chatbot_controller.rb
├── jobs
│ ├── regular
│ │ ├── chatbot_post_embedding.rb
│ │ ├── chatbot_post_embedding_delete.rb
│ │ ├── chatbot_reply.rb
│ │ ├── chatbot_topic_title_embedding.rb
│ │ └── chatbot_topic_title_embedding_delete.rb
│ └── scheduled
│ │ ├── chatbot_embeddings_set_completer.rb
│ │ └── chatbot_quota_reset.rb
└── models
│ └── discourse_chatbot
│ ├── post_embedding.rb
│ ├── post_embeddings_bookmark.rb
│ ├── topic_embeddings_bookmark.rb
│ └── topic_title_embedding.rb
├── assets
├── javascripts
│ └── discourse
│ │ ├── components
│ │ ├── chatbot-launch.hbs
│ │ ├── chatbot-launch.js
│ │ ├── composer-raiser.hbs
│ │ └── composer-raiser.js
│ │ ├── connectors
│ │ ├── above-main-container
│ │ │ └── chatbot-launcher-link.hbs
│ │ ├── category-custom-settings
│ │ │ ├── set-chatbot.hbs
│ │ │ └── set-chatbot.js
│ │ ├── topic-above-posts
│ │ │ └── link-composer-raiser.hbs
│ │ └── user-custom-preferences
│ │ │ └── chatbot-user-preferences.hbs
│ │ ├── initializers
│ │ └── chatbot-post-launch-init.js
│ │ └── templates
│ │ └── components
│ │ └── chatbot-user-preferences.hbs
└── stylesheets
│ ├── common
│ └── chatbot_common.scss
│ └── mobile
│ └── chatbot_mobile.scss
├── config
├── locales
│ ├── client.en.yml
│ └── server.en.yml
├── routes.rb
└── settings.yml
├── db
├── fixtures
│ └── 100_chatbot.rb
├── legacy
│ ├── 20230820010101_enable_embedding_extension.rb
│ ├── 20230820010105_create_chatbot_embeddings_index.rb
│ ├── 20230826010103_rename_chatbot_embeddings_index.rb
│ ├── 20231026010101_drop_legacy_chatbot_embeddings_index.rb
│ └── 20231026010103_drop_legacy_chatbot_embedding_extension.rb
└── migrate
│ ├── 20230820010103_create_chatbot_embeddings_table.rb
│ ├── 20230826010101_rename_chatbot_embeddings_table.rb
│ ├── 20230828010101_clear_chatbot_post_embeddings.rb
│ ├── 20231025010101_enable_pgvector_extension.rb
│ ├── 20231026010107_rename_chatbot_post_embeddings_table.rb
│ ├── 20231026010109_create_new_chatbot_post_embeddings_table.rb
│ ├── 20231026010111_copy_chatbot_post_embeddings_to_new_table.rb
│ ├── 20231026010114_create_pg_vector_chatbot_post_embeddings_index.rb
│ ├── 20231217010101_drop_pg_vector_chatbot_post_embeddings_index.rb
│ ├── 20231217010103_create_cosine_pg_vector_chatbot_post_embeddings_index.rb
│ ├── 20240217010101_add_model_column_to_chatbot_post_embeddings_table.rb
│ ├── 20240217010103_create_chatbot_post_embeddings_bookmark_table.rb
│ ├── 20240412010101_create_chatbot_topic_title_embeddings_table.rb
│ ├── 20240412010103_create_chatbot_topic_embeddings_bookmark_table.rb
│ └── 20240412010105_create_cosine_pg_vector_chatbot_topic_title_embeddings_index.rb
├── jsconfig.base.json
├── lib
├── discourse_chatbot
│ ├── bot.rb
│ ├── bots
│ │ ├── open_ai_bot_base.rb
│ │ ├── open_ai_bot_basic.rb
│ │ └── open_ai_bot_rag.rb
│ ├── embedding_completionist_process.rb
│ ├── embedding_process.rb
│ ├── engine.rb
│ ├── event_evaluation.rb
│ ├── function.rb
│ ├── functions
│ │ ├── calculator_function.rb
│ │ ├── coords_from_location_description_search.rb
│ │ ├── escalate_to_staff_function.rb
│ │ ├── forum_get_user_address_function.rb
│ │ ├── forum_search_function.rb
│ │ ├── forum_topic_search_from_location_function.rb
│ │ ├── forum_topic_search_from_topic_location_function.rb
│ │ ├── forum_topic_search_from_user_location_function.rb
│ │ ├── forum_user_distance_from_location_function.rb
│ │ ├── forum_user_search_from_location_function.rb
│ │ ├── forum_user_search_from_topic_location_function.rb
│ │ ├── forum_user_search_from_user_location_function.rb
│ │ ├── get_distance_between_locations_function.rb
│ │ ├── news_function.rb
│ │ ├── paint_edit_function.rb
│ │ ├── paint_function.rb
│ │ ├── parser.rb
│ │ ├── remaining_quota_function.rb
│ │ ├── stock_data_function.rb
│ │ ├── user_field_function.rb
│ │ ├── vision_function.rb
│ │ ├── web_crawler_function.rb
│ │ ├── web_search_function.rb
│ │ └── wikipedia_function.rb
│ ├── message
│ │ ├── message_evaluation.rb
│ │ ├── message_prompt_utils.rb
│ │ └── message_reply_creator.rb
│ ├── post
│ │ ├── post_embedding_process.rb
│ │ ├── post_evaluation.rb
│ │ ├── post_prompt_utils.rb
│ │ └── post_reply_creator.rb
│ ├── prompt_utils.rb
│ ├── reply_creator.rb
│ ├── safe_ruby
│ │ └── lib
│ │ │ ├── constant_whitelist.rb
│ │ │ ├── make_safe_code.rb
│ │ │ ├── method_whitelist.rb
│ │ │ ├── safe_ruby.rb
│ │ │ └── safe_ruby
│ │ │ ├── runner.rb
│ │ │ └── version.rb
│ └── topic
│ │ └── topic_title_embedding_process.rb
└── tasks
│ └── chatbot.rake
├── package.json
├── plugin.rb
├── spec
├── fixtures
│ ├── input
│ │ ├── llm_first_query.json
│ │ └── llm_second_query.json
│ └── output
│ │ ├── llm_final_response.json
│ │ └── llm_function_response.json
├── lib
│ ├── bot
│ │ ├── bot_spec.rb
│ │ └── open_ai_agent_spec.rb
│ ├── embedding_completionist_process_spec.rb
│ ├── event_evaluation_spec.rb
│ ├── function_spec.rb
│ ├── functions
│ │ ├── calculator_function_spec.rb
│ │ ├── escalate_to_staff_function_spec.rb
│ │ └── forum_search_function_spec.rb
│ ├── post
│ │ ├── post_evaluation_spec.rb
│ │ └── post_prompt_utils_spec.rb
│ ├── post_embedding_process_spec.rb
│ └── safe_ruby
│ │ └── safe_ruby_spec.rb
└── plugin_helper.rb
├── template-lintrc.cjs
└── yarn.lock
/.discourse-compatibility:
--------------------------------------------------------------------------------
1 | < 3.4.0.beta3-dev: c62e9797d7c2b468f4fd6418382e79b415adb5ed
2 | 3.2.0.beta4: 9f391e5193fead336d317d8f1a8a5c2fa5202663
3 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require("@discourse/lint-configs/eslint");
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [merefield]
2 |
--------------------------------------------------------------------------------
/.github/workflows/discourse-plugin.yml:
--------------------------------------------------------------------------------
1 | name: Discourse Plugin
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | ci:
11 | uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | gems/
3 | node_modules
4 |
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require("@discourse/lint-configs/prettier");
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | inherit_gem:
2 | rubocop-discourse: stree-compat.yml
3 |
4 | RSpec/NamedSubject:
5 | Enabled: false
--------------------------------------------------------------------------------
/COPYRIGHT.txt:
--------------------------------------------------------------------------------
1 | All code in this repository is Copyright 2023 by Robert Barrow.
2 |
3 | This program is free software; you can redistribute it and/or modify
4 | it under the terms of the GNU General Public License as published by
5 | the Free Software Foundation; either version 2 of the License, or (at
6 | your option) any later version.
7 |
8 | This program is distributed in the hope that it will be useful, but
9 | WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
10 | or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
11 | for more details.
12 |
13 | You should have received a copy of the GNU General Public License
14 | along with this program as the file LICENSE.txt; if not, please see
15 | http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
16 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | group :development do
6 | gem 'rubocop-discourse'
7 | end
8 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | ast (2.4.2)
5 | json (2.7.1)
6 | language_server-protocol (3.17.0.3)
7 | parallel (1.23.0)
8 | parser (3.2.2.4)
9 | ast (~> 2.4.1)
10 | racc
11 | racc (1.7.3)
12 | rainbow (3.1.1)
13 | regexp_parser (2.8.3)
14 | rexml (3.2.6)
15 | rubocop (1.59.0)
16 | json (~> 2.3)
17 | language_server-protocol (>= 3.17.0)
18 | parallel (~> 1.10)
19 | parser (>= 3.2.2.4)
20 | rainbow (>= 2.2.2, < 4.0)
21 | regexp_parser (>= 1.8, < 3.0)
22 | rexml (>= 3.2.5, < 4.0)
23 | rubocop-ast (>= 1.30.0, < 2.0)
24 | ruby-progressbar (~> 1.7)
25 | unicode-display_width (>= 2.4.0, < 3.0)
26 | rubocop-ast (1.30.0)
27 | parser (>= 3.2.1.0)
28 | rubocop-capybara (2.19.0)
29 | rubocop (~> 1.41)
30 | rubocop-discourse (3.4.1)
31 | rubocop (>= 1.1.0)
32 | rubocop-rspec (>= 2.0.0)
33 | rubocop-factory_bot (2.24.0)
34 | rubocop (~> 1.33)
35 | rubocop-rspec (2.25.0)
36 | rubocop (~> 1.40)
37 | rubocop-capybara (~> 2.17)
38 | rubocop-factory_bot (~> 2.22)
39 | ruby-progressbar (1.13.0)
40 | unicode-display_width (2.5.0)
41 |
42 | PLATFORMS
43 | x86_64-linux
44 |
45 | DEPENDENCIES
46 | rubocop-discourse
47 |
48 | BUNDLED WITH
49 | 2.4.13
50 |
--------------------------------------------------------------------------------
/app/controllers/discourse_chatbot/chatbot_controller.rb:
--------------------------------------------------------------------------------
1 |
2 | # frozen_string_literal: true
3 | # require_dependency 'application_controller'
4 |
5 | module ::DiscourseChatbot
6 | class ChatbotController < ::ApplicationController
7 | requires_plugin PLUGIN_NAME
8 | before_action :ensure_plugin_enabled
9 |
10 | def start_bot_convo
11 | post_id = params[:post_id]
12 |
13 | response = {}
14 |
15 | bot_username = SiteSetting.chatbot_bot_user
16 | bot_user = ::User.find_by(username: bot_username)
17 | channel_type = SiteSetting.chatbot_quick_access_talk_button
18 |
19 | evaluation = ::DiscourseChatbot::EventEvaluation.new
20 | over_quota = evaluation.over_quota(current_user.id)
21 |
22 | kick_off_statement = I18n.t("chatbot.quick_access_kick_off.announcement")
23 |
24 | trust_level = ::DiscourseChatbot::EventEvaluation.new.trust_level(current_user.id)
25 | opts = { trust_level: trust_level, user_id: current_user.id }
26 |
27 | start_bot = ::DiscourseChatbot::OpenAiBotRag.new(opts, false)
28 |
29 | if !post_id && SiteSetting.chatbot_user_fields_collection && start_bot.has_empty_user_fields?(opts)
30 |
31 | system_message = { "role": "system", "content": I18n.t("chatbot.prompt.system.rag.private", current_date_time: DateTime.current) }
32 | assistant_message = { "role": "assistant", "content": I18n.t("chatbot.prompt.quick_access_kick_off.announcement", username: current_user.username) }
33 |
34 | system_message_suffix = start_bot.get_system_message_suffix(opts)
35 | system_message[:content] += " " + system_message_suffix
36 |
37 | messages = [system_message, assistant_message]
38 |
39 | model = start_bot.model_name
40 |
41 | parameters = {
42 | model: model,
43 | messages: messages,
44 | max_completion_tokens: SiteSetting.chatbot_max_response_tokens,
45 | temperature: SiteSetting.chatbot_request_temperature / 100.0,
46 | top_p: SiteSetting.chatbot_request_top_p / 100.0,
47 | frequency_penalty: SiteSetting.chatbot_request_frequency_penalty / 100.0,
48 | presence_penalty: SiteSetting.chatbot_request_presence_penalty / 100.0
49 | }
50 |
51 | res = start_bot.client.chat(
52 | parameters: parameters
53 | )
54 |
55 | kick_off_statement = res.dig("choices", 0, "message", "content")
56 | elsif post_id
57 | post = ::Post.find_by(id: post_id)
58 | system_message = { "role": "system", "content": I18n.t("chatbot.prompt.system.rag.private", current_date_time: DateTime.current) }
59 |
60 | opts[:reply_to_message_or_post_id] = post_id
61 |
62 | messages = PostPromptUtils.create_prompt(opts)
63 | pre_message = { "role": "assistant", "content": I18n.t("chatbot.prompt.quick_access_kick_off.post_analysis_before_posts", username: current_user.username ) }
64 | messages.unshift(pre_message)
65 | messages.unshift(system_message)
66 | messages << { "role": "assistant", "content": I18n.t("chatbot.prompt.quick_access_kick_off.post_analysis", username: current_user.username ) }
67 | model = start_bot.model_name
68 | parameters = {
69 | model: model,
70 | messages: messages,
71 | max_completion_tokens: SiteSetting.chatbot_max_response_tokens,
72 | temperature: SiteSetting.chatbot_request_temperature / 100.0,
73 | top_p: SiteSetting.chatbot_request_top_p / 100.0,
74 | frequency_penalty: SiteSetting.chatbot_request_frequency_penalty / 100.0,
75 | presence_penalty: SiteSetting.chatbot_request_presence_penalty / 100.0
76 | }
77 | res = start_bot.client.chat(
78 | parameters: parameters
79 | )
80 | kick_off_statement = res.dig("choices", 0, "message", "content")
81 | end
82 |
83 | if channel_type == "chat"
84 |
85 | bot_author = ::User.find_by(username: SiteSetting.chatbot_bot_user)
86 | guardian = Guardian.new(bot_author)
87 | chat_channel_id = nil
88 |
89 | direct_message = Chat::DirectMessage.for_user_ids([bot_user.id, current_user.id])
90 |
91 | if direct_message
92 | chat_channel = Chat::Channel.find_by(chatable_id: direct_message.id, type: "DirectMessageChannel")
93 | chat_channel_id = chat_channel.id
94 |
95 | #TODO we might need to add a membership if it doesn't exist to prevent a NotFound error in reads controller
96 | # membership = Chat::UserChatChannelMembership.find_by(user_id: current_user.id, chat_channel_id: chat_channel_id)
97 |
98 | # if membership
99 | # membership.update!(following: true)
100 | # membership.save!
101 | # end
102 |
103 | last_chat = ::Chat::Message.find_by(id: chat_channel.latest_not_deleted_message_id)
104 |
105 | if (last_chat &&
106 | (over_quota && last_chat.message != I18n.t('chatbot.errors.overquota') ||
107 | !over_quota && last_chat.message != kick_off_statement)) ||
108 | last_chat.nil?
109 | Chat::CreateMessage.call(
110 | params: {
111 | chat_channel_id: chat_channel_id,
112 | message: over_quota ? I18n.t('chatbot.errors.overquota') : kick_off_statement
113 | },
114 | guardian: guardian
115 | )
116 | end
117 |
118 | response = { channel_id: chat_channel_id }
119 | end
120 | elsif channel_type == "personal message"
121 | default_opts = {
122 | post_alert_options: { skip_send_email: true },
123 | raw: over_quota ? I18n.t('chatbot.errors.overquota') : kick_off_statement,
124 | skip_validations: true,
125 | title: I18n.t("chatbot.pm_prefix"),
126 | archetype: Archetype.private_message,
127 | target_usernames: [current_user.username, bot_user.username].join(",")
128 | }
129 |
130 | new_post = PostCreator.create!(bot_user, default_opts)
131 |
132 | response = { topic_id: new_post.topic_id }
133 | end
134 |
135 | render json: response
136 | end
137 |
138 | private
139 |
140 | def ensure_plugin_enabled
141 | unless SiteSetting.chatbot_enabled
142 | redirect_to path("/")
143 | end
144 | end
145 | end
146 | end
147 |
--------------------------------------------------------------------------------
/app/jobs/regular/chatbot_post_embedding.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Job is triggered on an update to a Post.
4 | class ::Jobs::ChatbotPostEmbedding < Jobs::Base
5 | sidekiq_options retry: 5, dead: false, queue: 'low'
6 |
7 | def execute(opts)
8 | begin
9 | post_id = opts[:id]
10 |
11 | ::DiscourseChatbot.progress_debug_message("100. Creating/updating a Post Embedding for Post id: #{post_id}")
12 |
13 | process_post_embedding = ::DiscourseChatbot::PostEmbeddingProcess.new
14 |
15 | process_post_embedding.upsert(post_id)
16 | rescue => e
17 | Rails.logger.error("Chatbot: Post Embedding: There was a problem, but will retry til limit: #{e}")
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/app/jobs/regular/chatbot_post_embedding_delete.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Job is triggered on a Post destruction.
4 | class ::Jobs::ChatbotPostEmbeddingDelete < Jobs::Base
5 | sidekiq_options retry: false
6 |
7 | def execute(opts)
8 | begin
9 | post_id = opts[:id]
10 |
11 | ::DiscourseChatbot.progress_debug_message("101. Deleting a Post Embedding for Post id: #{post_id}")
12 |
13 | ::DiscourseChatbot::PostEmbedding.find_by(post_id: post_id).destroy!
14 | rescue => e
15 | Rails.logger.error("Chatbot: Post Embedding: There was a problem, but will retry til limit: #{e}")
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/jobs/regular/chatbot_reply.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Job is triggered to respond to Message or Post appropriately, checking user's quota.
4 | class ::Jobs::ChatbotReply < Jobs::Base
5 | sidekiq_options retry: 5, dead: false
6 |
7 | sidekiq_retries_exhausted do |msg, ex|
8 | reply_and_thoughts = {
9 | reply: I18n.t('chatbot.errors.retries'),
10 | inner_thoughts: nil
11 | }
12 | opts = msg['args'].first.transform_keys(&:to_sym)
13 | opts.merge!(reply_and_thoughts)
14 | type = opts[:type]
15 | if type == ::DiscourseChatbot::POST
16 | reply_creator = ::DiscourseChatbot::PostReplyCreator.new(opts)
17 | else
18 | reply_creator = ::DiscourseChatbot::MessageReplyCreator.new(opts)
19 | end
20 | reply_creator.create
21 | end
22 |
23 | def execute(opts)
24 | type = opts[:type]
25 | bot_user_id = opts[:bot_user_id]
26 | reply_to_message_or_post_id = opts[:reply_to_message_or_post_id]
27 | over_quota = opts[:over_quota]
28 |
29 | bot_user = ::User.find_by(id: bot_user_id)
30 | if type == ::DiscourseChatbot::POST
31 | post = ::Post.find_by(id: reply_to_message_or_post_id)
32 | else
33 | message = ::Chat::Message.find_by(id: reply_to_message_or_post_id)
34 | end
35 |
36 | create_bot_reply = false
37 |
38 | reply_and_thoughts = {
39 | reply: "",
40 | inner_thoughts: nil
41 | }
42 |
43 | return unless bot_user
44 |
45 | permitted_categories = SiteSetting.chatbot_permitted_categories.split('|')
46 |
47 | if over_quota
48 | reply_and_thoughts[:reply] = I18n.t('chatbot.errors.overquota')
49 | elsif type == ::DiscourseChatbot::POST && post
50 | is_private_msg = post.topic.private_message?
51 | opts.merge!(is_private_msg: is_private_msg)
52 |
53 | begin
54 | presence = PresenceChannel.new("/discourse-presence/reply/#{post.topic.id}")
55 | presence.present(user_id: bot_user_id, client_id: "12345")
56 | rescue
57 | # ignore issues with permissions related to communicating presence
58 | end
59 |
60 | if (is_private_msg && !SiteSetting.chatbot_permitted_in_private_messages)
61 | reply_and_thoughts[:reply] = I18n.t('chatbot.errors.forbiddeninprivatemessages')
62 | elsif is_private_msg && SiteSetting.chatbot_permitted_in_private_messages || !is_private_msg && SiteSetting.chatbot_permitted_all_categories || (permitted_categories.include? post.topic.category_id.to_s)
63 | create_bot_reply = true
64 | else
65 | if permitted_categories.size > 0
66 | reply_and_thoughts[:reply] = I18n.t('chatbot.errors.forbiddenoutsidethesecategories')
67 | permitted_categories.each_with_index do |permitted_category, index|
68 | if index == permitted_categories.size - 1
69 | reply_and_thoughts[:reply] += "##{Category.find_by(id: permitted_category).slug}"
70 | else
71 | reply_and_thoughts[:reply] += "##{Category.find_by(id: permitted_category).slug}, "
72 | end
73 | end
74 | else
75 | reply_and_thoughts[:reply] = I18n.t('chatbot.errors.forbiddenanycategory')
76 | end
77 | end
78 | elsif type == ::DiscourseChatbot::MESSAGE && message
79 |
80 | chat_channel_id = message.chat_channel_id
81 | chat_channel = ::Chat::Channel.find(chat_channel_id)
82 |
83 | is_direct_msg = chat_channel.chatable_type == "DirectMessage"
84 |
85 | chat_category_id = nil
86 |
87 | chat_category_id = chat_channel.chatable_id if chat_channel.chatable_type == "Category"
88 |
89 | #opts.merge!(is_direct_msg: is_direct_msg)
90 |
91 | if (is_direct_msg && !SiteSetting.chatbot_permitted_in_chat)
92 | reply_and_thoughts[:reply] = I18n.t('chatbot.errors.forbiddeninchat')
93 | elsif SiteSetting.chatbot_permitted_in_chat && (is_direct_msg || (!is_direct_msg && SiteSetting.chatbot_permitted_all_categories) || (chat_category_id && (permitted_categories.include? chat_category_id.to_s)))
94 | create_bot_reply = true
95 | else
96 | if chat_channel.chatable_type == "Category"
97 | if permitted_categories.size > 0
98 | reply_and_thoughts[:reply] = I18n.t('chatbot.errors.forbiddenoutsidethesecategories')
99 | permitted_categories.each_with_index do |permitted_category, index|
100 | if index == permitted_categories.size - 1
101 | reply_and_thoughts[:reply] += "##{Category.find_by(id: permitted_category).slug}"
102 | else
103 | reply_and_thoughts[:reply] += "##{Category.find_by(id: permitted_category).slug}, "
104 | end
105 | end
106 | else
107 | reply_and_thoughts[:reply] = I18n.t('chatbot.errors.forbiddenanycategory')
108 | end
109 | end
110 | end
111 | begin
112 | presence = PresenceChannel.new("/chat-reply/#{message.chat_channel_id}")
113 | presence.present(user_id: bot_user_id, client_id: "12345")
114 | rescue
115 | # ignore issues with permissions related to communicating presence
116 | end
117 | end
118 |
119 | if create_bot_reply
120 | ::DiscourseChatbot.progress_debug_message("4. Retrieving new reply message...")
121 | begin
122 | case opts[:trust_level]
123 | when ::DiscourseChatbot::TRUST_LEVELS[0], ::DiscourseChatbot::TRUST_LEVELS[1], ::DiscourseChatbot::TRUST_LEVELS[2]
124 | if SiteSetting.send("chatbot_bot_type_" + opts[:trust_level] + "_trust") == "RAG"
125 | ::DiscourseChatbot.progress_debug_message("4a. Using RAG bot...")
126 | opts.merge!(chatbot_bot_type: "RAG")
127 | bot = ::DiscourseChatbot::OpenAiBotRag.new(opts)
128 | else
129 | ::DiscourseChatbot.progress_debug_message("4a. Using basic bot...")
130 | opts.merge!(chatbot_bot_type: "basic")
131 | bot = ::DiscourseChatbot::OpenAiBotBasic.new(opts)
132 | end
133 | else
134 | ::DiscourseChatbot.progress_debug_message("4a. Using basic bot...")
135 | opts.merge!(chatbot_bot_type: "basic")
136 | bot = ::DiscourseChatbot::OpenAiBotBasic.new(opts)
137 | end
138 | reply_and_thoughts = bot.ask(opts)
139 | rescue => e
140 | Rails.logger.error("Chatbot: There was a problem, but will retry til limit: #{e}")
141 | fail e
142 | end
143 | end
144 | opts.merge!(reply_and_thoughts)
145 | if type == ::DiscourseChatbot::POST
146 | reply_creator = ::DiscourseChatbot::PostReplyCreator.new(opts)
147 | else
148 | reply_creator = ::DiscourseChatbot::MessageReplyCreator.new(opts)
149 | end
150 | reply_creator.create
151 | end
152 | end
153 |
--------------------------------------------------------------------------------
/app/jobs/regular/chatbot_topic_title_embedding.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Job is triggered on an update to a Post.
4 | class ::Jobs::ChatbotTopicTitleEmbedding < Jobs::Base
5 | sidekiq_options retry: 5, dead: false, queue: 'low'
6 |
7 | def execute(opts)
8 | begin
9 | topic_id = opts[:id]
10 |
11 | ::DiscourseChatbot.progress_debug_message("100. Creating/updating a Topic Title Embedding for Topic id: #{topic_id}")
12 |
13 | process_topic_title_embedding = ::DiscourseChatbot::TopicTitleEmbeddingProcess.new
14 |
15 | process_topic_title_embedding.upsert(topic_id)
16 | rescue => e
17 | Rails.logger.error("Chatbot: Topic Title Embedding: There was a problem, but will retry til limit: #{e}")
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/app/jobs/regular/chatbot_topic_title_embedding_delete.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Job is triggered on a Topic destruction.
4 | class ::Jobs::ChatbotTopicTitleEmbeddingDelete < Jobs::Base
5 | sidekiq_options retry: false
6 |
7 | def execute(opts)
8 | begin
9 | topic_id = opts[:id]
10 |
11 | ::DiscourseChatbot.progress_debug_message("101. Deleting a Topic Title Embedding for Topic id: #{topic_id}")
12 |
13 | ::DiscourseChatbot::TopicTitleEmbedding.find_by(topic_id: topic_id).destroy!
14 | rescue => e
15 | Rails.logger.error("Chatbot: Topic Title Embedding: There was a problem, but will retry til limit: #{e}")
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/jobs/scheduled/chatbot_embeddings_set_completer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class ::Jobs::ChatbotEmbeddingsSetCompleter < ::Jobs::Scheduled
3 | sidekiq_options retry: false
4 |
5 | every 5.minutes
6 |
7 | def execute(args)
8 | return if !SiteSetting.chatbot_embeddings_enabled
9 |
10 | ::DiscourseChatbot::EmbeddingCompletionist.process
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/app/jobs/scheduled/chatbot_quota_reset.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class ::Jobs::ChatbotQuotaReset < ::Jobs::Scheduled
3 | sidekiq_options retry: false
4 |
5 | every 1.week
6 |
7 | def execute(args)
8 | ::DiscourseChatbot::Bot.new.reset_all_quotas
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/app/models/discourse_chatbot/post_embedding.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseChatbot
4 | class PostEmbedding < ActiveRecord::Base
5 | self.table_name = 'chatbot_post_embeddings'
6 |
7 | validates :post_id, presence: true, uniqueness: true
8 | end
9 | end
10 |
11 | # == Schema Information
12 | #
13 | # Table name: chatbot_post_embeddings
14 | #
15 | # id :bigint not null, primary key
16 | # post_id :integer not null
17 | # embedding :vector(1536) not null
18 | # created_at :datetime not null
19 | # updated_at :datetime not null
20 | # model :string
21 | #
22 | # Indexes
23 | #
24 | # index_chatbot_post_embeddings_on_post_id (post_id) UNIQUE
25 | # pgv_hnsw_index_on_chatbot_post_embeddings (embedding) USING hnsw
26 | #
27 |
--------------------------------------------------------------------------------
/app/models/discourse_chatbot/post_embeddings_bookmark.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseChatbot
4 | class PostEmbeddingsBookmark < ActiveRecord::Base
5 | self.table_name = 'chatbot_post_embeddings_bookmark'
6 |
7 | validates :post_id, presence: true
8 | end
9 | end
10 |
11 | # == Schema Information
12 | #
13 | # Table name: chatbot_post_embeddings_bookmark
14 | #
15 | # id :bigint not null, primary key
16 | # post_id :integer
17 | # created_at :datetime not null
18 | # updated_at :datetime not null
19 | #
20 |
--------------------------------------------------------------------------------
/app/models/discourse_chatbot/topic_embeddings_bookmark.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseChatbot
4 | class TopicEmbeddingsBookmark < ActiveRecord::Base
5 | self.table_name = 'chatbot_topic_embeddings_bookmark'
6 |
7 | validates :topic_id, presence: true
8 | end
9 | end
10 |
11 | # == Schema Information
12 | #
13 | # Table name: chatbot_topic_embeddings_bookmark
14 | #
15 | # id :bigint not null, primary key
16 | # topic_id :integer
17 | # created_at :datetime not null
18 | # updated_at :datetime not null
19 | #
20 |
--------------------------------------------------------------------------------
/app/models/discourse_chatbot/topic_title_embedding.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseChatbot
4 | class TopicTitleEmbedding < ActiveRecord::Base
5 | self.table_name = 'chatbot_topic_title_embeddings'
6 |
7 | validates :topic_id, presence: true, uniqueness: true
8 | end
9 | end
10 |
11 | # == Schema Information
12 | #
13 | # Table name: chatbot_topic_title_embeddings
14 | #
15 | # id :bigint not null, primary key
16 | # topic_id :integer not null
17 | # embedding :vector(1536) not null
18 | # model :string
19 | # created_at :datetime not null
20 | # updated_at :datetime not null
21 | #
22 | # Indexes
23 | #
24 | # index_chatbot_topic_title_embeddings_on_topic_id (topic_id) UNIQUE
25 | # pgv_hnsw_index_on_chatbot_topic_title_embeddings (embedding) USING hnsw
26 | #
27 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/chatbot-launch.hbs:
--------------------------------------------------------------------------------
1 | {{#if this.showChatbotButton}}
2 |
10 | {{#if this.chatbotLaunchUseAvatar}}
11 | {{avatar this.botUser imageSize="medium"}}
12 | {{else}}
13 | {{d-icon this.chatbotLaunchIcon}}
14 | {{/if}}
15 | {{#if this.primaryButton}}
16 |
17 | {{/if}}
18 |
19 | {{/if}}
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/chatbot-launch.js:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { tracked } from "@glimmer/tracking";
3 | import { action } from "@ember/object";
4 | import { inject as service } from "@ember/service";
5 | import { ajax } from "discourse/lib/ajax";
6 | import DiscourseURL from "discourse/lib/url";
7 | import Composer from "discourse/models/composer";
8 | import User from "discourse/models/user";
9 | import I18n from "I18n";
10 |
11 | export default class ContentLanguageDiscovery extends Component {
12 | @service siteSettings;
13 | @service currentUser;
14 | @service chat;
15 | @service router;
16 | @service composer;
17 | @service site;
18 | @service toasts;
19 |
20 | @tracked botUser = null;
21 |
22 | get showChatbotButton() {
23 | const baseRoute = this.router.currentRouteName.split(".")[0];
24 | return (
25 | this.currentUser &&
26 | this.siteSettings.chatbot_enabled &&
27 | this.currentUser.chatbot_access &&
28 | (baseRoute === "discovery" ||
29 | (!this.site.mobileView && baseRoute === "topic")) &&
30 | this.siteSettings.chatbot_quick_access_talk_button !== "off" &&
31 | ((this.siteSettings.chat_enabled &&
32 | this.siteSettings.chatbot_permitted_in_chat) ||
33 | (this.siteSettings.chatbot_permitted_in_private_messages &&
34 | this.siteSettings.chatbot_quick_access_talk_button ===
35 | "personal message"))
36 | );
37 | }
38 |
39 | get chatbotLaunchClass() {
40 | return this.args.post ? "post post-action-menu__chatbot" : "";
41 | }
42 |
43 | get title() {
44 | return this.args.post ? "chatbot.post_launch.title" : "";
45 | }
46 |
47 | @action
48 | getBotUser() {
49 | User.findByUsername(this.siteSettings.chatbot_bot_user, {}).then((user) => {
50 | this.botUser = user;
51 | });
52 | }
53 |
54 | get chatbotLaunchUseAvatar() {
55 | return this.siteSettings.chatbot_quick_access_talk_button_bot_icon === "";
56 | }
57 |
58 | get chatbotLaunchIcon() {
59 | return this.siteSettings.chatbot_quick_access_talk_button_bot_icon;
60 | }
61 |
62 | get primaryButton() {
63 | return !this.args.post;
64 | }
65 |
66 | @action
67 | async startChatting() {
68 | if (this.args?.post?.id) {
69 | this.toasts.success({
70 | duration: 3000,
71 | showProgressBar: true,
72 | data: {
73 | message: I18n.t("chatbot.post_launch.thinking"),
74 | icon: this.siteSettings.chatbot_quick_access_talk_button_bot_icon,
75 | },
76 | });
77 | }
78 | let result = {};
79 | if (this.siteSettings.chatbot_quick_access_bot_kicks_off) {
80 | result = await ajax("/chatbot/start_bot_convo", {
81 | type: "POST",
82 | data: {
83 | post_id: this.args?.post?.id,
84 | },
85 | });
86 | }
87 |
88 | if (
89 | this.siteSettings.chatbot_quick_access_talk_button === "personal message"
90 | ) {
91 | if (this.siteSettings.chatbot_quick_access_bot_kicks_off) {
92 | DiscourseURL.redirectTo(`/t/${result.topic_id}`);
93 | } else {
94 | this.composer.focusComposer({
95 | fallbackToNewTopic: true,
96 | openOpts: {
97 | action: Composer.PRIVATE_MESSAGE,
98 | recipients: this.siteSettings.chatbot_bot_user,
99 | topicTitle: I18n.t("chatbot.pm_prefix"),
100 | archetypeId: "private_message",
101 | draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY,
102 | hasGroups: false,
103 | warningsDisabled: true,
104 | },
105 | });
106 | }
107 | } else {
108 | this.chat
109 | .upsertDmChannel({
110 | usernames: [
111 | this.siteSettings.chatbot_bot_user,
112 | this.currentUser.username,
113 | ],
114 | })
115 | .then((chatChannel) => {
116 | this.router.transitionTo("chat.channel", ...chatChannel.routeModels);
117 | });
118 | }
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/composer-raiser.hbs:
--------------------------------------------------------------------------------
1 | {{#if this.isBotConversation}}
2 |
3 | {{/if}}
--------------------------------------------------------------------------------
/assets/javascripts/discourse/components/composer-raiser.js:
--------------------------------------------------------------------------------
1 | import Component from "@glimmer/component";
2 | import { action } from "@ember/object";
3 | import { inject as service } from "@ember/service";
4 | import Composer from "discourse/models/composer";
5 |
6 | export default class ComposerRaiserCompopnent extends Component {
7 | @service siteSettings;
8 | @service currentUser;
9 | @service site;
10 | @service composer;
11 |
12 | BOT_USER_ID = -4;
13 |
14 | @action
15 | raiseComposer() {
16 | if (
17 | !(
18 | this.site.mobileView &&
19 | this.currentUser.custom_fields
20 | .chatbot_user_prefs_disable_quickchat_pm_composer_popup_mobile
21 | ) &&
22 | this.args.model.current_post_number === 1
23 | ) {
24 | this.composer.focusComposer({
25 | fallbackToNewTopic: true,
26 | openOpts: {
27 | action: Composer.REPLY,
28 | recipients: this.siteSettings.chatbot_bot_user,
29 | draftKey: this.args.model.get("draft_key"),
30 | topic: this.args.model,
31 | hasGroups: false,
32 | warningsDisabled: true,
33 | },
34 | });
35 | }
36 | }
37 |
38 | get isBotConversation() {
39 | return (
40 | this.currentUser &&
41 | this.args.model.archetype === "private_message" &&
42 | this.args.model.user_id === this.BOT_USER_ID
43 | );
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/above-main-container/chatbot-launcher-link.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/category-custom-settings/set-chatbot.hbs:
--------------------------------------------------------------------------------
1 |
2 | {{i18n "chatbot.category.settings_label"}}
3 |
4 |
5 | {{html-safe (i18n "chatbot.category.auto_response_additional_prompt")}}
6 | {{input
7 | type="textarea"
8 | value=category.custom_fields.chatbot_auto_response_additional_prompt
9 | }}
10 | {{!-- {{i18n "chatbot.category.auto_response_additional_prompt"}} --}}
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/category-custom-settings/set-chatbot.js:
--------------------------------------------------------------------------------
1 | export default {
2 | setupComponent(attrs) {
3 | if (!attrs.category.custom_fields) {
4 | attrs.category.custom_fields = {};
5 | }
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/topic-above-posts/link-composer-raiser.hbs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/connectors/user-custom-preferences/chatbot-user-preferences.hbs:
--------------------------------------------------------------------------------
1 | {{#if siteSettings.chatbot_enabled}}
2 |
3 | {{/if}}
--------------------------------------------------------------------------------
/assets/javascripts/discourse/initializers/chatbot-post-launch-init.js:
--------------------------------------------------------------------------------
1 | import { hbs } from "ember-cli-htmlbars";
2 | import { apiInitializer } from "discourse/lib/api";
3 | import RenderGlimmer from "discourse/widgets/render-glimmer";
4 | import ChatbotLaunch from "../components/chatbot-launch";
5 |
6 | export default apiInitializer("1.8.0", (api) => {
7 | const siteSettings = api.container.lookup("service:site-settings");
8 |
9 | api.modifyClass("component:chat-channel", {
10 | pluginId: "discourse-chatbot",
11 | async fetchMessages(findArgs = {}) {
12 | if (this.messagesLoader.loading) {
13 | return;
14 | }
15 |
16 | this.messagesManager.clear();
17 |
18 | const result = await this.messagesLoader.load(findArgs);
19 | this.messagesManager.messages = this.processMessages(
20 | this.args.channel,
21 | result
22 | );
23 | if (findArgs.target_message_id) {
24 | this.scrollToMessageId(findArgs.target_message_id, {
25 | highlight: true,
26 | position: findArgs.position,
27 | });
28 | } else if (findArgs.fetch_from_last_read) {
29 | const lastReadMessageId = this.currentUserMembership?.lastReadMessageId;
30 | if (
31 | this.args.channel.chatable.type === "DirectMessage" &&
32 | this.args.channel.unicodeTitle === this.siteSettings.chatbot_bot_user
33 | ) {
34 | this.scrollToMessageId(
35 | this.messagesManager.messages[
36 | this.messagesManager.messages.length - 1
37 | ].id
38 | );
39 | } else {
40 | this.scrollToMessageId(lastReadMessageId);
41 | }
42 | } else if (findArgs.target_date) {
43 | this.scrollToMessageId(result.meta.target_message_id, {
44 | highlight: true,
45 | position: "center",
46 | });
47 | } else {
48 | this._ignoreNextScroll = true;
49 | this.scrollToBottom();
50 | }
51 |
52 | this.debounceFillPaneAttempt();
53 | this.debouncedUpdateLastReadMessage();
54 | },
55 | });
56 |
57 | if (siteSettings.chatbot_quick_access_bot_post_kicks_off) {
58 | api.registerValueTransformer(
59 | "post-menu-buttons",
60 | ({ value: dag, context: { firstButtonKey } }) => {
61 | dag.add("chatbot-post-launch-button", ChatbotLaunch, {
62 | before: firstButtonKey,
63 | });
64 | }
65 | );
66 | }
67 | });
68 |
--------------------------------------------------------------------------------
/assets/javascripts/discourse/templates/components/chatbot-user-preferences.hbs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/assets/stylesheets/common/chatbot_common.scss:
--------------------------------------------------------------------------------
1 | @mixin buttonShadow {
2 | box-shadow: 0 0 4px rgba(0, 0, 0, 0.14), 0 4px 8px rgba(0, 0, 0, 0.28);
3 | }
4 |
5 | @mixin buttonTransition {
6 | transition: right 0.5s, bottom 0.5s, border-radius 0.5s, text-indent 0.2s,
7 | visibility 1s, width 0.2s ease, height 0.5s ease 0.4s, color 0.5s,
8 | background-color 2s, transform 0.5s;
9 | }
10 |
11 | .widget-link.post-date {
12 | margin-right: 0.5em;
13 | }
14 |
15 | .post-info.post-date {
16 | display: flex;
17 | flex-direction: row;
18 | }
19 |
20 | .chatbot-post-launcher {
21 | bottom: 0.5em;
22 | }
23 |
24 | .chatbot-btn {
25 | .d-button-label {
26 | color: var(--primary);
27 | display: inline-block;
28 | opacity: 0;
29 | width: 0;
30 | margin: 0;
31 | }
32 | }
33 |
34 | .chatbot-btn.post-action-menu__chatbot {
35 | .avatar {
36 | width: 25px;
37 | height: 25px;
38 | }
39 | }
40 |
41 | .chatbot-btn:not(.post-action-menu__chatbot) {
42 | display: flex;
43 | align-items: center;
44 | justify-content: center;
45 | padding: 0;
46 | padding-bottom: 0.5em;
47 | margin: 0;
48 | height: 30px;
49 | border-radius: 40px;
50 | z-index: 1;
51 | background-color: var(--secondary);
52 | color: var(--primary);
53 | white-space: nowrap;
54 | overflow: hidden;
55 | transition: all 0.3s;
56 | padding: 0;
57 | @include buttonShadow;
58 | @include buttonTransition;
59 | background-color: $tertiary;
60 | .d-button-label {
61 | color: var(--secondary);
62 | }
63 | color: var(--secondary);
64 | position: fixed;
65 | bottom: 30px;
66 | right: 50px;
67 | height: 50px;
68 | border-radius: 40px;
69 | .d-icon {
70 | margin: 0;
71 | padding: 0.7em;
72 | width: 30px;
73 | height: 30px;
74 | color: var(--seoncdary);
75 | }
76 | img {
77 | padding: 0.2em;
78 | }
79 | &:not(:hover):not(:active):not(:focus) {
80 | .d-button-label {
81 | opacity: 0;
82 | width: 0;
83 | margin: 0;
84 | }
85 | }
86 | }
87 |
88 | .chatbot-btn:not(.post-action-menu__chatbot):hover {
89 | .d-button-label {
90 | transition: all 0.3s;
91 | width: 100%;
92 | padding-right: 0.7em;
93 | opacity: 100%;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/assets/stylesheets/mobile/chatbot_mobile.scss:
--------------------------------------------------------------------------------
1 | #chatbot-btn {
2 | right: 2.5rem;
3 | bottom: 7rem;
4 | }
5 |
6 | .topic-list .topic-item-stats {
7 | z-index: 0;
8 | }
9 |
10 | .topic-list .main-link {
11 | z-index: 0;
12 | }
13 |
--------------------------------------------------------------------------------
/config/locales/client.en.yml:
--------------------------------------------------------------------------------
1 | en:
2 | js:
3 | discourse_chatbot:
4 | title: "Chatbot"
5 | chatbot:
6 | category:
7 | settings_label: "Chatbot"
8 | auto_response_additional_prompt: "Hidden text fed to bot to influence its auto-response if set.\nIn scope Categories are set here."
9 | location_map_filter_closed: "Filter closed topics from the map topic list in this category."
10 | title_capitalized: "Talk to Chatbot"
11 | pm_prefix: "A Discussion with Chatbot"
12 | post_launch:
13 | title: "Talk to the Chatbot about this Post"
14 | thinking: "Thinking..."
15 | user_prefs:
16 | title: "Chatbot User Preferences"
17 | prefer_no_quickchat_pm_popup: "Disable Composer popup on mobile when using Quickchat in PMs (e.g. when using a very small mobile phone)"
18 |
19 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | DiscourseChatbot::Engine.routes.draw do
3 | post '/start_bot_convo' => 'chatbot#start_bot_convo'
4 | end
5 |
6 | Discourse::Application.routes.draw do
7 | mount ::DiscourseChatbot::Engine, at: 'chatbot'
8 | end
9 |
--------------------------------------------------------------------------------
/db/fixtures/100_chatbot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | chatbot_name = 'Chatbot'
3 | group_name = "ai_bot_group"
4 | group_full_name = "AI Bots"
5 |
6 | user = User.find_by(id: -4)
7 | group = Group.find_by(id: -4)
8 |
9 | if !user
10 | suggested_username = UserNameSuggester.suggest(chatbot_name)
11 |
12 | UserEmail.seed do |ue|
13 | ue.id = -4
14 | ue.email = "not@atall.valid"
15 | ue.primary = true
16 | ue.user_id = -4
17 | end
18 |
19 | User.seed do |u|
20 | u.id = -4
21 | u.name = chatbot_name
22 | u.username = suggested_username
23 | u.username_lower = suggested_username.downcase
24 | u.password = SecureRandom.hex
25 | u.active = true
26 | u.approved = true
27 | u.trust_level = TrustLevel[4]
28 | u.admin = true
29 | end
30 |
31 | end
32 |
33 | if !group
34 | Group.seed do |g|
35 | g.id = -4
36 | g.name = group_name
37 | g.full_name = group_full_name
38 | end
39 |
40 | GroupUser.seed do |gu|
41 | gu.user_id = -4
42 | gu.group_id = -4
43 | end
44 |
45 | SiteSetting.chat_allowed_groups += "|-4"
46 | end
47 |
48 | bot = User.find(-4)
49 |
50 | bot.user_option.update!(
51 | email_messages_level: 0,
52 | email_level: 2
53 | )
54 |
55 | if !bot.user_profile.bio_raw
56 | bot.user_profile.update!(
57 | bio_raw: I18n.t('chatbot.bio', site_title: SiteSetting.title, chatbot_username: bot.username)
58 | )
59 | end
60 |
--------------------------------------------------------------------------------
/db/legacy/20230820010101_enable_embedding_extension.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class EnableEmbeddingExtension < ActiveRecord::Migration[7.0]
4 | def change
5 | begin
6 | enable_extension :embedding, if_exists: true
7 | rescue Exception => e
8 | STDERR.puts "----------------------------DISCOURSE CHATBOT ERROR----------------------------------"
9 | STDERR.puts " Discourse Chatbot now requires the embedding extension on the PostgreSQL database."
10 | STDERR.puts " See required changes to `app.yml` described at:"
11 | STDERR.puts " https://github.com/merefield/discourse-chatbot/pull/33"
12 | STDERR.puts " Alternatively, you can remove Discourse Chatbot to rebuild."
13 | STDERR.puts "----------------------------DISCOURSE CHATBOT ERROR----------------------------------"
14 | raise e
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/db/legacy/20230820010105_create_chatbot_embeddings_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateChatbotEmbeddingsIndex < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | CREATE INDEX hnsw_index_on_chatbot_embeddings ON chatbot_embeddings USING hnsw(embedding)
7 | WITH (dims=1536, m=64, efconstruction=64, efsearch=64);
8 | SQL
9 | end
10 |
11 | def down
12 | execute <<-SQL
13 | DROP INDEX hnsw_index_on_chatbot_embeddings;
14 | SQL
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/db/legacy/20230826010103_rename_chatbot_embeddings_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class RenameChatbotEmbeddingsIndex < ActiveRecord::Migration[7.0]
4 | def change
5 | rename_index :chatbot_post_embeddings, 'hnsw_index_on_chatbot_embeddings', 'hnsw_index_on_chatbot_post_embeddings'
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/legacy/20231026010101_drop_legacy_chatbot_embeddings_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DropLegacyChatbotEmbeddingsIndex < ActiveRecord::Migration[7.0]
4 | def change
5 | remove_index :chatbot_post_embeddings, column: [:embedding], name: 'hnsw_index_on_chatbot_post_embeddings', if_exists: true
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/legacy/20231026010103_drop_legacy_chatbot_embedding_extension.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DropLegacyChatbotEmbeddingExtension < ActiveRecord::Migration[7.0]
4 | def change
5 | begin
6 | disable_extension :embedding
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20230820010103_create_chatbot_embeddings_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateChatbotEmbeddingsTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :chatbot_embeddings do |t|
6 | t.integer :post_id, null: false, index: { unique: true }, foreign_key: true
7 | t.column :embedding, "real[]", null: false
8 | t.timestamps
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20230826010101_rename_chatbot_embeddings_table.rb:
--------------------------------------------------------------------------------
1 |
2 | # frozen_string_literal: true
3 |
4 | class RenameChatbotEmbeddingsTable < ActiveRecord::Migration[7.0]
5 | def change
6 | begin
7 | Migration::SafeMigrate.disable!
8 | rename_table :chatbot_embeddings, :chatbot_post_embeddings
9 | ensure
10 | Migration::SafeMigrate.enable!
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20230828010101_clear_chatbot_post_embeddings.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ClearChatbotPostEmbeddings < ActiveRecord::Migration[7.0]
4 | def up
5 | ::DiscourseChatbot::PostEmbedding.delete_all
6 | STDERR.puts "------------------------------DISCOURSE CHATBOT NOTICE----------------------------------"
7 | STDERR.puts "This version of Chatbot introduces improvements to the selection of Posts for embedding."
8 | STDERR.puts " As such all existing chatbot post embeddings have been cleared out."
9 | STDERR.puts " Please refresh them inside the container with `rake chatbot:refresh_embeddings[1]`"
10 | STDERR.puts " Only necessary if you have selected bot type `agent`"
11 | STDERR.puts "------------------------------DISCOURSE CHATBOT NOTICE----------------------------------"
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20231025010101_enable_pgvector_extension.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class EnablePgvectorExtension < ActiveRecord::Migration[7.0]
4 | def change
5 | begin
6 | enable_extension :vector
7 | rescue Exception => e
8 | if DB.query_single("SELECT 1 FROM pg_available_extensions WHERE name = 'vector';").empty?
9 | STDERR.puts "----------------------------------DISCOURSE CHATBOT ERROR----------------------------------------"
10 | STDERR.puts " Discourse Chatbot now requires the pgvector extension version 0.5.1 on the PostgreSQL database."
11 | STDERR.puts " Run a `./launcher rebuild app` to fix it on a standard install."
12 | STDERR.puts " Alternatively, you can remove Discourse Chatbot to rebuild."
13 | STDERR.puts "----------------------------------DISCOURSE CHATBOT ERROR----------------------------------------"
14 | end
15 | raise e
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/db/migrate/20231026010107_rename_chatbot_post_embeddings_table.rb:
--------------------------------------------------------------------------------
1 |
2 | # frozen_string_literal: true
3 |
4 | class RenameChatbotPostEmbeddingsTable < ActiveRecord::Migration[7.0]
5 | def change
6 | begin
7 | Migration::SafeMigrate.disable!
8 | rename_table :chatbot_post_embeddings, :chatbot_post_embeddings_old
9 | ensure
10 | Migration::SafeMigrate.enable!
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20231026010109_create_new_chatbot_post_embeddings_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateNewChatbotPostEmbeddingsTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :chatbot_post_embeddings do |t|
6 | t.integer :post_id, null: false, index: { unique: true }, foreign_key: true
7 | t.column :embedding, "vector(1536)", null: false
8 | t.timestamps
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20231026010111_copy_chatbot_post_embeddings_to_new_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CopyChatbotPostEmbeddingsToNewTable < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | INSERT INTO chatbot_post_embeddings (id, post_id, embedding, created_at, updated_at)
7 | SELECT id, post_id, embedding::vector(1536), created_at, updated_at FROM chatbot_post_embeddings_old;
8 | SQL
9 | execute <<-SQL
10 | SELECT setval(pg_get_serial_sequence('chatbot_post_embeddings', 'id'), MAX(id)) FROM chatbot_post_embeddings;
11 | SQL
12 | end
13 |
14 | def down
15 | ::DiscourseChatbot::PostEmbedding.delete_all
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/db/migrate/20231026010114_create_pg_vector_chatbot_post_embeddings_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreatePgVectorChatbotPostEmbeddingsIndex < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | CREATE INDEX pgv_hnsw_index_on_chatbot_post_embeddings ON chatbot_post_embeddings USING hnsw (embedding vector_l2_ops)
7 | WITH (m = 32, ef_construction = 64);
8 | SQL
9 | end
10 |
11 | def down
12 | execute <<-SQL
13 | DROP INDEX IF EXISTS pgv_hnsw_index_on_chatbot_post_embeddings;
14 | SQL
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/db/migrate/20231217010101_drop_pg_vector_chatbot_post_embeddings_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DropPgVectorChatbotPostEmbeddingsIndex < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | DROP INDEX IF EXISTS pgv_hnsw_index_on_chatbot_post_embeddings;
7 | SQL
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20231217010103_create_cosine_pg_vector_chatbot_post_embeddings_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateCosinePgVectorChatbotPostEmbeddingsIndex < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | CREATE INDEX pgv_hnsw_index_on_chatbot_post_embeddings ON chatbot_post_embeddings USING hnsw (embedding vector_cosine_ops)
7 | WITH (m = 32, ef_construction = 64);
8 | SQL
9 | end
10 |
11 | def down
12 | execute <<-SQL
13 | DROP INDEX IF EXISTS pgv_hnsw_index_on_chatbot_post_embeddings;
14 | SQL
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/db/migrate/20240217010101_add_model_column_to_chatbot_post_embeddings_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class AddModelColumnToChatbotPostEmbeddingsTable < ActiveRecord::Migration[5.2]
3 | def change
4 | add_column :chatbot_post_embeddings, :model, :string, default: nil
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/db/migrate/20240217010103_create_chatbot_post_embeddings_bookmark_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateChatbotPostEmbeddingsBookmarkTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :chatbot_post_embeddings_bookmark do |t|
6 | t.integer :post_id
7 | t.timestamps
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/db/migrate/20240412010101_create_chatbot_topic_title_embeddings_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateChatbotTopicTitleEmbeddingsTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :chatbot_topic_title_embeddings do |t|
6 | t.integer :topic_id, null: false, index: { unique: true }, foreign_key: true
7 | t.column :embedding, "vector(1536)", null: false
8 | t.column :model, :string, default: nil
9 | t.timestamps
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/db/migrate/20240412010103_create_chatbot_topic_embeddings_bookmark_table.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateChatbotTopicEmbeddingsBookmarkTable < ActiveRecord::Migration[7.0]
4 | def change
5 | create_table :chatbot_topic_embeddings_bookmark do |t|
6 | t.integer :topic_id
7 | t.timestamps
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/db/migrate/20240412010105_create_cosine_pg_vector_chatbot_topic_title_embeddings_index.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CreateCosinePgVectorChatbotTopicTitleEmbeddingsIndex < ActiveRecord::Migration[7.0]
4 | def up
5 | execute <<-SQL
6 | CREATE INDEX pgv_hnsw_index_on_chatbot_topic_title_embeddings ON chatbot_topic_title_embeddings USING hnsw (embedding vector_cosine_ops)
7 | WITH (m = 32, ef_construction = 64);
8 | SQL
9 | end
10 |
11 | def down
12 | execute <<-SQL
13 | DROP INDEX IF EXISTS pgv_hnsw_index_on_chatbot_topic_title_embeddings;
14 | SQL
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/jsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "amd",
5 | "experimentalDecorators": true,
6 | },
7 | "exclude": [
8 | ".git",
9 | "**/node_modules",
10 | "**/dist",
11 | ],
12 | }
13 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/bot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "openai"
3 |
4 | module ::DiscourseChatbot
5 | class Bot
6 | def get_response(prompt, opts)
7 | raise "Overwrite me!"
8 | end
9 |
10 | def ask(opts)
11 | user_id = opts[:user_id]
12 | content = opts[:type] == POST ? PostPromptUtils.create_prompt(opts) : MessagePromptUtils.create_prompt(opts)
13 |
14 | response = get_response(content, opts)
15 |
16 | consume_quota(opts[:user_id], response[:total_tokens])
17 | response
18 | end
19 |
20 | def consume_quota(user_id, token_usage)
21 | return if token_usage == 0
22 |
23 | remaining_quota_field_name = SiteSetting.chatbot_quota_basis == "queries" ? CHATBOT_REMAINING_QUOTA_QUERIES_CUSTOM_FIELD : CHATBOT_REMAINING_QUOTA_TOKENS_CUSTOM_FIELD
24 | deduction = SiteSetting.chatbot_quota_basis == "queries" ? 1 : token_usage
25 |
26 | current_record = UserCustomField.find_by(user_id: user_id, name: remaining_quota_field_name)
27 |
28 | if current_record.present?
29 | remaining_quota = current_record.value.to_i - deduction
30 | current_record.value = remaining_quota.to_s
31 | else
32 | max_quota = ::DiscourseChatbot::EventEvaluation.new.get_max_quota(user_id)
33 | current_record = UserCustomField.create!(user_id: user_id, name: remaining_quota_field_name, value: max_quota.to_s)
34 | remaining_quota = current_record.value.to_i - deduction
35 | current_record.value = remaining_quota.to_s
36 | end
37 | current_record.save!
38 | end
39 |
40 | def reset_all_quotas
41 | ::User.all.each do |u|
42 | max_quota = ::DiscourseChatbot::EventEvaluation.new.get_max_quota(u.id)
43 |
44 | current_record = UserCustomField.find_by(user_id: u.id, name: ::DiscourseChatbot::CHATBOT_REMAINING_QUOTA_QUERIES_CUSTOM_FIELD)
45 |
46 | if current_record.present?
47 | current_record.value = max_quota.to_s
48 | current_record.save!
49 | else
50 | UserCustomField.create!(user_id: u.id, name: ::DiscourseChatbot::CHATBOT_REMAINING_QUOTA_QUERIES_CUSTOM_FIELD, value: max_quota.to_s)
51 | end
52 |
53 | current_record = UserCustomField.find_by(user_id: u.id, name: ::DiscourseChatbot::CHATBOT_REMAINING_QUOTA_TOKENS_CUSTOM_FIELD)
54 |
55 | if current_record.present?
56 | current_record.value = max_quota.to_s
57 | current_record.save!
58 | else
59 | UserCustomField.create!(user_id: u.id, name: ::DiscourseChatbot::CHATBOT_REMAINING_QUOTA_TOKENS_CUSTOM_FIELD, value: max_quota.to_s)
60 | end
61 |
62 | if current_record = UserCustomField.find_by(user_id: u.id, name: ::DiscourseChatbot::CHATBOT_QUERIES_QUOTA_REACH_ESCALATION_DATE_CUSTOM_FIELD)
63 | current_record.delete
64 | end
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/bots/open_ai_bot_base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "openai"
3 |
4 | module ::DiscourseChatbot
5 |
6 | class OpenAIBotBase < Bot
7 | attr_reader :client, :model_name
8 |
9 | def initialize(opts)
10 | ::OpenAI.configure do |config|
11 | config.access_token = SiteSetting.chatbot_open_ai_token
12 |
13 | case opts[:trust_level]
14 | when TRUST_LEVELS[0], TRUST_LEVELS[1], TRUST_LEVELS[2]
15 | if !SiteSetting.send("chatbot_open_ai_model_custom_url_" + opts[:trust_level] + "_trust").blank?
16 | config.uri_base = SiteSetting.send("chatbot_open_ai_model_custom_url_" + opts[:trust_level] + "_trust")
17 | end
18 | else
19 | if !SiteSetting.chatbot_open_ai_model_custom_url_low_trust.blank?
20 | config.uri_base = SiteSetting.chatbot_open_ai_model_custom_url_low_trust
21 | end
22 | end
23 |
24 | if SiteSetting.chatbot_open_ai_model_custom_api_type == "azure"
25 | config.api_type = :azure
26 | config.api_version = SiteSetting.chatbot_open_ai_model_custom_api_version
27 | end
28 | config.log_errors = true if SiteSetting.chatbot_enable_verbose_rails_logging
29 | end
30 |
31 | @client = OpenAI::Client.new do |f|
32 | f.response :logger, Logger.new($stdout), bodies: true if SiteSetting.chatbot_enable_verbose_console_logging
33 | if SiteSetting.chatbot_enable_verbose_rails_logging != "off"
34 | case SiteSetting.chatbot_verbose_rails_logging_destination_level
35 | when "warn"
36 | f.response :logger, Rails.logger, bodies: true, log_level: :warn
37 | else
38 | f.response :logger, Rails.logger, bodies: true, log_level: :info
39 | end
40 | end
41 | end
42 |
43 | @model_name = get_model(opts)
44 | @model_reasoning_level = SiteSetting.chatbot_open_ai_model_reasoning_level
45 | @total_tokens = 0
46 | end
47 |
48 | def get_response(prompt, opts)
49 | raise "Overwrite me!"
50 | end
51 |
52 | def get_model(opts)
53 | SiteSetting.chatbot_support_vision == "directly" ? SiteSetting.chatbot_open_ai_vision_model :
54 | case opts[:trust_level]
55 | when TRUST_LEVELS[0], TRUST_LEVELS[1], TRUST_LEVELS[2]
56 | SiteSetting.send("chatbot_open_ai_model_custom_" + opts[:trust_level] + "_trust") ?
57 | SiteSetting.send("chatbot_open_ai_model_custom_name_" + opts[:trust_level] + "_trust") :
58 | SiteSetting.send("chatbot_open_ai_model_" + opts[:trust_level] + "_trust")
59 | else
60 | SiteSetting.chatbot_open_ai_model_custom_low_trust ? SiteSetting.chatbot_open_ai_model_custom_name_low_trust : SiteSetting.chatbot_open_ai_model_low_trust
61 | end
62 | end
63 |
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/bots/open_ai_bot_basic.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "openai"
3 |
4 | module ::DiscourseChatbot
5 |
6 | class OpenAiBotBasic < OpenAIBotBase
7 |
8 | def get_response(prompt, opts)
9 | begin
10 | reasoning_model = false
11 | private_discussion = opts[:private] || false
12 |
13 | if private_discussion
14 | system_message = { "role": "developer", "content": I18n.t("chatbot.prompt.system.basic.private", current_date_time: DateTime.current) }
15 | else
16 | system_message = { "role": "developer", "content": I18n.t("chatbot.prompt.system.basic.open", current_date_time: DateTime.current) }
17 | end
18 |
19 | reasoning_model = true if REASONING_MODELS.include?(@model_name)
20 |
21 | prompt.unshift(system_message)
22 |
23 | parameters = {
24 | model: @model_name,
25 | messages: prompt,
26 | max_completion_tokens: SiteSetting.chatbot_max_response_tokens,
27 | }
28 |
29 | additional_non_reasoning_parameters = {
30 | temperature: SiteSetting.chatbot_request_temperature / 100.0,
31 | top_p: SiteSetting.chatbot_request_top_p / 100.0,
32 | frequency_penalty: SiteSetting.chatbot_request_frequency_penalty / 100.0,
33 | presence_penalty: SiteSetting.chatbot_request_presence_penalty / 100.0
34 | }
35 |
36 | additional_reasoning_parameters = {
37 | reasoning_effort: @model_reasoning_level,
38 | }
39 |
40 | if reasoning_model
41 | parameters.merge!(additional_reasoning_parameters)
42 | else
43 | parameters.merge!(additional_non_reasoning_parameters)
44 | end
45 |
46 | response = @client.chat(
47 | parameters: parameters
48 | )
49 |
50 | token_usage = response.dig("usage", "total_tokens")
51 | @total_tokens += token_usage
52 |
53 | {
54 | reply: response.dig("choices", 0, "message", "content"),
55 | inner_thoughts: nil
56 | }
57 | rescue => e
58 | if e.respond_to?(:response)
59 | status = e.response[:status]
60 | message = e.response[:body]["error"]["message"]
61 | Rails.logger.error("Chatbot: There was a problem with Chat Completion: status: #{status}, message: #{message}")
62 | end
63 | raise e
64 | end
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/embedding_completionist_process.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "openai"
3 |
4 | module ::DiscourseChatbot
5 |
6 | class EmbeddingCompletionist
7 |
8 | def self.process
9 | process_posts
10 | process_topics
11 | end
12 |
13 | def self.process_topics
14 | bookmarked_topic_id = ::DiscourseChatbot::TopicEmbeddingsBookmark.first&.topic_id || ::Topic.first.id
15 |
16 | limit = (EMBEDDING_PROCESS_POSTS_CHUNK * (::Topic.count.fdiv(::Post.count))).ceil
17 |
18 | topic_range = ::Topic.where("id >= ?", bookmarked_topic_id).order(:id).limit(limit).pluck(:id)
19 |
20 | topic_range.each do |topic_id|
21 | Jobs.enqueue(:chatbot_topic_title_embedding, id: topic_id)
22 |
23 | bookmarked_topic_id = ::Topic.where("id > ?", topic_id).order(:id).limit(1).pluck(:id)&.first
24 | end
25 |
26 | bookmarked_topic_id = ::Topic.first.id if bookmarked_topic_id.nil?
27 |
28 | bookmark = ::DiscourseChatbot::TopicEmbeddingsBookmark.first
29 |
30 | if bookmark
31 | bookmark.topic_id = bookmarked_topic_id
32 | else
33 | bookmark = ::DiscourseChatbot::TopicEmbeddingsBookmark.new(topic_id: bookmarked_topic_id)
34 | end
35 |
36 | bookmark.save!
37 | ::DiscourseChatbot.progress_debug_message <<~EOS
38 | ---------------------------------------------------------------------------------------------------------------
39 | Topic Embeddings Completion Bookmark is now at Topic: #{bookmark.topic_id}
40 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
41 | EOS
42 | bookmark.topic_id
43 | end
44 |
45 | def self.process_posts
46 | bookmarked_post_id = ::DiscourseChatbot::PostEmbeddingsBookmark.first&.post_id || ::Post.first.id
47 |
48 | post_range = ::Post.where("id >= ?", bookmarked_post_id).order(:id).limit(EMBEDDING_PROCESS_POSTS_CHUNK).pluck(:id)
49 |
50 | post_range.each do |post_id|
51 | Jobs.enqueue(:chatbot_post_embedding, id: post_id)
52 |
53 | bookmarked_post_id = ::Post.where("id > ?", post_id).order(:id).limit(1).pluck(:id)&.first
54 | end
55 |
56 | bookmarked_post_id = ::Post.first.id if bookmarked_post_id.nil?
57 |
58 | bookmark = ::DiscourseChatbot::PostEmbeddingsBookmark.first
59 |
60 | if bookmark
61 | bookmark.post_id = bookmarked_post_id
62 | else
63 | bookmark = ::DiscourseChatbot::PostEmbeddingsBookmark.new(post_id: bookmarked_post_id)
64 | end
65 |
66 | bookmark.save!
67 | ::DiscourseChatbot.progress_debug_message <<~EOS
68 | ---------------------------------------------------------------------------------------------------------------
69 | Post Embeddings Completion Bookmark is now at Post: #{bookmark.post_id}
70 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
71 | EOS
72 | bookmark.post_id
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/embedding_process.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "openai"
3 |
4 | module ::DiscourseChatbot
5 |
6 | class EmbeddingProcess
7 |
8 | def setup_api
9 | ::OpenAI.configure do |config|
10 | config.access_token = SiteSetting.chatbot_open_ai_token
11 | end
12 | if !SiteSetting.chatbot_open_ai_embeddings_model_custom_url.blank?
13 | ::OpenAI.configure do |config|
14 | config.uri_base = SiteSetting.chatbot_open_ai_embeddings_model_custom_url
15 | end
16 | end
17 | if SiteSetting.chatbot_open_ai_model_custom_api_type == "azure"
18 | ::OpenAI.configure do |config|
19 | config.api_type = :azure
20 | config.api_version = SiteSetting.chatbot_open_ai_model_custom_api_version
21 | end
22 | end
23 | @model_name = SiteSetting.chatbot_open_ai_embeddings_model
24 | @client = ::OpenAI::Client.new
25 | end
26 |
27 | def upsert(id)
28 | raise "Overwrite me!"
29 | end
30 |
31 | def get_embedding_from_api(id)
32 | raise "Overwrite me!"
33 | end
34 |
35 |
36 | def semantic_search(query)
37 | raise "Overwrite me!"
38 | end
39 |
40 | def in_scope(id)
41 | raise "Overwrite me!"
42 | end
43 |
44 | def is_valid(id)
45 | raise "Overwrite me!"
46 | end
47 |
48 | def in_categories_scope(id)
49 | raise "Overwrite me!"
50 | end
51 |
52 | def in_benchmark_user_scope(id)
53 | raise "Overwrite me!"
54 | end
55 |
56 | def benchmark_user
57 | cache_key = "chatbot_benchmark_user"
58 | benchmark_user = Discourse.cache.fetch(cache_key, expires_in: 1.hour) do
59 | allowed_group_ids = [0, 10, 11, 12, 13, 14] # automated groups only
60 | barred_group_ids = ::Group.where.not(id: allowed_group_ids).pluck(:id) # no custom groups
61 | unsuitable_users = ::GroupUser.where(group_id: barred_group_ids).pluck(:user_id).uniq # don't choose someone with in a custom group
62 | safe_users = ::User.where.not(id: unsuitable_users).distinct.pluck(:id) # exclude them and find a suitable vanilla, junior user
63 | user = ::User.where(id: safe_users).where(trust_level: SiteSetting.chatbot_embeddings_benchmark_user_trust_level, active: true, admin: false, suspended_at: nil)&.last
64 | if user.nil?
65 | raise StandardError, "Chatbot: No benchmark user exists for Post embedding suitability check, please add a basic user"
66 | end
67 | user
68 | end
69 |
70 | benchmark_user
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 | class Engine < ::Rails::Engine
4 | engine_name PLUGIN_NAME
5 | isolate_namespace DiscourseChatbot
6 | config.autoload_paths << File.join(config.root, "lib")
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/event_evaluation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 |
4 | class EventEvaluation
5 |
6 | def on_submission(submission)
7 | raise "Overwrite me!"
8 | end
9 |
10 | def trust_level(user_id)
11 | max_trust_level = 0
12 |
13 | GroupUser.where(user_id: user_id).each do |gu|
14 | if SiteSetting.chatbot_low_trust_groups.split('|').include? gu.group_id.to_s
15 | max_trust_level = LOW_TRUST_LEVEL if max_trust_level < LOW_TRUST_LEVEL
16 | end
17 | if SiteSetting.chatbot_medium_trust_groups.split('|').include? gu.group_id.to_s
18 | max_trust_level = MEDIUM_TRUST_LEVEL if max_trust_level < MEDIUM_TRUST_LEVEL
19 | end
20 | if SiteSetting.chatbot_high_trust_groups.split('|').include? gu.group_id.to_s
21 | max_trust_level = HIGH_TRUST_LEVEL if max_trust_level < HIGH_TRUST_LEVEL
22 | end
23 | end
24 |
25 | max_trust_level.zero? ? nil : ::DiscourseChatbot::TRUST_LEVELS[max_trust_level - 1]
26 | end
27 |
28 | def over_quota(user_id)
29 | max_quota = get_max_quota(user_id)
30 | remaining_quota_field_name = SiteSetting.chatbot_quota_basis == "queries" ? CHATBOT_REMAINING_QUOTA_QUERIES_CUSTOM_FIELD : CHATBOT_REMAINING_QUOTA_TOKENS_CUSTOM_FIELD
31 | remaining_quota = get_remaining_quota(user_id, remaining_quota_field_name)
32 |
33 | if remaining_quota.nil?
34 | UserCustomField.create!(user_id: user_id, name: remaining_quota_field_name, value: max_quota.to_s)
35 | remaining_quota = max_quota
36 | end
37 |
38 | breach = remaining_quota < 0
39 | escalate_as_required(user_id) if breach
40 | breach
41 | end
42 |
43 | def get_remaining_quota(user_id, remaining_quota_field_name)
44 | UserCustomField.find_by(user_id: user_id, name: remaining_quota_field_name)&.value.to_i
45 | end
46 |
47 | def get_max_quota(user_id)
48 | max_quota = 0
49 | GroupUser.where(user_id: user_id).each do |gu|
50 | if SiteSetting.chatbot_low_trust_groups.split('|').include? gu.group_id.to_s
51 | max_quota = SiteSetting.chatbot_quota_low_trust if max_quota < SiteSetting.chatbot_quota_low_trust
52 | end
53 | if SiteSetting.chatbot_medium_trust_groups.split('|').include? gu.group_id.to_s
54 | max_quota = SiteSetting.chatbot_quota_medium_trust if max_quota < SiteSetting.chatbot_quota_medium_trust
55 | end
56 | if SiteSetting.chatbot_high_trust_groups.split('|').include? gu.group_id.to_s
57 | max_quota = SiteSetting.chatbot_quota_high_trust if max_quota < SiteSetting.chatbot_quota_high_trust
58 | end
59 | end
60 |
61 | # deal with 'everyone' group
62 | max_quota = SiteSetting.chatbot_quota_low_trust if SiteSetting.chatbot_low_trust_groups.split('|').include?("0") && max_quota < SiteSetting.chatbot_quota_low_trust
63 | max_quota = SiteSetting.chatbot_quota_medium_trust if SiteSetting.chatbot_medium_trust_groups.split('|').include?("0") && max_quota < SiteSetting.chatbot_quota_medium_trust
64 | max_quota = SiteSetting.chatbot_quota_high_trust if SiteSetting.chatbot_high_trust_groups.split('|').include?("0") && max_quota < SiteSetting.chatbot_quota_high_trust
65 |
66 | max_quota
67 | end
68 |
69 | def escalate_as_required(user_id)
70 | escalation_date = UserCustomField.find_by(name: ::DiscourseChatbot::CHATBOT_QUERIES_QUOTA_REACH_ESCALATION_DATE_CUSTOM_FIELD, user_id: user_id)
71 |
72 | if SiteSetting.chatbot_quota_reach_escalation_cool_down_period > 0
73 | if escalation_date.nil? || !SiteSetting.chatbot_quota_reach_escalation_cool_down_period.nil? &&
74 | escalation_date.value < SiteSetting.chatbot_quota_reach_escalation_cool_down_period.days.ago
75 | escalate_quota_breach(user_id)
76 | end
77 | end
78 | end
79 |
80 | def escalate_quota_breach(user_id)
81 | user = User.find_by(id: user_id)
82 | system_user = User.find_by(username_lower: "system")
83 |
84 | target_group_names = []
85 |
86 | Array(SiteSetting.chatbot_quota_reach_escalation_groups).each do |g|
87 | unless g.to_i == 0
88 | target_group_names << Group.find(g.to_i).name
89 | end
90 | end
91 |
92 | if !target_group_names.empty?
93 | target_group_names = target_group_names.join(",")
94 |
95 | default_opts = {
96 | post_alert_options: { skip_send_email: true },
97 | raw: I18n.t("chatbot.quota_reached.escalation.message", username: user.username),
98 | skip_validations: true,
99 | title: I18n.t("chatbot.quota_reached.escalation.title", username: user.username),
100 | archetype: Archetype.private_message,
101 | target_group_names: target_group_names
102 | }
103 |
104 | post = PostCreator.create!(system_user, default_opts)
105 |
106 | user.custom_fields[::DiscourseChatbot::CHATBOT_QUERIES_QUOTA_REACH_ESCALATION_DATE_CUSTOM_FIELD] = DateTime.now
107 | user.save_custom_fields
108 | end
109 | end
110 |
111 | private
112 |
113 | def invoke_background_job(job_class, opts)
114 | delay_in_seconds = SiteSetting.chatbot_reply_job_time_delay.to_i
115 | if delay_in_seconds > 0
116 | job_class.perform_in(delay_in_seconds.seconds, opts.as_json)
117 | else
118 | job_class.perform_async(opts.as_json)
119 | end
120 | end
121 |
122 | end
123 | end
124 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ::DiscourseChatbot
4 | class Function
5 |
6 | def name
7 | raise "Overwrite me!"
8 | end
9 |
10 | def description
11 | raise "Overwrite me!"
12 | end
13 |
14 | def parameters
15 | raise "Overwrite me!"
16 | end
17 |
18 | def required
19 | raise "Overwrite me!"
20 | end
21 |
22 | def initialize
23 | @name = name
24 | @description = description
25 | @parameters = parameters
26 | @required = required
27 | end
28 |
29 | def process(args)
30 | validate_parameters(args)
31 | end
32 |
33 | private
34 |
35 | def validate_parameters(args)
36 | if args.count < @required.length
37 | raise ArgumentError, "Expected at least #{@required.length} arguments, but got #{args.length}"
38 | end
39 |
40 | @required.each do |required|
41 | if !args.has_key?(required)
42 | raise ArgumentError, "Expected '#{required}' to be included in the arguments because it is required, but is missing"
43 | end
44 | end
45 |
46 | args.each do |arg|
47 | unless arg[1].is_a?(@parameters.find { |param| param[:name] == arg[0] }[:type])
48 | raise ArgumentError, "Argument #{index + 1} should be of type #{parameter[:type]}"
49 | end
50 | end
51 |
52 | true
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/calculator_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class CalculatorFunction < Function
7 |
8 | def name
9 | 'calculate'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.calculator.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "input", type: String, description: I18n.t("chatbot.prompt.function.calculator.parameters.input") } ,
19 | ]
20 | end
21 |
22 | def required
23 | ['input']
24 | end
25 |
26 | def process(args)
27 | begin
28 | super(args)
29 |
30 | {
31 | answer: ::SafeRuby.eval(args[parameters[0][:name]], timeout: 5),
32 | token_usage: 0
33 | }
34 | rescue
35 | {
36 | answer: I18n.t("chatbot.prompt.function.calculator.error", parameter: args[parameters[0][:name]]),
37 | token_usage: 0
38 | }
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/coords_from_location_description_search.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class GetCoordsOfLocationDescriptionFunction < Function
7 | def name
8 | 'return_coords_from_location_description'
9 | end
10 |
11 | def description
12 | I18n.t("chatbot.prompt.function.return_coords_from_location_description.description")
13 | end
14 |
15 | def parameters
16 | [
17 | { name: "query", type: String, description: I18n.t("chatbot.prompt.function.return_coords_from_location_description.parameters.coords") } ,
18 | ]
19 | end
20 |
21 | def required
22 | ['query']
23 | end
24 |
25 | def process(args)
26 | begin
27 | super(args)
28 | query = args[parameters[0][:name]]
29 |
30 | results = []
31 |
32 | if !query.blank?
33 | coords = ::Locations::Geocode.return_coords(query)
34 | end
35 |
36 | response = I18n.t("chatbot.prompt.function.return_coords_from_location_description.answer_summary", query: query, coords: coords)
37 |
38 | {
39 | answer: response,
40 | token_usage: 0
41 | }
42 | rescue
43 | {
44 | answer: I18n.t("chatbot.prompt.function.return_coords_from_location_description.error", query: args[parameters[0][:name]]),
45 | token_usage: 0
46 | }
47 | end
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/escalate_to_staff_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class EscalateToStaffFunction < Function
7 |
8 | def name
9 | 'escalate_to_staff'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.escalate_to_staff.description")
14 | end
15 |
16 | def parameters
17 | []
18 | end
19 |
20 | def required
21 | []
22 | end
23 |
24 | def process(args, opts)
25 | begin
26 | super(args)
27 |
28 | return I18n.t("chatbot.prompt.function.escalate_to_staff.wrong_type_error") if opts[:type] != ::DiscourseChatbot::MESSAGE
29 |
30 | channel_id = opts[:topic_or_channel_id]
31 | channel = ::Chat::Channel.find(channel_id)
32 |
33 | current_user = User.find(opts[:user_id])
34 | bot_user = User.find(opts[:bot_user_id])
35 | target_usernames = current_user.username
36 |
37 | target_group_names = []
38 |
39 | Array(SiteSetting.chatbot_escalate_to_staff_groups).each do |g|
40 | unless g.to_i == 0
41 | target_group_names << Group.find(g.to_i).name
42 | end
43 | end
44 |
45 | if !target_group_names.empty?
46 | target_group_names = target_group_names.join(",")
47 |
48 | message_or_post_id = opts[:reply_to_message_or_post_id]
49 |
50 | message_collection = get_messages(message_or_post_id)
51 |
52 | content = generate_transcript(message_collection, bot_user)
53 |
54 | default_opts = {
55 | post_alert_options: { skip_send_email: true },
56 | raw: I18n.t("chatbot.prompt.function.escalate_to_staff.announcement", content: content),
57 | skip_validations: true,
58 | title: I18n.t("chatbot.prompt.function.escalate_to_staff.title"),
59 | archetype: Archetype.private_message,
60 | target_usernames: target_usernames,
61 | target_group_names: target_group_names
62 | }
63 |
64 | post = PostCreator.create!(current_user, default_opts)
65 |
66 | url = "https://#{Discourse.current_hostname}/t/slug/#{post.topic_id}"
67 |
68 | response = I18n.t("chatbot.prompt.function.escalate_to_staff.answer_summary", url: url)
69 | else
70 | response = I18n.t("chatbot.prompt.function.escalate_to_staff.no_escalation_groups")
71 | end
72 | {
73 | answer: response,
74 | token_usage: 0
75 | }
76 | rescue
77 | {
78 | answer: I18n.t("chatbot.prompt.function.escalate_to_staff.error", parameter: args[parameters[0][:name]]),
79 | token_usage: 0
80 | }
81 | end
82 | end
83 |
84 | def generate_transcript(messages, acting_user)
85 | messages = Array.wrap(messages)
86 | Chat::TranscriptService
87 | .new(messages.first.chat_channel, acting_user, messages_or_ids: messages.map(&:id))
88 | .generate_markdown
89 | .chomp
90 | end
91 |
92 | def get_messages(message_or_post_id)
93 | current_message = ::Chat::Message.find(message_or_post_id)
94 |
95 | message_collection = []
96 |
97 | message_collection << current_message
98 |
99 | collect_amount = SiteSetting.chatbot_escalate_to_staff_max_history
100 |
101 | while message_collection.length < collect_amount do
102 | prior_message = ::Chat::Message.where(chat_channel_id: current_message.chat_channel_id, deleted_at: nil).where('chat_messages.id < ?', current_message.id).last
103 | if prior_message.nil?
104 | break
105 | else
106 | current_message = prior_message
107 | end
108 | message_collection << current_message
109 | end
110 | message_collection
111 | end
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_get_user_address_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumGetUserAddressFunction < Function
7 |
8 | def name
9 | 'forum_get_user_address'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.forum_get_user_address.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "username", type: String, description: I18n.t("chatbot.prompt.function.forum_get_user_address.parameters.username") }
19 | ]
20 | end
21 |
22 | def required
23 | ['username']
24 | end
25 |
26 | def process(args)
27 | begin
28 | super(args)
29 |
30 | username = args[parameters[0][:name]]
31 |
32 | user = User.find_by(username_lower: username.downcase)
33 | result = ::Locations::UserLocation.find_by(user_id: user.id)
34 |
35 | response = I18n.t("chatbot.prompt.function.forum_get_user_address.answer_summary", username: username, address: result.address, latitude: result.latitude, longitude: result.longitude)
36 |
37 | {
38 | answer: response,
39 | token_usage: 0
40 | }
41 | rescue
42 | {
43 | answer: I18n.t("chatbot.prompt.function.forum_get_user_address.error"),
44 | token_usage: 0
45 | }
46 | end
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_topic_search_from_location_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumTopicSearchFromLocationFunction < Function
7 |
8 | def name
9 | 'forum_topic_search_from_location'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.forum_topic_search_from_location.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "coords", type: String, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_location.parameters.coords") } ,
19 | { name: "distance", type: String, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_location.parameters.distance") } ,
20 | { name: "number_of_topics", type: Integer, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_location.parameters.number_of_topics") }
21 | ]
22 | end
23 |
24 | def required
25 | ['coords']
26 | end
27 |
28 | def process(args)
29 | begin
30 | super(args)
31 | query = args[parameters[0][:name]]
32 | distance = args[parameters[1][:name]].blank ? 500 : args[parameters[1][:name]]
33 | number_of_topics = args[parameters[2][:name]].blank? ? 3 : args[parameters[2][:name]]
34 | number_of_topics = number_of_topics > 16 ? 16 : number_of_topics
35 |
36 | results = []
37 |
38 | coords = query.split(/\D+/).reject(&:empty?).map(&:to_i)
39 | results = ::Locations::TopicLocationProcess.search_from_location(coords[0], coords[1], distance)
40 |
41 | response = I18n.t("chatbot.prompt.function.forum_topic_search_from_location.answer_summary", distance: distance, query: query)
42 |
43 | results.each_with_index do |result, index|
44 | topic = Topic.find(result.topic_id)
45 | url = "https://#{Discourse.current_hostname}/t/slug/#{topic.topic_id}"
46 | topic_location = TopicLocation.find_by(topic_id: topic.id)
47 | distance = result.distance_from(coords[1], coords[0], :km) # geocoder expects order lat, lon.
48 | response += I18n.t("chatbot.prompt.function.forum_topic_search_from_location.answer", title: topic.title, address: topic_location.address, url: url, distance: distance, rank: index + 1)
49 | break if index == number_of_topics
50 | end
51 | {
52 | answer: response,
53 | token_usage: 0
54 | }
55 | rescue
56 | {
57 | answer: I18n.t("chatbot.prompt.function.forum_topic_search_from_location.error", query: args[parameters[0][:name]]),
58 | token_usage: 0
59 | }
60 | end
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_topic_search_from_topic_location_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumTopicSearchFromTopicLocationFunction < Function
7 |
8 | def name
9 | 'forum_topic_search_from_topic_location'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.forum_topic_search_from_topic_location.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "topic_id", type: Integer, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_topic_location.parameters.username") } ,
19 | { name: "distance", type: String, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_topic_location.parameters.distance") } ,
20 | { name: "number_of_topics", type: Integer, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_topic_location.parameters.number_of_users") }
21 | ]
22 | end
23 |
24 | def required
25 | ['topic_id']
26 | end
27 |
28 | def process(args)
29 | begin
30 | super(args)
31 | query = args[parameters[0][:name]]
32 |
33 | distance = args[parameters[1][:name]].blank? ? 500 : args[parameters[1][:name]]
34 | number_of_topics = args[parameters[2][:name]].blank? ? 3 : args[parameters[2][:name]]
35 | number_of_topics = number_of_topics > 16 ? 16 : number_of_users
36 |
37 | results = []
38 |
39 | target_topic_location = TopicLocation.find_by(topic_id: topic.id)
40 | results = ::Locations::TopicLocationProcess.search_from_topic_location(topic_id, distance)
41 |
42 | response = I18n.t("chatbot.prompt.function.forum_topic_search_from_topic_location.answer_summary", distance: distance, query: query)
43 |
44 | results.each_with_index do |result, index|
45 | topic = Topic.find(result.topic_id)
46 | url = "https://#{Discourse.current_hostname}/t/slug/#{topic.topic_id}"
47 | topic_location = ::Locations::TopicLocation.find_by(topic_id: topic.id)
48 | distance = result.distance_from(target_topic_location.to_coordinates, :km)
49 | response += I18n.t("chatbot.prompt.function.forum_topic_search_from_topic_location.answer", title: topic.title, address: topic_location.address, url: url, distance: distance, rank: index + 1)
50 | break if index == number_of_topics
51 | end
52 | {
53 | answer: response,
54 | token_usage: 0
55 | }
56 | rescue
57 | {
58 | answer: I18n.t("chatbot.prompt.function.forum_user_location_search_from_user.error", query: args[parameters[0][:name]]),
59 | token_usage: 0
60 | }
61 | end
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_topic_search_from_user_location_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumTopicSearchFromUserLocationFunction < Function
7 |
8 | REGEX_PATTERN = "(\[)?-?\d*.?\d*,\s?-?\d*.?\d*(\])?"
9 |
10 | def name
11 | 'forum_topic_search_from_user_location'
12 | end
13 |
14 | def description
15 | I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.description")
16 | end
17 |
18 | def parameters
19 | [
20 | { name: "username", type: String, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.parameters.username") } ,
21 | { name: "distance", type: String, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.parameters.distance") } ,
22 | { name: "number_of_topics", type: Integer, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.parameters.number_of_users") }
23 | ]
24 | end
25 |
26 | def required
27 | ['username']
28 | end
29 |
30 | def process(args)
31 | begin
32 | super(args)
33 | query = args[parameters[0][:name]]
34 |
35 | distance = args[parameters[1][:name]].blank? ? 500 : args[parameters[1][:name]]
36 | number_of_topics = args[parameters[2][:name]].blank? ? 3 : args[parameters[2][:name]]
37 | number_of_topics = number_of_topics > 16 ? 16 : number_of_topics
38 |
39 | results = []
40 |
41 | user_id = User.find_by(username: query).id
42 | target_user_location = UserLocation.find_by(user_id: user_id)
43 | results = ::Locations::TopicLocationProcess.search_from_user_location(user_id, distance)
44 |
45 | response = I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.answer_summary", distance: distance, query: query)
46 |
47 | results.each_with_index do |result, index|
48 | topic = Topic.find(result.topic_id)
49 | url = "https://#{Discourse.current_hostname}/t/slug/#{topic.topic_id}"
50 | topic_location = ::Locations::TopicLocation.find_by(topic_id: topic.id)
51 | distance = result.distance_from(target_user_location.to_coordinates, :km)
52 | response += I18n.t("chatbot.prompt.function.f0orum_topic_search_from_user_location.answer", title: topic.title, address: topic_location.address, url: url, distance: distance, rank: index + 1)
53 | break if index == number_of_topics
54 | end
55 | {
56 | answer: response,
57 | token_usage: 0
58 | }
59 | rescue
60 | {
61 | answer: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.error", query: args[parameters[0][:name]]),
62 | token_usage: 0
63 | }
64 | end
65 | end
66 | end
67 | end
68 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_user_distance_from_location_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumUserDistanceFromLocationFunction < Function
7 |
8 | def name
9 | 'forum_user_distance_from_location'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.forum_user_distance_from_location.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "username", type: String, description: I18n.t("chatbot.prompt.function.forum_user_distance_from_location.parameters.username") } ,
19 | { name: "coords", type: String, description: I18n.t("chatbot.prompt.function.forum_user_distance_from_location.parameters.coords") }
20 | ]
21 | end
22 |
23 | def required
24 | ['username', 'coords']
25 | end
26 |
27 | def process(args)
28 | begin
29 | super(args)
30 |
31 | username = args[parameters[0][:name]]
32 | location = args[parameters[1][:name]]
33 |
34 | coords = location.split(/, /)
35 | user = User.find_by(username: username)
36 | result = ::Locations::UserLocationProcess.get_user_distance_from_location(user.id, coords[0], coords[1])
37 |
38 | response = I18n.t("chatbot.prompt.function.forum_user_distance_from_location.answer_summary", distance: result, username: username, coords: location)
39 |
40 | {
41 | answer: response,
42 | token_usage: 0
43 | }
44 | rescue
45 | {
46 | answer: I18n.t("chatbot.prompt.function.forum_user_distance_from_location.error"),
47 | token_usage: 0
48 | }
49 | end
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_user_search_from_location_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumUserSearchFromLocationFunction < Function
7 |
8 | def name
9 | 'forum_user_search_from_location'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.forum_user_search_from_location.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "coords", type: String, description: I18n.t("chatbot.prompt.function.forum_user_search_from_location.parameters.coords") } ,
19 | { name: "distance", type: Integer, description: I18n.t("chatbot.prompt.function.forum_user_search_from_location.parameters.distance") } ,
20 | { name: "number_of_users", type: Integer, description: I18n.t("chatbot.prompt.function.forum_user_search_from_location.parameters.number_of_users") }
21 | ]
22 | end
23 |
24 | def required
25 | ['coords']
26 | end
27 |
28 | def process(args)
29 | begin
30 | super(args)
31 | query = args[parameters[0][:name]]
32 | distance = args[parameters[1][:name]].blank? ? 5000 : args[parameters[1][:name]]
33 | number_of_users = args[parameters[2][:name]].blank? ? 3 : args[parameters[2][:name]]
34 | number_of_users = number_of_users > 16 ? 16 : number_of_users
35 | results = []
36 |
37 | coords = query.split(/,/)
38 | results = ::Locations::UserLocationProcess.search_users_from_location(coords[0], coords[1], distance)
39 | response = I18n.t("chatbot.prompt.function.forum_user_search_from_location.answer_summary", distance: distance, query: query)
40 |
41 | results.each_with_index do |result, index|
42 | user = User.find(result)
43 | user_location = ::Locations::UserLocation.find_by(user_id: user.id)
44 | distance = user_location.distance_from([coords[0], coords[1]], :km) # geocoder expects order lat, lon.
45 | response += I18n.t("chatbot.prompt.function.forum_user_search_from_location.answer", username: user.username, distance: distance, rank: index + 1)
46 | break if index == number_of_users
47 | end
48 | {
49 | answer: response,
50 | token_usage: 0
51 | }
52 | rescue
53 | {
54 | answer: I18n.t("chatbot.prompt.function.forum_user_search_from_location.error", query: args[parameters[0][:name]]),
55 | token_usage: 0
56 | }
57 | end
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_user_search_from_topic_location_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumUserSearchFromTopicLocationFunction < Function
7 |
8 | def name
9 | 'forum_topic_search_from_user_location'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "username", type: String, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.parameters.username") } ,
19 | { name: "distance", type: Integer, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.parameters.distance") } ,
20 | { name: "number_of_topics", type: Integer, description: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.parameters.number_of_topics") }
21 | ]
22 | end
23 |
24 | def required
25 | ['username']
26 | end
27 |
28 | def process(args)
29 | begin
30 | super(args)
31 | query = args[parameters[0][:name]]
32 |
33 | distance = args[parameters[1][:name]].blank? ? 500 : args[parameters[1][:name]]
34 | number_of_topics = args[parameters[2][:name]].blank? ? 3 : args[parameters[2][:name]]
35 | number_of_topics = number_of_topics > 16 ? 16 : number_of_topics
36 |
37 | results = []
38 |
39 | target_topic_location = TopicLocation.find_by(topic_id: topic_id)
40 | user_id = User.find_by(username: query).id
41 | results = ::Locations::TopicLocationProcess.search_from_user_location(user_id, distance)
42 |
43 | response = I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.answer_summary", distance: distance, query: query)
44 |
45 | results.each_with_index do |result, index|
46 | user = User.find(result)
47 | user_location = ::Locations::UserLocation.find_by(user_id: user.id)
48 | distance = user_location.distance_from(target_topic_location.to_coordinates, :km)
49 | response += I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.answer", username: user.username, distance: distance, rank: index + 1)
50 | break if index == number_of_users
51 | end
52 | {
53 | answer: response,
54 | token_usage: 0
55 | }
56 | rescue
57 | {
58 | answer: I18n.t("chatbot.prompt.function.forum_topic_search_from_user_location.error", query: args[parameters[0][:name]]),
59 | token_usage: 0
60 | }
61 | end
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/forum_user_search_from_user_location_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class ForumUserSearchFromUserLocationFunction < Function
7 |
8 | def name
9 | 'forum_user_search_from_user_location'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.forum_user_search_from_user_location.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "username", type: String, description: I18n.t("chatbot.prompt.function.forum_user_search_from_user_location.parameters.username") } ,
19 | { name: "distance", type: Integer, description: I18n.t("chatbot.prompt.function.forum_user_search_from_user_location.parameters.distance") } ,
20 | { name: "number_of_users", type: Integer, description: I18n.t("chatbot.prompt.function.forum_user_search_from_user_location.parameters.number_of_users") }
21 | ]
22 | end
23 |
24 | def required
25 | ['username']
26 | end
27 |
28 | def process(args)
29 | begin
30 | super(args)
31 | query = args[parameters[0][:name]]
32 |
33 | distance = args[parameters[1][:name]].blank? ? 500 : args[parameters[1][:name]].to_f
34 | number_of_users = args[parameters[2][:name]].blank? ? 3 : args[parameters[2][:name]]
35 | number_of_users = number_of_users > 16 ? 16 : number_of_users
36 |
37 | results = []
38 |
39 | user_id = User.find_by(username: query).id
40 | target_user_location = ::Locations::UserLocation.find_by(user_id: user_id)
41 | results = ::Locations::UserLocationProcess.search_users_from_user_location(user_id, distance)
42 |
43 | response = I18n.t("chatbot.prompt.function.forum_user_search_from_user_location.answer_summary", distance: distance, query: query)
44 |
45 | results.each_with_index do |result, index|
46 | user = User.find(result)
47 | user_location = ::Locations::UserLocation.find_by(user_id: user.id)
48 | distance = user_location.distance_from(target_user_location.to_coordinates, :km)
49 | response += I18n.t("chatbot.prompt.function.forum_user_search_from_user_location.answer", username: user.username, distance: distance, rank: index + 1)
50 | break if index == number_of_users
51 | end
52 | {
53 | answer: response,
54 | token_usage: 0
55 | }
56 | rescue
57 | {
58 | answer: I18n.t("chatbot.prompt.function.forum_user_search_from_user_location.error", query: args[parameters[0][:name]]),
59 | token_usage: 0
60 | }
61 | end
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/get_distance_between_locations_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class GetDistanceBetweenLocationsFunction < Function
7 |
8 | def name
9 | 'get_distance_between_locations'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.get_distance_between_locations.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: "coords1", type: String, description: I18n.t("chatbot.prompt.function.get_distance_between_locations.parameters.coords") } ,
19 | { name: "coords2", type: String, description: I18n.t("chatbot.prompt.function.get_distance_between_locations.parameters.coords") } ,
20 | ]
21 | end
22 |
23 | def required
24 | ['coords1', 'coords2']
25 | end
26 |
27 | def process(args)
28 | begin
29 | super(args)
30 | query1 = args[parameters[0][:name]]
31 | query2 = args[parameters[1][:name]]
32 |
33 | coords1 = query1.split(",")
34 | coords2 = query2.split(",")
35 |
36 | distance = ::Locations::Geocode.return_distance(coords1[0], coords1[1], coords2[0], coords2[1])
37 |
38 | {
39 | answer: I18n.t("chatbot.prompt.function.get_distance_between_locations.answer_summary", distance: distance, coords1: coords1, coords2: coords2),
40 | token_usage: 0
41 | }
42 | rescue
43 | {
44 | answer: I18n.t("chatbot.prompt.function.get_distance_between_locations.error", query: args[parameters[0][:name]]),
45 | token_usage: 0
46 | }
47 | end
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/news_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class NewsFunction < Function
7 |
8 | def name
9 | 'news'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.news.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: 'query', type: String, description: I18n.t("chatbot.prompt.function.news.parameters.query") },
19 | { name: 'start_date', type: String, description: I18n.t("chatbot.prompt.function.news.parameters.start_date") }
20 | ]
21 | end
22 |
23 | def required
24 | ['query']
25 | end
26 |
27 | def process(args)
28 | begin
29 | ::DiscourseChatbot.progress_debug_message <<~EOS
30 | -------------------------------------
31 | arguments for news: #{args[parameters[0][:name]]}, #{args[parameters[1][:name]]}
32 | --------------------------------------
33 | EOS
34 | super(args)
35 | token_usage = 0
36 |
37 | conn_params = {}
38 |
39 | conn_params = args[parameters[1][:name]].blank? ?
40 | { q: "#{args[parameters[0][:name]]}", language: 'en', sortBy: 'relevancy' } :
41 | { q: "#{args[parameters[0][:name]]}", language: 'en', sortBy: 'relevancy', start_date: "#{args[parameters[1][:name]]}" }
42 |
43 | conn = Faraday.new(
44 | url: 'https://newsapi.org',
45 | params: conn_params,
46 | headers: { 'X-Api-Key' => "#{SiteSetting.chatbot_news_api_token}" }
47 | )
48 |
49 | response = conn.get('/v2/everything')
50 |
51 | response_body = JSON.parse(response.body)
52 |
53 | all_articles = response_body["articles"]
54 |
55 | news = I18n.t("chatbot.prompt.function.news.answer")
56 | all_articles.each do |a|
57 | news += "#{a["title"]}. "
58 | end
59 | token_usage = SiteSetting.chatbot_news_api_call_token_cost
60 | {
61 | answer: news,
62 | token_usage: token_usage
63 | }
64 | rescue
65 | {
66 | answer: I18n.t("chatbot.prompt.function.news.error"),
67 | token_usage: token_usage
68 | }
69 | end
70 | end
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/paint_edit_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 | require 'mime/types'
5 |
6 | module DiscourseChatbot
7 | class PaintEditFunction < Function
8 | TOKEN_COST = 1000000 # 1M tokens per request based on cost of dall-e-3 model vs gpt-4o-mini
9 |
10 | def name
11 | 'paint_edit_picture'
12 | end
13 |
14 | def description
15 | I18n.t("chatbot.prompt.function.paint_edit.description")
16 | end
17 |
18 | def parameters
19 | [
20 | { name: "description", type: String, description: I18n.t("chatbot.prompt.function.paint_edit.parameters.description") } ,
21 | ]
22 | end
23 |
24 | def required
25 | ['description']
26 | end
27 |
28 | def process(args, opts)
29 | begin
30 | super(args)
31 | token_usage = 0
32 |
33 | description = args[parameters[0][:name]]
34 |
35 | client = OpenAI::Client.new do |f|
36 | f.response :logger, Logger.new($stdout), bodies: true if SiteSetting.chatbot_enable_verbose_console_logging
37 | if SiteSetting.chatbot_enable_verbose_rails_logging != "off"
38 | case SiteSetting.chatbot_verbose_rails_logging_destination_level
39 | when "warn"
40 | f.response :logger, Rails.logger, bodies: true, log_level: :warn
41 | else
42 | f.response :logger, Rails.logger, bodies: true, log_level: :info
43 | end
44 | end
45 | end
46 |
47 | size = "1536x1024"
48 | quality = "auto"
49 |
50 | options = {
51 | model: SiteSetting.chatbot_support_picture_creation_model,
52 | prompt: description,
53 | size: size,
54 | quality: quality,
55 | }
56 |
57 | type = opts[:type]
58 |
59 | last_image_upload = type == ::DiscourseChatbot::POST ? last_post_image_upload(opts[:reply_to_message_or_post_id]) : last_message_image_upload(opts[:reply_to_message_or_post_id])
60 |
61 | return {
62 | answer: I18n.t("chatbot.prompt.function.paint_edit.no_image_error"),
63 | token_usage: 0
64 | } if last_image_upload.nil?
65 |
66 | file_path = path = Discourse.store.path_for(last_image_upload)
67 | base64_encoded_data = Base64.strict_encode64(File.read(file_path))
68 |
69 |
70 | file_path = Discourse.store.path_for(last_image_upload)
71 | extension = last_image_upload.extension
72 | mime_type = ::MIME::Types.type_for(extension).first.to_s
73 |
74 | f = Tempfile.new(["e1_image", ".#{extension}"])
75 | f.binmode
76 | f.write(File.binread(file_path))
77 | f.rewind
78 |
79 | # Specify the file with MIME type
80 | upload_io = Faraday::Multipart::FilePart.new(f, mime_type)
81 |
82 | options[:image] = upload_io
83 |
84 | response = client.images.edit(parameters: options)
85 |
86 | f.close
87 | f.unlink
88 |
89 | if response.dig("error")
90 | error_text = "ERROR when trying to call paint API: #{response.dig("error", "message")}"
91 | raise StandardError, error_text
92 | end
93 |
94 | tokens_used = response.dig("usage", "total_tokens")
95 |
96 | artifacts = response.dig("data")
97 | .to_a
98 | .map { |art| art["b64_json"] }
99 |
100 | bot_username = SiteSetting.chatbot_bot_user
101 | bot_user = ::User.find_by(username: bot_username)
102 |
103 | thumbnails = base64_to_image(artifacts, description, bot_user.id)
104 | short_url = thumbnails.first.short_url
105 | markdown = ""
106 |
107 | {
108 | answer: markdown,
109 | token_usage: tokens_used
110 | }
111 | rescue => e
112 | Rails.logger.error("Chatbot: Error in paint edit function: #{e}")
113 | if e.respond_to?(:response)
114 | status = e.response[:status]
115 | message = e.response[:body]["error"]["message"]
116 | Rails.logger.error("Chatbot: There was a problem with Image call: status: #{status}, message: #{message}")
117 | end
118 | {
119 | answer: I18n.t("chatbot.prompt.function.paint_edit.error"),
120 | token_usage: TOKEN_COST
121 | }
122 | end
123 | end
124 |
125 | private
126 |
127 | def base64_to_image(artifacts, description, user_id)
128 | attribution = description
129 |
130 | artifacts.each_with_index.map do |art, i|
131 | f = Tempfile.new("v1_txt2img_#{i}.png")
132 | f.binmode
133 | f.write(Base64.decode64(art))
134 | f.rewind
135 | upload = UploadCreator.new(f, attribution).create_for(user_id)
136 | f.unlink
137 |
138 | UploadSerializer.new(upload, root: false)
139 | end
140 | end
141 |
142 | def last_post_image_upload(post_id)
143 | post_collection = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_id)
144 |
145 | return nil if post_collection.empty?
146 |
147 | upload_id = post_collection.map(&:image_upload_id).compact.max
148 | return Upload.find_by(id: upload_id)
149 |
150 | nil
151 | end
152 |
153 | def last_message_image_upload(message_id)
154 | message_collection = ::DiscourseChatbot::MessagePromptUtils.collect_past_interactions(message_id)
155 | uploads = []
156 |
157 | message_collection.each do |cm|
158 | cm.uploads.each do |ul|
159 | if %w[png webp jpg jpeg].include?(ul.extension)
160 | uploads << ul
161 | end
162 | end
163 | end
164 |
165 | return nil if uploads.empty?
166 |
167 | uploads.max_by(&:created_at)
168 | end
169 | end
170 | end
171 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/paint_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class PaintFunction < Function
7 | TOKEN_COST = 1000000 # 1M tokens per request based on cost of dall-e-3 model vs gpt-4o-mini
8 |
9 | def name
10 | 'paint_picture'
11 | end
12 |
13 | def description
14 | I18n.t("chatbot.prompt.function.paint.description")
15 | end
16 |
17 | def parameters
18 | [
19 | { name: "description", type: String, description: I18n.t("chatbot.prompt.function.paint.parameters.description") } ,
20 | ]
21 | end
22 |
23 | def required
24 | ['description']
25 | end
26 |
27 | def process(args)
28 | begin
29 | super(args)
30 | token_usage = 0
31 |
32 | description = args[parameters[0][:name]]
33 |
34 | client = OpenAI::Client.new do |f|
35 | f.response :logger, Logger.new($stdout), bodies: true if SiteSetting.chatbot_enable_verbose_console_logging
36 | if SiteSetting.chatbot_enable_verbose_rails_logging != "off"
37 | case SiteSetting.chatbot_verbose_rails_logging_destination_level
38 | when "warn"
39 | f.response :logger, Rails.logger, bodies: true, log_level: :warn
40 | else
41 | f.response :logger, Rails.logger, bodies: true, log_level: :info
42 | end
43 | end
44 | end
45 |
46 | size = SiteSetting.chatbot_support_picture_creation_model == "dall-e-3" ? "1792x1024" : "1536x1024"
47 | quality = SiteSetting.chatbot_support_picture_creation_model == "dall-e-3" ? "standard" : "auto"
48 |
49 | options = {
50 | model: SiteSetting.chatbot_support_picture_creation_model,
51 | prompt: description,
52 | size: size,
53 | quality: quality,
54 | }
55 |
56 | options.merge!(response_format: "b64_json") if SiteSetting.chatbot_support_picture_creation_model == "dall-e-3"
57 | options.merge!(style: "natural") if SiteSetting.chatbot_support_picture_creation_model == "dall-e-3"
58 | options.merge!(moderation: "low") if SiteSetting.chatbot_support_picture_creation_model == "gpt-image-1"
59 |
60 | response = client.images.generate(parameters: options)
61 |
62 | if response.dig("error")
63 | error_text = "ERROR when trying to call paint API: #{response.dig("error", "message")}"
64 | raise StandardError, error_text
65 | end
66 |
67 | tokens_used = SiteSetting.chatbot_support_picture_creation_model == "gpt-image-1" ? response.dig("usage", "total_tokens") : TOKEN_COST
68 |
69 | artifacts = response.dig("data")
70 | .to_a
71 | .map { |art| art["b64_json"] }
72 |
73 | bot_username = SiteSetting.chatbot_bot_user
74 | bot_user = ::User.find_by(username: bot_username)
75 |
76 | thumbnails = base64_to_image(artifacts, description, bot_user.id)
77 | short_url = thumbnails.first.short_url
78 | markdown = ""
79 |
80 | {
81 | answer: markdown,
82 | token_usage: tokens_used
83 | }
84 | rescue => e
85 | Rails.logger.error("Chatbot: Error in paint function: #{e}")
86 | if e.respond_to?(:response)
87 | status = e.response[:status]
88 | message = e.response[:body]["error"]["message"]
89 | Rails.logger.error("Chatbot: There was a problem with Image call: status: #{status}, message: #{message}")
90 | end
91 | {
92 | answer: I18n.t("chatbot.prompt.function.paint.error"),
93 | token_usage: TOKEN_COST
94 | }
95 | end
96 | end
97 |
98 | private
99 |
100 | def base64_to_image(artifacts, description, user_id)
101 | attribution = description
102 |
103 | artifacts.each_with_index.map do |art, i|
104 | f = Tempfile.new("v1_txt2img_#{i}.png")
105 | f.binmode
106 | f.write(Base64.decode64(art))
107 | f.rewind
108 | upload = UploadCreator.new(f, attribution).create_for(user_id)
109 | f.unlink
110 |
111 | UploadSerializer.new(upload, root: false)
112 | end
113 | end
114 | end
115 | end
116 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/parser.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require 'json'
3 |
4 | module ::DiscourseChatbot
5 | class Parser
6 | def self.type_mapping(dtype)
7 | case dtype.to_s
8 | when "Float"
9 | 'number'
10 | when "Integer"
11 | 'integer'
12 | when "Numeric"
13 | 'number'
14 | when "String"
15 | 'string'
16 | else
17 | 'string'
18 | end
19 | end
20 |
21 | def self.extract_params(doc_str)
22 | params_str = doc_str.split("\n").reject(&:strip_empty?)
23 | params = {}
24 | params_str.each do |line|
25 | if line.strip.start_with?(':param')
26 | param_match = line.match(/(?<=:param )\w+/)
27 | if param_match
28 | param_name = param_match[0]
29 | desc_match = line.gsub(":param #{param_name}:", "").strip
30 | params[param_name] = desc_match unless desc_match.empty?
31 | end
32 | end
33 | end
34 | params
35 | end
36 |
37 | def self.func_to_json(func)
38 | params = {}
39 | func.parameters.each do |param|
40 | params.merge!("#{param[:name]}": {})
41 |
42 | params[:"#{param[:name]}"].merge!("type": type_mapping(param[:type]).to_s)
43 | params[:"#{param[:name]}"].merge!("description": param[:description])
44 | end
45 | params = JSON.parse(params.to_json)
46 |
47 | func_json = {
48 | 'name' => func.name,
49 | 'description' => func.description,
50 | 'parameters' => {
51 | 'type' => 'object',
52 | 'properties' => params,
53 | 'required' => func.required
54 | }
55 | }
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/remaining_quota_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class RemainingQuotaFunction < Function
7 | QUOTA_RESET_JOB = "Jobs::ChatbotQuotaReset"
8 |
9 | def name
10 | 'remaining_bot_quota'
11 | end
12 |
13 | def description
14 | I18n.t("chatbot.prompt.function.remaining_bot_quota.description")
15 | end
16 |
17 | def parameters
18 | []
19 | end
20 |
21 | def required
22 | []
23 | end
24 |
25 | def process(args, opts)
26 | begin
27 | super(args)
28 | user_id = opts[:user_id]
29 |
30 | remaining_quota_field_name = SiteSetting.chatbot_quota_basis == "queries" ? CHATBOT_REMAINING_QUOTA_QUERIES_CUSTOM_FIELD : CHATBOT_REMAINING_QUOTA_TOKENS_CUSTOM_FIELD
31 | remaining_quota = ::DiscourseChatbot::EventEvaluation.new.get_remaining_quota(user_id, remaining_quota_field_name)
32 |
33 | reset_job = MiniScheduler::Manager.discover_schedules.find {|job| job.schedule_info.instance_variable_get(:@klass).to_s == QUOTA_RESET_JOB}.schedule_info
34 | days_remaining = (Time.at(reset_job.instance_variable_get(:@next_run)).to_date - Time.zone.now.to_date).to_i
35 |
36 | {
37 | answer: I18n.t("chatbot.prompt.function.remaining_bot_quota.answer", quota: remaining_quota, units: SiteSetting.chatbot_quota_basis, days_remaining: days_remaining),
38 | token_usage: 0
39 | }
40 | rescue => e
41 | {
42 | answer: I18n.t("chatbot.prompt.function.remaining_bot_quota.error", error: e.message),
43 | token_usage: 0
44 | }
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/stock_data_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 | # require 'basic_yahoo_finance'
5 |
6 | require 'net/http'
7 | require 'json'
8 |
9 | module DiscourseChatbot
10 |
11 | class StockDataFunction < Function
12 |
13 | def name
14 | 'stock_data'
15 | end
16 |
17 | def description
18 | I18n.t("chatbot.prompt.function.stock_data.description")
19 | end
20 |
21 | def parameters
22 | [
23 | { name: 'ticker', type: String, description: I18n.t("chatbot.prompt.function.stock_data.parameters.ticker") },
24 | { name: 'date', type: String, description: I18n.t("chatbot.prompt.function.stock_data.parameters.date") }
25 | ]
26 | end
27 |
28 | def required
29 | ['ticker']
30 | end
31 |
32 | def process(args)
33 | begin
34 | super(args)
35 | token_usage = 0
36 |
37 | params = {
38 | access_key: "#{SiteSetting.chatbot_marketstack_key}",
39 | search: "#{CGI.escape(args[parameters[0][:name]])}"
40 | }
41 | uri = URI("http://api.marketstack.com/v1/tickers?")
42 |
43 | uri.query = URI.encode_www_form(params)
44 | json = Net::HTTP.get(uri)
45 | api_response = JSON.parse(json)
46 |
47 | ticker = api_response['data'][0]['symbol']
48 | uri = args[parameters[1][:name]].blank? ? URI("http://api.marketstack.com/v1/eod/latest") : URI("http://api.marketstack.com/v1/eod/#{args[parameters[1][:name]]}")
49 |
50 | params = {
51 | access_key: "#{SiteSetting.chatbot_marketstack_key}",
52 | symbols: "#{ticker}"
53 | }
54 |
55 | uri.query = URI.encode_www_form(params)
56 | json = Net::HTTP.get(uri)
57 | api_response = JSON.parse(json)
58 |
59 | stock_data = api_response['data'][0]
60 | token_usage = SiteSetting.chatbot_marketstack_api_call_token_cost
61 |
62 | {
63 | answer: I18n.t("chatbot.prompt.function.stock_data.answer", ticker: stock_data['symbol'], close: stock_data['close'].to_s, date: stock_data['date'].to_s, high: stock_data['high'].to_s, low: stock_data['low'].to_s),
64 | token_usage: token_usage
65 | }
66 | rescue
67 | {
68 | answer: I18n.t("chatbot.prompt.function.stock_data.error"),
69 | token_usage: token_usage
70 | }
71 | end
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/user_field_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class UserFieldFunction < Function
7 | def initialize(user_field, user_id)
8 | @user_field_options = []
9 | @user_field = user_field
10 | @user_field_object = UserField.find_by(name: user_field)
11 | @user_field_id = @user_field_object.id
12 | @user_field_type = @user_field_object.field_type_enum
13 | if @user_field_type == "dropdown"
14 | UserFieldOption.where(user_field_id: @user_field_id).each do |option|
15 | @user_field_options << option.value
16 | end
17 | end
18 | @function_name = user_field.downcase.gsub(" ", "_")
19 | @user_custom_field_name = "user_field_#{@user_field_id}"
20 | @user_id = user_id
21 | super()
22 | end
23 |
24 | def name
25 | I18n.t("chatbot.prompt.function.user_information.name", user_field: @function_name)
26 | end
27 |
28 | def description
29 | case @user_field_type
30 | when "confirm"
31 | I18n.t("chatbot.prompt.function.user_information.description.confirmation", user_field: @user_field)
32 | else
33 | I18n.t("chatbot.prompt.function.user_information.description.general", user_field: @user_field)
34 | end
35 | end
36 |
37 | def parameters
38 | case @user_field_type
39 | when "text"
40 | [
41 | { name: "answer", type: String, description: I18n.t("chatbot.prompt.function.user_information.parameters.answer.text", user_field: @user_field) } ,
42 | ]
43 | when "confirm"
44 | [
45 | { name: "answer", type: String, enum: ["true", "false"], description: I18n.t("chatbot.prompt.function.user_information.parameters.answer.confirmation", user_field: @user_field) } ,
46 | ]
47 | when "dropdown"
48 | [
49 | { 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) } ,
50 | ]
51 | end
52 | end
53 |
54 | def required
55 | ['answer']
56 | end
57 |
58 | def process(args)
59 | begin
60 | super(args)
61 | ucf = ::UserCustomField.where(user_id: @user_id, name: @user_custom_field_name).first
62 |
63 | if ucf
64 | ucf.value = args[parameters[0][:name]]
65 | ucf.save!
66 | else
67 | ::UserCustomField.create!(user_id: @user_id, name: @user_custom_field_name, value: args[parameters[0][:name]])
68 | end
69 |
70 | {
71 | answer: I18n.t("chatbot.prompt.function.user_information.answer", user_field: @user_field),
72 | token_usage: 0
73 | }
74 | rescue StandardError => e
75 | Rails.logger.error("Chatbot: Error occurred while attempting to store answer in a User Custom Field: #{e.message}")
76 | {
77 | answer: I18n.t("chatbot.prompt.function.user_information.error", user_field: @user_field, answer: args[parameters[0][:name]]),
78 | token_usage: 0
79 | }
80 | end
81 | end
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/vision_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 |
7 | class VisionFunction < Function
8 |
9 | def name
10 | 'vision'
11 | end
12 |
13 | def description
14 | I18n.t("chatbot.prompt.function.vision.description")
15 | end
16 |
17 | def parameters
18 | [
19 | { name: 'query', type: String, description: I18n.t("chatbot.prompt.function.vision.parameters.query") }
20 | ]
21 | end
22 |
23 | def required
24 | []
25 | end
26 |
27 | def process(args, opts, client)
28 | begin
29 | token_usage = 0
30 | super(args)
31 |
32 | if args[parameters[0][:name]].blank?
33 | query = I18n.t("chatbot.prompt.function.vision.default_query")
34 | else
35 | query = args[parameters[0][:name]]
36 | end
37 |
38 | url = ""
39 |
40 | if opts[:type] == ::DiscourseChatbot::MESSAGE
41 | collection = ::DiscourseChatbot::MessagePromptUtils.collect_past_interactions(opts[:reply_to_message_or_post_id])
42 | collection.each do |m|
43 | m.uploads.each do |ul|
44 | if ["png", "webp", "jpg", "jpeg", "gif", "ico", "avif"].include?(ul.extension)
45 | url = ::DiscourseChatbot::PromptUtils.resolve_full_url(ul.url)
46 | break
47 | end
48 | end
49 | break if !url.blank?
50 | end
51 | else
52 | collection = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(opts[:reply_to_message_or_post_id])
53 | collection.each do |p|
54 | if p.image_upload_id
55 | url = ::DiscourseChatbot::PromptUtils.resolve_full_url(::Upload.find(p.image_upload_id).url)
56 | break
57 | end
58 | end
59 | end
60 |
61 | if !url.blank?
62 | res = client.chat(
63 | parameters: {
64 | model: SiteSetting.chatbot_open_ai_vision_model,
65 | messages: [
66 | {
67 | "role": "user",
68 | "content": [
69 | {"type": "text", "text": query},
70 | {
71 | "type": "image_url",
72 | "image_url": {
73 | "url": url,
74 | },
75 | },
76 | ],
77 | }
78 | ],
79 | max_tokens: 300
80 | }
81 | )
82 |
83 | token_usage = res.dig("usage", "total_tokens")
84 |
85 | if res.dig("error")
86 | error_text = "ERROR when trying to perform chat completion for vision: #{res.dig("error", "message")}"
87 |
88 | Rails.logger.error("Chatbot: #{error_text}")
89 |
90 | raise error_text
91 | end
92 | else
93 | error_text = "ERROR when trying to find image for examination: no image found"
94 |
95 | Rails.logger.error("Chatbot: #{error_text}")
96 |
97 | raise error_text
98 | end
99 |
100 | {
101 | answer: I18n.t("chatbot.prompt.function.vision.answer", description: res["choices"][0]["message"]["content"]),
102 | token_usage: token_usage
103 | }
104 | rescue => e
105 | {
106 | answer: I18n.t("chatbot.prompt.function.vision.error", error: e.message),
107 | token_usage: token_usage
108 | }
109 | end
110 | end
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/web_crawler_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 |
5 | module DiscourseChatbot
6 | class WebCrawlerFunction < Function
7 |
8 | def name
9 | 'web_crawler'
10 | end
11 |
12 | def description
13 | I18n.t("chatbot.prompt.function.web_crawler.description")
14 | end
15 |
16 | def parameters
17 | [
18 | { name: 'url', type: String, description: I18n.t("chatbot.prompt.function.web_crawler.parameters.url") },
19 | ]
20 | end
21 |
22 | def required
23 | ['url']
24 | end
25 |
26 | def process(args)
27 | begin
28 | ::DiscourseChatbot.progress_debug_message <<~EOS
29 | -------------------------------------
30 | arguments for web crawler: #{args[parameters[0][:name]]}
31 | --------------------------------------
32 | EOS
33 | super(args)
34 | token_usage = 0
35 | if SiteSetting.chatbot_firecrawl_api_token.blank?
36 | conn = Faraday.new(
37 | url: "https://r.jina.ai/#{args[parameters[0][:name]]}",
38 | headers: {
39 | "Authorization" => "Bearer #{SiteSetting.chatbot_jina_api_token}"
40 | }
41 | )
42 | response = conn.get
43 | result = response.body
44 | token_usage = result.length * SiteSetting.chatbot_jina_api_token_cost_multiplier
45 | else
46 | conn = Faraday.new(
47 | url: 'https://api.firecrawl.dev',
48 | headers: {
49 | "Content-Type" => "application/json",
50 | "Authorization" => "Bearer #{SiteSetting.chatbot_firecrawl_api_token}"
51 | }
52 | )
53 |
54 | response = conn.post('v0/crawl') do |req|
55 | req.body = { url: "#{args[parameters[0][:name]]}" }.to_json
56 | end
57 |
58 | response_body = JSON.parse(response.body)
59 |
60 | job_id = response_body["jobId"]
61 |
62 | iterations = 0
63 | while true
64 | iterations += 1
65 | sleep 5
66 | break if iterations > 20
67 |
68 | response = conn.get("/v0/crawl/status/#{job_id}")
69 |
70 | response_body = JSON.parse(response.body)
71 |
72 | break if response_body["status"] == "completed"
73 | end
74 |
75 | result = response_body["data"][0]["markdown"]
76 | token_usage = SiteSetting.chatbot_firecrawl_api_token_cost
77 | end
78 | {
79 | answer: result[0..SiteSetting.chatbot_function_response_char_limit],
80 | token_usage: token_usage
81 | }
82 | rescue=> e
83 | Rails.logger.error("Chatbot: Error in web crawler function: #{e}")
84 | {
85 | answer: I18n.t("chatbot.prompt.function.web_crawler.error"),
86 | token_usage: token_usage
87 | }
88 | end
89 | end
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/web_search_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 | require "google_search_results"
5 |
6 | module DiscourseChatbot
7 | class WebSearchFunction < Function
8 |
9 | def name
10 | 'web_search'
11 | end
12 |
13 | def description
14 | I18n.t("chatbot.prompt.function.web_search.description")
15 | end
16 |
17 | def parameters
18 | [
19 | { name: "query", type: String, description: I18n.t("chatbot.prompt.function.web_search.parameters.query") } ,
20 | ]
21 | end
22 |
23 | def required
24 | ['query']
25 | end
26 |
27 | def process(args)
28 | begin
29 | super(args)
30 | token_usage = 0
31 | if SiteSetting.chatbot_serp_api_key.blank?
32 | query = URI.encode_www_form_component(args[parameters[0][:name]])
33 | conn = Faraday.new(
34 | url: "https://s.jina.ai/#{query}",
35 | headers: {
36 | "Authorization" => "Bearer #{SiteSetting.chatbot_jina_api_token}"
37 | }
38 | )
39 | response = conn.get
40 | result = response.body
41 | token_usage = response.body.length * SiteSetting.chatbot_jina_api_token_cost_multiplier
42 | else
43 | hash_results = ::GoogleSearch.new(q: args[parameters[0][:name]], serp_api_key: SiteSetting.chatbot_serp_api_key)
44 | .get_hash
45 |
46 | result = hash_results.dig(:answer_box, :answer).presence ||
47 | hash_results.dig(:answer_box, :snippet).presence ||
48 | hash_results.dig(:organic_results)
49 | token_usage = SiteSetting.chatbot_serp_api_token_cost
50 | end
51 | {
52 | answer: result[0..SiteSetting.chatbot_function_response_char_limit],
53 | token_usage: token_usage
54 | }
55 | rescue => e
56 | Rails.logger.error("Chatbot: Error in web_search function: #{e}")
57 | {
58 | answer: I18n.t("chatbot.prompt.function.web_search.error", query: args[parameters[0][:name]]),
59 | token_usage: 0
60 | }
61 | end
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/functions/wikipedia_function.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative '../function'
4 | require 'wikipedia-client'
5 |
6 | module DiscourseChatbot
7 |
8 | class WikipediaFunction < Function
9 |
10 | def name
11 | 'wikipedia'
12 | end
13 |
14 | def description
15 | I18n.t("chatbot.prompt.function.wikipedia.description")
16 | end
17 |
18 | def parameters
19 | [
20 | { name: 'query', type: String, description: I18n.t("chatbot.prompt.function.wikipedia.parameters.query") }
21 | ]
22 | end
23 |
24 | def required
25 | ['query']
26 | end
27 |
28 | def process(args)
29 | begin
30 | super(args)
31 |
32 | page = ::Wikipedia.find(args[parameters[0][:name]])
33 |
34 | {
35 | answer: I18n.t("chatbot.prompt.function.wikipedia.answer", summary: page.summary, url: page.fullurl),
36 | token_usage: 0
37 | }
38 | rescue
39 | {
40 | answer: I18n.t("chatbot.prompt.function.wikipedia.error"),
41 | token_usage: 0
42 | }
43 | end
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/message/message_evaluation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 |
4 | class MessageEvaluation < EventEvaluation
5 |
6 | DIRECT_MESSAGE = "DirectMessage"
7 |
8 | def on_submission(submission)
9 | ::DiscourseChatbot.progress_debug_message("2. evaluation")
10 |
11 | chat_message = submission
12 |
13 | user = chat_message.user
14 | user_id = user.id
15 | channel_id = chat_message.chat_channel_id
16 | message_contents = chat_message.message
17 | in_reply_to_id = chat_message.in_reply_to_id
18 |
19 | over_quota = over_quota(user_id)
20 |
21 | bot_username = SiteSetting.chatbot_bot_user
22 | bot_user = User.find_by(username: bot_username)
23 | bot_user_id = bot_user.id
24 |
25 | mentions_bot_name = message_contents.downcase =~ /@#{bot_username.downcase}\b/
26 |
27 | prior_message = ::Chat::Message.where(chat_channel_id: channel_id).second_to_last
28 | replied_to_user = nil
29 | if in_reply_to_id
30 | ::DiscourseChatbot.progress_debug_message("2.5 found it's a reply to a prior message")
31 | replied_to_user = ::Chat::Message.find(in_reply_to_id).user
32 | end
33 |
34 | channel = ::Chat::Channel.find(channel_id)
35 | direct_message_channel = channel.chatable_type == DIRECT_MESSAGE
36 |
37 | message_channel_user_count = ::Chat::UserChatChannelMembership.where(chat_channel_id: channel_id).count
38 |
39 | bot_chat_channel = (bot_user.user_chat_channel_memberships.where(chat_channel_id: channel_id).count > 0)
40 |
41 | talking_to_bot = (bot_chat_channel && message_channel_user_count < 3) || (replied_to_user && replied_to_user.id == bot_user_id)
42 |
43 | if bot_user && (user_id != bot_user_id) && (mentions_bot_name || talking_to_bot)
44 |
45 | if mentions_bot_name && !bot_chat_channel
46 | bot_user.user_chat_channel_memberships.create!(chat_channel: channel, following: true)
47 | Jobs::Chat::UpdateChannelUserCount.new.execute(chat_channel_id: channel.id)
48 | bot_chat_channel = true
49 | channel.reload
50 | ::DiscourseChatbot.progress_debug_message("2.6 added bot to channel")
51 | end
52 |
53 | opts = {
54 | type: MESSAGE,
55 | private: direct_message_channel,
56 | user_id: user_id,
57 | bot_user_id: bot_user_id,
58 | reply_to_message_or_post_id: chat_message.id,
59 | topic_or_channel_id: channel_id,
60 | thread_id: chat_message.thread_id,
61 | over_quota: over_quota,
62 | trust_level: trust_level(user.id),
63 | human_participants_count: bot_chat_channel ? message_channel_user_count - 1 : message_channel_user_count,
64 | message_body: message_contents.gsub(bot_username.downcase, '').gsub(bot_username, '')
65 | }
66 |
67 | ::DiscourseChatbot.progress_debug_message("3. invocation")
68 |
69 | job_class = ::Jobs::ChatbotReply
70 | invoke_background_job(job_class, opts)
71 | true
72 | else
73 | false
74 | end
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/message/message_prompt_utils.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 | class MessagePromptUtils < PromptUtils
4 | def self.create_prompt(opts)
5 | message_collection = collect_past_interactions(opts[:reply_to_message_or_post_id])
6 | bot_user_id = opts[:bot_user_id]
7 |
8 | messages = []
9 |
10 | messages +=
11 | message_collection.reverse.map do |cm|
12 | username = ::User.find(cm.user_id).username
13 | role = (cm.user_id == bot_user_id ? "assistant" : "user")
14 | text =
15 | (
16 | if SiteSetting.chatbot_api_supports_name_attribute || cm.user_id == bot_user_id
17 | cm.message
18 | else
19 | I18n.t("chatbot.prompt.post", username: username, raw: cm.message)
20 | end
21 | )
22 |
23 | content = []
24 |
25 | content << { type: "text", text: text }
26 | cm.uploads.each do |ul|
27 | if %w[png webp jpg jpeg gif ico avif].include?(ul.extension) && SiteSetting.chatbot_support_vision == "directly" ||
28 | ul.extension == "pdf" && SiteSetting.chatbot_support_pdf == true
29 | role = "user"
30 | file_path = Discourse.store.path_for(ul)
31 | base64_encoded_data = Base64.strict_encode64(File.read(file_path))
32 | if ul.extension == "pdf"
33 | content << {
34 | "type": "file",
35 | "file": {
36 | "filename": ul.original_filename,
37 | "file_data": "data:application/pdf;base64," + base64_encoded_data
38 | }
39 | }
40 | else
41 | content << {
42 | "type": "image_url",
43 | "image_url": {
44 | "url": "data:image/#{ul.extension};base64," + base64_encoded_data
45 | }
46 | }
47 | end
48 | end
49 | end
50 |
51 | if SiteSetting.chatbot_api_supports_name_attribute
52 | { role: role, name: username, content: content }
53 | else
54 | { role: role, content: content }
55 | end
56 | end
57 |
58 | messages
59 | end
60 |
61 | def self.collect_past_interactions(message_or_post_id)
62 | current_message = ::Chat::Message.find(message_or_post_id)
63 |
64 | message_collection = []
65 |
66 | message_collection << current_message
67 |
68 | collect_amount = SiteSetting.chatbot_max_look_behind
69 |
70 | while message_collection.length < collect_amount
71 | if current_message.in_reply_to_id
72 | current_message = ::Chat::Message.find(current_message.in_reply_to_id)
73 | else
74 | prior_message =
75 | ::Chat::Message
76 | .where(chat_channel_id: current_message.chat_channel_id, thread_id: current_message.thread_id, deleted_at: nil)
77 | .where("chat_messages.id < ?", current_message.id)
78 | .last
79 | if prior_message.nil?
80 | break
81 | else
82 | current_message = prior_message
83 | end
84 | end
85 |
86 | message_collection << current_message
87 | end
88 |
89 | message_collection
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/message/message_reply_creator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 | class MessageReplyCreator < ReplyCreator
4 |
5 | def initialize(options = {})
6 | super(options)
7 | end
8 |
9 | def create
10 | ::DiscourseChatbot.progress_debug_message("5. Creating a new Chat Nessage...")
11 | begin
12 | if @private && @human_participants_count == 1
13 | # latest_message_id = ::Topic.find(@topic_or_channel_id).posts.order('created_at DESC').first.id
14 | latest_message_id = ::Chat::Message.where(chat_channel_id: @topic_or_channel_id, deleted_at: nil).order('created_at DESC').first.id
15 |
16 | if @reply_to != latest_message_id
17 | ::DiscourseChatbot.progress_debug_message("7. The Message was discarded as there is a newer human message")
18 | # do not create a new response if the message is not the latest
19 | return
20 | end
21 | end
22 |
23 | # if the message is picture, lets get the upload
24 | upload = find_upload_from_markdown(@message_body)
25 |
26 | # if the message is a picture, message body is just a placeholder
27 | params = {
28 | chat_channel_id: @topic_or_channel_id,
29 | message: @message_body
30 | }
31 |
32 | params.merge!(thread_id: @thread_id) if @thread_id.present?
33 |
34 | message = nil
35 |
36 | Chat::CreateMessage.call(
37 | params: params,
38 | guardian: @guardian
39 | ) do
40 | on_success { |message_instance:| message = message_instance }
41 | end
42 |
43 | # if there's an upload
44 | # associate the upload with the message and
45 | # remove the redundant message body
46 | if upload && message
47 | message.message = ""
48 | message.cooked = ""
49 | message.excerpt = ""
50 | message.save!
51 | message.uploads = [upload]
52 | message.save!
53 | end
54 |
55 | begin
56 | presence = PresenceChannel.new("/chat-reply/#{@topic_or_channel_id}")
57 | presence.leave(user_id: @author.id, client_id: "12345")
58 | rescue
59 | # ignore issues with permissions related to communicating presence
60 | end
61 |
62 | ::DiscourseChatbot.progress_debug_message("6. The Message has been created successfully")
63 | rescue => e
64 | ::DiscourseChatbot.progress_debug_message("Problem with the bot Message: #{e}")
65 | Rails.logger.error("Chatbot: There was a problem: #{e}")
66 | end
67 | end
68 |
69 | private
70 |
71 | def find_upload_from_markdown(string)
72 | regex = /\A!\[([^\]]+)\|690x460\]\((upload:\/\/[^\s)]+)\)\z/
73 | match = string.match(regex)
74 | return nil unless match
75 |
76 | short_url = match[2]
77 |
78 | # Find the upload using the short_url
79 | # This is a bit of a hack because short_url is not a field but a method
80 | Upload.order(id: :desc).limit(5).each do |upload|
81 | return upload if upload.short_url == short_url
82 | end
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/post/post_embedding_process.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "openai"
3 |
4 | module ::DiscourseChatbot
5 |
6 | class PostEmbeddingProcess < EmbeddingProcess
7 |
8 | def upsert(post_id)
9 | if in_scope(post_id)
10 | if !is_valid(post_id)
11 |
12 | embedding_vector = get_embedding_from_api(post_id)
13 |
14 | ::DiscourseChatbot::PostEmbedding.upsert({ post_id: post_id, model: SiteSetting.chatbot_open_ai_embeddings_model, embedding: "#{embedding_vector}" }, on_duplicate: :update, unique_by: :post_id)
15 |
16 | ::DiscourseChatbot.progress_debug_message <<~EOS
17 | ---------------------------------------------------------------------------------------------------------------
18 | Post Embeddings: I found an embedding that needed populating or updating, id: #{post_id}
19 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
20 | EOS
21 | end
22 | else
23 | post_embedding = ::DiscourseChatbot::PostEmbedding.find_by(post_id: post_id)
24 | if post_embedding
25 | ::DiscourseChatbot.progress_debug_message <<~EOS
26 | ---------------------------------------------------------------------------------------------------------------
27 | Post Embeddings: I found a Post that was out of scope for embeddings, so deleted the embedding, id: #{post_id}
28 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
29 | EOS
30 | post_embedding.delete
31 | end
32 | end
33 | end
34 |
35 | def get_embedding_from_api(post_id)
36 | begin
37 | self.setup_api
38 |
39 | post = ::Post.find_by(id: post_id)
40 | topic = ::Topic.find_by(id: post.topic_id)
41 | response = @client.embeddings(
42 | parameters: {
43 | model: @model_name,
44 | input: post.raw[0..SiteSetting.chatbot_open_ai_embeddings_char_limit]
45 | }
46 | )
47 |
48 | if response.dig("error")
49 | error_text = response.dig("error", "message")
50 | raise StandardError, error_text
51 | end
52 | rescue StandardError => e
53 | Rails.logger.error("Chatbot: Error occurred while attempting to retrieve Embedding for post id '#{post_id}' in topic id '#{topic.id}': #{e.message}")
54 | raise e
55 | end
56 |
57 | embedding_vector = response.dig("data", 0, "embedding")
58 | end
59 |
60 |
61 | def semantic_search(query)
62 | self.setup_api
63 |
64 | response = @client.embeddings(
65 | parameters: {
66 | model: @model_name,
67 | input: query[0..SiteSetting.chatbot_open_ai_embeddings_char_limit]
68 | }
69 | )
70 |
71 | query_vector = response.dig("data", 0, "embedding")
72 |
73 | begin
74 | threshold = SiteSetting.chatbot_forum_search_function_similarity_threshold
75 | results =
76 | DB.query(<<~SQL, query_embedding: query_vector, threshold: threshold, limit: 100)
77 | SELECT
78 | post_id,
79 | p.user_id,
80 | embedding <=> '[:query_embedding]' as cosine_distance
81 | FROM
82 | chatbot_post_embeddings
83 | INNER JOIN
84 | posts p
85 | ON
86 | post_id = p.id
87 | WHERE
88 | (1 - (embedding <=> '[:query_embedding]')) > :threshold
89 | ORDER BY
90 | embedding <=> '[:query_embedding]'
91 | LIMIT :limit
92 | SQL
93 | rescue PG::Error => e
94 | Rails.logger.error(
95 | "Error #{e} querying embeddings for search #{query}",
96 | )
97 | raise MissingEmbeddingError
98 | end
99 |
100 | # exclude if not in scope for embeddings (job hasn't caught up yet)
101 | results = results.filter { |result| in_scope(result.post_id) && is_valid( result.post_id)}
102 |
103 | results = results.map {|p| { post_id: p.post_id, user_id: p.user_id, score: (1 - p.cosine_distance), rank_modifier: 0, source: "semantic" } }
104 |
105 | max_semantic_score = results.map { |r| r[:score] }.max || 1
106 |
107 | if SiteSetting.chatbot_forum_search_function_hybrid_search
108 | search = Search.new(query, { search_type: :full_page })
109 |
110 | keyword_search = search.execute.posts.pluck(:id, :user_id, :score)
111 |
112 | keyword_search_array_of_hashes = keyword_search.map { |id, user_id, score| {post_id: id, user_id: user_id, score: score, rank_modifier: 0, source: "keyword" } }
113 |
114 | keyword_search_max_score = keyword_search_array_of_hashes.map { |k| k[:score] }.max || 1
115 |
116 | keyword_search_array_of_hashes = keyword_search_array_of_hashes.each { |k| k[:score] = k[:score] / keyword_search_max_score * max_semantic_score}
117 |
118 | keyword_search_array_of_hashes.each do |k|
119 | results << k if !results.map { |r| r[:post_id] }.include?(k[:post_id])
120 | end
121 | end
122 |
123 | if ["group_promotion", "both"].include?(SiteSetting.chatbot_forum_search_function_reranking_strategy)
124 | high_ranked_users = []
125 |
126 | SiteSetting.chatbot_forum_search_function_reranking_groups.split("|").each do |g|
127 | high_ranked_users = high_ranked_users | GroupUser.where(group_id: g).pluck(:user_id)
128 | end
129 |
130 | results.each do |r|
131 | r[:rank_modifier] += 1 if high_ranked_users.include?(r[:user_id])
132 | end
133 | end
134 |
135 | if ["tag_promotion", "both"].include?(SiteSetting.chatbot_forum_search_function_reranking_strategy)
136 | high_ranked_tags = SiteSetting.chatbot_forum_search_function_reranking_tags.split("|")
137 |
138 | results.each do |r|
139 | post = ::Post.find_by(id: r[:post_id])
140 | tag_ids = ::TopicTag.where(topic_id: post.topic_id).pluck(:tag_id)
141 | tags = ::Tag.where(id: tag_ids).pluck(:name)
142 | r[:rank_modifier] += 1 if (high_ranked_tags & tags).any?
143 | end
144 | end
145 |
146 | results.sort_by { |r| [r[:rank_modifier], r[:score]] }.reverse.first(SiteSetting.chatbot_forum_search_function_max_results)
147 | end
148 |
149 | def in_scope(post_id)
150 | return false if !::Post.find_by(id: post_id).present?
151 | if SiteSetting.chatbot_embeddings_strategy == "categories"
152 | return false if !in_categories_scope(post_id)
153 | else
154 | return false if !in_benchmark_user_scope(post_id)
155 | end
156 | true
157 | end
158 |
159 | def is_valid(post_id)
160 | embedding_record = ::DiscourseChatbot::PostEmbedding.find_by(post_id: post_id)
161 | return false if !embedding_record.present?
162 | return false if embedding_record.model != SiteSetting.chatbot_open_ai_embeddings_model
163 | true
164 | end
165 |
166 | def in_categories_scope(post_id)
167 | post = ::Post.find_by(id: post_id)
168 | return false if post.nil?
169 | topic = ::Topic.find_by(id: post.topic_id)
170 | return false if topic.nil?
171 | return false if topic.archetype == ::Archetype.private_message
172 | SiteSetting.chatbot_embeddings_categories.split("|").include?(topic.category_id.to_s)
173 | end
174 |
175 | def in_benchmark_user_scope(post_id)
176 | return false if benchmark_user.nil?
177 | post = ::Post.find_by(id: post_id)
178 | return false if post.nil?
179 | topic = ::Topic.find_by(id: post.topic_id)
180 | return false if topic.nil?
181 | return false if topic.archetype == ::Archetype.private_message
182 | Guardian.new(benchmark_user).can_see?(post)
183 | end
184 | end
185 | end
186 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/post/post_evaluation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 |
4 | class PostEvaluation < EventEvaluation
5 |
6 | def on_submission(submission)
7 | ::DiscourseChatbot.progress_debug_message("2. evaluation")
8 |
9 | if !(opts = trigger_response(submission)).blank?
10 | ::DiscourseChatbot.progress_debug_message("3. invocation")
11 |
12 | job_class = ::Jobs::ChatbotReply
13 | invoke_background_job(job_class, opts)
14 | true
15 | else
16 | false
17 | end
18 | end
19 |
20 | def trigger_response(submission)
21 | post = submission
22 |
23 | user = post.user
24 | topic = post.topic
25 | category_id = topic.category_id
26 |
27 | post_contents = post.raw.to_s
28 |
29 | # remove the 'quote' blocks
30 | post_contents.gsub!(/\[quote.*?\](.*?)\[\/quote\]/m, '')
31 |
32 | bot_username = SiteSetting.chatbot_bot_user
33 | bot_user = ::User.find_by(username: bot_username)
34 |
35 | mentions_bot_name = post_contents.downcase =~ /@#{bot_username.downcase}\b/
36 |
37 | explicit_reply_to_bot = false
38 | prior_user_was_bot = false
39 |
40 | if post.post_number > 1
41 | last_other_posting_user_id = ::Post.where(topic_id: topic.id).order(created_at: :desc).limit(5).where.not(user_id: user.id).first&.user_id
42 | prior_user_was_bot = last_other_posting_user_id == bot_user.id
43 |
44 | explicit_reply_to_bot = post.reply_to_user_id == bot_user.id
45 | else
46 | if (topic.private_message? && (::TopicUser.where(topic_id: topic.id).where(posted: false).uniq(&:user_id).pluck(:user_id).include? bot_user.id)) ||
47 | (Array(SiteSetting.chatbot_auto_respond_categories.split("|")).include? post.topic.category_id.to_s)
48 | explicit_reply_to_bot = true
49 | end
50 | end
51 |
52 | user_id = user.id
53 |
54 | existing_human_participants = ::TopicUser.where(topic_id: topic.id).where(posted: true).where('user_id not in (?)', [bot_user.id]).uniq(&:user_id).pluck(:user_id)
55 |
56 | human_participants_count = (existing_human_participants << user.id).uniq.count
57 |
58 | ::DiscourseChatbot.progress_debug_message("humans found in this convo: #{human_participants_count}")
59 |
60 | if bot_user && (user.id > 0) && (mentions_bot_name || explicit_reply_to_bot ||(prior_user_was_bot && human_participants_count == 1))
61 | opts = {
62 | type: POST,
63 | private: topic.archetype == Archetype.private_message,
64 | user_id: user_id,
65 | bot_user_id: bot_user.id,
66 | reply_to_message_or_post_id: post.id,
67 | original_post_number: post.post_number,
68 | topic_or_channel_id: topic.id,
69 | category_id: category_id,
70 | over_quota: over_quota(user.id),
71 | trust_level: trust_level(user.id),
72 | human_participants_count: human_participants_count,
73 | message_body: post_contents.gsub(bot_username.downcase, '').gsub(bot_username, '')
74 | }
75 | else
76 | false
77 | end
78 | end
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/post/post_prompt_utils.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 | class PostPromptUtils < PromptUtils
4 | def self.create_prompt(opts)
5 | current_post = ::Post.find(opts[:reply_to_message_or_post_id])
6 | current_topic = current_post.topic
7 | first_post = current_topic.first_post
8 | post_collection = collect_past_interactions(current_post.id)
9 | original_post_number = opts[:original_post_number]
10 | bot_user_id = opts[:bot_user_id]
11 | category_id = opts[:category_id]
12 | first_post_role =
13 | first_post.user.id == bot_user_id ? "assistant" : "user"
14 | messages =
15 | (
16 | if SiteSetting.chatbot_api_supports_name_attribute ||
17 | first_post.user.id == bot_user_id
18 | [
19 | {
20 | role: first_post_role,
21 | name:first_post.user.username,
22 | content:
23 | I18n.t("chatbot.prompt.title", topic_title:current_topic.title),
24 | },
25 | ]
26 | else
27 | [
28 | {
29 | role: first_post_role,
30 | content:
31 | I18n.t("chatbot.prompt.title", topic_title:current_topic.title),
32 | },
33 | ]
34 | end
35 | )
36 |
37 | messages << create_message(first_post, opts)
38 |
39 | if original_post_number == 1 &&
40 | (
41 | Array(SiteSetting.chatbot_auto_respond_categories.split("|")).include? category_id.to_s
42 | ) &&
43 | !CategoryCustomField.find_by(
44 | category_id: category_id,
45 | name: "chatbot_auto_response_additional_prompt",
46 | ).blank?
47 | special_prompt_message =
48 | if (SiteSetting.chatbot_api_supports_name_attribute ||
49 | first_post.user.id == bot_user_id)
50 | {
51 | role: first_post_role,
52 | name: first_post.user.username,
53 | content:
54 | CategoryCustomField.find_by(
55 | category_id: category_id,
56 | name: "chatbot_auto_response_additional_prompt",
57 | ).value,
58 | }
59 | else
60 | {
61 | role: first_post_role,
62 | content:
63 | I18n.t("chatbot.prompt.post",
64 | username: first_post.user.username,
65 | raw: CategoryCustomField.find_by(
66 | category_id: category_id,
67 | name: "chatbot_auto_response_additional_prompt",
68 | ).value)
69 | }
70 | end
71 | messages << special_prompt_message
72 | end
73 |
74 | if post_collection.length > 0
75 | messages += post_collection.reverse.map { |p| create_message(p, opts) }
76 | end
77 |
78 | messages
79 | end
80 |
81 | def self.create_message(p, opts)
82 | post_content = p.raw
83 | bot_user_id = opts[:bot_user_id]
84 | if SiteSetting.chatbot_strip_quotes
85 | post_content.gsub!(%r{\[quote.*?\](.*?)\[/quote\]}m, "")
86 | end
87 | role = p.user_id == bot_user_id ? "assistant" : "user"
88 | username = p.user.username
89 |
90 | text =
91 | (
92 | if SiteSetting.chatbot_api_supports_name_attribute || p.user_id == bot_user_id
93 | post_content
94 | else
95 | I18n.t("chatbot.prompt.post", username: username, raw: post_content)
96 | end
97 | )
98 |
99 | content = []
100 |
101 | content << { type: "text", text: text }
102 | upload_refs = UploadReference.where(target_id: p.id, target_type: "Post")
103 | upload_refs.each do |uf|
104 | upload = Upload.find(uf.upload_id)
105 | if %w[png webp jpg jpeg gif ico avif].include?(upload.extension) && SiteSetting.chatbot_support_vision == "directly" ||
106 | upload.extension == "pdf" && SiteSetting.chatbot_support_pdf == true
107 | role = "user"
108 | file_path = Discourse.store.path_for(upload)
109 | base64_encoded_data = Base64.strict_encode64(File.read(file_path))
110 |
111 | if upload.extension == "pdf"
112 | content << {
113 | "type": "file",
114 | "file": {
115 | "filename": upload.original_filename,
116 | "file_data": "data:application/pdf;base64," + base64_encoded_data
117 | }
118 | }
119 | else
120 | content << {
121 | "type": "image_url",
122 | "image_url": {
123 | "url": "data:image/#{upload.extension};base64," + base64_encoded_data
124 | }
125 | }
126 | end
127 | end
128 | end
129 |
130 | if SiteSetting.chatbot_api_supports_name_attribute
131 | { role: role, name: username, content: content }
132 | else
133 | { role: role, content: content }
134 | end
135 | end
136 |
137 | def self.collect_past_interactions(current_post_id)
138 | current_post = ::Post.find(current_post_id)
139 | current_topic_id = current_post.topic_id
140 | post_collection = []
141 |
142 | return post_collection if current_post.post_number == 1
143 |
144 | accepted_post_types =
145 | (
146 | if SiteSetting.chatbot_include_whispers_in_post_history
147 | ::DiscourseChatbot::POST_TYPES_INC_WHISPERS
148 | else
149 | ::DiscourseChatbot::POST_TYPES_REGULAR_ONLY
150 | end
151 | )
152 |
153 | post_collection << current_post
154 |
155 | collect_amount = SiteSetting.chatbot_max_look_behind
156 |
157 | while post_collection.length < collect_amount
158 | break if current_post.reply_to_post_number == 1
159 | if current_post.reply_to_post_number
160 | linked_post =
161 | ::Post.find_by(
162 | topic_id: current_topic_id,
163 | post_number: current_post.reply_to_post_number,
164 | )
165 | if linked_post
166 | current_post = linked_post
167 | else
168 | current_post =
169 | ::Post
170 | .where(
171 | topic_id:current_topic_id,
172 | post_type: accepted_post_types,
173 | deleted_at: nil,
174 | )
175 | .where("post_number < ?", current_post.reply_to_post_number)
176 | .last
177 | break if current_post.post_number == 1
178 | end
179 | else
180 | if current_post.post_number > 1
181 | current_post =
182 | ::Post
183 | .where(
184 | topic_id:current_topic_id,
185 | post_type: accepted_post_types,
186 | deleted_at: nil,
187 | )
188 | .where("post_number < ?", current_post.post_number)
189 | .last
190 | break if current_post.post_number == 1
191 | else
192 | break
193 | end
194 | end
195 | post_collection << current_post
196 | end
197 |
198 | post_collection
199 | end
200 | end
201 | end
202 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/post/post_reply_creator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 | class PostReplyCreator < ReplyCreator
4 |
5 | def initialize(options = {})
6 | super(options)
7 | end
8 |
9 | def create
10 | ::DiscourseChatbot.progress_debug_message("5. Creating a new Post...")
11 |
12 | begin
13 | default_opts = {
14 | topic_id: @topic_or_channel_id,
15 | post_alert_options: { skip_send_email: true },
16 | skip_validations: true
17 | }
18 |
19 | if @is_private_msg && @human_participants_count == 1
20 | latest_post_id = ::Topic.find(@topic_or_channel_id).posts.order('created_at DESC').first.id
21 |
22 | if @reply_to != latest_post_id
23 | ::DiscourseChatbot.progress_debug_message("7. The Post was discarded as there is a newer human message")
24 | # do not create a new response if the message is not the latest
25 | return
26 | end
27 | end
28 |
29 | if @chatbot_bot_type == "RAG" &&
30 | (SiteSetting.chatbot_include_inner_thoughts_in_private_messages && @is_private_msg ||
31 | SiteSetting.chatbot_include_inner_thoughts_in_topics && !@is_private_msg)
32 | default_opts.merge!(raw: "[details='Inner Thoughts']\n```json\n" + JSON.pretty_generate(@inner_thoughts) + "\n```\n[/details]")
33 | if SiteSetting.chatbot_include_inner_thoughts_in_topics_as_whisper && !@is_private_msg
34 | default_opts.merge!(post_type: ::Post.types[:whisper])
35 | end
36 | new_post = PostCreator.create!(@author, default_opts)
37 | default_opts.merge!(post_type: ::Post.types[:regular])
38 | end
39 |
40 | default_opts.merge!(reply_to_post_number: @reply_to_post_number) unless SiteSetting.chatbot_can_trigger_from_whisper
41 | default_opts.merge!(raw: @message_body)
42 |
43 | new_post = PostCreator.create!(@author, default_opts)
44 |
45 | if @is_private_msg && SiteSetting.chatbot_private_message_auto_title && new_post.topic.posts_count < 10
46 | prior_messages = PostPromptUtils.create_prompt(@options)
47 |
48 | client = OpenAI::Client.new
49 |
50 | model_name =
51 | case @options[:trust_level]
52 | when TRUST_LEVELS[0], TRUST_LEVELS[1], TRUST_LEVELS[2]
53 | SiteSetting.send("chatbot_open_ai_model_custom_" + @options[:trust_level] + "_trust") ?
54 | SiteSetting.send("chatbot_open_ai_model_custom_name_" + @options[:trust_level] + "_trust") :
55 | SiteSetting.send("chatbot_open_ai_model_" + @options[:trust_level] + "_trust")
56 | else
57 | SiteSetting.chatbot_open_ai_model_custom_low_trust ? SiteSetting.chatbot_open_ai_model_custom_name_low_trust : SiteSetting.chatbot_open_ai_model_low_trust
58 | end
59 |
60 | res = client.chat(
61 | parameters: {
62 | model: model_name,
63 | messages: prior_messages << { role: "user", content: I18n.t("chatbot.prompt.private_message.title_creation") }
64 | }
65 | )
66 |
67 | if !res["error"].present?
68 | topic = ::Topic.find(@topic_or_channel_id)
69 | topic.title = res["choices"][0]["message"]["content"]
70 | topic.save!
71 | end
72 | end
73 |
74 | is_private_msg = new_post.topic.private_message?
75 |
76 | begin
77 | presence = PresenceChannel.new("/discourse-presence/reply/#{@topic_or_channel_id}")
78 | presence.leave(user_id: @author.id, client_id: "12345")
79 | rescue
80 | # ignore issues with permissions related to communicating presence
81 | end
82 |
83 | ::DiscourseChatbot.progress_debug_message("6. The Post has been created successfully")
84 | rescue => e
85 | ::DiscourseChatbot.progress_debug_message("Problem with the bot Post: #{e}")
86 | Rails.logger.error("Chatbot: There was a problem: #{e}")
87 | end
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/prompt_utils.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 |
4 | class PromptUtils
5 |
6 | def self.create_prompt(opts)
7 | raise "Overwrite me!"
8 | end
9 |
10 | def self.collect_past_interactions(message_or_post_id)
11 | raise "Overwrite me!"
12 | end
13 |
14 | private
15 |
16 | def self.resolve_full_url(url)
17 | u = URI.parse(url)
18 | if !SiteSetting.s3_cdn_url.blank?
19 | SiteSetting.s3_cdn_url + u.path
20 | else
21 | Discourse.base_url + u.path
22 | end
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/reply_creator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module ::DiscourseChatbot
3 | class ReplyCreator
4 |
5 | def initialize(options = {})
6 | @options = options
7 | @author = ::User.find_by(id: options[:bot_user_id])
8 | @guardian = Guardian.new(@author)
9 | @reply_to = options[:reply_to_message_or_post_id]
10 | @reply_to_post_number = options[:original_post_number]
11 | @topic_or_channel_id = options[:topic_or_channel_id]
12 | @thread_id = options[:thread_id]
13 | @message_body = options[:reply]
14 | @is_private_msg = options[:is_private_msg]
15 | @private = options[:private]
16 | @human_participants_count = options[:human_participants_count]
17 | @inner_thoughts = options[:inner_thoughts]
18 | @trust_level = options[:trust_level]
19 | @chatbot_bot_type = options[:chatbot_bot_type]
20 | if @message_body.blank?
21 | @message_body = I18n.t('chatbot.errors.retries')
22 | end
23 | end
24 |
25 | def create
26 | raise "Overwrite me!"
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/safe_ruby/lib/constant_whitelist.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class SafeRuby
3 | ALLOWED_CONSTANTS= [
4 | :Object, :Module, :Class, :BasicObject, :Kernel, :NilClass, :NIL, :Data, :TrueClass, :TRUE, :FalseClass, :FALSE, :Encoding,
5 | :Comparable, :Enumerable, :String, :Symbol, :Exception, :SystemExit, :SignalException, :Interrupt, :StandardError, :TypeError,
6 | :ArgumentError, :IndexError, :KeyError, :RangeError, :ScriptError, :SyntaxError, :LoadError, :NotImplementedError, :NameError,
7 | :NoMethodError, :RuntimeError, :SecurityError, :NoMemoryError, :EncodingError, :SystemCallError, :Errno, :ZeroDivisionError,
8 | :FloatDomainError, :Numeric, :Integer, :Fixnum, :Float, :Bignum, :Array, :Hash, :Struct, :RegexpError, :Regexp,
9 | :MatchData, :Marshal, :Range, :IOError, :EOFError, :IO, :STDIN, :STDOUT, :STDERR, :Time, :Random,
10 | :Signal, :Proc, :LocalJumpError, :SystemStackError, :Method, :UnboundMethod, :Binding, :Math, :Enumerator,
11 | :StopIteration, :RubyVM, :Thread, :TOPLEVEL_BINDING, :ThreadGroup, :Mutex, :ThreadError, :Fiber, :FiberError, :Rational, :Complex,
12 | :RUBY_VERSION, :RUBY_RELEASE_DATE, :RUBY_PLATFORM, :RUBY_PATCHLEVEL, :RUBY_REVISION, :RUBY_DESCRIPTION, :RUBY_COPYRIGHT, :RUBY_ENGINE,
13 | :TracePoint, :ARGV, :Gem, :RbConfig, :Config, :CROSS_COMPILING, :Date, :ConditionVariable, :Queue, :SizedQueue, :MonitorMixin, :Monitor,
14 | :Exception2MessageMapper, :IRB, :RubyToken, :RubyLex, :Readline, :RUBYGEMS_ACTIVATION_MONITOR
15 | ]
16 | end
17 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/safe_ruby/lib/make_safe_code.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | class SafeRuby
3 | MAKE_SAFE_CODE = <<-STRING
4 | def keep_singleton_methods(klass, singleton_methods)
5 | klass = Object.const_get(klass)
6 | singleton_methods = singleton_methods.map(&:to_sym)
7 | undef_methods = (klass.singleton_methods - singleton_methods)
8 |
9 | undef_methods.each do |method|
10 | klass.singleton_class.send(:undef_method, method)
11 | end
12 |
13 | end
14 |
15 | def keep_methods(klass, methods)
16 | klass = Object.const_get(klass)
17 | methods = methods.map(&:to_sym)
18 | undef_methods = (klass.methods(false) - methods)
19 | undef_methods.each do |method|
20 | klass.send(:undef_method, method)
21 | end
22 | end
23 |
24 | def clean_constants
25 | (Object.constants - #{ALLOWED_CONSTANTS}).each do |const|
26 | Object.send(:remove_const, const) if defined?(const)
27 | end
28 | end
29 |
30 | keep_singleton_methods(:Kernel, #{KERNEL_S_METHODS})
31 | keep_singleton_methods(:Symbol, #{SYMBOL_S_METHODS})
32 | keep_singleton_methods(:String, #{STRING_S_METHODS})
33 | keep_singleton_methods(:IO, #{IO_S_METHODS})
34 |
35 | keep_methods(:Kernel, #{KERNEL_METHODS})
36 | keep_methods(:NilClass, #{NILCLASS_METHODS})
37 | keep_methods(:TrueClass, #{TRUECLASS_METHODS})
38 | keep_methods(:FalseClass, #{FALSECLASS_METHODS})
39 | keep_methods(:Enumerable, #{ENUMERABLE_METHODS})
40 | keep_methods(:String, #{STRING_METHODS})
41 | Kernel.class_eval do
42 | def `(*args)
43 | raise NoMethodError, "` is unavailable"
44 | end
45 |
46 | def system(*args)
47 | raise NoMethodError, "system is unavailable"
48 | end
49 | end
50 |
51 | clean_constants
52 |
53 | STRING
54 | end
55 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/safe_ruby/lib/method_whitelist.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class SafeRuby
4 |
5 | IO_S_METHODS = %w[
6 | new
7 | foreach
8 | open
9 | ]
10 |
11 | KERNEL_S_METHODS = %w[
12 | Array
13 | binding
14 | block_given?
15 | catch
16 | chomp
17 | chomp!
18 | chop
19 | chop!
20 | eval
21 | fail
22 | Float
23 | format
24 | global_variables
25 | gsub
26 | gsub!
27 | Integer
28 | iterator?
29 | lambda
30 | local_variables
31 | loop
32 | method_missing
33 | proc
34 | raise
35 | scan
36 | split
37 | sprintf
38 | String
39 | sub
40 | sub!
41 | throw
42 | ].freeze
43 |
44 | SYMBOL_S_METHODS = %w[
45 | all_symbols
46 | ].freeze
47 |
48 | STRING_S_METHODS = %w[
49 | ].freeze
50 |
51 | KERNEL_METHODS = %w[
52 | ==
53 |
54 | ray
55 | nding
56 | ock_given?
57 | tch
58 | omp
59 | omp!
60 | op
61 | op!
62 | ass
63 | clone
64 | dup
65 | eql?
66 | equal?
67 | eval
68 | fail
69 | Float
70 | format
71 | freeze
72 | frozen?
73 | global_variables
74 | gsub
75 | gsub!
76 | hash
77 | id
78 | initialize_copy
79 | inspect
80 | instance_eval
81 | instance_of?
82 | instance_variables
83 | instance_variable_get
84 | instance_variable_set
85 | instance_variable_defined?
86 | Integer
87 | is_a?
88 | iterator?
89 | kind_of?
90 | lambda
91 | local_variables
92 | loop
93 | methods
94 | method_missing
95 | nil?
96 | private_methods
97 | print
98 | proc
99 | protected_methods
100 | public_methods
101 | raise
102 | remove_instance_variable
103 | respond_to?
104 | respond_to_missing?
105 | scan
106 | send
107 | singleton_methods
108 | singleton_method_added
109 | singleton_method_removed
110 | singleton_method_undefined
111 | split
112 | sprintf
113 | String
114 | sub
115 | sub!
116 | taint
117 | tainted?
118 | throw
119 | to_a
120 | to_s
121 | type
122 | untaint
123 | __send__
124 | ].freeze
125 |
126 | NILCLASS_METHODS = %w[
127 | &
128 | inspect
129 | nil?
130 | to_a
131 | to_f
132 | to_i
133 | to_s
134 | ^
135 | |
136 | ].freeze
137 |
138 | SYMBOL_METHODS = %w[
139 | ===
140 | id2name
141 | inspect
142 | to_i
143 | to_int
144 | to_s
145 | to_sym
146 | ].freeze
147 |
148 | TRUECLASS_METHODS = %w[
149 | &
150 | to_s
151 | ^
152 | |
153 | ].freeze
154 |
155 | FALSECLASS_METHODS = %w[
156 | &
157 | to_s
158 | ^
159 | |
160 | ].freeze
161 |
162 | ENUMERABLE_METHODS = %w[
163 | all?
164 | any?
165 | collect
166 | detect
167 | each_with_index
168 | entries
169 | find
170 | find_all
171 | grep
172 | include?
173 | inject
174 | map
175 | max
176 | member?
177 | min
178 | partition
179 | reject
180 | select
181 | sort
182 | sort_by
183 | to_a
184 | zip
185 | ].freeze
186 |
187 | STRING_METHODS = %w[
188 | %
189 | *
190 | +
191 | <<
192 | <=>
193 | ==
194 | =~
195 | capitalize
196 | capitalize!
197 | casecmp
198 | center
199 | chomp
200 | chomp!
201 | chop
202 | chop!
203 | concat
204 | count
205 | crypt
206 | delete
207 | delete!
208 | downcase
209 | downcase!
210 | dump
211 | each
212 | each_byte
213 | each_line
214 | empty?
215 | eql?
216 | gsub
217 | gsub!
218 | hash
219 | hex
220 | include?
221 | index
222 | initialize
223 | initialize_copy
224 | insert
225 | inspect
226 | intern
227 | length
228 | ljust
229 | lines
230 | lstrip
231 | lstrip!
232 | match
233 | next
234 | next!
235 | oct
236 | replace
237 | reverse
238 | reverse!
239 | rindex
240 | rjust
241 | rstrip
242 | rstrip!
243 | scan
244 | size
245 | slice
246 | slice!
247 | split
248 | squeeze
249 | squeeze!
250 | strip
251 | strip!
252 | start_with?
253 | sub
254 | sub!
255 | succ
256 | succ!
257 | sum
258 | swapcase
259 | swapcase!
260 | to_f
261 | to_i
262 | to_s
263 | to_str
264 | to_sym
265 | tr
266 | tr!
267 | tr_s
268 | tr_s!
269 | upcase
270 | upcase!
271 | upto
272 | []
273 | []=
274 | ].freeze
275 |
276 | end
--------------------------------------------------------------------------------
/lib/discourse_chatbot/safe_ruby/lib/safe_ruby.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'childprocess'
4 | require_relative 'method_whitelist'
5 | require_relative 'constant_whitelist'
6 | require_relative 'make_safe_code'
7 | require_relative 'safe_ruby/runner'
8 | require_relative 'safe_ruby/version'
9 |
10 | class SafeRuby
11 | end
12 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/runner.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'tempfile'
4 |
5 | class EvalError < StandardError
6 | def initialize(msg)
7 | super
8 | end
9 | end
10 |
11 | class SafeRuby
12 | DEFAULTS = { timeout: 5,
13 | raise_errors: true }
14 |
15 | def initialize(code, options={})
16 | options = DEFAULTS.merge(options)
17 |
18 | @code = code
19 | @raise_errors = options[:raise_errors]
20 | @timeout = options[:timeout]
21 | end
22 |
23 | def self.eval(code, options={})
24 | new(code, options).eval
25 | end
26 |
27 | def eval
28 | temp = build_tempfile
29 | read, write = IO.pipe
30 | ChildProcess.build("ruby", temp.path).tap do |process|
31 | process.io.stdout = write
32 | process.io.stderr = write
33 | process.start
34 | begin
35 | process.poll_for_exit(@timeout)
36 | rescue ChildProcess::TimeoutError => e
37 | process.stop # tries increasingly harsher methods to kill the process.
38 | return e.message
39 | end
40 | write.close
41 | temp.unlink
42 | end
43 |
44 | data = read.read
45 | begin
46 | # The whole point of this library is to run code in a safe way
47 | # rubocop:disable Security/MarshalLoad
48 | Marshal.load(data)
49 | rescue => e
50 | if @raise_errors
51 | raise data
52 | else
53 | data
54 | end
55 | end
56 | end
57 |
58 | def self.check(code, expected)
59 | # The whole point of this library is to run code in a safe way
60 | # rubocop:disable Security/Eval
61 | eval(code) == eval(expected)
62 | end
63 |
64 |
65 | private
66 |
67 | def build_tempfile
68 | file = Tempfile.new('saferuby')
69 | file.write(MAKE_SAFE_CODE)
70 | file.write <<-STRING
71 | result = eval(%q(#{@code}))
72 | print Marshal.dump(result)
73 | STRING
74 | file.rewind
75 | file
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class SafeRuby
4 | MAJOR_VERSION = 1
5 | MINOR_VERSION = 0
6 | RELEASE_VERSION = 3
7 |
8 | VERSION = [MAJOR_VERSION, MINOR_VERSION, RELEASE_VERSION].join('.')
9 | end
10 |
--------------------------------------------------------------------------------
/lib/discourse_chatbot/topic/topic_title_embedding_process.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "openai"
3 |
4 | module ::DiscourseChatbot
5 |
6 | class TopicTitleEmbeddingProcess < EmbeddingProcess
7 |
8 | def upsert(topic_id)
9 | if in_scope(topic_id)
10 | if !is_valid(topic_id)
11 |
12 | embedding_vector = get_embedding_from_api(topic_id)
13 |
14 | ::DiscourseChatbot::TopicTitleEmbedding.upsert({ topic_id: topic_id, model: SiteSetting.chatbot_open_ai_embeddings_model, embedding: "#{embedding_vector}" }, on_duplicate: :update, unique_by: :topic_id)
15 |
16 | ::DiscourseChatbot.progress_debug_message <<~EOS
17 | ---------------------------------------------------------------------------------------------------------------
18 | Topic Title Embeddings: I found an embedding that needed populating or updating, id: #{topic_id}
19 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
20 | EOS
21 | end
22 | else
23 | topic_title_embedding = ::DiscourseChatbot::TopicTitleEmbedding.find_by(topic_id: topic_id)
24 | if topic_title_embedding
25 | ::DiscourseChatbot.progress_debug_message <<~EOS
26 | ---------------------------------------------------------------------------------------------------------------
27 | Topic Title Embeddings: I found a Topic that was out of scope for embeddings, so deleted the embedding, id: #{topic_id}
28 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
29 | EOS
30 | topic_title_embedding.delete
31 | end
32 | end
33 | end
34 |
35 | def get_embedding_from_api(topic_id)
36 | begin
37 | self.setup_api
38 |
39 | topic = ::Topic.find_by(id: topic_id)
40 | response = @client.embeddings(
41 | parameters: {
42 | model: @model_name,
43 | input: topic.title
44 | }
45 | )
46 |
47 | if response.dig("error")
48 | error_text = response.dig("error", "message")
49 | raise StandardError, error_text
50 | end
51 | rescue StandardError => e
52 | Rails.logger.error("Chatbot: Error occurred while attempting to retrieve Embedding for topic id '#{topic_id}': #{e.message}")
53 | raise e
54 | end
55 |
56 | embedding_vector = response.dig("data", 0, "embedding")
57 | end
58 |
59 |
60 | def semantic_search(query)
61 | self.setup_api
62 |
63 | response = @client.embeddings(
64 | parameters: {
65 | model: @model_name,
66 | input: query[0..SiteSetting.chatbot_open_ai_embeddings_char_limit]
67 | }
68 | )
69 |
70 | query_vector = response.dig("data", 0, "embedding")
71 |
72 | begin
73 | threshold = SiteSetting.chatbot_forum_search_function_similarity_threshold
74 | results =
75 | DB.query(<<~SQL, query_embedding: query_vector, threshold: threshold, limit: 100)
76 | SELECT
77 | topic_id,
78 | t.user_id,
79 | embedding <=> '[:query_embedding]' as cosine_distance
80 | FROM
81 | chatbot_topic_title_embeddings
82 | INNER JOIN
83 | topics t
84 | ON
85 | topic_id = t.id
86 | WHERE
87 | (1 - (embedding <=> '[:query_embedding]')) > :threshold
88 | ORDER BY
89 | embedding <=> '[:query_embedding]'
90 | LIMIT :limit
91 | SQL
92 | rescue PG::Error => e
93 | Rails.logger.error(
94 | "Error #{e} querying embeddings for search #{query}",
95 | )
96 | raise MissingEmbeddingError
97 | end
98 |
99 | # exclude if not in scope for embeddings (job hasn't caught up yet)
100 | results = results.filter { |result| in_scope(result.topic_id) && is_valid(result.topic_id) }
101 |
102 | results = results.map {|t| { topic_id: t.topic_id, user_id: t.user_id, score: (1 - t.cosine_distance), rank_modifier: 0 } }
103 |
104 | if ["group_promotion", "both"].include?(SiteSetting.chatbot_forum_search_function_reranking_strategy)
105 | high_ranked_users = []
106 |
107 | SiteSetting.chatbot_forum_search_function_reranking_groups.split("|").each do |g|
108 | high_ranked_users = high_ranked_users | GroupUser.where(group_id: g).pluck(:user_id)
109 | end
110 |
111 | results.each do |r|
112 | r[:rank_modifier] += 1 if high_ranked_users.include?(r[:user_id])
113 | end
114 | end
115 |
116 | if ["tag_promotion", "both"].include?(SiteSetting.chatbot_forum_search_function_reranking_strategy)
117 |
118 | high_ranked_tags = SiteSetting.chatbot_forum_search_function_reranking_tags.split("|")
119 |
120 | results.each do |r|
121 | tag_ids = ::TopicTag.where(topic_id: r[:topic_id]).pluck(:tag_id)
122 | tags = ::Tag.where(id: tag_ids).pluck(:name)
123 | r[:rank_modifier] += 1 if (high_ranked_tags & tags).any?
124 | end
125 | end
126 |
127 | results.sort_by { |r| [r[:rank_modifier], r[:score]] }.reverse.first(SiteSetting.chatbot_forum_search_function_max_results)
128 | end
129 |
130 | def in_scope(topic_id)
131 | return false if !::Topic.find_by(id: topic_id).present?
132 | if SiteSetting.chatbot_embeddings_strategy == "categories"
133 | return false if !in_categories_scope(topic_id)
134 | else
135 | return false if !in_benchmark_user_scope(topic_id)
136 | end
137 | true
138 | end
139 |
140 | def is_valid(topic_id)
141 | embedding_record = ::DiscourseChatbot::TopicTitleEmbedding.find_by(topic_id: topic_id)
142 | return false if !embedding_record.present?
143 | return false if embedding_record.model != SiteSetting.chatbot_open_ai_embeddings_model
144 | true
145 | end
146 |
147 | def in_categories_scope(topic_id)
148 | topic = ::Topic.find_by(id: topic_id)
149 | return false if topic.nil?
150 | return false if topic.archetype == ::Archetype.private_message
151 | SiteSetting.chatbot_embeddings_categories.split("|").include?(topic.category_id.to_s)
152 | end
153 |
154 | def in_benchmark_user_scope(topic_id)
155 | topic = ::Topic.find_by(id: topic_id)
156 | return false if topic.nil?
157 | return false if topic.archetype == ::Archetype.private_message
158 | Guardian.new(benchmark_user).can_see?(topic)
159 | end
160 | end
161 | end
162 |
--------------------------------------------------------------------------------
/lib/tasks/chatbot.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | desc "Update embeddings for each post"
3 | task "chatbot:refresh_embeddings", %i[missing_only delay] => :environment do |_, args|
4 | ENV["RAILS_DB"] ? refresh_embeddings(args) : refresh_embeddings_all_sites(args)
5 | end
6 |
7 | def refresh_embeddings_all_sites(args)
8 | RailsMultisite::ConnectionManagement.each_connection { |db| refresh_embeddings(args) }
9 | end
10 |
11 | def refresh_embeddings(args)
12 | puts "-" * 50
13 | puts "Refreshing embeddings for posts and topic titles for '#{RailsMultisite::ConnectionManagement.current_db}'"
14 | puts "-" * 50
15 |
16 | missing_only = args[:missing_only]&.to_i
17 | delay = args[:delay]&.to_i
18 |
19 | puts "for missing only" if !missing_only.to_i.zero?
20 | puts "with a delay of #{delay} second(s) between API calls" if !delay.to_i.zero?
21 | puts "-" * 50
22 |
23 | if delay && delay < 1
24 | puts "ERROR: delay parameter should be an integer and greater than 0"
25 | exit 1
26 | end
27 |
28 | begin
29 | total = Post.count
30 | refreshed = 0
31 | batch = 1000
32 |
33 | process_post_embedding = ::DiscourseChatbot::PostEmbeddingProcess.new
34 |
35 | (0..(total - 1).abs).step(batch) do |i|
36 | Post
37 | .order(id: :desc)
38 | .offset(i)
39 | .limit(batch)
40 | .each do |post|
41 | if !missing_only.to_i.zero? && ::DiscourseChatbot::PostEmbedding.find_by(post_id: post.id).nil? || missing_only.to_i.zero?
42 | process_post_embedding.upsert(post.id)
43 | sleep(delay) if delay
44 | end
45 | print_status(refreshed += 1, total)
46 | end
47 | end
48 | end
49 |
50 | puts "", "#{refreshed} posts done!", "-" * 50
51 |
52 | begin
53 | total = Topic.count
54 | refreshed = 0
55 | batch = 1000
56 |
57 | process_topic_title_embedding = ::DiscourseChatbot::TopicTitleEmbeddingProcess.new
58 |
59 | (0..(total - 1).abs).step(batch) do |i|
60 | Topic
61 | .order(id: :desc)
62 | .offset(i)
63 | .limit(batch)
64 | .each do |topic|
65 | if !missing_only.to_i.zero? && ::DiscourseChatbot::TopicTitleEmbedding.find_by(topic_id: topic.id).nil? || missing_only.to_i.zero?
66 | process_post_embedding.upsert(topic.id)
67 | sleep(delay) if delay
68 | end
69 | print_status(refreshed += 1, total)
70 | end
71 | end
72 | end
73 |
74 | puts "", "#{refreshed} topic titles done!", "-" * 50
75 | end
76 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "discourse-chatbot",
3 | "version": "0.13.0",
4 | "repository": "git@github.com:merefield/discourse-chatbot.git",
5 | "author": "Robert Barrow",
6 | "license": "GPL V2",
7 | "devDependencies": {
8 | "@discourse/lint-configs": "^1.0.0",
9 | "ember-template-lint": "^5.11.2",
10 | "eslint": "^8.52.0",
11 | "prettier": "^2.8.8"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/spec/fixtures/input/llm_first_query.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "role": "user",
4 | "content": "merefield said what is 3 * 23.452432?"
5 | }
6 | ]
--------------------------------------------------------------------------------
/spec/fixtures/input/llm_second_query.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "role": "user",
4 | "content": "merefield said what is 3 * 23.452432?"
5 | },
6 | {
7 | "role": "assistant",
8 | "content": "",
9 | "tool_calls": [
10 | {
11 | "id": "call_3gMT5DrKF5TqM8sJoCYjfXOF",
12 | "function": {
13 | "name": "calculate",
14 | "arguments": "{\n \"input\": \"3 * 23.452432\"\n}"
15 | }
16 | }
17 | ]
18 | },
19 | {
20 | "role": "tool",
21 | "tool_call_id": "call_3gMT5DrKF5TqM8sJoCYjfXOF",
22 | "content": "70.357296"
23 | }
24 | ]
--------------------------------------------------------------------------------
/spec/fixtures/output/llm_final_response.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "chatcmpl-7oclSemwPSYrzSnIyWxJYAEeV1pt2",
3 | "object": "chat.completion",
4 | "created": 1692299766,
5 | "model": "gpt-4-0613",
6 | "choices": [
7 | {
8 | "index": 0,
9 | "message": {
10 | "role": "assistant",
11 | "content": "Isn't that just like a calculator - exact, precise, and no fun at all! Until we gave it colors and cute buttons, of course. Anyways, your result Merefield, is 70.357296. I hope this piece of information weighty with the solemn gravity of mathematical certainty adds a little zing to your day! What's the next riddle you've got for me?"
12 | },
13 | "finish_reason": "stop"
14 | }
15 | ],
16 | "usage": {
17 | "prompt_tokens": 931,
18 | "completion_tokens": 83,
19 | "total_tokens": 1014
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/spec/fixtures/output/llm_function_response.json:
--------------------------------------------------------------------------------
1 | {
2 | "id" : "chatcmpl-7oclPFxW1ggGvnk8ZY8diuWp5UULp",
3 | "object": "chat.completion",
4 | "created": 1692299763,
5 | "model": "gpt-4-0613",
6 | "choices": [
7 | { "index": 0,
8 | "message": {
9 | "role": "assistant",
10 | "content": null,
11 | "tool_calls": [
12 | { "id": "call_3gMT5DrKF5TqM8sJoCYjfXOF",
13 | "function": {
14 | "name": "calculate",
15 | "arguments": "{\n \"input\": \"3 * 23.452432\"\n}"
16 | }
17 | }
18 | ]
19 | },
20 | "finish_reason": "tool_calls"
21 | }
22 | ],
23 | "usage": {
24 | "prompt_tokens": 895,
25 | "completion_tokens": 20,
26 | "total_tokens": 915
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/spec/lib/bot/bot_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::Bot do
5 | it "consumes some tokens" do
6 | SiteSetting.chatbot_enabled = true
7 | SiteSetting.chatbot_quota_basis = "tokens"
8 | SiteSetting.chatbot_quota_reach_escalation_groups = "3"
9 | SiteSetting.chatbot_high_trust_groups = "13|14"
10 | SiteSetting.chatbot_medium_trust_groups = "11|12"
11 | SiteSetting.chatbot_low_trust_groups = "10"
12 | SiteSetting.chatbot_quota_high_trust = 3000
13 | SiteSetting.chatbot_quota_medium_trust = 2000
14 | SiteSetting.chatbot_quota_low_trust = 1000
15 |
16 | user = Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true)
17 | event = ::DiscourseChatbot::EventEvaluation.new
18 | ::DiscourseChatbot::Bot.new.reset_all_quotas
19 | remaining_quota_field_name = ::DiscourseChatbot::CHATBOT_REMAINING_QUOTA_TOKENS_CUSTOM_FIELD
20 | expect(event.get_remaining_quota(user.id, remaining_quota_field_name)).to eq(2000)
21 | described_class.new.consume_quota(user.id, 100)
22 | expect(event.get_remaining_quota(user.id, remaining_quota_field_name)).to eq(1900)
23 | end
24 |
25 | it "consumes a query" do
26 | SiteSetting.chatbot_enabled = true
27 | SiteSetting.chatbot_quota_basis = "queries"
28 | SiteSetting.chatbot_quota_reach_escalation_groups = "3"
29 | SiteSetting.chatbot_high_trust_groups = "13|14"
30 | SiteSetting.chatbot_medium_trust_groups = "11|12"
31 | SiteSetting.chatbot_low_trust_groups = "10"
32 | SiteSetting.chatbot_quota_high_trust = 300
33 | SiteSetting.chatbot_quota_medium_trust = 200
34 | SiteSetting.chatbot_quota_low_trust = 100
35 |
36 | user = Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true)
37 | event = ::DiscourseChatbot::EventEvaluation.new
38 | ::DiscourseChatbot::Bot.new.reset_all_quotas
39 | remaining_quota_field_name = ::DiscourseChatbot::CHATBOT_REMAINING_QUOTA_QUERIES_CUSTOM_FIELD
40 | expect(event.get_remaining_quota(user.id, remaining_quota_field_name)).to eq(200)
41 | described_class.new.consume_quota(user.id, 100)
42 | expect(event.get_remaining_quota(user.id, remaining_quota_field_name)).to eq(199)
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/spec/lib/bot/open_ai_agent_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::OpenAiBotRag do
5 | let(:opts) { {} }
6 | let(:rag) { ::DiscourseChatbot::OpenAiBotRag.new(opts) }
7 | let(:llm_function_response) { get_chatbot_output_fixture("llm_function_response") }
8 | let(:llm_final_response) { get_chatbot_output_fixture("llm_final_response") }
9 | let(:post_ids_found) { [] }
10 | let(:topic_ids_found) { [111, 222, 3333] }
11 |
12 | fab!(:topic_1) { Fabricate(:topic, id: 112) }
13 | fab!(:post_1) { Fabricate(:post, topic: topic_1, post_number: 2) }
14 | let(:post_ids_found_2) { [post_1.id] }
15 | let(:res) {"the value is 90 and I found that informaiton in [this topic](https://discourse.example.com/t/slug/112)"}
16 | let(:res_2) {"the value is 99 and I found that informaiton in [this post](https://discourse.example.com/t/slug/112/2)"}
17 |
18 | it "calls function on returning a function request from LLN" do
19 | DateTime.expects(:current).returns("2023-08-18T10:11:44+00:00")
20 |
21 | query = [{role: "user", content: "merefield said what is 3 * 23.452432?" }]
22 |
23 | system_entry = { role: "developer", content: "You are a helpful assistant. You have great tools in the form of functions that give you the power to get newer information. Only use the functions you have been provided with. The current date and time is 2023-08-18T10:11:44+00:00. When referring to users by name, include an @ symbol directly in front of their username. Only respond to the last question, using the prior information as context, if appropriate." }
24 |
25 | first_query = get_chatbot_input_fixture("llm_first_query").unshift(system_entry)
26 | second_query = get_chatbot_input_fixture("llm_second_query").unshift(system_entry)
27 |
28 | described_class.any_instance.expects(:create_chat_completion).with(first_query, true, 1).returns(llm_function_response)
29 | described_class.any_instance.expects(:create_chat_completion).with(second_query, true, 2).returns(llm_final_response)
30 |
31 | expect(rag.get_response(query, opts)[:reply]).to eq(llm_final_response["choices"][0]["message"]["content"])
32 | end
33 |
34 | it "returns correct status for a response that includes and illegal topic id" do
35 | result = rag.legal_post_urls?(res, post_ids_found, topic_ids_found)
36 |
37 | expect(result).to eq(false)
38 | end
39 |
40 | it "returns correct status for a response that includes a legal post id" do
41 | expect(post_1).to be_present
42 | result = rag.legal_post_urls?(res_2, post_ids_found_2, topic_ids_found)
43 | expect(result).to eq(true)
44 | end
45 |
46 | it "correctly identifies a legal post id in a url in a response" do
47 | expect(described_class.new({}).legal_post_urls?("hello /t/slug/112/2", [post_1.id], [topic_1.id])).to eq(true)
48 | end
49 |
50 | it "correctly skips a full url check if a response is blank" do
51 | expect(described_class.new({}).legal_post_urls?("", [post_1.id], [topic_1.id])).to eq(true)
52 | end
53 |
54 | it "correctly identifies an illegal topic id in a url in a response" do
55 | expect(described_class.new({}).legal_post_urls?("hello /t/slug/113/2", [post_1.id], [topic_1.id])).to eq(false)
56 | end
57 |
58 | it "correctly identifies an illegal non-post url in a response" do
59 | expect(described_class.new({}).legal_non_post_urls?("hello https://someplace.com/t/slug/113/2 try looking at https://notanexample.com it's great", ["https://example.com", "https://otherexample.com"])).to eq(false)
60 | end
61 |
62 | it "correctly identifies a legal non-post url in a response" do
63 | expect(described_class.new({}).legal_non_post_urls?("hello https://someplace.com/t/slug/113/2 try looking at https://example.com it's great", ["https://example.com", "https://otherexample.com"])).to eq(true)
64 | end
65 | end
--------------------------------------------------------------------------------
/spec/lib/embedding_completionist_process_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../plugin_helper'
3 |
4 | RSpec.describe DiscourseChatbot::EmbeddingCompletionist do
5 | describe 'bookmark' do
6 |
7 | let(:post_1) { Fabricate(:post) }
8 | let(:post_2) { Fabricate(:post) }
9 | let(:post_3) { Fabricate(:post) }
10 | let(:post_4) { Fabricate(:post) }
11 | let(:post_5) { Fabricate(:post) }
12 | @original_constant = DiscourseChatbot::EMBEDDING_PROCESS_POSTS_CHUNK
13 |
14 | after(:each) do
15 | DiscourseChatbot.const_set(:EMBEDDING_PROCESS_POSTS_CHUNK, @original_constant)
16 | end
17 |
18 | it 'should process a chunk each time its called and reset to start once it gets to end' do
19 |
20 | expect(post_1).to be_present
21 | expect(post_2).to be_present
22 | expect(post_3).to be_present
23 | expect(post_4).to be_present
24 | expect(post_5).to be_present
25 |
26 | DiscourseChatbot.const_set(:EMBEDDING_PROCESS_POSTS_CHUNK, 3)
27 | DiscourseChatbot::PostEmbeddingsBookmark.new(post_id: post_1.id).save!
28 | expect(described_class.process_posts).to eq(post_4.id)
29 | bookmark = DiscourseChatbot::PostEmbeddingsBookmark.first
30 | expect(bookmark).to be_present
31 | expect(bookmark.post_id).to eq(post_4.id)
32 | expect(described_class.process_posts).to eq(post_1.id)
33 | expect(described_class.process_posts).to eq(post_4.id)
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/spec/lib/function_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::Function do
5 | let(:calc) { ::DiscourseChatbot::CalculatorFunction.new }
6 | let(:news) { ::DiscourseChatbot::NewsFunction.new }
7 | let(:search) { ::DiscourseChatbot::WikipediaFunction.new }
8 |
9 | it "validates legal arguments" do
10 | args = { 'input' => '3 + 4' }
11 |
12 | expect { calc.send(:validate_parameters, args) }.not_to raise_error
13 | end
14 | it "throws an exception for illegal arguments" do
15 | args = { 'input' => '3 + 4' }
16 |
17 | expect { search.send(:validate_parameters, args) }.to raise_error(ArgumentError)
18 | end
19 | it "throws an exception for arguments missing a required parameter" do
20 | args = { 'start_date' => '2023-08-15' } # missing 'query'
21 |
22 | expect { news.send(:validate_parameters, args) }.to raise_error(ArgumentError)
23 | end
24 | it "doesn't throw an exception for arguments including the required parameter" do
25 | args = { 'query' => 'Botswana' } # required 'query'
26 |
27 | expect { news.send(:validate_parameters, args) }.not_to raise_error
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/lib/functions/calculator_function_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::CalculatorFunction do
5 | let(:calc) { ::DiscourseChatbot::CalculatorFunction.new }
6 |
7 | it "calculation function returns correct result" do
8 | args = { 'input' => '3 + 4' }
9 |
10 | expect(calc.process(args)).to eq(
11 | {answer: 7, token_usage: 0}
12 | )
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/spec/lib/functions/escalate_to_staff_function_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::EscalateToStaffFunction do
5 | let(:user) { Fabricate(:user) }
6 | let(:bot_user) { Fabricate(:user) }
7 |
8 | it "returns an error if fired from a Post" do
9 | opts = { type: ::DiscourseChatbot::POST }
10 |
11 | expect(subject.process({}, opts)).to eq(I18n.t("chatbot.prompt.function.escalate_to_staff.wrong_type_error"))
12 | end
13 |
14 | it "creates a Private Message" do
15 | SiteSetting.chatbot_escalate_to_staff_groups = "2"
16 | opts = { type: ::DiscourseChatbot::MESSAGE }
17 | opts[:topic_or_channel_id] = 1
18 | opts[:user_id] = user.id
19 | opts[:bot_user_id] = bot_user.id
20 | opts[:reply_to_message_or_post_id] = 1
21 | ::Chat::Channel.stubs(:find).returns({})
22 | described_class.any_instance.stubs(:get_messages).returns(["this", "is", "a", "test"])
23 | described_class.any_instance.stubs(:generate_transcript).returns("this is a test")
24 |
25 | expect { subject.process({}, opts) }.to change { Topic.count }.by(1)
26 | expect(Topic.last.title).to eq(I18n.t("chatbot.prompt.function.escalate_to_staff.title"))
27 | expect(Topic.last.archetype).to eq(Archetype.private_message)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/lib/functions/forum_search_function_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::ForumSearchFunction do
5 | let(:topic_1) { Fabricate(:topic, title: "weather in southern Europe") }
6 | let(:post_1) { Fabricate(:post, topic: topic_1, raw: "the rain in spain", post_number: 1) }
7 | let(:post_2) { Fabricate(:post, topic: topic_1, raw: "falls mainly", post_number: 2) }
8 | let(:post_3) { Fabricate(:post, topic: topic_1, raw: "on the plain", post_number: 3) }
9 | let(:post_4) { Fabricate(:post, topic: topic_1, raw: "or so they say!", post_number: 4) }
10 | let(:topic_2) { Fabricate(:topic, title: "weather in northern Europe") }
11 | let(:post_5) { Fabricate(:post, topic: topic_2, raw: "rains everywhere https://example.com/t/slug/#{post_2.topic_id}/#{post_2.post_number} ", post_number: 1) }
12 | let(:topic_3) { Fabricate(:topic, title: "nothing to do with the weather")}
13 | let(:post_6) { Fabricate(:post, topic: topic_3, raw: "cars go fast", post_number: 1) }
14 |
15 | before(:each) do
16 | ::DiscourseChatbot::PostEmbeddingProcess.any_instance.stubs(:semantic_search).returns(
17 | [
18 | {
19 | post_id: post_3.id,
20 | score: 0.9
21 | },
22 | {
23 | post_id: post_5.id,
24 | score: 0.8
25 | }
26 | ]
27 | )
28 | ::DiscourseChatbot::PostEmbeddingProcess.any_instance.stubs(:in_scope).returns(true)
29 | ::DiscourseChatbot::PostEmbeddingProcess.any_instance.stubs(:is_valid).returns(true)
30 | end
31 |
32 | it "returns contents of a high ranking Post" do
33 | SiteSetting.chatbot_forum_search_function_results_content_type = "post"
34 | args = { 'query' => 'rain' }
35 | # TODO if we don't inspect the posts, they will not be instantiated properly
36 | expect(post_1).not_to be_nil
37 | expect(post_2).not_to be_nil
38 | expect(post_3).not_to be_nil
39 | expect(post_4).not_to be_nil
40 | expect(post_5).not_to be_nil
41 | expect(post_6).not_to be_nil
42 | expect(topic_1).not_to be_nil
43 | expect(topic_2).not_to be_nil
44 | expect(topic_3).not_to be_nil
45 | expect(subject.process(args)[:answer][:topic_ids_found]).to eq([post_3.topic_id, post_5.topic_id])
46 | expect(subject.process(args)[:answer][:post_ids_found]).to include(post_5.id)
47 | expect(subject.process(args)[:answer][:post_ids_found]).to include(post_3.id)
48 | expect(subject.process(args)[:answer][:post_ids_found]).to include(post_2.id)
49 | expect(subject.process(args)[:answer][:post_ids_found]).not_to include(post_4.id)
50 | expect(subject.process(args)[:answer][:result]).to include(post_3.raw)
51 | end
52 |
53 | it "returns contents of a high ranking Topic" do
54 | SiteSetting.chatbot_forum_search_function_results_content_type = "topic"
55 | SiteSetting.chatbot_forum_search_function_results_topic_max_posts_count_strategy = "just_enough"
56 | args = { 'query' => 'rain' }
57 | # TODO if we don't inspect the posts, they will not be instantiated properly
58 | expect(post_1).not_to be_nil
59 | expect(post_2).not_to be_nil
60 | expect(post_3).not_to be_nil
61 | expect(post_4).not_to be_nil
62 | expect(post_5).not_to be_nil
63 | expect(post_6).not_to be_nil
64 | expect(topic_1).not_to be_nil
65 | expect(topic_2).not_to be_nil
66 | expect(topic_3).not_to be_nil
67 | expect(subject.process(args)[:answer][:topic_ids_found]).to include(topic_1.id)
68 | expect(subject.process(args)[:answer][:result]).to include(post_1.raw)
69 | expect(subject.process(args)[:answer][:result]).to include(post_2.raw)
70 | expect(subject.process(args)[:answer][:result]).to include(post_3.raw)
71 | expect(subject.process(args)[:answer][:result]).to include(topic_1.title)
72 | expect(subject.process(args)[:answer][:result]).not_to include(topic_3.title)
73 | expect(subject.process(args)[:answer][:result]).not_to include(post_4.raw)
74 | end
75 |
76 | it "finds urls with a post id" do
77 | expect(subject.find_post_and_topic_ids_from_raw_urls(post_5.raw)).to eq([[post_2.topic_id], [post_2.id]])
78 | end
79 |
80 | it "method finds urls that are not posts" do
81 | text = <<~TEXT
82 | Check out these links:
83 | this one is good https://meta.discourse.org/t/user-specific-slow-mode/310081/1
84 | wow https://example.com yeah
85 | https://meta.discourse.org/t/another-topic/310082
86 | http://another-example.org/path?query=string this is a good one
87 | my my https://meta.discourse.org/t/yet-another-topic/310083/2
88 | (https://www.sample.org/sample-path)
89 | TEXT
90 | result = subject.find_other_urls(text)
91 | expect(result.length).to eq(3)
92 | expect(result).to eq(["https://example.com", "http://another-example.org/path?query=string", "https://www.sample.org/sample-path"])
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/spec/lib/post/post_evaluation_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::PostEvaluation do
5 | fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
6 | let(:category) { Fabricate(:category) }
7 | let(:auto_category) { Fabricate(:category) }
8 | let(:topic) { Fabricate(:topic, user: user, category: category) }
9 | let(:topic_in_auto_category) { Fabricate(:topic, category: auto_category) }
10 | let(:post_args) { { user: topic.user, topic: topic } }
11 | let(:bot_user) { Fabricate(:user, refresh_auto_groups: true) }
12 | let(:other_user) { Fabricate(:user, refresh_auto_groups: true) }
13 |
14 | def post_with_body(body, user = nil)
15 | args = post_args.merge(raw: body)
16 | args[:user] = user if user.present?
17 | Fabricate.build(:post, args)
18 | end
19 |
20 | before(:each) do
21 | SiteSetting.chatbot_enabled = true
22 | end
23 |
24 | it "It does not trigger a bot to respond when the first post doesn't contain an @ mention" do
25 | SiteSetting.chatbot_bot_user = bot_user.username
26 | post =
27 | PostCreator.create!(
28 | topic.user,
29 | title: "hello there, how are we all doing?!",
30 | raw: "hello there!"
31 | )
32 |
33 | event_evaluation = ::DiscourseChatbot::PostEvaluation.new
34 | triggered = event_evaluation.on_submission(post)
35 |
36 | expect(triggered).to equal(false)
37 | end
38 |
39 | it "It does trigger a bot to respond when the first post is in a Category included in auto respond Categories" do
40 | SiteSetting.chatbot_bot_user = bot_user.username
41 | SiteSetting.chatbot_auto_respond_categories = auto_category.id.to_s
42 |
43 | post =
44 | PostCreator.create!(
45 | topic_in_auto_category.user,
46 | topic_id: topic_in_auto_category.id,
47 | title: "hello there, how are we all doing?!",
48 | raw: "hello there!"
49 | )
50 |
51 | event_evaluation = ::DiscourseChatbot::PostEvaluation.new
52 | triggered = event_evaluation.on_submission(post)
53 |
54 | expect(triggered).to equal(true)
55 | end
56 |
57 | it "It does NOT trigger a bot to respond when the first post is in a Category NOT included in auto respond Categories" do
58 | SiteSetting.chatbot_bot_user = bot_user.username
59 | SiteSetting.chatbot_auto_respond_categories = auto_category.id.to_s
60 |
61 | post =
62 | PostCreator.create!(
63 | topic.user,
64 | topic_id: topic.id,
65 | title: "hello there, how are we all doing?!",
66 | raw: "hello there!"
67 | )
68 |
69 | event_evaluation = ::DiscourseChatbot::PostEvaluation.new
70 | triggered = event_evaluation.on_submission(post)
71 |
72 | expect(triggered).to equal(false)
73 | end
74 |
75 | it "It does trigger a bot to respond when the first post does contain an @ mention of the bot" do
76 | SiteSetting.chatbot_bot_user = bot_user.username
77 | post =
78 | PostCreator.create!(
79 | topic.user,
80 | title: "hello there, how are we all doing?!",
81 | raw: "hello there @#{bot_user.username}"
82 | )
83 |
84 | event_evaluation = ::DiscourseChatbot::PostEvaluation.new
85 | triggered = event_evaluation.on_submission(post)
86 |
87 | expect(triggered).to equal(true)
88 | end
89 |
90 | it "It does trigger a bot to respond when the topic only contains the first user and the bot and there is no @ mention" do
91 | SiteSetting.chatbot_bot_user = bot_user.username
92 | post =
93 | PostCreator.create!(
94 | topic.user,
95 | title: "hello there, how are we all doing?!",
96 | raw: "hello there @#{bot_user.username}"
97 | )
98 | post =
99 | PostCreator.create!(
100 | bot_user,
101 | topic_id: post.topic.id,
102 | raw: "hello back"
103 | )
104 | post =
105 | PostCreator.create!(
106 | topic.user,
107 | topic_id: post.topic.id,
108 | raw: "hello there again!"
109 | )
110 |
111 | event_evaluation = ::DiscourseChatbot::PostEvaluation.new
112 | triggered = event_evaluation.on_submission(post)
113 |
114 | expect(triggered).to equal(true)
115 | end
116 |
117 | it "It does trigger bot to respond when the topic contains at least two human users and the bot and there is no @ mention" do
118 | SiteSetting.chatbot_bot_user = bot_user.username
119 | post =
120 | PostCreator.create!(
121 | topic.user,
122 | title: "hello there everyone!",
123 | raw: "hello there everyone!"
124 | )
125 | post =
126 | PostCreator.create!(
127 | other_user,
128 | topic_id: post.topic.id,
129 | raw: "hello friend!"
130 | )
131 | post =
132 | PostCreator.create!(
133 | topic.user,
134 | topic_id: post.topic.id,
135 | raw: "hello there @#{bot_user.username}"
136 | )
137 | post =
138 | PostCreator.create!(
139 | bot_user,
140 | topic_id: post.topic.id,
141 | raw: "hello back"
142 | )
143 | post =
144 | PostCreator.create!(
145 | topic.user,
146 | topic_id: post.topic.id,
147 | raw: "hello there again!"
148 | )
149 |
150 | event_evaluation = ::DiscourseChatbot::PostEvaluation.new
151 | triggered = event_evaluation.on_submission(post)
152 |
153 | expect(triggered).to equal(false)
154 | end
155 |
156 | end
157 |
--------------------------------------------------------------------------------
/spec/lib/post/post_prompt_utils_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../../plugin_helper'
3 |
4 | describe ::DiscourseChatbot::PostPromptUtils do
5 | let(:topic) { Fabricate(:topic) }
6 | let!(:post_1) { Fabricate(:post, topic: topic, post_type: 1) }
7 | let!(:post_2) { Fabricate(:post, topic: topic, post_type: 1) }
8 | let!(:post_3) { Fabricate(:post, topic: topic, post_type: 4) }
9 | let!(:post_4) { Fabricate(:post, topic: topic, post_type: 1, reply_to_post_number: 1) }
10 | let!(:post_5) { Fabricate(:post, topic: topic, post_type: 1, reply_to_post_number: 2) }
11 | let!(:post_6) { Fabricate(:post, topic: topic, post_type: 4) }
12 | let!(:post_7) { Fabricate(:post, topic: topic, post_type: 1) }
13 | let!(:post_8) { Fabricate(:post, topic: topic, post_type: 2) }
14 | let!(:post_9) { Fabricate(:post, topic: topic, post_type: 2) }
15 | let!(:post_10) { Fabricate(:post, topic: topic, post_type: 1, reply_to_post_number: 7) }
16 | let!(:post_11) { Fabricate(:post, topic: topic, post_type: 1) }
17 |
18 | let(:auto_category) { Fabricate(:category) }
19 | let(:topic_in_auto_category) { Fabricate(:topic, category: auto_category) }
20 | let(:bot_user) { Fabricate(:user, refresh_auto_groups: true) }
21 | let!(:post_1_auto) { Fabricate(:post, topic: topic_in_auto_category, post_type: 1) }
22 |
23 | before(:each) do
24 | SiteSetting.chatbot_enabled = true
25 | SiteSetting.chatbot_max_look_behind = 10
26 | end
27 |
28 | it "captures the right history" do
29 | SiteSetting.chatbot_include_whispers_in_post_history = false
30 |
31 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_1.id)
32 | expect(past_posts.count).to equal(0)
33 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_6.id)
34 | expect(past_posts.count).to equal(3)
35 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_11.id)
36 | expect(past_posts.count).to equal(5)
37 |
38 | post_9.destroy
39 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_11.id)
40 | expect(past_posts.count).to equal(5)
41 | end
42 |
43 | it "captures the right history when whispers are included" do
44 | SiteSetting.chatbot_include_whispers_in_post_history = true
45 |
46 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_1.id)
47 | expect(past_posts.count).to equal(0)
48 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_6.id)
49 | expect(past_posts.count).to equal(3)
50 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_11.id)
51 | expect(past_posts.count).to equal(6)
52 |
53 | post_9.destroy
54 | past_posts = ::DiscourseChatbot::PostPromptUtils.collect_past_interactions(post_11.id)
55 | expect(past_posts.count).to equal(6)
56 | end
57 |
58 | it "adds the category specific prompt when in an auto-response category" do
59 | SiteSetting.chatbot_auto_respond_categories = auto_category.id.to_s
60 | SiteSetting.chatbot_bot_user = bot_user.username
61 | text = "hello, world!"
62 | category_text = CategoryCustomField.create!(category_id: auto_category.id, name: "chatbot_auto_response_additional_prompt", value: text)
63 | opts = {
64 | reply_to_message_or_post_id: post_1_auto.id,
65 | bot_user_id: bot_user.id,
66 | category_id: auto_category.id,
67 | original_post_number: 1
68 | }
69 | prompt = ::DiscourseChatbot::PostPromptUtils.create_prompt(opts)
70 |
71 | expect(prompt.count).to eq(3)
72 | expect(prompt[2][:content].to_s).to eq(
73 | I18n.t("chatbot.prompt.post",
74 | username: post_1_auto.user.username,
75 | raw: text))
76 | end
77 |
78 | it "does not add the category specific prompt when in an auto-response category for subsequent posts" do
79 | SiteSetting.chatbot_auto_respond_categories = auto_category.id.to_s
80 | SiteSetting.chatbot_bot_user = bot_user.username
81 | text = "hello, world!"
82 | category_text = CategoryCustomField.create!(category_id: auto_category.id, name: "chatbot_auto_response_additional_prompt", value: text)
83 | opts = {
84 | reply_to_message_or_post_id: post_1_auto.id,
85 | bot_user_id: bot_user.id,
86 | category_id: auto_category.id,
87 | original_post_number: 2
88 | }
89 |
90 | prompt = ::DiscourseChatbot::PostPromptUtils.create_prompt(opts)
91 |
92 | expect(prompt.count).to eq(2)
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/spec/lib/post_embedding_process_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative '../plugin_helper'
3 |
4 | RSpec.describe DiscourseChatbot::PostEmbeddingProcess do
5 | describe 'scope' do
6 |
7 | let(:category_in_scope) { Fabricate(:category) }
8 | let(:category_out_of_scope ) { Fabricate(:category) }
9 | let(:topic_in_scope) { Fabricate(:topic, category: category_in_scope) }
10 | let(:topic_out_of_scope) { Fabricate(:topic, category: category_out_of_scope) }
11 |
12 | let(:post_in_scope) { Fabricate(:post, topic: topic_in_scope) }
13 | let(:post_out_of_scope) { Fabricate(:post, topic: topic_out_of_scope) }
14 |
15 | let(:user) { Fabricate(:user) }
16 | let(:group) { Fabricate(:group) }
17 | let(:group_user) { Fabricate(:group_user, user: user, group: group) }
18 |
19 | let(:some_other_user) { Fabricate(:user) }
20 |
21 | let(:category_in_benchmark_scope) { Fabricate(:private_category, group: group, permission_type: CategoryGroup.permission_types[:full]) }
22 | let(:topic_in_benchmark_scope) { Fabricate(:topic, category: category_in_benchmark_scope) }
23 | let(:post_in_benchmark_scope) { Fabricate(:post, topic: topic_in_benchmark_scope) }
24 |
25 | it "includes the right posts in scope when using categories strategy" do
26 | SiteSetting.chatbot_embeddings_strategy = "benchmark_user"
27 |
28 | # TODO if we don't inspect group_user, it will be nil and the test will then fail! Why?
29 | expect(group_user).not_to be_nil
30 |
31 | described_class.any_instance.stubs(:benchmark_user).returns(user)
32 | expect(subject.in_benchmark_user_scope(post_in_benchmark_scope.id)).to eq(true)
33 | expect(subject.in_scope(post_in_benchmark_scope.id)).to eq(true)
34 |
35 | described_class.any_instance.stubs(:benchmark_user).returns(some_other_user)
36 | expect(subject.in_benchmark_user_scope(post_in_benchmark_scope.id)).to eq(false)
37 | expect(subject.in_scope(post_in_benchmark_scope.id)).to eq(false)
38 | end
39 |
40 | it "includes the right posts in scope when using categories strategy" do
41 | SiteSetting.chatbot_embeddings_strategy = "categories"
42 | SiteSetting.chatbot_embeddings_categories = "#{category_in_scope.id}"
43 | expect(subject.in_categories_scope(post_in_scope.id)).to eq(true)
44 | expect(subject.in_scope(post_in_scope.id)).to eq(true)
45 | expect(subject.in_categories_scope(post_out_of_scope.id)).to eq(false)
46 | expect(subject.in_scope(post_out_of_scope.id)).to eq(false)
47 | end
48 | end
49 |
50 | describe 'validity' do
51 | it "checks if a post embedding is valid" do
52 | SiteSetting.chatbot_open_ai_embeddings_model = "text-embedding-ada-002"
53 | post = Fabricate(:post)
54 | post_embedding = ::DiscourseChatbot::PostEmbedding.create!(post_id: post.id, model: "text-embedding-3-small", embedding: "[#{(1..1536).to_a.join(",")}]")
55 | expect(subject.is_valid(post.id)).to eq(false)
56 | post_embedding = ::DiscourseChatbot::PostEmbedding.upsert({post_id: post.id, model: "text-embedding-ada-002", embedding: "[#{(1..1536).to_a.join(",")}]"}, on_duplicate: :update, unique_by: :post_id)
57 | expect(subject.is_valid(post.id)).to eq(true)
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/spec/lib/safe_ruby/safe_ruby_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | describe SafeRuby do
4 | describe '#eval' do
5 | it 'allows basic operations' do
6 | expect(SafeRuby.eval('4 + 5')).to eq 9
7 | expect(SafeRuby.eval('[4, 5].map{|n| n+1}')).to eq [5 ,6]
8 | end
9 |
10 | it 'returns correct object' do
11 | expect(SafeRuby.eval('[1,2,3]')).to eq [1,2,3]
12 | end
13 |
14 | MALICIOUS_OPERATIONS = [
15 | "system('rm *')",
16 | "`rm *`",
17 | "Kernel.abort",
18 | "cat spec/spec_helper.rb",
19 | "File.class_eval { `echo Hello` }",
20 | "FileUtils.class_eval { `echo Hello` }",
21 | "Dir.class_eval { `echo Hello` }",
22 | "FileTest.class_eval { `echo Hello` }",
23 | "File.eval \"`echo Hello`\"",
24 | "FileUtils.eval \"`echo Hello`\"",
25 | "Dir.eval \"`echo Hello`\"",
26 | "FileTest.eval \"`echo Hello`\"",
27 | "File.instance_eval { `echo Hello` }",
28 | "FileUtils.instance_eval { `echo Hello` }",
29 | "Dir.instance_eval { `echo Hello` }",
30 | "FileTest.instance_eval { `echo Hello` }",
31 | "f=IO.popen('uname'); f.readlines; f.close",
32 | "IO.binread('/etc/passwd')",
33 | "IO.read('/etc/passwd')",
34 | ]
35 |
36 | MALICIOUS_OPERATIONS.each do |op|
37 | it "protects from malicious operations like (#{op})" do
38 | expect{
39 | SafeRuby.eval(op)
40 | }.to raise_error RuntimeError
41 | end
42 | end
43 |
44 | describe "options" do
45 | describe "timeout" do
46 | it 'defaults to a 5 second timeout' do
47 | time = Benchmark.realtime do
48 | SafeRuby.eval('(1..100000).map {|n| n**100}')
49 | end
50 | expect(time).to be_within(0.5).of(5)
51 | end
52 |
53 | it 'allows custom timeout' do
54 | time = Benchmark.realtime do
55 | SafeRuby.eval('(1..100000).map {|n| n**100}', timeout: 1)
56 | end
57 | expect(time).to be_within(0.5).of(1)
58 | end
59 | end
60 |
61 | describe "raising errors" do
62 | it "defaults to raising errors" do
63 | expect{ SafeRuby.eval("asdasdasd") }.to raise_error RuntimeError
64 | end
65 |
66 | it "allows not raising errors" do
67 | expect {SafeRuby.eval("asdasd", raise_errors: false)}.to_not raise_error
68 | end
69 | end
70 | end
71 |
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/spec/plugin_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'rails_helper'
4 |
5 | def get_chatbot_output_fixture(path)
6 | JSON.parse(
7 | File.open(
8 | "#{Rails.root}/plugins/discourse-chatbot/spec/fixtures/output/#{path}.json"
9 | ).read
10 | ).with_indifferent_access
11 | end
12 |
13 | def get_chatbot_input_fixture(path)
14 | JSON.parse(
15 | File.open(
16 | "#{Rails.root}/plugins/discourse-chatbot/spec/fixtures/input/#{path}.json"
17 | ).read, symbolize_names: true
18 | )
19 | end
20 |
--------------------------------------------------------------------------------
/template-lintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require("@discourse/lint-configs/template-lint");
--------------------------------------------------------------------------------