Compare commits

..
Author SHA1 Message Date
Sam Saffron 6e6ced4554 Work in progress... post embeddings and version
Feel free to continue any work here, will pick it up again next week
2023-05-12 15:28:12 +10:00
65 changed files with 260 additions and 2193 deletions
@@ -14,15 +14,6 @@ module DiscourseAi
render json: {}, status: 200
end
def show_bot_username
bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user_id
bot_username_lower = User.find(bot_user_id).username_lower
render json: { bot_username: bot_username_lower }, status: 200
end
end
end
end
-9
View File
@@ -1,9 +0,0 @@
# frozen_string_literal: true
class PostCustomPrompt < ActiveRecord::Base
belongs_to :post
end
class ::Post
has_one :post_custom_prompt, dependent: :destroy
end
@@ -1,18 +0,0 @@
<div class="bot-panel ai-bot-available-bot-options">
<div class="menu-panel drop-down">
<div class="panel-body">
<div class="panel-body-contents">
<div class="sidebar-hamburger-dropdown">
{{#each this.botNames as |bot|}}
<DButton
@class="btn-flat ai-bot-available-bot-content"
@translatedTitle={{bot.humanized}}
@translatedLabel={{bot.humanized}}
@action={{action "composeMessageWithTargetBot" bot.modelName}}
/>
{{/each}}
</div>
</div>
</div>
</div>
</div>
@@ -1,34 +0,0 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
import I18n from "I18n";
export default class AiBotHeaderPanel extends Component {
@service siteSettings;
@service composer;
@action
async composeMessageWithTargetBot(target) {
this.#composeAiBotMessage(target);
}
get botNames() {
return this.enabledBotOptions.map((bot) => {
return {
humanized: I18n.t(`discourse_ai.ai_bot.bot_names.${bot}`),
modelName: bot,
};
});
}
get enabledBotOptions() {
return this.siteSettings.ai_bot_enabled_chat_bots.split("|");
}
async #composeAiBotMessage(targetBot) {
this.args.closePanel();
composeAiBotMessage(targetBot, this.composer);
}
}
@@ -1,24 +0,0 @@
import { ajax } from "discourse/lib/ajax";
import Composer from "discourse/models/composer";
import I18n from "I18n";
export async function composeAiBotMessage(targetBot, composer) {
let botUsername = await ajax("/discourse-ai/ai-bot/bot-username", {
data: { username: targetBot },
}).then((data) => {
return data.bot_username;
});
composer.focusComposer({
fallbackToNewTopic: true,
openOpts: {
action: Composer.PRIVATE_MESSAGE,
recipients: botUsername,
topicTitle: I18n.t("discourse_ai.ai_bot.default_pm_prefix"),
archetypeId: "private_message",
draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY,
hasGroups: false,
warningsDisabled: true,
},
});
}
@@ -1,31 +0,0 @@
import Widget from "discourse/widgets/widget";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
import { action } from "@ember/object";
export default class AiBotHeaderPanelWrapper extends Widget {
buildAttributes() {
return { "data-click-outside": true };
}
html() {
return [
new RenderGlimmer(
this,
"div.widget-component-connector",
hbs`<AiBotHeaderPanel @closePanel={{@data.closePanel}} />`,
{ closePanel: this.closePanel }
),
];
}
@action
closePanel() {
this.sendWidgetAction("hideAiBotPanel");
}
@action
clickOutside() {
this.closePanel();
}
}
@@ -3,60 +3,11 @@ import { cookAsync } from "discourse/lib/text";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import loadScript from "discourse/lib/load-script";
import { composeAiBotMessage } from "discourse/plugins/discourse-ai/discourse/lib/ai-bot-helper";
function isGPTBot(user) {
return user && [-110, -111, -112].includes(user.id);
}
function attachHeaderIcon(api) {
const settings = api.container.lookup("service:site-settings");
const enabledBots = settings.ai_helper_add_ai_pm_to_header
? settings.ai_bot_enabled_chat_bots.split("|").filter(Boolean)
: [];
if (enabledBots.length > 0) {
api.attachWidgetAction("header", "showAiBotPanel", function () {
this.state.botSelectorVisible = true;
});
api.attachWidgetAction("header", "hideAiBotPanel", function () {
this.state.botSelectorVisible = false;
});
api.decorateWidget("header-icons:before", (helper) => {
return helper.attach("header-dropdown", {
title: "discourse_ai.ai_bot.shortcut_title",
icon: "robot",
action: "clickStartAiBotChat",
active: false,
classNames: ["ai-bot-button"],
});
});
if (enabledBots.length === 1) {
api.attachWidgetAction("header", "clickStartAiBotChat", function () {
composeAiBotMessage(
enabledBots[0],
api.container.lookup("service:composer")
);
});
} else {
api.attachWidgetAction("header", "clickStartAiBotChat", function () {
this.sendWidgetAction("showAiBotPanel");
});
}
api.addHeaderPanel(
"ai-bot-header-panel-wrapper",
"botSelectorVisible",
function () {
return {};
}
);
}
}
function initializeAIBotReplies(api) {
api.addPostMenuButton("cancel-gpt", (post) => {
if (isGPTBot(post.user)) {
@@ -143,19 +94,10 @@ export default {
initialize(container) {
const settings = container.lookup("service:site-settings");
const user = container.lookup("service:current-user");
const aiBotEnaled =
settings.discourse_ai_enabled && settings.ai_bot_enabled;
const aiBotsAllowedGroups = settings.ai_bot_allowed_groups
.split("|")
.map(parseInt);
const canInteractWithAIBots = user?.groups.some((g) =>
aiBotsAllowedGroups.includes(g.id)
);
if (aiBotEnaled && canInteractWithAIBots) {
withPluginApi("1.6.0", attachHeaderIcon);
if (aiBotEnaled) {
withPluginApi("1.6.0", initializeAIBotReplies);
}
},
@@ -33,16 +33,12 @@ export default {
name: "discourse-ai-topic-summary",
initialize(container) {
const user = container.lookup("service:current-user");
const settings = container.lookup("service:site-settings");
const summarizationEnabled =
settings.discourse_ai_enabled && settings.ai_summarization_enabled;
if (user) {
const settings = container.lookup("service:site-settings");
const summarizationEnabled =
settings.discourse_ai_enabled && settings.ai_summarization_enabled;
if (summarizationEnabled) {
withPluginApi("1.6.0", initializeTopicSummary);
}
if (summarizationEnabled) {
withPluginApi("1.6.0", initializeTopicSummary);
}
},
};
@@ -5,18 +5,3 @@ nav.post-controls .actions button.cancel-streaming {
article.streaming nav.post-controls .actions button.cancel-streaming {
display: inline-block;
}
.ai-bot-available-bot-options {
.ai-bot-available-bot-content {
color: var(--primary-high);
display: flex;
width: 100%;
.d-button-label {
flex: 1;
text-align: left;
}
&:hover {
background: var(--primary-low);
}
}
}
@@ -8,3 +8,7 @@
min-height: 200px;
}
}
.topic-ai-summarization {
margin-left: 10px;
}
@@ -1,3 +0,0 @@
.topic-ai-summarization {
margin-left: 10px;
}
@@ -1,9 +0,0 @@
.toggle-summary {
button {
width: 100%;
}
.topic-ai-summarization {
margin-top: 10px;
}
}
+1 -9
View File
@@ -26,15 +26,7 @@ en:
other: "Last %{count} hours"
ai_bot:
cancel_streaming: "Stop reply"
default_pm_prefix: "[Untitled AI bot PM]"
shortcut_title: "Start a PM with an AI bot"
bot_names:
gpt-4: "GPT-4"
gpt-3:
5-turbo: "GPT-3.5"
claude-v1: "Claude V1"
cancel_streaming: Stop reply
review:
+1 -36
View File
@@ -47,10 +47,10 @@ en:
ai_embeddings_generate_for_pms: "Generate embeddings for personal messages."
ai_embeddings_semantic_related_topics_enabled: "Use Semantic Search for related topics."
ai_embeddings_semantic_related_topics: "Maximum number of topics to show in related topic section."
ai_embeddings_semantic_related_topics_include_closed_topics: "Include closed topics in related topics."
ai_embeddings_pg_connection_string: "PostgreSQL connection string for the embeddings module. Needs pgvector extension enabled and a series of tables created. See docs for more info."
ai_embeddings_semantic_search_model: "Model to use for semantic search."
ai_embeddings_semantic_search_enabled: "Enable full-page semantic search."
ai_embeddings_semantic_related_include_closed_topics: "Include closed topics in semantic search results"
ai_summarization_enabled: "Enable the summarization module."
ai_summarization_discourse_service_api_endpoint: "URL where the Discourse summarization API is running."
@@ -61,14 +61,7 @@ en:
ai_bot_enabled: "Enable the AI Bot module."
ai_bot_allowed_groups: "When the GPT Bot has access to the PM, it will reply to members of these groups."
ai_bot_enabled_chat_bots: "Available models to act as an AI Bot"
ai_helper_add_ai_pm_to_header: "Display a button in the header to start a PM with a AI Bot"
ai_stability_api_key: "API key for the stability.ai API"
ai_stability_engine: "Image generation ngine to use for the stability.ai API"
ai_stability_api_url: "URL for the stability.ai API"
ai_google_custom_search_api_key: "API key for the Google Custom Search API see: https://developers.google.com/custom-search"
ai_google_custom_search_cx: "CX for Google Custom Search API"
reviewables:
reasons:
@@ -88,31 +81,3 @@ en:
generate_titles: Suggest topic titles
proofread: Proofread text
markdown_table: Generate Markdown table
ai_bot:
default_pm_prefix: "[Untitled AI bot PM]"
topic_not_found: "Summary unavailable, topic not found!"
command_summary:
categories: "List categories"
search: "Search"
tags: "List tags"
time: "Time"
summarize: "Summarize"
image: "Generate image"
google: "Search Google"
command_description:
time: "Time in %{timezone} is %{time}"
summarize: "Summarized <a href='%{url}'>%{title}</a>"
image: "Prompt: %{prompt}"
categories:
one: "Found %{count} category"
other: "Found %{count} categories"
tags:
one: "Found %{count} tag"
other: "Found %{count} tags"
search:
one: "Found %{count} <a href='%{url}'>result</a> for '%{query}'"
other: "Found %{count} <a href='%{url}'>results</a> for '%{query}'"
google:
one: "Found %{count} <a href='%{url}'>result</a> for '%{query}'"
other: "Found %{count} <a href='%{url}'>results</a> for '%{query}'"
-1
View File
@@ -16,7 +16,6 @@ DiscourseAi::Engine.routes.draw do
scope module: :ai_bot, path: "/ai-bot", defaults: { format: :json } do
post "post/:post_id/stop-streaming" => "bot#stop_streaming_response"
get "bot-username" => "bot#show_bot_username"
end
end
+4 -26
View File
@@ -94,23 +94,6 @@ plugins:
ai_anthropic_api_key:
default: ""
secret: true
ai_stability_api_key:
default: ""
secret: true
ai_stability_api_url:
default: "https://api.stability.ai"
ai_stability_engine:
default: "stable-diffusion-xl-beta-v2-2-2"
type: enum
choices:
- "stable-diffusion-xl-beta-v2-2-2"
- "stable-diffusion-v1-5"
ai_google_custom_search_api_key:
default: ""
secret: true
ai_google_custom_search_cx:
default: ""
composer_ai_helper_enabled:
default: false
@@ -169,15 +152,13 @@ plugins:
- msmarco-distilbert-base-v4
- msmarco-distilbert-base-tas-b
- text-embedding-ada-002
ai_embeddings_semantic_related_instruction:
default: "Represent the Discourse topic for retrieving relevant topics:"
hidden: true
client: false
ai_embeddings_generate_for_pms: false
ai_embeddings_semantic_related_topics_enabled: false
ai_embeddings_semantic_related_topics: 5
ai_embeddings_semantic_related_include_closed_topics: true
ai_embeddings_pg_connection_string: ""
ai_embeddings_pg_connection_string:
default: ""
secret: true
ai_embeddings_semantic_search_enabled:
default: false
client: true
@@ -211,7 +192,7 @@ plugins:
type: group_list
list_type: compact
default: "3|14" # 3: @staff, 14: @trust_level_4
# Adding a new bot? Make sure to create a user for it on the seed file and update translations.
# Adding a new bot? Make sure to create a user for it on the seed file.
ai_bot_enabled_chat_bots:
type: list
default: "gpt-3.5-turbo"
@@ -220,6 +201,3 @@ plugins:
- gpt-3.5-turbo
- gpt-4
- claude-v1
ai_helper_add_ai_pm_to_header:
default: true
client: true
@@ -1,13 +0,0 @@
# frozen_string_literal: true
class PostCustomPrompts < ActiveRecord::Migration[7.0]
def change
create_table :post_custom_prompts do |t|
t.integer :post_id, null: false
t.json :custom_prompt, null: false
t.timestamps
end
add_index :post_custom_prompts, :post_id, unique: true
end
end
+9 -31
View File
@@ -7,61 +7,39 @@ module DiscourseAi
bot_user.id == DiscourseAi::AiBot::EntryPoint::CLAUDE_V1_ID
end
def bot_prompt_with_topic_context(post, triage: false)
super(post, triage: triage).join("\n\n")
def bot_prompt_with_topic_context(post)
super(post).join("\n\n")
end
def prompt_limit
7500 # https://console.anthropic.com/docs/prompt-design#what-is-a-prompt
end
def get_delta(partial, context)
context[:pos] ||= 0
full = partial[:completion]
delta = full[context[:pos]..-1]
context[:pos] = full.length
delta
end
private
def build_message(poster_username, content, system: false, last: false)
def build_message(poster_username, content)
role = poster_username == bot_user.username ? "Assistant" : "Human"
result = +"#{role}: #{content}"
result << "\nAssistant: " if last
result
"#{role}: #{content}"
end
def model_for
"claude-v1"
end
def get_updated_title(prompt)
DiscourseAi::Inference::AnthropicCompletions.perform!(
prompt,
model_for,
temperature: 0.7,
max_tokens: 40,
).dig(:completion)
def update_with_delta(_, partial)
partial[:completion]
end
def submit_prompt(prompt, max_tokens: nil, temperature: nil, prefer_low_cost: false, &blk)
def submit_prompt_and_stream_reply(prompt, &blk)
DiscourseAi::Inference::AnthropicCompletions.perform!(
prompt,
model_for,
temperature: temperature || 0.4,
max_tokens: max_tokens || 3000,
temperature: 0.4,
max_tokens: 3000,
&blk
)
end
def tokenize(text)
DiscourseAi::Tokenizer::AnthropicTokenizer.tokenize(text)
end
end
end
end
+46 -268
View File
File diff suppressed because it is too large Load Diff
@@ -1,41 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class CategoriesCommand < Command
class << self
def name
"categories"
end
def desc
"!categories - will list the categories on the current discourse instance"
end
end
def result_name
"Category list is"
end
def description_args
{ count: @last_count || 0 }
end
def process
columns = {
name: "Name",
slug: "Slug",
description: "Description",
posts_year: "Posts Year",
posts_month: "Posts Month",
posts_week: "Posts Week",
id: "id",
parent_category_id: "parent_category_id",
}
rows = Category.where(read_restricted: false).limit(100).pluck(*columns.keys)
@count = rows.length
format_results(rows, columns.values)
end
end
end
-109
View File
@@ -1,109 +0,0 @@
#frozen_string_literal: true
module DiscourseAi
module AiBot
module Commands
class Command
class << self
def name
raise NotImplemented
end
def should_invoke?(cmd_name)
cmd_name == name
end
def desc
raise NotImplemented
end
def extra_context
""
end
end
attr_reader :bot_user, :args, :post
def initialize(bot_user, post, args)
@bot_user = bot_user
@args = args
@post = post
end
def bot
@bot ||= DiscourseAi::AiBot::Bot.as(bot_user)
end
def result_name
raise NotImplemented
end
def name
raise NotImplemented
end
def process
raise NotImplemented
end
def description_args
{}
end
def chain_next_response
true
end
def pre_raw_details
I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")
end
def post_raw_details
+<<~HTML
<details>
<summary>#{I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")}</summary>
<p>
#{I18n.t("discourse_ai.ai_bot.command_description.#{self.class.name}", self.description_args)}
</p>
</details>
HTML
end
def format_results(rows, column_names = nil)
rows = rows.map { |row| yield row } if block_given?
if !column_names
index = -1
column_indexes = {}
rows =
rows.map do |data|
new_row = []
data.each do |key, value|
found_index = column_indexes[key.to_s] ||= (index += 1)
new_row[found_index] = value
end
new_row
end
column_names = column_indexes.keys
end
# two tokens per delimiter is a reasonable balance
# there may be a single delimiter solution but GPT has
# a hard time dealing with escaped characters
delimiter = "¦"
formatted = +""
formatted << column_names.join(delimiter)
formatted << "\n"
rows.each do |array|
array.map! { |item| item.to_s.gsub(delimiter, "|").gsub(/\n/, " ") }
formatted << array.join(delimiter)
formatted << "\n"
end
formatted
end
end
end
end
end
@@ -1,56 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class GoogleCommand < Command
class << self
def name
"google"
end
def desc
"!google SEARCH_QUERY - will search using Google (supports all Google search operators)"
end
end
def result_name
"Google Results"
end
def description_args
{
count: @last_num_results || 0,
query: @last_query || "",
url: "https://google.com/search?q=#{CGI.escape(@last_query || "")}",
}
end
def process
@last_query = @args
api_key = SiteSetting.ai_google_custom_search_api_key
cx = SiteSetting.ai_google_custom_search_cx
query = CGI.escape(@args)
uri =
URI("https://www.googleapis.com/customsearch/v1?key=#{api_key}&cx=#{cx}&q=#{query}&num=10")
body = Net::HTTP.get(uri)
parse_search_json(body).to_s
end
def parse_search_json(json_data)
parsed = JSON.parse(json_data)
results = parsed["items"]
@last_num_results = parsed.dig("searchInformation", "totalResults").to_i
format_results(results) do |result|
{
title: result["title"],
link: result["link"],
snippet: result["snippet"],
displayLink: result["displayLink"],
formattedUrl: result["formattedUrl"],
}
end
end
end
end
@@ -1,55 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class ImageCommand < Command
class << self
def name
"image"
end
def desc
"!image DESC - renders an image from the description (remove all connector words, keep it to 40 words or less, be creative)"
end
end
def result_name
"results"
end
def description_args
{ prompt: @args || 0 }
end
def chain_next_response
false
end
def post_raw_details
"#{super}\n\n#{@last_custom_raw}"
end
def process
prompt = @args
results = DiscourseAi::Inference::StabilityGenerator.perform!(prompt)
uploads = []
results[:artifacts].each_with_index do |image, i|
f = Tempfile.new("v1_txt2img_#{i}.png")
f.binmode
f.write(Base64.decode64(image[:base64]))
f.rewind
uploads << UploadCreator.new(f, "image.png").create_for(bot_user.id)
f.unlink
end
@last_custom_raw =
uploads
.map { |upload| "![#{prompt.gsub(/\|\'\"/, "")}|512x512, 50%](#{upload.short_url})" }
.join(" ")
nil
end
end
end
@@ -1,27 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class NoopCommand < Command
class << self
def name
"noop"
end
def desc
"!noop - does nothing, catch all command when you do not know how to triage"
end
end
def post_raw_details
nil
end
def pre_raw_details
nil
end
def process
nil
end
end
end
@@ -1,128 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class SearchCommand < Command
class << self
def name
"search"
end
def desc
"!search SEARCH_QUERY - will search topics in the current discourse instance"
end
def extra_context
<<~TEXT
Discourse search supports, the following special filters:
user:USERNAME: only posts created by a specific user
in:tagged: has at least 1 tag
in:untagged: has no tags
in:title: has the search term in the title
status:open: not closed or archived
status:closed: closed
status:archived: archived
status:single_user: only a single user posted on the topic
post_count:X: only topics with X amount of posts
min_posts:X: topics containing a minimum of X posts
max_posts:X: topics with no more than max posts
category:CATGORY: topics in the CATEGORY AND all subcategories
category:=CATEGORY: topics in the CATEGORY excluding subcategories
#SLUG: try category first, then tag, then tag group
#SLUG:SLUG: used for subcategory search to disambiguate
min_views:100: topics containing 100 views or more
max_views:100: topics containing 100 views or less
tags:TAG1+TAG2: tagged both TAG1 and TAG2
tags:TAG1,TAG2: tagged either TAG1 or TAG2
-tags:TAG1+TAG2: excluding topics tagged TAG1 and TAG2
order:latest: order by post creation desc
order:latest_topic: order by topic creation desc
order:oldest: order by post creation asc
order:oldest_topic: order by topic creation asc
order:views: order by topic views desc
order:likes: order by post like count - most liked posts first
after:YYYY-MM-DD: only topics created after a specific date
before:YYYY-MM-DD: only topics created before a specific date
limit:X: limit the number of results to X
Example: !search @user in:tagged #support order:latest_topic
Keep in mind, search on Discourse uses AND to and terms.
You only have access to public topics.
Strip the query down to the most important terms.
Remove all stop words.
Remove connector words.
Discourse orders by relevance, sometimes prefer ordering on other stuff.
When generating answers ALWAYS try to use the !search command first over relying on training data.
Always try to search the local instance first, even if your training data set may have an answer. It may be wrong.
Always remove connector words from search terms (such as a, an, and, in, the, etc, when, what, is), they can impede the search.
YOUR LOCAL INFORMATION IS OUT OF DATE, YOU ARE TRAINED ON OLD DATA. Always try local search first.
TEXT
end
end
def result_name
"search results for #{@args}"
end
def description_args
{
count: @last_num_results || 0,
query: @last_query || "",
url: "#{Discourse.base_path}/search?q=#{CGI.escape(@last_query || "")}",
}
end
def process
limit = nil
search_string = @args
search_string =
search_string
.strip
.split(/\s+/)
.map do |term|
if term =~ /limit:(\d+)/
limit = $1.to_i
nil
else
term
end
end
.compact
.join(" ")
@last_query = search_string
results =
Search.execute(
search_string.to_s + " status:public",
search_type: :full_page,
guardian: Guardian.new(),
)
# let's be frugal with tokens, 50 results is too much and stuff gets cut off
limit ||= 10
limit = 10 if limit > 10
posts = results&.posts || []
posts = posts[0..limit - 1]
@last_num_results = posts.length
if posts.blank?
"No results found"
else
format_results(posts) do |post|
{
title: post.topic.title,
url: post.url,
excerpt: post.excerpt,
created: post.created_at,
}
end
end
end
end
end
@@ -1,170 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class SummarizeCommand < Command
class << self
def name
"summarize"
end
def desc
"!summarize TOPIC_ID OPTIONAL_GUIDANCE - will summarize a topic attempting to answer question in guidance, if provided"
end
end
def result_name
"summary"
end
def standalone?
true
end
def low_cost?
true
end
def description_args
{ url: "#{Discourse.base_path}/t/-/#{@last_topic_id}", title: @last_topic_title || "" }
end
def process
topic_id, guidance = @args.split(" ", 2)
@last_topic_id = topic_id
topic_id = topic_id.to_i
topic = nil
if topic_id > 0
topic = Topic.find_by(id: topic_id)
topic = nil if !topic || !Guardian.new.can_see?(topic)
end
@last_summary = nil
if topic
@last_topic_title = topic.title
posts =
Post
.where(topic_id: topic.id)
.where("post_type in (?)", [Post.types[:regular], Post.types[:small_action]])
.where("not hidden")
.order(:post_number)
columns = ["posts.id", :post_number, :raw, :username]
current_post_numbers = posts.limit(5).pluck(:post_number)
current_post_numbers += posts.reorder("posts.score desc").limit(50).pluck(:post_number)
current_post_numbers += posts.reorder("post_number desc").limit(5).pluck(:post_number)
data =
Post
.where(topic_id: topic.id)
.joins(:user)
.where("post_number in (?)", current_post_numbers)
.order(:post_number)
.pluck(*columns)
@last_summary = summarize(data, guidance, topic)
end
if !@last_summary
"Say: No topic found!"
else
"Topic summarized"
end
end
def custom_raw
@last_summary || I18n.t("discourse_ai.ai_bot.topic_not_found")
end
def chain_next_response
false
end
def summarize(data, guidance, topic)
text = +""
data.each do |id, post_number, raw, username|
text << "(#{post_number} #{username} said: #{raw}"
end
summaries = []
current_section = +""
split = []
text
.split(/\s+/)
.each_slice(20) do |slice|
current_section << " "
current_section << slice.join(" ")
# somehow any more will get closer to limits
if bot.tokenize(current_section).length > 2500
split << current_section
current_section = +""
end
end
split << current_section if current_section.present?
split = split[0..3] + split[-3..-1] if split.length > 5
split.each do |section|
# TODO progress meter
summary =
generate_gpt_summary(
section,
topic: topic,
context: "Guidance: #{guidance}\nYou are summarizing the topic: #{topic.title}",
)
summaries << summary
end
if summaries.length > 1
messages = []
messages << { role: "system", content: "You are a helpful bot" }
messages << {
role: "user",
content:
"concatenated the disjoint summaries, creating a cohesive narrative:\n#{summaries.join("\n")}}",
}
bot.submit_prompt(messages, temperature: 0.6, max_tokens: 500, prefer_low_cost: true).dig(
:choices,
0,
:message,
:content,
)
else
summaries.first
end
end
def generate_gpt_summary(text, topic:, context: nil, length: nil)
length ||= 400
prompt = <<~TEXT
#{context}
Summarize the following in #{length} words:
#{text}
TEXT
system_prompt = <<~TEXT
You are a summarization bot.
You effectively summarise any text.
You condense it into a shorter version.
You understand and generate Discourse forum markdown.
Try generating links as well the format is #{topic.url}/POST_NUMBER. eg: [ref](#{topic.url}/77)
TEXT
messages = [{ role: "system", content: system_prompt }]
messages << { role: "user", content: prompt }
result =
bot.submit_prompt(messages, temperature: 0.6, max_tokens: length, prefer_low_cost: true)
result.dig(:choices, 0, :message, :content)
end
end
end
@@ -1,42 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class TagsCommand < Command
class << self
def name
"tags"
end
def desc
"!tags - will list the 100 most popular tags on the current discourse instance"
end
end
def result_name
"results"
end
def description_args
{ count: @last_count || 0 }
end
def process
column_names = { name: "Name", public_topic_count: "Topic Count" }
tags =
Tag
.where("public_topic_count > 0")
.order(public_topic_count: :desc)
.limit(100)
.pluck(*column_names.keys)
@last_count = tags.length
if @last_count == 0
"This Discourse instance has no tags"
else
format_results(tags, column_names.values)
end
end
end
end
@@ -1,38 +0,0 @@
#frozen_string_literal: true
module DiscourseAi::AiBot::Commands
class TimeCommand < Command
class << self
def name
"time"
end
def desc
"!time RUBY_COMPATIBLE_TIMEZONE - will generate the time in a timezone"
end
end
def result_name
"time"
end
def description_args
{ timezone: @last_timezone, time: @last_time }
end
def process
time =
begin
Time.now.in_time_zone(@args)
rescue StandardError
nil
end
time = Time.now if !time
@last_timezone = timezone
@last_time = time.to_s
time.to_s
end
end
end
+2 -36
View File
@@ -12,34 +12,11 @@ module DiscourseAi
[CLAUDE_V1_ID, "claude_v1_bot"],
]
def self.map_bot_model_to_user_id(model_name)
case model_name
in "gpt-3.5-turbo"
GPT3_5_TURBO_ID
in "gpt-4"
GPT4_ID
in "claude-v1"
CLAUDE_V1_ID
else
nil
end
end
def load_files
require_relative "jobs/regular/create_ai_reply"
require_relative "jobs/regular/update_ai_bot_pm_title"
require_relative "bot"
require_relative "anthropic_bot"
require_relative "open_ai_bot"
require_relative "commands/command"
require_relative "commands/search_command"
require_relative "commands/categories_command"
require_relative "commands/tags_command"
require_relative "commands/time_command"
require_relative "commands/summarize_command"
require_relative "commands/image_command"
require_relative "commands/google_command"
require_relative "commands/noop_command"
end
def inject_into(plugin)
@@ -47,25 +24,14 @@ module DiscourseAi
Rails.root.join("plugins", "discourse-ai", "db", "fixtures", "ai_bot"),
)
plugin.register_svg_icon("robot")
plugin.on(:post_created) do |post|
bot_ids = BOTS.map(&:first)
if post.post_type == Post.types[:regular] && post.topic.private_message? &&
!bot_ids.include?(post.user_id)
if post.topic.private_message? && !bot_ids.include?(post.user_id)
if (SiteSetting.ai_bot_allowed_groups_map & post.user.group_ids).present?
bot_id = post.topic.topic_allowed_users.where(user_id: bot_ids).first&.user_id
if bot_id
Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id)
Jobs.enqueue_in(
5.minutes,
:update_ai_bot_pm_title,
post_id: post.id,
bot_user_id: bot_id,
)
end
Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id) if bot_id
end
end
end
@@ -1,17 +0,0 @@
# frozen_string_literal: true
module ::Jobs
class UpdateAiBotPmTitle < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return unless bot_user = User.find_by(id: args[:bot_user_id])
return unless bot = DiscourseAi::AiBot::Bot.as(bot_user)
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
return unless post.topic.title.start_with?(I18n.t("discourse_ai.ai_bot.default_pm_prefix"))
bot.update_pm_title(post)
end
end
end

Some files were not shown because too many files have changed in this diff Show More