discourse-ai/spec/lib/utils/ai_staff_action_logger_spec.rb
Keegan George 9be1049de6
DEV: Log AI related configuration to staff action log (#1416)
is update adds logging for changes made in the AI admin panel. When making configuration changes to Embeddings, LLMs, Personas, Tools, or Spam that aren't site setting related, changes will now be logged in Admin > Logs & Screening. This will help admins debug issues related to AI. In this update a helper lib is created called `AiStaffActionLogger` which can be easily used in the future to add logging support for any other admin config we need logged for AI.
2025-06-12 12:39:58 -07:00

481 lines
16 KiB
Ruby

# 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