321 lines
14 KiB
Ruby
Raw Permalink Normal View History

# frozen_string_literal: true
RSpec.describe DiscourseAi::InferredConcepts::Applier do
subject(:applier) { described_class.new }
fab!(:topic) { Fabricate(:topic, title: "Ruby Programming Tutorial") }
fab!(:post) { Fabricate(:post, raw: "This post is about advanced testing techniques") }
fab!(:user) { Fabricate(:user, username: "dev_user") }
fab!(:concept1) { Fabricate(:inferred_concept, name: "programming") }
fab!(:concept2) { Fabricate(:inferred_concept, name: "testing") }
fab!(:llm_model) { Fabricate(:fake_model) }
before do
SiteSetting.inferred_concepts_match_persona = -1
SiteSetting.inferred_concepts_enabled = true
# Set up the post's user
post.update!(user: user)
end
describe "#apply_to_topic" do
it "does nothing for blank topic or concepts" do
expect { applier.apply_to_topic(nil, [concept1]) }.not_to raise_error
expect { applier.apply_to_topic(topic, []) }.not_to raise_error
expect { applier.apply_to_topic(topic, nil) }.not_to raise_error
end
it "associates concepts with topic" do
applier.apply_to_topic(topic, [concept1, concept2])
expect(topic.inferred_concepts).to include(concept1, concept2)
expect(concept1.topics).to include(topic)
expect(concept2.topics).to include(topic)
end
end
describe "#apply_to_post" do
it "does nothing for blank post or concepts" do
expect { applier.apply_to_post(nil, [concept1]) }.not_to raise_error
expect { applier.apply_to_post(post, []) }.not_to raise_error
expect { applier.apply_to_post(post, nil) }.not_to raise_error
end
it "associates concepts with post" do
applier.apply_to_post(post, [concept1, concept2])
expect(post.inferred_concepts).to include(concept1, concept2)
expect(concept1.posts).to include(post)
expect(concept2.posts).to include(post)
end
end
describe "#topic_content_for_analysis" do
it "returns empty string for blank topic" do
expect(applier.topic_content_for_analysis(nil)).to eq("")
end
it "extracts title and posts content" do
# Create additional posts for the topic
post1 = Fabricate(:post, topic: topic, post_number: 1, raw: "First post content", user: user)
post2 = Fabricate(:post, topic: topic, post_number: 2, raw: "Second post content", user: user)
content = applier.topic_content_for_analysis(topic)
expect(content).to include(topic.title)
expect(content).to include("First post content")
expect(content).to include("Second post content")
expect(content).to include(user.username)
expect(content).to include("1)")
expect(content).to include("2)")
end
it "limits to first 10 posts" do
# Create 12 posts for the topic
12.times { |i| Fabricate(:post, topic: topic, post_number: i + 1, user: user) }
allow(Post).to receive(:where).with(topic_id: topic.id).and_call_original
allow_any_instance_of(ActiveRecord::Relation).to receive(:limit).with(10).and_call_original
applier.topic_content_for_analysis(topic)
expect(Post).to have_received(:where).with(topic_id: topic.id)
end
end
describe "#post_content_for_analysis" do
it "returns empty string for blank post" do
expect(applier.post_content_for_analysis(nil)).to eq("")
end
it "extracts post content with topic context" do
content = applier.post_content_for_analysis(post)
expect(content).to include(post.topic.title)
expect(content).to include(post.raw)
expect(content).to include(post.user.username)
expect(content).to include("Topic:")
expect(content).to include("Post by")
end
it "handles post without topic" do
# Mock the post to return nil for topic
allow(post).to receive(:topic).and_return(nil)
content = applier.post_content_for_analysis(post)
expect(content).to include(post.raw)
expect(content).to include(post.user.username)
expect(content).to include("Topic: ")
end
end
describe "#match_existing_concepts" do
let(:manager) { instance_double(DiscourseAi::InferredConcepts::Manager) }
before do
allow(DiscourseAi::InferredConcepts::Manager).to receive(:new).and_return(manager)
allow(manager).to receive(:list_concepts).and_return(%w[programming testing ruby])
end
it "returns empty array for blank topic" do
expect(applier.match_existing_concepts(nil)).to eq([])
end
it "returns empty array when no existing concepts" do
allow(manager).to receive(:list_concepts).and_return([])
result = applier.match_existing_concepts(topic)
expect(result).to eq([])
end
it "matches concepts and applies them to topic" do
# Test the real implementation without stubbing internal methods
allow(InferredConcept).to receive(:where).with(name: ["programming"]).and_return([concept1])
# Mock the LLM interaction
persona_instance_double = instance_spy("DiscourseAi::Personas::Persona")
bot_double = instance_spy(DiscourseAi::Personas::Bot)
structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput")
persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles
allow(AiPersona).to receive(:all_personas).and_return([persona_class_double])
allow(persona_class_double).to receive(:id).and_return(SiteSetting.inferred_concepts_match_persona.to_i)
allow(persona_class_double).to receive(:new).and_return(persona_instance_double)
allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id)
allow(persona_instance_double).to receive(:class).and_return(persona_class_double)
allow(LlmModel).to receive(:find).and_return(llm_model)
allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double)
allow(bot_double).to receive(:reply).and_yield(
structured_output_double,
nil,
:structured_output,
)
allow(structured_output_double).to receive(:read_buffered_property).with(
:matching_concepts,
).and_return(["programming"])
result = applier.match_existing_concepts(topic)
expect(result).to eq([concept1])
end
end
describe "#match_existing_concepts_for_post" do
let(:manager) { instance_double(DiscourseAi::InferredConcepts::Manager) }
before do
allow(DiscourseAi::InferredConcepts::Manager).to receive(:new).and_return(manager)
allow(manager).to receive(:list_concepts).and_return(%w[programming testing ruby])
end
it "returns empty array for blank post" do
expect(applier.match_existing_concepts_for_post(nil)).to eq([])
end
it "returns empty array when no existing concepts" do
allow(manager).to receive(:list_concepts).and_return([])
result = applier.match_existing_concepts_for_post(post)
expect(result).to eq([])
end
it "matches concepts and applies them to post" do
# Test the real implementation without stubbing internal methods
allow(InferredConcept).to receive(:where).with(name: ["testing"]).and_return([concept2])
# Mock the LLM interaction
persona_instance_double = instance_spy("DiscourseAi::Personas::Persona")
bot_double = instance_spy(DiscourseAi::Personas::Bot)
structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput")
persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles
allow(AiPersona).to receive(:all_personas).and_return([persona_class_double])
allow(persona_class_double).to receive(:id).and_return(SiteSetting.inferred_concepts_match_persona.to_i)
allow(persona_class_double).to receive(:new).and_return(persona_instance_double)
allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id)
allow(persona_instance_double).to receive(:class).and_return(persona_class_double)
allow(LlmModel).to receive(:find).and_return(llm_model)
allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double)
allow(bot_double).to receive(:reply).and_yield(
structured_output_double,
nil,
:structured_output,
)
allow(structured_output_double).to receive(:read_buffered_property).with(
:matching_concepts,
).and_return(["testing"])
result = applier.match_existing_concepts_for_post(post)
expect(result).to eq([concept2])
end
end
describe "#match_concepts_to_content" do
it "returns empty array for blank content or concept list" do
expect(applier.match_concepts_to_content("", ["concept1"])).to eq([])
expect(applier.match_concepts_to_content(nil, ["concept1"])).to eq([])
expect(applier.match_concepts_to_content("content", [])).to eq([])
expect(applier.match_concepts_to_content("content", nil)).to eq([])
end
it "uses ConceptMatcher persona to match concepts" do
content = "This is about Ruby programming"
concept_list = %w[programming testing ruby]
structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput")
persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles
persona_instance_double = instance_spy("DiscourseAi::Personas::Persona")
bot_double = instance_spy(DiscourseAi::Personas::Bot)
allow(AiPersona).to receive(:all_personas).and_return([persona_class_double])
allow(persona_class_double).to receive(:id).and_return(SiteSetting.inferred_concepts_match_persona.to_i)
allow(persona_class_double).to receive(:new).and_return(persona_instance_double)
allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id)
allow(persona_instance_double).to receive(:class).and_return(persona_class_double)
allow(LlmModel).to receive(:find).and_return(llm_model)
allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double)
allow(bot_double).to receive(:reply).and_yield(
structured_output_double,
nil,
:structured_output,
)
allow(structured_output_double).to receive(:read_buffered_property).with(
:matching_concepts,
).and_return(%w[programming ruby])
result = applier.match_concepts_to_content(content, concept_list)
expect(result).to eq(%w[programming ruby])
expect(bot_double).to have_received(:reply)
expect(structured_output_double).to have_received(:read_buffered_property).with(
:matching_concepts,
)
end
it "handles no structured output gracefully" do
content = "Test content"
concept_list = ["concept1"]
persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles
persona_instance_double = instance_double("DiscourseAi::Personas::Persona")
bot_double = instance_double("DiscourseAi::Personas::Bot")
allow(AiPersona).to receive(:all_personas).and_return([persona_class_double])
allow(persona_class_double).to receive(:id).and_return(SiteSetting.inferred_concepts_match_persona.to_i)
allow(persona_class_double).to receive(:new).and_return(persona_instance_double)
allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id)
allow(persona_instance_double).to receive(:class).and_return(persona_class_double)
allow(LlmModel).to receive(:find).and_return(llm_model)
allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double)
allow(bot_double).to receive(:reply).and_yield(nil, nil, :text)
result = applier.match_concepts_to_content(content, concept_list)
expect(result).to eq([])
end
it "returns empty array when no matching concepts found" do
content = "This is about something else"
concept_list = %w[programming testing]
expected_response = [['{"matching_concepts": []}']]
persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles
persona_instance_double = instance_double("DiscourseAi::Personas::Persona")
bot_double = instance_double("DiscourseAi::Personas::Bot")
allow(AiPersona).to receive(:all_personas).and_return([persona_class_double])
allow(persona_class_double).to receive(:id).and_return(SiteSetting.inferred_concepts_match_persona.to_i)
allow(persona_class_double).to receive(:new).and_return(persona_instance_double)
allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id)
allow(persona_instance_double).to receive(:class).and_return(persona_class_double)
allow(LlmModel).to receive(:find).and_return(llm_model)
allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double)
allow(bot_double).to receive(:reply).and_return(expected_response)
result = applier.match_concepts_to_content(content, concept_list)
expect(result).to eq([])
end
it "handles missing matching_concepts key in response" do
content = "Test content"
concept_list = ["concept1"]
expected_response = [['{"other_key": ["value"]}']]
persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles
persona_instance_double = instance_double("DiscourseAi::Personas::Persona")
bot_double = instance_double("DiscourseAi::Personas::Bot")
allow(AiPersona).to receive(:all_personas).and_return([persona_class_double])
allow(persona_class_double).to receive(:id).and_return(SiteSetting.inferred_concepts_match_persona.to_i)
allow(persona_class_double).to receive(:new).and_return(persona_instance_double)
allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id)
allow(persona_instance_double).to receive(:class).and_return(persona_class_double)
allow(LlmModel).to receive(:find).and_return(llm_model)
allow(DiscourseAi::Personas::Bot).to receive(:as).and_return(bot_double)
allow(bot_double).to receive(:reply).and_return(expected_response)
result = applier.match_concepts_to_content(content, concept_list)
expect(result).to eq([])
end
end
end