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
24 changed files with 334 additions and 243 deletions
+10 -17
View File
@@ -7,8 +7,8 @@ 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
@@ -22,25 +22,18 @@ module DiscourseAi
delta = full[context[:pos]..-1]
context[:pos] = full.length
if !context[:processed]
delta = ""
index = full.index("Assistant: ")
if index
delta = full[index + 11..-1]
context[:processed] = true
end
end
delta
end
private
def build_message(poster_username, content, system: false)
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
@@ -56,12 +49,12 @@ module DiscourseAi
).dig(:completion)
end
def submit_prompt(prompt, prefer_low_cost: false, &blk)
def submit_prompt(prompt, max_tokens: nil, temperature: nil, prefer_low_cost: false, &blk)
DiscourseAi::Inference::AnthropicCompletions.perform!(
prompt,
model_for,
temperature: 0.4,
max_tokens: 3000,
temperature: temperature || 0.4,
max_tokens: max_tokens || 3000,
&blk
)
end
+168 -102
View File
File diff suppressed because it is too large Load Diff
@@ -13,14 +13,14 @@ module DiscourseAi::AiBot::Commands
end
def result_name
"results"
"Category list is"
end
def description_args
{ count: @last_count || 0 }
end
def process(_args)
def process
columns = {
name: "Name",
slug: "Slug",
+10 -43
View File
@@ -9,7 +9,7 @@ module DiscourseAi
raise NotImplemented
end
def invoked?(cmd_name)
def should_invoke?(cmd_name)
cmd_name == name
end
@@ -22,25 +22,18 @@ module DiscourseAi
end
end
attr_reader :bot_user, :args
attr_reader :bot_user, :args, :post
def initialize(bot_user, args)
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 standalone?
false
end
def low_cost?
false
end
def result_name
raise NotImplemented
end
@@ -49,7 +42,7 @@ module DiscourseAi
raise NotImplemented
end
def process(post)
def process
raise NotImplemented
end
@@ -57,45 +50,23 @@ module DiscourseAi
{}
end
def custom_raw
end
def chain_next_response
true
end
def invoke_and_attach_result_to(post)
post.post_custom_prompt ||= post.build_post_custom_prompt(custom_prompt: [])
prompt = post.post_custom_prompt.custom_prompt || []
def pre_raw_details
I18n.t("discourse_ai.ai_bot.command_summary.#{self.class.name}")
end
prompt << ["!#{self.class.name} #{args}", bot_user.username]
prompt << [process(args), result_name]
post.post_custom_prompt.update!(custom_prompt: prompt)
raw = +<<~HTML
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
raw << custom_raw if custom_raw.present?
replacement = "!#{self.class.name} #{args}"
raw = post.raw.sub(replacement, raw) if post.raw.include?(replacement)
if chain_next_response
post.raw = raw
post.save!(validate: false)
else
post.revise(bot_user, { raw: raw }, skip_validations: true, skip_revision: true)
end
chain_next_response
end
def format_results(rows, column_names = nil)
@@ -132,10 +103,6 @@ module DiscourseAi
formatted
end
protected
attr_reader :bot_user, :args
end
end
end
@@ -13,7 +13,7 @@ module DiscourseAi::AiBot::Commands
end
def result_name
"results"
"Google Results"
end
def description_args
@@ -24,11 +24,11 @@ module DiscourseAi::AiBot::Commands
}
end
def process(search_string)
@last_query = search_string
def process
@last_query = @args
api_key = SiteSetting.ai_google_custom_search_api_key
cx = SiteSetting.ai_google_custom_search_cx
query = CGI.escape(search_string)
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)
+11 -8
View File
@@ -8,7 +8,7 @@ module DiscourseAi::AiBot::Commands
end
def desc
"!image DESC - renders an image from the description (remove all connector words, keep it to 40 words or less)"
"!image DESC - renders an image from the description (remove all connector words, keep it to 40 words or less, be creative)"
end
end
@@ -17,19 +17,20 @@ module DiscourseAi::AiBot::Commands
end
def description_args
{ prompt: @last_prompt || 0 }
end
def custom_raw
@last_custom_raw
{ prompt: @args || 0 }
end
def chain_next_response
false
end
def process(prompt)
@last_prompt = prompt
def post_raw_details
"#{super}\n\n#{@last_custom_raw}"
end
def process
prompt = @args
results = DiscourseAi::Inference::StabilityGenerator.perform!(prompt)
uploads = []
@@ -47,6 +48,8 @@ module DiscourseAi::AiBot::Commands
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
@@ -22,13 +22,10 @@ module DiscourseAi::AiBot::Commands
status:open: not closed or archived
status:closed: closed
status:archived: archived
status:noreplies: post count is 1
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
in:pinned: in all pinned topics (either global or per category pins)
created:@USERNAME: topics created by a specific user
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
@@ -46,6 +43,7 @@ module DiscourseAi::AiBot::Commands
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
@@ -53,13 +51,12 @@ module DiscourseAi::AiBot::Commands
You only have access to public topics.
Strip the query down to the most important terms.
Remove all stop words.
Cast a wide net instead of trying to be over specific.
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.
When generating answers ALWAYS try to reference specific local links.
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), they can impede the search.
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
@@ -67,7 +64,7 @@ module DiscourseAi::AiBot::Commands
end
def result_name
"results"
"search results for #{@args}"
end
def description_args
@@ -78,8 +75,9 @@ module DiscourseAi::AiBot::Commands
}
end
def process(search_string)
def process
limit = nil
search_string = @args
search_string =
search_string
@@ -8,7 +8,7 @@ module DiscourseAi::AiBot::Commands
end
def desc
"!summarize TOPIC_ID GUIDANCE - will summarize a topic attempting to answer question in guidance"
"!summarize TOPIC_ID OPTIONAL_GUIDANCE - will summarize a topic attempting to answer question in guidance, if provided"
end
end
@@ -28,8 +28,8 @@ module DiscourseAi::AiBot::Commands
{ url: "#{Discourse.base_path}/t/-/#{@last_topic_id}", title: @last_topic_title || "" }
end
def process(instructions)
topic_id, guidance = instructions.split(" ", 2)
def process
topic_id, guidance = @args.split(" ", 2)
@last_topic_id = topic_id
+6 -2
View File
@@ -20,7 +20,7 @@ module DiscourseAi::AiBot::Commands
{ count: @last_count || 0 }
end
def process(_args)
def process
column_names = { name: "Name", public_topic_count: "Topic Count" }
tags =
@@ -32,7 +32,11 @@ module DiscourseAi::AiBot::Commands
@last_count = tags.length
format_results(tags, column_names.values)
if @last_count == 0
"This Discourse instance has no tags"
else
format_results(tags, column_names.values)
end
end
end
end
+2 -2
View File
@@ -20,10 +20,10 @@ module DiscourseAi::AiBot::Commands
{ timezone: @last_timezone, time: @last_time }
end
def process(timezone)
def process
time =
begin
Time.now.in_time_zone(timezone)
Time.now.in_time_zone(@args)
rescue StandardError
nil
end
+1
View File
@@ -39,6 +39,7 @@ module DiscourseAi
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)
+5 -13
View File
@@ -30,21 +30,13 @@ module DiscourseAi
1500
end
{ temperature: 0.4, top_p: 0.9, max_tokens: max_tokens }
{ temperature: 0.4, max_tokens: max_tokens }
end
def submit_prompt(
prompt,
prefer_low_cost: false,
temperature: nil,
top_p: nil,
max_tokens: nil,
&blk
)
def submit_prompt(prompt, prefer_low_cost: false, temperature: nil, max_tokens: nil, &blk)
params =
reply_params.merge(
temperature: temperature,
top_p: top_p,
max_tokens: max_tokens,
) { |key, old_value, new_value| new_value.nil? ? old_value : new_value }
@@ -56,9 +48,7 @@ module DiscourseAi
DiscourseAi::Tokenizer::OpenAiTokenizer.tokenize(text)
end
private
def build_message(poster_username, content, system: false)
def build_message(poster_username, content, system: false, last: false)
is_bot = poster_username == bot_user.username
if system
@@ -70,6 +60,8 @@ module DiscourseAi
{ role: role, content: is_bot ? content : "#{poster_username}: #{content}" }
end
private
def model_for
return "gpt-4" if bot_user.id == DiscourseAi::AiBot::EntryPoint::GPT4_ID
"gpt-3.5-turbo"
+12 -4
View File
@@ -87,10 +87,18 @@ module ::DiscourseAi
data = line.split("data: ", 2)[1]
next if !data || data.squish == "[DONE]"
if !cancelled && partial = JSON.parse(data, symbolize_names: true)
response_data << partial[:completion].to_s
yield partial, cancel
if !cancelled
# anthropic sends a bunch of partial JSON that can not be parsed
partial =
begin
JSON.parse(data, symbolize_names: true)
rescue JSON::ParserError
nil
end
if partial
response_data << partial[:completion].to_s
yield partial, cancel
end
end
end
rescue IOError
+12 -6
View File
@@ -13,14 +13,20 @@ RSpec.describe DiscourseAi::AiBot::AnthropicBot do
context = {}
reply = +""
reply << subject.get_delta({ completion: "\n\nAssist" }, context)
expect(reply).to eq("")
full = +"test"
reply << subject.get_delta({ completion: "\n\nAssistant: test" }, context)
expect(reply).to eq("test")
reply << subject.get_delta({ completion: full }, context)
expect(reply).to eq(full)
reply << subject.get_delta({ completion: "\n\nAssistant: test\nworld" }, context)
expect(reply).to eq("test\nworld")
full << "test2"
reply << subject.get_delta({ completion: full }, context)
expect(reply).to eq(full)
full << "test3"
reply << subject.get_delta({ completion: full }, context)
expect(reply).to eq(full)
end
end
end
+12 -11
View File
@@ -25,12 +25,10 @@ RSpec.describe DiscourseAi::AiBot::Bot do
describe "#system_prompt" do
it "includes relevant context in system prompt" do
bot.system_prompt_style!(:standard)
SiteSetting.title = "My Forum"
SiteSetting.site_description = "My Forum Description"
system_prompt = bot.system_prompt(second_post)
system_prompt = bot.system_prompt(second_post, triage: false)
expect(system_prompt).to include(SiteSetting.title)
expect(system_prompt).to include(SiteSetting.site_description)
@@ -41,20 +39,23 @@ RSpec.describe DiscourseAi::AiBot::Bot do
describe "#reply_to" do
it "can respond to !search" do
bot.system_prompt_style!(:simple)
expected_response = "!search test search"
expected_response = "ok, searching...\n!search test search"
prompt = bot.bot_prompt_with_topic_context(second_post)
prompt = bot.bot_prompt_with_topic_context(second_post, triage: true)
OpenAiCompletionsInferenceStubs.stub_streamed_response(
prompt,
[{ content: expected_response }],
req_opts: bot.reply_params.merge(stream: true),
req_opts: bot.triage_params.merge(stream: true),
)
prompt << { role: "assistant", content: "!search test search" }
prompt << { role: "user", content: "results: No results found" }
# second post will contain the search instruction
prompt = bot.bot_prompt_with_topic_context(first_post)
command = DiscourseAi::AiBot::Commands::SearchCommand.new(nil, nil, SecureRandom.hex(10))
content, username = bot.context_prompt(second_post, command.process, command.result_name)
prompt << bot.build_message(username, content)
OpenAiCompletionsInferenceStubs.stub_streamed_response(
prompt,
@@ -69,10 +70,10 @@ RSpec.describe DiscourseAi::AiBot::Bot do
expect(last.raw).to include("<details>")
expect(last.raw).to include("<summary>Search</summary>")
expect(last.raw).not_to include("translation missing")
expect(last.raw).to include("ok, searching...")
expect(last.raw).to include("We are done now")
expect(last.post_custom_prompt.custom_prompt.to_s).to include("We are done now")
expect(last.post_custom_prompt.custom_prompt.to_s).not_to include("details")
end
end
@@ -7,7 +7,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::CategoriesCommand do
it "can generate correct info" do
Fabricate(:category, name: "america", posts_year: 999)
info = DiscourseAi::AiBot::Commands::CategoriesCommand.new(nil, nil).process(nil)
info = DiscourseAi::AiBot::Commands::CategoriesCommand.new(nil, nil, nil).process
expect(info).to include("america")
expect(info).to include("999")
end
@@ -4,7 +4,7 @@ require_relative "../../../../support/openai_completions_inference_stubs"
RSpec.describe DiscourseAi::AiBot::Commands::Command do
fab!(:bot_user) { User.find(DiscourseAi::AiBot::EntryPoint::GPT3_5_TURBO_ID) }
let(:command) { DiscourseAi::AiBot::Commands::Command.new(bot_user, nil) }
let(:command) { DiscourseAi::AiBot::Commands::Command.new(bot_user, nil, nil) }
describe "#format_results" do
it "can generate efficient tables of data" do
@@ -32,8 +32,8 @@ RSpec.describe DiscourseAi::AiBot::Commands::GoogleCommand do
"https://www.googleapis.com/customsearch/v1?cx=cx&key=abc&num=10&q=some%20search%20term",
).to_return(status: 200, body: json_text, headers: {})
google = described_class.new(bot_user, post)
info = google.process("some search term")
google = described_class.new(bot_user, post, "some search term")
info = google.process
expect(google.description_args[:count]).to eq(1)
expect(info).to include("title1")
@@ -11,9 +11,9 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
describe "#process" do
it "can handle no results" do
post1 = Fabricate(:post)
search = described_class.new(bot_user, post1)
search = described_class.new(bot_user, post1, "order:fake ABDDCDCEDGDG")
results = search.process("order:fake ABDDCDCEDGDG")
results = search.process
expect(results).to eq("No results found")
end
@@ -23,15 +23,15 @@ RSpec.describe DiscourseAi::AiBot::Commands::SearchCommand do
_post3 = Fabricate(:post, user: post1.user)
# search has no built in support for limit: so handle it from the outside
search = described_class.new(bot_user, post1)
search = described_class.new(bot_user, post1, "@#{post1.user.username} limit:2")
results = search.process("@#{post1.user.username} limit:2")
results = search.process
# title + 2 rows
expect(results.split("\n").length).to eq(3)
# just searching for everything
results = search.process("order:latest_topic")
search = described_class.new(bot_user, post1, "order:latest_topic")
results = search.process
expect(results.split("\n").length).to be > 1
end
end
@@ -14,8 +14,8 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
body: JSON.dump({ choices: [{ message: { content: "summary stuff" } }] }),
)
summarizer = described_class.new(bot_user, post)
info = summarizer.process("#{post.topic_id} why did it happen?")
summarizer = described_class.new(bot_user, post, "#{post.topic_id} why did it happen?")
info = summarizer.process
expect(info).to include("Topic summarized")
expect(summarizer.custom_raw).to include("summary stuff")
@@ -30,8 +30,8 @@ RSpec.describe DiscourseAi::AiBot::Commands::SummarizeCommand do
topic = Fabricate(:topic, category_id: category.id)
post = Fabricate(:post, topic: topic)
summarizer = described_class.new(bot_user, post)
info = summarizer.process("#{post.topic_id} why did it happen?")
summarizer = described_class.new(bot_user, post, "#{post.topic_id} why did it happen?")
info = summarizer.process
expect(info).not_to include(post.raw)
@@ -10,7 +10,7 @@ RSpec.describe DiscourseAi::AiBot::Commands::TagsCommand do
Fabricate(:tag, name: "america", public_topic_count: 100)
Fabricate(:tag, name: "not_here", public_topic_count: 0)
info = DiscourseAi::AiBot::Commands::TagsCommand.new(nil, nil).process(nil)
info = DiscourseAi::AiBot::Commands::TagsCommand.new(nil, nil, nil).process
expect(info).to include("america")
expect(info).not_to include("not_here")
@@ -23,12 +23,24 @@ RSpec.describe Jobs::CreateAiReply do
# time needs to be frozen so time in prompt does not drift
freeze_time
OpenAiCompletionsInferenceStubs.stub_streamed_response(
DiscourseAi::AiBot::OpenAiBot.new(bot_user).bot_prompt_with_topic_context(
post,
triage: true,
),
[{ content: "!noop" }],
req_opts: {
temperature: 0.1,
max_tokens: 100,
stream: true,
},
)
OpenAiCompletionsInferenceStubs.stub_streamed_response(
DiscourseAi::AiBot::OpenAiBot.new(bot_user).bot_prompt_with_topic_context(post),
deltas,
req_opts: {
temperature: 0.4,
top_p: 0.9,
max_tokens: 1500,
stream: true,
},
@@ -66,12 +78,25 @@ RSpec.describe Jobs::CreateAiReply do
end
context "when chatting with Claude from Anthropic" do
let(:claude_response) { "Assistant: #{expected_response}" }
let(:claude_response) { "#{expected_response}" }
let(:deltas) { claude_response.split(" ").map { |w| "#{w} " } }
before do
bot_user = User.find(DiscourseAi::AiBot::EntryPoint::CLAUDE_V1_ID)
AnthropicCompletionStubs.stub_streamed_response(
DiscourseAi::AiBot::AnthropicBot.new(bot_user).bot_prompt_with_topic_context(
post,
triage: true,
),
[{ content: "!noop" }],
req_opts: {
temperature: 0.1,
max_tokens_to_sample: 100,
stream: true,
},
)
AnthropicCompletionStubs.stub_streamed_response(
DiscourseAi::AiBot::AnthropicBot.new(bot_user).bot_prompt_with_topic_context(post),
deltas,
+1 -1
View File
@@ -33,7 +33,7 @@ RSpec.describe DiscourseAi::AiBot::OpenAiBot do
it "trims the prompt" do
prompt_messages = subject.bot_prompt_with_topic_context(post_1)
expect(prompt_messages[-2][:role]).to eq("assistant")
expect(prompt_messages[-2][:role]).to eq("system")
expect(prompt_messages[-1][:role]).to eq("user")
# trimming is tricky... it needs to account for system message as
# well... just make sure we trim for now