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

Commit 44391e2

Browse files
committed
FEATURE: Extend inferred concepts to include posts
* Adds support for concepts to be inferred from and applied to posts * Replaces daily task with one that handles both topics and posts * Adds database migration for posts_inferred_concepts join table * Updates PersonaContext to include inferred concepts
1 parent 9e68d4f commit 44391e2

18 files changed

+482
-181
lines changed

.claude/settings.local.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(bundle exec rails g migration:*)"
5+
],
6+
"deny": []
7+
}
8+
}

app/jobs/regular/apply_inferred_concepts.rb

Lines changed: 0 additions & 47 deletions
This file was deleted.

app/jobs/regular/generate_inferred_concepts.rb

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,64 @@ module Jobs
44
class GenerateInferredConcepts < ::Jobs::Base
55
sidekiq_options queue: 'low'
66

7-
# Process a batch of topics to generate new concepts (without applying them to topics)
7+
# Process items to generate new concepts
88
#
99
# @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
10+
# @option args [String] :item_type Required - Type of items to process ('topics' or 'posts')
11+
# @option args [Array<Integer>] :item_ids Required - List of item IDs to process
12+
# @option args [Integer] :batch_size (100) Number of items to process in each batch
13+
# @option args [Boolean] :match_only (false) Only match against existing concepts without generating new ones
1214
def execute(args = {})
13-
return if args[:topic_ids].blank?
15+
return if args[:item_ids].blank? || args[:item_type].blank?
1416

15-
# Process topics in smaller batches to avoid memory issues
17+
unless ['topics', 'posts'].include?(args[:item_type])
18+
Rails.logger.error("Invalid item_type for GenerateInferredConcepts: #{args[:item_type]}")
19+
return
20+
end
21+
22+
# Process items in smaller batches to avoid memory issues
1623
batch_size = args[:batch_size] || 100
1724

18-
# Get the list of topic IDs
19-
topic_ids = args[:topic_ids]
25+
# Get the list of item IDs
26+
item_ids = args[:item_ids]
27+
match_only = args[:match_only] || false
2028

21-
# Process topics in batches
22-
topic_ids.each_slice(batch_size) do |batch_topic_ids|
23-
process_batch(batch_topic_ids)
29+
# Process items in batches
30+
item_ids.each_slice(batch_size) do |batch_item_ids|
31+
process_batch(batch_item_ids, args[:item_type], match_only)
2432
end
2533
end
2634

2735
private
2836

29-
def process_batch(topic_ids)
30-
topics = Topic.where(id: topic_ids)
37+
def process_batch(item_ids, item_type, match_only)
38+
klass = item_type.singularize.classify.constantize
39+
items = klass.where(id: item_ids)
3140

32-
topics.each do |topic|
41+
items.each do |item|
3342
begin
34-
process_topic(topic)
43+
process_item(item, item_type, match_only)
3544
rescue => e
36-
Rails.logger.error("Error generating concepts from topic #{topic.id}: #{e.message}\n#{e.backtrace.join("\n")}")
45+
Rails.logger.error("Error generating concepts from #{item_type.singularize} #{item.id}: #{e.message}\n#{e.backtrace.join("\n")}")
3746
end
3847
end
3948
end
4049

41-
def process_topic(topic)
50+
def process_item(item, item_type, match_only)
4251
# 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)
52+
if match_only
53+
if item_type == 'topics'
54+
DiscourseAi::InferredConcepts::Manager.match_topic_to_concepts(item)
55+
else # posts
56+
DiscourseAi::InferredConcepts::Manager.match_post_to_concepts(item)
57+
end
58+
else
59+
if item_type == 'topics'
60+
DiscourseAi::InferredConcepts::Manager.analyze_topic(item)
61+
else # posts
62+
DiscourseAi::InferredConcepts::Manager.analyze_post(item)
63+
end
64+
end
4565
end
4666
end
4767
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
module Jobs
4+
class GenerateConceptsFromPopularItems < ::Jobs::Scheduled
5+
every 1.day
6+
7+
# This job runs daily and generates new concepts from popular topics and posts
8+
# It selects items based on engagement metrics and generates concepts from their content
9+
def execute(args = {})
10+
return unless SiteSetting.inferred_concepts_enabled
11+
12+
process_popular_topics
13+
process_popular_posts
14+
end
15+
16+
private
17+
18+
def process_popular_topics
19+
20+
# Find candidate topics that are popular and don't have concepts yet
21+
candidates = DiscourseAi::InferredConcepts::Manager.find_candidate_topics(
22+
limit: SiteSetting.inferred_concepts_daily_topics_limit || 20,
23+
min_posts: SiteSetting.inferred_concepts_min_posts || 5,
24+
min_likes: SiteSetting.inferred_concepts_min_likes || 10,
25+
min_views: SiteSetting.inferred_concepts_min_views || 100,
26+
created_after: SiteSetting.inferred_concepts_lookback_days.days.ago
27+
)
28+
29+
return if candidates.blank?
30+
31+
# Process candidate topics - first generate concepts, then match
32+
Jobs.enqueue(
33+
:generate_inferred_concepts,
34+
item_type: 'topics',
35+
item_ids: candidates.map(&:id),
36+
batch_size: 10
37+
)
38+
39+
# Schedule a follow-up job to match existing concepts
40+
Jobs.enqueue_in(
41+
1.hour,
42+
:generate_inferred_concepts,
43+
item_type: 'topics',
44+
item_ids: candidates.map(&:id),
45+
batch_size: 10,
46+
match_only: true
47+
)
48+
end
49+
50+
def process_popular_posts
51+
52+
# Find candidate posts that are popular and don't have concepts yet
53+
candidates = DiscourseAi::InferredConcepts::Manager.find_candidate_posts(
54+
limit: SiteSetting.inferred_concepts_daily_posts_limit || 30,
55+
min_likes: SiteSetting.inferred_concepts_post_min_likes || 5,
56+
exclude_first_posts: true,
57+
created_after: SiteSetting.inferred_concepts_lookback_days.days.ago
58+
)
59+
60+
return if candidates.blank?
61+
62+
# Process candidate posts - first generate concepts, then match
63+
Jobs.enqueue(
64+
:generate_inferred_concepts,
65+
item_type: 'posts',
66+
item_ids: candidates.map(&:id),
67+
batch_size: 10
68+
)
69+
70+
# Schedule a follow-up job to match against existing concepts
71+
Jobs.enqueue_in(
72+
1.hour,
73+
:generate_inferred_concepts,
74+
item_type: 'posts',
75+
item_ids: candidates.map(&:id),
76+
batch_size: 10,
77+
match_only: true
78+
)
79+
end
80+
end
81+
end

app/jobs/scheduled/generate_concepts_from_popular_topics.rb

Lines changed: 0 additions & 38 deletions
This file was deleted.

app/models/inferred_concept.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
class InferredConcept < ActiveRecord::Base
44
has_and_belongs_to_many :topics
5+
has_and_belongs_to_many :posts
56

67
validates :name, presence: true, uniqueness: true
78
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
class AiInferredConceptPostSerializer < ApplicationSerializer
4+
attributes :id,
5+
:post_number,
6+
:topic_id,
7+
:topic_title,
8+
:username,
9+
:avatar_template,
10+
:created_at,
11+
:updated_at,
12+
:excerpt,
13+
:truncated,
14+
:inferred_concepts
15+
16+
def avatar_template
17+
User.avatar_template(object.username, object.uploaded_avatar_id)
18+
end
19+
20+
def excerpt
21+
Post.excerpt(object.cooked)
22+
end
23+
24+
def truncated
25+
object.cooked.length > SiteSetting.post_excerpt_maxlength
26+
end
27+
28+
def inferred_concepts
29+
ActiveModel::ArraySerializer.new(
30+
object.inferred_concepts,
31+
each_serializer: InferredConceptSerializer
32+
)
33+
end
34+
end

config/locales/server.en.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,12 @@ en:
323323
short_summarizer:
324324
name: "Summarizer (short form)"
325325
description: "Default persona used to power AI short summaries for topic lists' items"
326+
concept_finder:
327+
name: "Concept Finder"
328+
description: "AI Bot specialized in identifying concepts and themes in content"
329+
concept_matcher:
330+
name: "Concept Matcher"
331+
description: "AI Bot specialized in matching content against existing concepts"
326332
topic_not_found: "Summary unavailable, topic not found!"
327333
summarizing: "Summarizing topic"
328334
searching: "Searching for: '%{query}'"

config/settings.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,4 +415,12 @@ discourse_ai:
415415
default: 30
416416
client: false
417417
description: "Only consider topics created within this many days for concept generation"
418+
inferred_concepts_daily_posts_limit:
419+
default: 30
420+
client: false
421+
description: "Maximum number of posts to process each day for concept generation"
422+
inferred_concepts_post_min_likes:
423+
default: 5
424+
client: false
425+
description: "Minimum number of likes a post must have to be considered for concept generation"
418426

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 CreatePostsInferredConcepts < ActiveRecord::Migration[7.0]
4+
def change
5+
create_table :posts_inferred_concepts do |t|
6+
t.integer :post_id, null: false
7+
t.integer :inferred_concept_id, null: false
8+
t.timestamps
9+
end
10+
11+
add_index :posts_inferred_concepts, [:post_id, :inferred_concept_id], unique: true, name: 'idx_unique_post_inferred_concept'
12+
add_index :posts_inferred_concepts, :post_id
13+
add_index :posts_inferred_concepts, :inferred_concept_id
14+
end
15+
end

0 commit comments

Comments
 (0)