Compare commits

..
Author SHA1 Message Date
Sam Saffron d544f2101b more tweaks... 2023-05-31 17:15:32 +10:00
Sam Saffron 2d299900b9 we certainly do not need search through pinned topics for now...
This just causes bots to get confused.
2023-05-29 14:15:12 +10:00
Sam Saffron c7781c57fc Fix all specs
Also ensure triage is more consistent by reducing temp
2023-05-29 14:11:32 +10:00
Sam Saffron db11fe391d FEATURE: use triage command to attempt to ground models on GPT 3.5
Previous to this change our results were ungrounded and simpler models like
GPT 3.5 and claude had a real tough time figuring out how to run commands

This splits responses into 2 phases:

Phase 1: figure out if you need to run a command
Phase 2: respond to user

This seems to produce better results on both Claude and GPT 3.5 but still
needs a fair bit of tuning.
2023-05-26 17:07:52 +10:00
SamandKrzysztof Kotlarek 96d521198b FIX: missing localization (#81)
blog.start_gpt_chat -> was on my blog

This also slightly tunes the search prompt to support filtering by oldest
and try a tiny bit harder to guide GPT 3.5 which is a bit of a losing battle

Co-authored-by: Krzysztof Kotlarek <[email protected]>
2023-05-25 11:05:02 +10:00
Rafael dos Santos SilvaandDavid Taylor cfc6e388df FIX: Ensure embeddings database outages are handled gracefully (#80)
The rails_failover middleware will intercept all `PG::ConnectionBad` errors and put the cluster into readonly mode. It does not have any handling for multiple databases. Therefore, an issue with the embeddings database was taking the whole cluster into readonly.

This commit fixes the issue by rescuing `PG::Error` from all AI database accesses, and re-raises errors with a different class. It also adds a spec to ensure that an embeddings database outage does not affect the functionality of the topics/show route.

Co-authored-by: David Taylor <[email protected]>
2023-05-23 22:57:52 +01:00
Rafael dos Santos Silva b213fe7f94 FIX: Give up trying to reuse the DB connection and rely on pgbouncer (#79) 2023-05-23 15:12:59 -03:00
Roman Rizzi c582e3b848 DEV: Fix toxicity test (#78) 2023-05-23 11:02:11 -03:00
Sam d85b503ed4 FIX: guide GPT 3.5 better (#77)
* FIX: guide GPT 3.5 better

This limits search results to 10 cause we were blowing the whole token
budget on search results, additionally it includes a quick exchange at
the start of a session to try and guide GPT 3.5 to follow instructions

Sadly GPT 3.5 drifts off very quickly but this does improve stuff a bit.

It also attempts to correct some issues with anthropic, though it still is
surprisingly hard to ground

* add status:public, this is a bit of a hack but ensures that we can search
for any filter provided

* fix specs
2023-05-23 23:08:17 +10:00
Sam b82fc1e692 FIX: ensure we only attempt embedding once every 15 minutes (#76)
This also heavily reduced log noise and ensures our exception handling is
more surgical.
2023-05-23 10:43:24 +10:00
Sam 074d00ca32 FEATURE: improve search prompt (#75)
- We only support searching public topics - make it clear
- Stop using bug/feature, cause is poisons system - these may not exist
- Add after: and before: which are very handy for bounding search results
2023-05-23 07:52:14 +10:00
Sam e0cf7b7d70 FIX: results will be nil for invalid queries (#74)
Previous to this change invalid searches would break the command.
2023-05-22 15:14:26 +10:00
Sam 92fb84e24d iterate commands (#73)
* FEATURE: introduce a more efficient formatter

Previous formatting style was space inefficient given JSON consumes lots
of tokens, the new format is now used consistently across commands

Also fixes

- search limited to 10
- search breaking on limit: non existent directive

* Slight improvement to summarizer
Stop blowing up context with custom prompts

* ensure we include the guiding message

* correct spec

* langchain style summarizer ...

much more accurate (albeit more expensive)

* lint
2023-05-22 12:09:14 +10:00
SamandRoman Rizzi d59ed1091b FEATURE: add support for GPT <-> Forum integration
This change-set connects GPT based chat with the forum it runs on. Allowing it to perform search, lookup tags and categories and summarize topics. 

The integration is currently restricted to public portions of the forum. 

Changes made:

- Do not run ai reply job for small actions
- Improved composable system prompt
- Trivial summarizer for topics
- Image generator 
- Google command for searching via Google
- Corrected trimming of posts raw (was replacing with numbers) 
- Bypass of problem specs

The feature works best with GPT-4


---------

Co-authored-by: Roman Rizzi <[email protected]>
2023-05-20 17:45:54 +10:00
Rafael dos Santos Silva 262ed4753e FEATURE: Basic StableDiffusion text2img support (#72) 2023-05-20 09:38:08 +10:00
Roman Rizzi ba65d42940 FIX: Hide summarization button from anons (#70) 2023-05-18 12:34:49 -03:00
David Taylor 261fe13599 DEV: Tweaks for drop-down implementation (#69)
- Remove unused 'toggleAiBotPanel' widget action

- Switch from appEvents to closure actions

- Convert widget definition to native class syntax, so that we can use `@action` decorator. (alternatively, we could have done `{ closePanel: this.hideAiBotPanel.bind(this) }` in `RenderGlimmer`
2023-05-18 09:28:43 +01:00
Sam deb34bb52f UX: improve drop down menu for enabled bots (#68)
Previously we were not using using HeaderPanel for drop down, which caused
it not to properly act like a header panel.

- Not styled right
- Not hidden when other buttons clicked

Etc...

Header is sadly full of legacy so this is somewhat hacky weaving widgets.
2023-05-18 16:10:08 +10:00
Rafael dos Santos Silva 739b314312 Fixes for embeddings and truncate (#67) 2023-05-18 09:21:28 +10:00
Roman Rizzi 9ae8f86850 UX: Improvements to the AI Bot header shortcut (#66)
- Humanize model names
- Focus composer editor when starting a PM with a bot
- Correctly close the models dropdown when clicking outside
2023-05-17 15:59:48 -03:00
Rafael dos Santos Silva e9ae28f773 FIX: Non instructor OSS embeddings was broken (#65) 2023-05-17 12:10:10 -03:00
Roman Rizzi 5ade5d7404 UX: Add missing label translation (#64) 2023-05-16 16:56:30 -03:00
Roman RizziandRafael dos Santos Silva 362f6167d1 FEATURE: Less friction for starting a conversation with an AI bot. (#63)
* FEATURE: Less friction for starting a conversation with an AI bot.

This PR adds a new header icon as a shortcut to start a conversation with one of our AI Bots. After clicking and selecting one from the dropdown menu, we'll open the composer with some fields already filled (recipients and title).

If you leave the title as is, we'll queue a job after five minutes to update it using a bot suggestion.

* Update assets/javascripts/initializers/ai-bot-replies.js

Co-authored-by: Rafael dos Santos Silva <[email protected]>

* Update assets/javascripts/initializers/ai-bot-replies.js

Co-authored-by: Rafael dos Santos Silva <[email protected]>

---------

Co-authored-by: Rafael dos Santos Silva <[email protected]>
2023-05-16 14:38:21 -03:00
Rafael dos Santos Silva 2ed1f874c2 Use correct API signature for instructor embeddings (#62) 2023-05-15 17:18:11 -03:00
Rafael dos Santos Silva 3c9513e754 Refinements to embeddings and tokenizers (#61)
* Refinements to embeddings and tokenizers

* lint

* Truncate with tokenizers for summary

* fix
2023-05-15 15:10:42 -03:00
65 changed files with 2195 additions and 262 deletions
@@ -14,6 +14,15 @@ 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
@@ -0,0 +1,9 @@
# frozen_string_literal: true
class PostCustomPrompt < ActiveRecord::Base
belongs_to :post
end
class ::Post
has_one :post_custom_prompt, dependent: :destroy
end
@@ -0,0 +1,18 @@
<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>
@@ -0,0 +1,34 @@
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);
}
}
@@ -0,0 +1,24 @@
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,
},
});
}
@@ -0,0 +1,31 @@
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,11 +3,60 @@ 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)) {
@@ -94,10 +143,19 @@ 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;
if (aiBotEnaled) {
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);
withPluginApi("1.6.0", initializeAIBotReplies);
}
},
@@ -33,12 +33,16 @@ export default {
name: "discourse-ai-topic-summary",
initialize(container) {
const settings = container.lookup("service:site-settings");
const summarizationEnabled =
settings.discourse_ai_enabled && settings.ai_summarization_enabled;
const user = container.lookup("service:current-user");
if (summarizationEnabled) {
withPluginApi("1.6.0", initializeTopicSummary);
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);
}
}
},
};
@@ -5,3 +5,18 @@ 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,7 +8,3 @@
min-height: 200px;
}
}
.topic-ai-summarization {
margin-left: 10px;
}
@@ -0,0 +1,3 @@
.topic-ai-summarization {
margin-left: 10px;
}
@@ -0,0 +1,9 @@
.toggle-summary {
button {
width: 100%;
}
.topic-ai-summarization {
margin-top: 10px;
}
}
+9 -1
View File
@@ -26,7 +26,15 @@ en:
other: "Last %{count} hours"
ai_bot:
cancel_streaming: Stop reply
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"
review:
+36 -1
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,7 +61,14 @@ 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:
@@ -81,3 +88,31 @@ 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,6 +16,7 @@ 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
+26 -4
View File
@@ -94,6 +94,23 @@ 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
@@ -152,13 +169,15 @@ 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:
default: ""
secret: true
ai_embeddings_pg_connection_string: ""
ai_embeddings_semantic_search_enabled:
default: false
client: true
@@ -192,7 +211,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.
# Adding a new bot? Make sure to create a user for it on the seed file and update translations.
ai_bot_enabled_chat_bots:
type: list
default: "gpt-3.5-turbo"
@@ -201,3 +220,6 @@ plugins:
- gpt-3.5-turbo
- gpt-4
- claude-v1
ai_helper_add_ai_pm_to_header:
default: true
client: true
@@ -0,0 +1,13 @@
# 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
+33 -11
View File
@@ -7,39 +7,61 @@ module DiscourseAi
bot_user.id == DiscourseAi::AiBot::EntryPoint::CLAUDE_V1_ID
end
def bot_prompt_with_topic_context(post)
super(post).join("\n\n")
def bot_prompt_with_topic_context(post, triage: false)
super(post, triage: triage).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)
def build_message(poster_username, content, system: false, last: false)
role = poster_username == bot_user.username ? "Assistant" : "Human"
"#{role}: #{content}"
result = +"#{role}: #{content}"
result << "\nAssistant: " if last
result
end
def model_for
"claude-v1"
end
def update_with_delta(_, partial)
partial[:completion]
end
def submit_prompt_and_stream_reply(prompt, &blk)
def get_updated_title(prompt)
DiscourseAi::Inference::AnthropicCompletions.perform!(
prompt,
model_for,
temperature: 0.4,
max_tokens: 3000,
temperature: 0.7,
max_tokens: 40,
).dig(:completion)
end
def submit_prompt(prompt, max_tokens: nil, temperature: nil, prefer_low_cost: false, &blk)
DiscourseAi::Inference::AnthropicCompletions.perform!(
prompt,
model_for,
temperature: temperature || 0.4,
max_tokens: max_tokens || 3000,
&blk
)
end
def tokenize(text)
DiscourseAi::Tokenizer::AnthropicTokenizer.tokenize(text)
end
end
end
end
+268 -46
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
#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
@@ -0,0 +1,109 @@
#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
@@ -0,0 +1,56 @@
#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
@@ -0,0 +1,55 @@
#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
@@ -0,0 +1,27 @@
#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
@@ -0,0 +1,128 @@
#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
@@ -0,0 +1,170 @@
#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
@@ -0,0 +1,42 @@
#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
@@ -0,0 +1,38 @@
#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
+36 -2
View File
@@ -12,11 +12,34 @@ 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)
@@ -24,14 +47,25 @@ 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.topic.private_message? && !bot_ids.include?(post.user_id)
if post.post_type == Post.types[:regular] && 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
Jobs.enqueue(:create_ai_reply, post_id: post.id, bot_user_id: bot_id) if bot_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
end
end
end
@@ -0,0 +1,17 @@
# 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