# frozen_string_literal: true RSpec.describe DiscourseAi::InferredConcepts::Finder do subject(:finder) { described_class.new } fab!(:topic) { Fabricate(:topic, posts_count: 5, views: 200, like_count: 15) } fab!(:post) { Fabricate(:post, like_count: 10) } 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_generate_persona = -1 SiteSetting.inferred_concepts_deduplicate_persona = -1 SiteSetting.inferred_concepts_enabled = true end describe "#identify_concepts" do it "returns empty array for blank content" do expect(finder.identify_concepts("")).to eq([]) expect(finder.identify_concepts(nil)).to eq([]) end it "uses ConceptFinder persona to identify concepts" do content = "This is about Ruby programming and testing" structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput") # Mock the persona and bot interaction persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles 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_generate_persona.to_i, ) allow(persona_class_double).to receive(:new).and_return(persona_instance_double) allow(persona_instance_double).to receive(:class).and_return(persona_class_double) allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) allow(LlmModel).to receive(:find).with(llm_model.id).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( :concepts, ).and_return(%w[ruby programming testing]) result = finder.identify_concepts(content) expect(result).to eq(%w[ruby programming testing]) end it "handles no structured output gracefully" do content = "Test content" persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles 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_generate_persona.to_i, ) allow(persona_class_double).to receive(:new).and_return(persona_instance_double) allow(persona_instance_double).to receive(:class).and_return(persona_class_double) allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) allow(LlmModel).to receive(:find).with(llm_model.id).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 = finder.identify_concepts(content) expect(result).to eq([]) end end describe "#create_or_find_concepts" do it "returns empty array for blank concept names" do expect(finder.create_or_find_concepts([])).to eq([]) expect(finder.create_or_find_concepts(nil)).to eq([]) end it "creates new concepts for new names" do concept_names = %w[new_concept1 new_concept2] result = finder.create_or_find_concepts(concept_names) expect(result.length).to eq(2) expect(result.map(&:name)).to match_array(concept_names) expect(InferredConcept.where(name: concept_names).count).to eq(2) end it "finds existing concepts" do concept_names = %w[programming testing] result = finder.create_or_find_concepts(concept_names) expect(result.length).to eq(2) expect(result).to include(concept1, concept2) end it "handles mix of new and existing concepts" do concept_names = %w[programming new_concept] result = finder.create_or_find_concepts(concept_names) expect(result.length).to eq(2) expect(result.map(&:name)).to match_array(concept_names) end end describe "#find_candidate_topics" do let!(:good_topic) { Fabricate(:topic, posts_count: 6, views: 150, like_count: 12) } let!(:bad_topic) { Fabricate(:topic, posts_count: 2, views: 50, like_count: 2) } let!(:topic_with_concepts) do t = Fabricate(:topic, posts_count: 8, views: 200, like_count: 20) t.inferred_concepts << concept1 t end it "finds topics meeting minimum criteria" do candidates = finder.find_candidate_topics(min_posts: 5, min_views: 100, min_likes: 10) expect(candidates).to include(good_topic) expect(candidates).not_to include(bad_topic) expect(candidates).not_to include(topic_with_concepts) # already has concepts end it "respects limit parameter" do candidates = finder.find_candidate_topics(limit: 1) expect(candidates.length).to be <= 1 end it "excludes specified topic IDs" do candidates = finder.find_candidate_topics(exclude_topic_ids: [good_topic.id]) expect(candidates).not_to include(good_topic) end it "filters by category IDs when provided" do category = Fabricate(:category) topic_in_category = Fabricate(:topic, category: category, posts_count: 6, views: 150, like_count: 12) candidates = finder.find_candidate_topics(category_ids: [category.id]) expect(candidates).to include(topic_in_category) expect(candidates).not_to include(good_topic) end it "filters by creation date" do old_topic = Fabricate(:topic, posts_count: 6, views: 150, like_count: 12, created_at: 45.days.ago) candidates = finder.find_candidate_topics(created_after: 30.days.ago) expect(candidates).to include(good_topic) expect(candidates).not_to include(old_topic) end end describe "#find_candidate_posts" do let!(:good_post) { Fabricate(:post, like_count: 8, post_number: 2) } let!(:bad_post) { Fabricate(:post, like_count: 2, post_number: 2) } let!(:first_post) { Fabricate(:post, like_count: 10, post_number: 1) } let!(:post_with_concepts) do p = Fabricate(:post, like_count: 15, post_number: 3) p.inferred_concepts << concept1 p end it "finds posts meeting minimum criteria" do candidates = finder.find_candidate_posts(min_likes: 5) expect(candidates).to include(good_post) expect(candidates).not_to include(bad_post) expect(candidates).not_to include(post_with_concepts) # already has concepts end it "excludes first posts by default" do candidates = finder.find_candidate_posts(min_likes: 5) expect(candidates).not_to include(first_post) end it "can include first posts when specified" do candidates = finder.find_candidate_posts(min_likes: 5, exclude_first_posts: false) expect(candidates).to include(first_post) end it "respects limit parameter" do candidates = finder.find_candidate_posts(limit: 1) expect(candidates.length).to be <= 1 end it "excludes specified post IDs" do candidates = finder.find_candidate_posts(exclude_post_ids: [good_post.id]) expect(candidates).not_to include(good_post) end it "filters by category IDs when provided" do category = Fabricate(:category) topic_in_category = Fabricate(:topic, category: category) post_in_category = Fabricate(:post, topic: topic_in_category, like_count: 8, post_number: 2) candidates = finder.find_candidate_posts(category_ids: [category.id]) expect(candidates).to include(post_in_category) expect(candidates).not_to include(good_post) end it "filters by creation date" do old_post = Fabricate(:post, like_count: 8, post_number: 2, created_at: 45.days.ago) candidates = finder.find_candidate_posts(created_after: 30.days.ago) expect(candidates).to include(good_post) expect(candidates).not_to include(old_post) end end describe "#deduplicate_concepts" do it "returns empty result for blank concept names" do result = finder.deduplicate_concepts([]) expect(result).to eq({ deduplicated_concepts: [], mapping: {} }) result = finder.deduplicate_concepts(nil) expect(result).to eq({ deduplicated_concepts: [], mapping: {} }) end it "uses ConceptDeduplicator persona to deduplicate concepts" do concept_names = ["ruby", "Ruby programming", "testing", "unit testing"] structured_output_double = instance_double("DiscourseAi::Completions::StructuredOutput") persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles 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_deduplicate_persona.to_i, ) allow(persona_class_double).to receive(:new).and_return(persona_instance_double) allow(persona_instance_double).to receive(:class).and_return(persona_class_double) allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) allow(LlmModel).to receive(:find).with(llm_model.id).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( :streamlined_tags, ).and_return(%w[ruby testing]) result = finder.deduplicate_concepts(concept_names) expect(result).to eq(%w[ruby testing]) end it "handles no structured output gracefully" do concept_names = %w[concept1 concept2] persona_class_double = double("PersonaClass") # rubocop:disable RSpec/VerifiedDoubles persona_instance_double = double("PersonaInstance") # rubocop:disable RSpec/VerifiedDoubles 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_deduplicate_persona.to_i, ) allow(persona_class_double).to receive(:new).and_return(persona_instance_double) allow(persona_instance_double).to receive(:class).and_return(persona_class_double) allow(persona_class_double).to receive(:default_llm_id).and_return(llm_model.id) allow(LlmModel).to receive(:find).with(llm_model.id).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 = finder.deduplicate_concepts(concept_names) expect(result).to eq([]) end end end