discourse-ai/spec/lib/utils/ai_staff_action_logger_spec.rb

481 lines
16 KiB
Ruby
Raw Permalink Normal View History

# frozen_string_literal: true
RSpec.describe DiscourseAi::Utils::AiStaffActionLogger do
fab!(:admin)
fab!(:llm_model)
fab!(:ai_persona)
fab!(:group)
subject { described_class.new(admin) }
describe "#log_creation" do
it "logs creation of an entity with field configuration" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
# Create field configuration
field_config = { name: {}, provider: {}, url: {}, api_key: { type: :sensitive } }
# Create entity details
entity_details = {
model_id: llm_model.id,
model_name: llm_model.name,
display_name: llm_model.display_name,
}
# Setup model with sensitive data
llm_model.update!(api_key: "secret_key")
subject.log_creation("llm_model", llm_model, field_config, entity_details)
expect(staff_action_logger).to have_received(:log_custom).with(
"create_ai_llm_model",
hash_including(
"model_id" => llm_model.id,
"name" => llm_model.name,
"provider" => llm_model.provider,
"url" => llm_model.url,
"api_key" => "[FILTERED]",
),
)
end
it "handles large text fields with type declaration" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
# Create a persona with a large system prompt
large_prompt = "a" * 200
ai_persona.update!(system_prompt: large_prompt)
# Create entity details
entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name }
field_config = { name: {}, description: {}, system_prompt: { type: :large_text } }
subject.log_creation("persona", ai_persona, field_config, entity_details)
# Verify with have_received
expect(staff_action_logger).to have_received(:log_custom).with(
"create_ai_persona",
hash_including(
"persona_id" => ai_persona.id,
"name" => ai_persona.name,
"system_prompt" => an_instance_of(String),
),
) do |action, details|
# Check that system_prompt was truncated
expect(details["system_prompt"].length).to be < 200
end
end
it "allows excluding fields from extraction" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
field_config = {
name: {
},
display_name: {
},
provider: {
extract: false,
}, # Should be excluded
url: {
},
}
# Create entity details
entity_details = {
model_id: llm_model.id,
model_name: llm_model.name,
display_name: llm_model.display_name,
}
subject.log_creation("llm_model", llm_model, field_config, entity_details)
expect(staff_action_logger).to have_received(:log_custom).with(
"create_ai_llm_model",
hash_including(
"model_id" => llm_model.id,
"name" => llm_model.name,
"display_name" => llm_model.display_name,
"url" => llm_model.url,
),
) do |action, details|
# Provider should not be present
expect(details).not_to have_key("provider")
end
end
end
describe "#log_update" do
it "handles empty arrays and complex JSON properly" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
# Setup initial attributes with empty JSON arrays
initial_attributes = { "name" => "Old Name", "allowed_group_ids" => [] }
# Update with complex JSON
ai_persona.update!(name: "New Name", allowed_group_ids: [group.id, 999])
field_config = { name: {}, json_fields: %w[allowed_group_ids] }
# Create entity details
entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name }
subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details)
# Verify with have_received
expect(staff_action_logger).to have_received(:log_custom).with(
"update_ai_persona",
hash_including(
"persona_id" => ai_persona.id,
"persona_name" => ai_persona.name,
"name" => "Old Name → New Name",
"allowed_group_ids" => "updated",
),
)
end
it "logs changes to attributes based on field configuration" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
initial_attributes = {
"name" => "Old Name",
"display_name" => "Old Display Name",
"provider" => "open_ai",
"api_key" => "old_secret",
}
llm_model.update!(
name: "New Name",
display_name: "New Display Name",
provider: "anthropic",
api_key: "new_secret",
)
field_config = { name: {}, display_name: {}, provider: {}, api_key: { type: :sensitive } }
# Create entity details
entity_details = {
model_id: llm_model.id,
model_name: llm_model.name,
display_name: llm_model.display_name,
}
subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details)
# Verify with have_received
expect(staff_action_logger).to have_received(:log_custom).with(
"update_ai_llm_model",
hash_including(
"model_id" => llm_model.id,
"name" => "Old Name → New Name",
"display_name" => "Old Display Name → New Display Name",
"provider" => "open_ai → anthropic",
"api_key" => "updated", # Not showing actual values
),
)
end
it "doesn't log when there are no changes" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
initial_attributes = {
"name" => llm_model.name,
"display_name" => llm_model.display_name,
"provider" => llm_model.provider,
}
field_config = { name: {}, display_name: {}, provider: {} }
# Create entity details
entity_details = {
model_id: llm_model.id,
model_name: llm_model.name,
display_name: llm_model.display_name,
}
subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details)
# Verify log_custom was not called
expect(staff_action_logger).not_to have_received(:log_custom)
end
it "handles fields marked as not to be tracked" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
initial_attributes = {
"name" => "Old Name",
"display_name" => "Old Display Name",
"provider" => "open_ai",
}
llm_model.update!(name: "New Name", display_name: "New Display Name", provider: "anthropic")
field_config = {
name: {
},
display_name: {
},
provider: {
track: false,
}, # Should not be tracked even though it changed
}
# Create entity details
entity_details = {
model_id: llm_model.id,
model_name: llm_model.name,
display_name: llm_model.display_name,
}
subject.log_update("llm_model", llm_model, initial_attributes, field_config, entity_details)
# Provider should not appear in the logged changes
expect(staff_action_logger).to have_received(:log_custom).with(
"update_ai_llm_model",
hash_including(
"model_id" => llm_model.id,
"name" => "Old Name → New Name",
"display_name" => "Old Display Name → New Display Name",
),
) do |action, details|
expect(details).not_to have_key("provider")
end
end
it "handles json fields properly" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
# Setup initial attributes with JSON fields
initial_attributes = {
"name" => "Old Name",
"tools" => [["search", { "base_query" => "test" }, true]],
}
# Update with different JSON
ai_persona.update!(
name: "New Name",
tools: [["search", { "base_query" => "updated" }, true], ["categories", {}, false]],
)
field_config = { name: {}, json_fields: %w[tools] }
# Create entity details
entity_details = { persona_id: ai_persona.id, persona_name: ai_persona.name }
subject.log_update("persona", ai_persona, initial_attributes, field_config, entity_details)
# Verify with have_received
expect(staff_action_logger).to have_received(:log_custom).with(
"update_ai_persona",
hash_including(
"persona_id" => ai_persona.id,
"persona_name" => ai_persona.name,
"name" => "Old Name → New Name",
"tools" => "updated",
),
)
end
end
describe "#log_deletion" do
it "logs deletion with the correct entity type" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
details = {
model_id: llm_model.id,
display_name: llm_model.display_name,
name: llm_model.name,
}
subject.log_deletion("llm_model", details)
# Verify with have_received
expect(staff_action_logger).to have_received(:log_custom).with(
"delete_ai_llm_model",
hash_including(
"model_id" => details[:model_id],
"display_name" => details[:display_name],
"name" => details[:name],
),
)
end
end
describe "#log_custom" do
it "delegates to StaffActionLogger#log_custom" do
staff_action_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_action_logger)
allow(staff_action_logger).to receive(:log_custom)
details = { key: "value" }
subject.log_custom("custom_action_type", details)
# Verify with have_received
expect(staff_action_logger).to have_received(:log_custom).with(
"custom_action_type",
hash_including("key" => details[:key]),
)
end
end
describe "Special cases from controllers" do
context "with EmbeddingDefinition" do
fab!(:embedding_definition) {
Fabricate(
:embedding_definition,
display_name: "Test Embedding",
dimensions: 768,
provider: "open_ai"
)
}
it "includes dimensions in logged data" do
# Setup
staff_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger)
allow(staff_logger).to receive(:log_custom)
# Create entity details
entity_details = { embedding_id: embedding_definition.id, subject: embedding_definition.display_name }
# Field config without dimensions
field_config = {
display_name: {},
provider: {},
url: {}
}
logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin)
logger.log_creation("embedding", embedding_definition, field_config, entity_details)
# Verify with have_received
expect(staff_logger).to have_received(:log_custom).with(
"create_ai_embedding",
hash_including("dimensions" => 768)
)
end
end
context "with LlmModel quotas" do
before do
# Create a quota for the model
@quota = Fabricate(:llm_quota, llm_model: llm_model, group: group, max_tokens: 1000)
end
it "handles quota changes in log_llm_model_creation" do
# Setup
staff_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger)
allow(staff_logger).to receive(:log_custom)
# Call the method directly as it would be called from the controller
logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin)
field_config = { display_name: {}, name: {} }
# Create entity details
entity_details = {
model_id: llm_model.id,
model_name: llm_model.name,
display_name: llm_model.display_name,
}
log_details = entity_details.dup
log_details.merge!(logger.send(:extract_entity_attributes, llm_model, field_config))
# Add quota information as a special case
log_details[:quotas] = llm_model
.llm_quotas
.map do |quota|
"Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s"
end
.join("; ")
logger.log_custom("create_ai_llm_model", log_details)
# Verify with have_received
expect(staff_logger).to have_received(:log_custom).with(
"create_ai_llm_model",
hash_including(
"model_id" => llm_model.id,
"model_name" => llm_model.name,
"display_name" => llm_model.display_name,
),
)
expect(staff_logger).to have_received(:log_custom).with(
"create_ai_llm_model",
hash_including("quotas" => a_string_including("Group #{group.id}", "1000 tokens")),
)
end
it "handles quota changes in log_llm_model_update" do
initial_quotas = llm_model.llm_quotas.map(&:attributes)
# Update the quota
@quota.update!(max_tokens: 2000)
current_quotas = llm_model.llm_quotas.reload.map(&:attributes)
# Setup
staff_logger = instance_double(StaffActionLogger)
allow(StaffActionLogger).to receive(:new).with(admin).and_return(staff_logger)
allow(staff_logger).to receive(:log_custom)
# Simulate the special quota handling in the controller
logger = DiscourseAi::Utils::AiStaffActionLogger.new(admin)
changes = {}
# Track quota changes separately as they're a special case
if initial_quotas != current_quotas
initial_quota_summary =
initial_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
current_quota_summary =
current_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
changes[:quotas] = "#{initial_quota_summary}#{current_quota_summary}"
end
# Create entity details
entity_details = {
model_id: llm_model.id,
model_name: llm_model.name,
display_name: llm_model.display_name,
}
log_details = entity_details.dup.merge(changes)
logger.log_custom("update_ai_llm_model", log_details)
# Verify with have_received
expect(staff_logger).to have_received(:log_custom).with(
"update_ai_llm_model",
hash_including(
"model_id" => llm_model.id,
"quotas" => a_string_including("1000 tokens", "2000 tokens"),
),
)
end
end
end
end