mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-03-09 11:48:47 +00:00
This is a rather huge refactor with 1 new feature (tool details can be suppressed) Previously we use the name "Command" to describe "Tools", this unifies all the internal language and simplifies the code. We also amended the persona UI to use less DToggles which aligns with our design guidelines. Co-authored-by: Martin Brennan <martin@discourse.org>
403 lines
14 KiB
Ruby
403 lines
14 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" }]],
|
|
mentionable: true,
|
|
default_llm: "anthropic:claude-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["mentionable"]).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)
|
|
|
|
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" }]])
|
|
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_persona_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" }]],
|
|
top_p: 0.1,
|
|
temperature: 0.5,
|
|
mentionable: true,
|
|
default_llm: "anthropic:claude-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["mentionable"]).to eq(true)
|
|
expect(persona_json["default_llm"]).to eq("anthropic:claude-2")
|
|
|
|
persona = AiPersona.find(persona_json["id"])
|
|
|
|
expect(persona.tools).to eq([["search", { "base_query" => "test" }]])
|
|
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"])
|
|
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 "POST #upload_file" do
|
|
it "works" do
|
|
post "/admin/plugins/discourse-ai/ai-personas/files/upload.json",
|
|
params: {
|
|
file: Rack::Test::UploadedFile.new(file_from_fixtures("spec.txt", "md")),
|
|
}
|
|
|
|
expect(response.status).to eq(200)
|
|
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
|
|
end
|