mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-01 20:12:15 +00:00
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.
481 lines
16 KiB
Ruby
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
|