mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-01 03:52:34 +00:00
321 lines
14 KiB
Ruby
321 lines
14 KiB
Ruby
|
# 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
|