Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 9e68d4f

Browse files
committed
FEATURE: add inferred concepts system
This commit adds a new inferred concepts system that: - Creates a model for storing concept labels that can be applied to topics - Provides AI personas for finding new concepts and matching existing ones - Adds jobs for generating concepts from popular topics - Includes a scheduled job that automatically processes engaging topics
1 parent cf45e68 commit 9e68d4f

15 files changed

+581
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class ApplyInferredConcepts < ::Jobs::Base
5+
sidekiq_options queue: 'low'
6+
7+
# Process a batch of topics to apply existing concepts to them
8+
#
9+
# @param args [Hash] Contains job arguments
10+
# @option args [Array<Integer>] :topic_ids Required - List of topic IDs to process
11+
# @option args [Integer] :batch_size (100) Number of topics to process in each batch
12+
def execute(args = {})
13+
return if args[:topic_ids].blank?
14+
15+
# Process topics in smaller batches to avoid memory issues
16+
batch_size = args[:batch_size] || 100
17+
18+
# Get the list of topic IDs
19+
topic_ids = args[:topic_ids]
20+
21+
# Process topics in batches
22+
topic_ids.each_slice(batch_size) do |batch_topic_ids|
23+
process_batch(batch_topic_ids)
24+
end
25+
end
26+
27+
private
28+
29+
def process_batch(topic_ids)
30+
topics = Topic.where(id: topic_ids)
31+
32+
topics.each do |topic|
33+
begin
34+
process_topic(topic)
35+
rescue => e
36+
Rails.logger.error("Error applying concepts to topic #{topic.id}: #{e.message}\n#{e.backtrace.join("\n")}")
37+
end
38+
end
39+
end
40+
41+
def process_topic(topic)
42+
# Match topic against existing concepts and apply them
43+
# Pass the topic object directly
44+
DiscourseAi::InferredConcepts::Manager.match_topic_to_concepts(topic)
45+
end
46+
end
47+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class GenerateInferredConcepts < ::Jobs::Base
5+
sidekiq_options queue: 'low'
6+
7+
# Process a batch of topics to generate new concepts (without applying them to topics)
8+
#
9+
# @param args [Hash] Contains job arguments
10+
# @option args [Array<Integer>] :topic_ids Required - List of topic IDs to process
11+
# @option args [Integer] :batch_size (100) Number of topics to process in each batch
12+
def execute(args = {})
13+
return if args[:topic_ids].blank?
14+
15+
# Process topics in smaller batches to avoid memory issues
16+
batch_size = args[:batch_size] || 100
17+
18+
# Get the list of topic IDs
19+
topic_ids = args[:topic_ids]
20+
21+
# Process topics in batches
22+
topic_ids.each_slice(batch_size) do |batch_topic_ids|
23+
process_batch(batch_topic_ids)
24+
end
25+
end
26+
27+
private
28+
29+
def process_batch(topic_ids)
30+
topics = Topic.where(id: topic_ids)
31+
32+
topics.each do |topic|
33+
begin
34+
process_topic(topic)
35+
rescue => e
36+
Rails.logger.error("Error generating concepts from topic #{topic.id}: #{e.message}\n#{e.backtrace.join("\n")}")
37+
end
38+
end
39+
end
40+
41+
def process_topic(topic)
42+
# Use the Manager method that handles both identifying and creating concepts
43+
# Pass the topic object directly
44+
DiscourseAi::InferredConcepts::Manager.generate_concepts_from_topic(topic)
45+
end
46+
end
47+
end
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class GenerateConceptsFromPopularTopics < ::Jobs::Scheduled
5+
every 1.day
6+
7+
# This job runs daily and generates new concepts from popular topics
8+
# It selects topics based on engagement metrics and generates concepts from their content
9+
def execute(args = {})
10+
# Find candidate topics that are popular and don't have concepts yet
11+
candidates = DiscourseAi::InferredConcepts::Manager.find_candidate_topics(
12+
limit: SiteSetting.inferred_concepts_daily_topics_limit || 20,
13+
min_posts: SiteSetting.inferred_concepts_min_posts || 5,
14+
min_likes: SiteSetting.inferred_concepts_min_likes || 10,
15+
min_views: SiteSetting.inferred_concepts_min_views || 100,
16+
created_after: SiteSetting.inferred_concepts_lookback_days.days.ago
17+
)
18+
19+
return if candidates.blank?
20+
21+
# Process the candidate topics in batches using the regular job
22+
Jobs.enqueue(
23+
:generate_inferred_concepts,
24+
topic_ids: candidates.map(&:id),
25+
batch_size: 10
26+
)
27+
28+
# Schedule a follow-up job to apply the concepts to topics
29+
# This runs after a delay to ensure concepts have been generated
30+
Jobs.enqueue_in(
31+
1.hour,
32+
:apply_inferred_concepts,
33+
topic_ids: candidates.map(&:id),
34+
batch_size: 10
35+
)
36+
end
37+
end
38+
end

app/models/inferred_concept.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
class InferredConcept < ActiveRecord::Base
4+
has_and_belongs_to_many :topics
5+
6+
validates :name, presence: true, uniqueness: true
7+
end
8+
9+
# == Schema Information
10+
#
11+
# Table name: inferred_concepts
12+
#
13+
# id :bigint not null, primary key
14+
# name :string not null
15+
# created_at :datetime not null
16+
# updated_at :datetime not null
17+
#
18+
# Indexes
19+
#
20+
# index_inferred_concepts_on_name (name) UNIQUE
21+
#
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class InferredConceptSerializer < ApplicationSerializer
4+
attributes :id, :name, :created_at, :updated_at
5+
end

config/settings.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,28 @@ discourse_ai:
391391
default: true
392392
client: true
393393

394+
inferred_concepts_enabled:
395+
default: false
396+
client: true
397+
description: "Enable the inferred concepts system that automatically generates and applies concepts to topics"
398+
inferred_concepts_daily_topics_limit:
399+
default: 20
400+
client: false
401+
description: "Maximum number of topics to process each day for concept generation"
402+
inferred_concepts_min_posts:
403+
default: 5
404+
client: false
405+
description: "Minimum number of posts a topic must have to be considered for concept generation"
406+
inferred_concepts_min_likes:
407+
default: 10
408+
client: false
409+
description: "Minimum number of likes a topic must have to be considered for concept generation"
410+
inferred_concepts_min_views:
411+
default: 100
412+
client: false
413+
description: "Minimum number of views a topic must have to be considered for concept generation"
414+
inferred_concepts_lookback_days:
415+
default: 30
416+
client: false
417+
description: "Only consider topics created within this many days for concept generation"
418+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
class CreateInferredConceptsTable < ActiveRecord::Migration[7.2]
3+
def change
4+
create_table :inferred_concepts do |t|
5+
t.string :name, null: false
6+
t.timestamps
7+
end
8+
9+
add_index :inferred_concepts, :name, unique: true
10+
end
11+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
class CreateTopicsInferredConcepts < ActiveRecord::Migration[7.0]
4+
def change
5+
create_table :topics_inferred_concepts do |t|
6+
t.integer :topic_id, null: false
7+
t.integer :inferred_concept_id, null: false
8+
t.timestamps
9+
end
10+
11+
add_index :topics_inferred_concepts, [:topic_id, :inferred_concept_id], unique: true, name: 'idx_unique_topic_inferred_concept'
12+
add_index :topics_inferred_concepts, :topic_id
13+
add_index :topics_inferred_concepts, :inferred_concept_id
14+
end
15+
end

lib/inferred_concepts/applier.rb

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module InferredConcepts
5+
class Applier
6+
# Associates the provided concepts with a topic
7+
# topic: a Topic instance
8+
# concepts: an array of InferredConcept instances
9+
def self.apply_to_topic(topic, concepts)
10+
return if topic.blank? || concepts.blank?
11+
12+
concepts.each do |concept|
13+
# Use the join table to associate the concept with the topic
14+
# Avoid duplicates by using find_or_create_by
15+
ActiveRecord::Base.connection.execute(<<~SQL)
16+
INSERT INTO topics_inferred_concepts (topic_id, inferred_concept_id, created_at, updated_at)
17+
VALUES (#{topic.id}, #{concept.id}, NOW(), NOW())
18+
ON CONFLICT (topic_id, inferred_concept_id) DO NOTHING
19+
SQL
20+
end
21+
end
22+
23+
# Extracts content from a topic for concept analysis
24+
# Returns a string with the topic title and first few posts
25+
def self.topic_content_for_analysis(topic)
26+
return "" if topic.blank?
27+
28+
# Combine title and first few posts for analysis
29+
posts = Post.where(topic_id: topic.id).order(:post_number).limit(10)
30+
31+
content = "Title: #{topic.title}\n\n"
32+
content += posts.map do |p|
33+
"#{p.post_number}) #{p.user.username}: #{p.raw}"
34+
end.join("\n\n")
35+
36+
content
37+
end
38+
39+
# Comprehensive method to analyze a topic and apply concepts
40+
def self.analyze_and_apply(topic)
41+
return if topic.blank?
42+
43+
# Get content to analyze
44+
content = topic_content_for_analysis(topic)
45+
46+
# Identify concepts
47+
concept_names = Finder.identify_concepts(content)
48+
49+
# Create or find concepts in the database
50+
concepts = Finder.create_or_find_concepts(concept_names)
51+
52+
# Apply concepts to the topic
53+
apply_to_topic(topic, concepts)
54+
55+
concepts
56+
end
57+
58+
# Match a topic with existing concepts
59+
def self.match_existing_concepts(topic)
60+
return [] if topic.blank?
61+
62+
# Get content to analyze
63+
content = topic_content_for_analysis(topic)
64+
65+
# Get all existing concepts
66+
existing_concepts = InferredConcept.all.pluck(:name)
67+
return [] if existing_concepts.empty?
68+
69+
# Use the ConceptMatcher persona to match concepts
70+
matched_concept_names = match_concepts_to_content(content, existing_concepts)
71+
72+
# Find concepts in the database
73+
matched_concepts = InferredConcept.where(name: matched_concept_names)
74+
75+
# Apply concepts to the topic
76+
apply_to_topic(topic, matched_concepts)
77+
78+
matched_concepts
79+
end
80+
81+
# Use ConceptMatcher persona to match content against provided concepts
82+
def self.match_concepts_to_content(content, concept_list)
83+
return [] if content.blank? || concept_list.blank?
84+
85+
# Prepare user message with content and concept list
86+
user_message = <<~MESSAGE
87+
Content to analyze:
88+
#{content}
89+
90+
Available concepts to match:
91+
#{concept_list.join(", ")}
92+
MESSAGE
93+
94+
# Use the ConceptMatcher persona to match concepts
95+
llm = DiscourseAi::Completions::Llm.default_llm
96+
persona = DiscourseAi::Personas::ConceptMatcher.new
97+
context = DiscourseAi::Personas::BotContext.new(
98+
messages: [{ type: :user, content: user_message }],
99+
user: Discourse.system_user
100+
)
101+
102+
prompt = persona.craft_prompt(context)
103+
response = llm.completion(prompt, extract_json: true)
104+
105+
return [] unless response.success?
106+
107+
matching_concepts = response.parsed_output["matching_concepts"]
108+
matching_concepts || []
109+
end
110+
end
111+
end
112+
end

0 commit comments

Comments
 (0)