discourse-ai/spec/requests/admin/ai_personas_controller_spec.rb
Sam 52a7dd2a4b
FEATURE: optional tool detail blocks (#662)
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>
2024-06-11 18:14:14 +10:00

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