mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-02 04:22:40 +00:00
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
|