├── .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 = "![#{description}|690x460](#{short_url})" 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 = "![#{description}|690x460](#{short_url})" 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"); --------------------------------------------------------------------------------