mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-24 06:53:41 +00:00
This allows us to inject information into the system prompt which can help shape replies without repeating over and over in messages.
607 lines
21 KiB
Ruby
607 lines
21 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
RSpec.describe DiscourseAi::Admin::AiPersonasController do
|
|
fab!(:admin)
|
|
fab!(:ai_persona)
|
|
|
|
before do
|
|
sign_in(admin)
|
|
|
|
SiteSetting.ai_embeddings_enabled = true
|
|
SiteSetting.ai_embeddings_discourse_service_api_endpoint = "http://test.com"
|
|
end
|
|
|
|
describe "GET #index" do
|
|
it "returns a success response" do
|
|
get "/admin/plugins/discourse-ai/ai-personas.json"
|
|
expect(response).to be_successful
|
|
|
|
expect(response.parsed_body["ai_personas"].length).to eq(AiPersona.count)
|
|
expect(response.parsed_body["meta"]["tools"].length).to eq(
|
|
DiscourseAi::AiBot::Personas::Persona.all_available_tools.length,
|
|
)
|
|
end
|
|
|
|
it "sideloads llms" do
|
|
get "/admin/plugins/discourse-ai/ai-personas.json"
|
|
expect(response).to be_successful
|
|
|
|
expect(response.parsed_body["meta"]["llms"]).to eq(
|
|
DiscourseAi::Configuration::LlmEnumerator.values.map do |hash|
|
|
{ "id" => hash[:value], "name" => hash[:name] }
|
|
end,
|
|
)
|
|
end
|
|
|
|
it "returns tool options with each tool" do
|
|
persona1 = Fabricate(:ai_persona, name: "search1", tools: ["SearchCommand"])
|
|
persona2 =
|
|
Fabricate(
|
|
:ai_persona,
|
|
name: "search2",
|
|
tools: [["SearchCommand", { base_query: "test" }, true]],
|
|
allow_topic_mentions: true,
|
|
allow_personal_messages: true,
|
|
allow_chat_channel_mentions: true,
|
|
allow_chat_direct_messages: true,
|
|
default_llm: "anthropic:claude-2",
|
|
forced_tool_count: 2,
|
|
)
|
|
persona2.create_user!
|
|
|
|
get "/admin/plugins/discourse-ai/ai-personas.json"
|
|
expect(response).to be_successful
|
|
|
|
serializer_persona1 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona1.id }
|
|
serializer_persona2 = response.parsed_body["ai_personas"].find { |p| p["id"] == persona2.id }
|
|
|
|
expect(serializer_persona2["allow_topic_mentions"]).to eq(true)
|
|
expect(serializer_persona2["allow_personal_messages"]).to eq(true)
|
|
expect(serializer_persona2["allow_chat_channel_mentions"]).to eq(true)
|
|
expect(serializer_persona2["allow_chat_direct_messages"]).to eq(true)
|
|
|
|
expect(serializer_persona2["default_llm"]).to eq("anthropic:claude-2")
|
|
expect(serializer_persona2["user_id"]).to eq(persona2.user_id)
|
|
expect(serializer_persona2["user"]["id"]).to eq(persona2.user_id)
|
|
expect(serializer_persona2["forced_tool_count"]).to eq(2)
|
|
|
|
tools = response.parsed_body["meta"]["tools"]
|
|
search_tool = tools.find { |c| c["id"] == "Search" }
|
|
|
|
expect(search_tool["help"]).to eq(I18n.t("discourse_ai.ai_bot.tool_help.search"))
|
|
|
|
expect(search_tool["options"]).to eq(
|
|
{
|
|
"base_query" => {
|
|
"type" => "string",
|
|
"name" => I18n.t("discourse_ai.ai_bot.tool_options.search.base_query.name"),
|
|
"description" =>
|
|
I18n.t("discourse_ai.ai_bot.tool_options.search.base_query.description"),
|
|
},
|
|
"max_results" => {
|
|
"type" => "integer",
|
|
"name" => I18n.t("discourse_ai.ai_bot.tool_options.search.max_results.name"),
|
|
"description" =>
|
|
I18n.t("discourse_ai.ai_bot.tool_options.search.max_results.description"),
|
|
},
|
|
"search_private" => {
|
|
"type" => "boolean",
|
|
"name" => I18n.t("discourse_ai.ai_bot.tool_options.search.search_private.name"),
|
|
"description" =>
|
|
I18n.t("discourse_ai.ai_bot.tool_options.search.search_private.description"),
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(serializer_persona1["tools"]).to eq(["SearchCommand"])
|
|
expect(serializer_persona2["tools"]).to eq(
|
|
[["SearchCommand", { "base_query" => "test" }, true]],
|
|
)
|
|
end
|
|
|
|
context "with translations" do
|
|
before do
|
|
SiteSetting.default_locale = "fr"
|
|
|
|
TranslationOverride.upsert!(
|
|
SiteSetting.default_locale,
|
|
"discourse_ai.ai_bot.personas.general.name",
|
|
"Général",
|
|
)
|
|
TranslationOverride.upsert!(
|
|
SiteSetting.default_locale,
|
|
"discourse_ai.ai_bot.personas.general.description",
|
|
"Général Description",
|
|
)
|
|
end
|
|
|
|
after do
|
|
TranslationOverride.revert!(
|
|
SiteSetting.default_locale,
|
|
"discourse_ai.ai_bot.personas.general.name",
|
|
)
|
|
TranslationOverride.revert!(
|
|
SiteSetting.default_locale,
|
|
"discourse_ai.ai_bot.personas.general.description",
|
|
)
|
|
end
|
|
|
|
it "returns localized persona names and descriptions" do
|
|
get "/admin/plugins/discourse-ai/ai-personas.json"
|
|
|
|
id =
|
|
DiscourseAi::AiBot::Personas::Persona.system_personas[
|
|
DiscourseAi::AiBot::Personas::General
|
|
]
|
|
persona = response.parsed_body["ai_personas"].find { |p| p["id"] == id }
|
|
|
|
expect(persona["name"]).to eq("Général")
|
|
expect(persona["description"]).to eq("Général Description")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "GET #show" do
|
|
it "returns a success response" do
|
|
get "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json"
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body["ai_persona"]["name"]).to eq(ai_persona.name)
|
|
end
|
|
|
|
it "includes rag uploads for each persona" do
|
|
upload = Fabricate(:upload)
|
|
RagDocumentFragment.link_target_and_uploads(ai_persona, [upload.id])
|
|
|
|
get "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json"
|
|
expect(response).to be_successful
|
|
|
|
serialized_persona = response.parsed_body["ai_persona"]
|
|
|
|
expect(serialized_persona.dig("rag_uploads", 0, "id")).to eq(upload.id)
|
|
expect(serialized_persona.dig("rag_uploads", 0, "original_filename")).to eq(
|
|
upload.original_filename,
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "POST #create" do
|
|
context "with valid params" do
|
|
let(:valid_attributes) do
|
|
{
|
|
name: "superbot",
|
|
description: "Assists with tasks",
|
|
system_prompt: "you are a helpful bot",
|
|
tools: [["search", { "base_query" => "test" }, true]],
|
|
top_p: 0.1,
|
|
temperature: 0.5,
|
|
allow_topic_mentions: true,
|
|
allow_personal_messages: true,
|
|
allow_chat_channel_mentions: true,
|
|
allow_chat_direct_messages: true,
|
|
default_llm: "anthropic:claude-2",
|
|
forced_tool_count: 2,
|
|
}
|
|
end
|
|
|
|
it "creates a new AiPersona" do
|
|
expect {
|
|
post "/admin/plugins/discourse-ai/ai-personas.json",
|
|
params: { ai_persona: valid_attributes }.to_json,
|
|
headers: {
|
|
"CONTENT_TYPE" => "application/json",
|
|
}
|
|
expect(response).to be_successful
|
|
persona_json = response.parsed_body["ai_persona"]
|
|
|
|
expect(persona_json["name"]).to eq("superbot")
|
|
expect(persona_json["top_p"]).to eq(0.1)
|
|
expect(persona_json["temperature"]).to eq(0.5)
|
|
expect(persona_json["default_llm"]).to eq("anthropic:claude-2")
|
|
expect(persona_json["forced_tool_count"]).to eq(2)
|
|
expect(persona_json["allow_topic_mentions"]).to eq(true)
|
|
expect(persona_json["allow_personal_messages"]).to eq(true)
|
|
expect(persona_json["allow_chat_channel_mentions"]).to eq(true)
|
|
expect(persona_json["allow_chat_direct_messages"]).to eq(true)
|
|
|
|
persona = AiPersona.find(persona_json["id"])
|
|
|
|
expect(persona.tools).to eq([["search", { "base_query" => "test" }, true]])
|
|
expect(persona.top_p).to eq(0.1)
|
|
expect(persona.temperature).to eq(0.5)
|
|
}.to change(AiPersona, :count).by(1)
|
|
end
|
|
end
|
|
|
|
context "with invalid params" do
|
|
it "renders a JSON response with errors for the new ai_persona" do
|
|
post "/admin/plugins/discourse-ai/ai-personas.json", params: { ai_persona: { foo: "" } } # invalid attribute
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.content_type).to include("application/json")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "POST #create_user" do
|
|
it "creates a user for the persona" do
|
|
post "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}/create-user.json"
|
|
ai_persona.reload
|
|
|
|
expect(response).to be_successful
|
|
expect(response.parsed_body["user"]["id"]).to eq(ai_persona.user_id)
|
|
end
|
|
end
|
|
|
|
describe "PUT #update" do
|
|
it "allows us to trivially clear top_p and temperature" do
|
|
persona = Fabricate(:ai_persona, name: "test_bot2", top_p: 0.5, temperature: 0.1)
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json",
|
|
params: {
|
|
ai_persona: {
|
|
top_p: "",
|
|
temperature: "",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
persona.reload
|
|
|
|
expect(persona.top_p).to eq(nil)
|
|
expect(persona.temperature).to eq(nil)
|
|
end
|
|
|
|
it "supports updating rag params" do
|
|
persona = Fabricate(:ai_persona, name: "test_bot2")
|
|
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json",
|
|
params: {
|
|
ai_persona: {
|
|
rag_chunk_tokens: "102",
|
|
rag_chunk_overlap_tokens: "12",
|
|
rag_conversation_chunks: "13",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
persona.reload
|
|
|
|
expect(persona.rag_chunk_tokens).to eq(102)
|
|
expect(persona.rag_chunk_overlap_tokens).to eq(12)
|
|
expect(persona.rag_conversation_chunks).to eq(13)
|
|
end
|
|
|
|
it "supports updating vision params" do
|
|
persona = Fabricate(:ai_persona, name: "test_bot2")
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{persona.id}.json",
|
|
params: {
|
|
ai_persona: {
|
|
vision_enabled: true,
|
|
vision_max_pixels: 512 * 512,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
persona.reload
|
|
|
|
expect(persona.vision_enabled).to eq(true)
|
|
expect(persona.vision_max_pixels).to eq(512 * 512)
|
|
end
|
|
|
|
it "does not allow temperature and top p changes on stock personas" do
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
|
|
params: {
|
|
ai_persona: {
|
|
top_p: 0.5,
|
|
temperature: 0.1,
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
end
|
|
|
|
context "with valid params" do
|
|
it "updates the requested ai_persona" do
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json",
|
|
params: {
|
|
ai_persona: {
|
|
name: "SuperBot",
|
|
enabled: false,
|
|
tools: ["search"],
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(response.content_type).to include("application/json")
|
|
|
|
ai_persona.reload
|
|
expect(ai_persona.name).to eq("SuperBot")
|
|
expect(ai_persona.enabled).to eq(false)
|
|
expect(ai_persona.tools).to eq([["search", nil, false]])
|
|
end
|
|
end
|
|
|
|
context "with system personas" do
|
|
it "does not allow editing of system prompts" do
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
|
|
params: {
|
|
ai_persona: {
|
|
system_prompt: "you are not a helpful bot",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
end
|
|
|
|
it "does not allow editing of tools" do
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
|
|
params: {
|
|
ai_persona: {
|
|
tools: %w[SearchCommand ImageCommand],
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
end
|
|
|
|
it "does not allow editing of name and description cause it is localized" do
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
|
|
params: {
|
|
ai_persona: {
|
|
name: "bob",
|
|
description: "the bob",
|
|
},
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
end
|
|
|
|
it "does allow some actions" do
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json",
|
|
params: {
|
|
ai_persona: {
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_1]],
|
|
enabled: false,
|
|
priority: 989,
|
|
},
|
|
}
|
|
|
|
expect(response).to be_successful
|
|
end
|
|
end
|
|
|
|
context "with invalid params" do
|
|
it "renders a JSON response with errors for the ai_persona" do
|
|
put "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json",
|
|
params: {
|
|
ai_persona: {
|
|
name: "",
|
|
},
|
|
} # invalid attribute
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.content_type).to include("application/json")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "DELETE #destroy" do
|
|
it "destroys the requested ai_persona" do
|
|
expect {
|
|
delete "/admin/plugins/discourse-ai/ai-personas/#{ai_persona.id}.json"
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
}.to change(AiPersona, :count).by(-1)
|
|
end
|
|
|
|
it "is not allowed to delete system personas" do
|
|
expect {
|
|
delete "/admin/plugins/discourse-ai/ai-personas/#{DiscourseAi::AiBot::Personas::Persona.system_personas.values.first}.json"
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.parsed_body["errors"].join).not_to be_blank
|
|
# let's make sure this is translated
|
|
expect(response.parsed_body["errors"].join).not_to include("en.discourse")
|
|
}.not_to change(AiPersona, :count)
|
|
end
|
|
end
|
|
|
|
describe "#stream_reply" do
|
|
fab!(:llm) { Fabricate(:llm_model, name: "fake_llm", provider: "fake") }
|
|
let(:fake_endpoint) { DiscourseAi::Completions::Endpoints::Fake }
|
|
|
|
before { fake_endpoint.delays = [] }
|
|
|
|
after { fake_endpoint.reset! }
|
|
|
|
it "ensures persona exists" do
|
|
post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json"
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
# this ensures localization key is actually in the yaml
|
|
expect(response.body).to include("persona_name")
|
|
end
|
|
|
|
it "ensures question exists" do
|
|
ai_persona.update!(default_llm: "custom:#{llm.id}")
|
|
|
|
post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json",
|
|
params: {
|
|
persona_id: ai_persona.id,
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
}
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.body).to include("query")
|
|
end
|
|
|
|
it "ensure persona has a user specified" do
|
|
ai_persona.update!(default_llm: "custom:#{llm.id}")
|
|
|
|
post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json",
|
|
params: {
|
|
persona_id: ai_persona.id,
|
|
query: "how are you today?",
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
}
|
|
|
|
expect(response).to have_http_status(:unprocessable_entity)
|
|
expect(response.body).to include("associated")
|
|
end
|
|
|
|
def validate_streamed_response(raw_http, expected)
|
|
lines = raw_http.split("\r\n")
|
|
|
|
header_lines, _, payload_lines = lines.chunk { |l| l == "" }.map(&:last)
|
|
|
|
preamble = (<<~PREAMBLE).strip
|
|
HTTP/1.1 200 OK
|
|
Content-Type: text/plain; charset=utf-8
|
|
Transfer-Encoding: chunked
|
|
Cache-Control: no-cache, no-store, must-revalidate
|
|
Connection: close
|
|
X-Accel-Buffering: no
|
|
X-Content-Type-Options: nosniff
|
|
PREAMBLE
|
|
|
|
expect(header_lines.join("\n")).to eq(preamble)
|
|
|
|
parsed = +""
|
|
|
|
context_info = nil
|
|
|
|
payload_lines.each_slice(2) do |size, data|
|
|
size = size.to_i(16)
|
|
data = data.to_s
|
|
expect(data.bytesize).to eq(size)
|
|
|
|
if size > 0
|
|
json = JSON.parse(data)
|
|
parsed << json["partial"].to_s
|
|
|
|
context_info = json if json["topic_id"]
|
|
end
|
|
end
|
|
|
|
expect(parsed).to eq(expected)
|
|
|
|
context_info
|
|
end
|
|
|
|
it "is able to create a new conversation" do
|
|
Jobs.run_immediately!
|
|
# trust level 0
|
|
SiteSetting.ai_bot_allowed_groups = "10"
|
|
|
|
fake_endpoint.fake_content = ["This is a test! Testing!", "An amazing title"]
|
|
|
|
ai_persona.create_user!
|
|
ai_persona.update!(
|
|
allowed_group_ids: [Group::AUTO_GROUPS[:trust_level_0]],
|
|
default_llm: "custom:#{llm.id}",
|
|
allow_personal_messages: true,
|
|
system_prompt: "you are a helpful bot",
|
|
)
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json",
|
|
params: {
|
|
persona_name: ai_persona.name,
|
|
query: "how are you today?",
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
preferred_username: "test_user",
|
|
custom_instructions: "To be appended to system prompt",
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
# this is a fake response but it catches errors
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
raw = io_out.read
|
|
context_info = validate_streamed_response(raw, "This is a test! Testing!")
|
|
|
|
system_prompt = fake_endpoint.previous_calls[-2][:dialect].prompt.messages.first[:content]
|
|
|
|
expect(system_prompt).to eq("you are a helpful bot\nTo be appended to system prompt")
|
|
|
|
expect(context_info["topic_id"]).to be_present
|
|
topic = Topic.find(context_info["topic_id"])
|
|
last_post = topic.posts.order(:created_at).last
|
|
expect(last_post.raw).to eq("This is a test! Testing!")
|
|
|
|
user_post = topic.posts.find_by(post_number: 1)
|
|
expect(user_post.raw).to eq("how are you today?")
|
|
|
|
# need ai persona and user
|
|
expect(topic.topic_allowed_users.count).to eq(2)
|
|
expect(topic.archetype).to eq(Archetype.private_message)
|
|
expect(topic.title).to eq("An amazing title")
|
|
expect(topic.posts.count).to eq(2)
|
|
|
|
# now let's try to make a reply with a tool call
|
|
function_call = <<~XML
|
|
<function_calls>
|
|
<invoke>
|
|
<tool_name>categories</tool_name>
|
|
</invoke>
|
|
</function_calls>
|
|
XML
|
|
|
|
fake_endpoint.fake_content = [function_call, "this is the response after the tool"]
|
|
# this simplifies function calls
|
|
fake_endpoint.chunk_count = 1
|
|
|
|
ai_persona.update!(tools: ["Categories"])
|
|
|
|
# lets also unstage the user and add the user to tl0
|
|
# this will ensure there are no feedback loops
|
|
new_user = user_post.user
|
|
new_user.update!(staged: false)
|
|
Group.user_trust_level_change!(new_user.id, new_user.trust_level)
|
|
|
|
# double check this happened and user is in group
|
|
personas = AiPersona.allowed_modalities(user: new_user.reload, allow_personal_messages: true)
|
|
expect(personas.count).to eq(1)
|
|
|
|
io_out, io_in = IO.pipe
|
|
|
|
post "/admin/plugins/discourse-ai/ai-personas/stream-reply.json",
|
|
params: {
|
|
persona_id: ai_persona.id,
|
|
query: "how are you now?",
|
|
user_unique_id: "site:test.com:user_id:1",
|
|
preferred_username: "test_user",
|
|
topic_id: context_info["topic_id"],
|
|
},
|
|
env: {
|
|
"rack.hijack" => lambda { io_in },
|
|
}
|
|
|
|
# this is a fake response but it catches errors
|
|
expect(response).to have_http_status(:no_content)
|
|
|
|
raw = io_out.read
|
|
context_info = validate_streamed_response(raw, "this is the response after the tool")
|
|
|
|
topic = topic.reload
|
|
last_post = topic.posts.order(:created_at).last
|
|
|
|
expect(last_post.raw).to end_with("this is the response after the tool")
|
|
# function call is visible in the post
|
|
expect(last_post.raw[0..8]).to eq("<details>")
|
|
|
|
user_post = topic.posts.find_by(post_number: 3)
|
|
expect(user_post.raw).to eq("how are you now?")
|
|
expect(user_post.user.username).to eq("test_user")
|
|
expect(user_post.user.custom_fields).to eq(
|
|
{ "ai-stream-conversation-unique-id" => "site:test.com:user_id:1" },
|
|
)
|
|
|
|
expect(topic.posts.count).to eq(4)
|
|
end
|
|
end
|
|
end
|