FEATURE: allow researcher to also research specific topics (#1339)

* FEATURE: allow researcher to also research specific topics

Also improve UI around research with more accurate info

* this ensures that under no conditions PMs will be included
This commit is contained in:
Sam 2025-05-15 17:48:21 +10:00 committed by GitHub
parent 2c6459429f
commit 1b3fdad5c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 9 deletions

View File

@ -399,6 +399,7 @@ en:
create_image: "Creating image" create_image: "Creating image"
edit_image: "Editing image" edit_image: "Editing image"
researcher: "Researching" researcher: "Researching"
researcher_dry_run: "Preparing research"
tool_help: tool_help:
read_artifact: "Read a web artifact using the AI Bot" read_artifact: "Read a web artifact using the AI Bot"
update_artifact: "Update a web artifact using the AI Bot" update_artifact: "Update a web artifact using the AI Bot"
@ -461,11 +462,11 @@ en:
setting_context: "Reading context for: %{setting_name}" setting_context: "Reading context for: %{setting_name}"
schema: "%{tables}" schema: "%{tables}"
researcher_dry_run: researcher_dry_run:
one: "Proposed research: %{goals}\n\nFound %{count} result for '%{filter}'" one: "Proposed goals: %{goals}\n\nFound %{count} post matching '%{filter}'"
other: "Proposed research: %{goals}\n\nFound %{count} result for '%{filter}'" other: "Proposed goals: %{goals}\n\nFound %{count} posts matching '%{filter}'"
researcher: researcher:
one: "Researching: %{goals}\n\nFound %{count} result for '%{filter}'" one: "Researching: %{goals}\n\nFound %{count} post matching '%{filter}'"
other: "Researching: %{goals}\n\nFound %{count} result for '%{filter}'" other: "Researching: %{goals}\n\nFound %{count} posts matching '%{filter}'"
search_settings: search_settings:
one: "Found %{count} result for '%{query}'" one: "Found %{count} result for '%{query}'"
other: "Found %{count} results for '%{query}'" other: "Found %{count} results for '%{query}'"

View File

@ -22,7 +22,7 @@ module DiscourseAi
}, },
{ {
name: "dry_run", name: "dry_run",
description: "When true, only count matching items without processing data", description: "When true, only count matching posts without processing data",
type: "boolean", type: "boolean",
}, },
], ],
@ -41,6 +41,7 @@ module DiscourseAi
- keywords (keywords:keyword1,keyword2) - specific words to search for in posts - keywords (keywords:keyword1,keyword2) - specific words to search for in posts
- max_results (max_results:10) the maximum number of results to return (optional) - max_results (max_results:10) the maximum number of results to return (optional)
- order (order:latest, order:oldest, order:latest_topic, order:oldest_topic) - the order of the results (optional) - order (order:latest, order:oldest, order:latest_topic, order:oldest_topic) - the order of the results (optional)
- topic (topic:topic_id1,topic_id2) - add specific topics to the filter, topics will unconditionally be included
If multiple tags or categories are specified, they are treated as OR conditions. If multiple tags or categories are specified, they are treated as OR conditions.
@ -89,7 +90,7 @@ module DiscourseAi
blk.call details blk.call details
if dry_run if dry_run
{ dry_run: true, goals: goals, filter: @filter, number_of_results: @result_count } { dry_run: true, goals: goals, filter: @filter, number_of_posts: @result_count }
else else
process_filter(filter, goals, post, &blk) process_filter(filter, goals, post, &blk)
end end
@ -103,6 +104,14 @@ module DiscourseAi
end end
end end
def summary
if @dry_run
I18n.t("discourse_ai.ai_bot.tool_summary.researcher_dry_run")
else
I18n.t("discourse_ai.ai_bot.tool_summary.researcher")
end
end
def description_args def description_args
{ count: @result_count || 0, filter: @filter || "", goals: @goals || "" } { count: @result_count || 0, filter: @filter || "", goals: @goals || "" }
end end

View File

@ -188,6 +188,23 @@ module DiscourseAi
relation relation
end end
register_filter(/\Atopics?:(.*)\z/i) do |relation, topic_param, filter|
if topic_param.include?(",")
topic_ids = topic_param.split(",").map(&:strip).map(&:to_i).reject(&:zero?)
return relation.where("1 = 0") if topic_ids.empty?
filter.always_return_topic_ids!(topic_ids)
relation
else
topic_id = topic_param.to_i
if topic_id > 0
filter.always_return_topic_ids!([topic_id])
relation
else
relation.where("1 = 0") # No results if topic_id is invalid
end
end
end
def initialize(term, guardian: nil, limit: nil, offset: nil) def initialize(term, guardian: nil, limit: nil, offset: nil)
@term = term.to_s @term = term.to_s
@guardian = guardian || Guardian.new @guardian = guardian || Guardian.new
@ -196,6 +213,7 @@ module DiscourseAi
@filters = [] @filters = []
@valid = true @valid = true
@order = :latest_post @order = :latest_post
@topic_ids = nil
@term = process_filters(@term) @term = process_filters(@term)
end end
@ -204,17 +222,40 @@ module DiscourseAi
@order = order @order = order
end end
def always_return_topic_ids!(topic_ids)
if @topic_ids
@topic_ids = @topic_ids + topic_ids
else
@topic_ids = topic_ids
end
end
def limit_by_user!(limit) def limit_by_user!(limit)
@limit = limit if limit.to_i < @limit.to_i || @limit.nil? @limit = limit if limit.to_i < @limit.to_i || @limit.nil?
end end
def search def search
filtered = Post.secured(@guardian).joins(:topic).merge(Topic.secured(@guardian)) filtered =
Post
.secured(@guardian)
.joins(:topic)
.merge(Topic.secured(@guardian))
.where("topics.archetype = 'regular'")
original_filtered = filtered
@filters.each do |filter_block, match_data| @filters.each do |filter_block, match_data|
filtered = filter_block.call(filtered, match_data, self) filtered = filter_block.call(filtered, match_data, self)
end end
if @topic_ids.present?
filtered =
original_filtered.where(
"posts.topic_id IN (?) OR posts.id IN (?)",
@topic_ids,
filtered.select("posts.id"),
)
end
filtered = filtered.limit(@limit) if @limit.to_i > 0 filtered = filtered.limit(@limit) if @limit.to_i > 0
filtered = filtered.offset(@offset) if @offset.to_i > 0 filtered = filtered.offset(@offset) if @offset.to_i > 0

View File

@ -35,7 +35,7 @@ RSpec.describe DiscourseAi::Personas::Tools::Researcher do
expect(results[:filter]).to eq("tag:research after:2023") expect(results[:filter]).to eq("tag:research after:2023")
expect(results[:goals]).to eq("analyze post patterns") expect(results[:goals]).to eq("analyze post patterns")
expect(results[:dry_run]).to eq(true) expect(results[:dry_run]).to eq(true)
expect(results[:number_of_results]).to be > 0 expect(results[:number_of_posts]).to be > 0
expect(researcher.filter).to eq("tag:research after:2023") expect(researcher.filter).to eq("tag:research after:2023")
expect(researcher.result_count).to be > 0 expect(researcher.result_count).to be > 0
end end

View File

@ -2,7 +2,10 @@
describe DiscourseAi::Utils::Research::Filter do describe DiscourseAi::Utils::Research::Filter do
describe "integration tests" do describe "integration tests" do
before_all { SiteSetting.min_topic_title_length = 3 } before_all do
SiteSetting.min_topic_title_length = 3
SiteSetting.min_personal_message_title_length = 3
end
fab!(:user) fab!(:user)
@ -51,6 +54,46 @@ describe DiscourseAi::Utils::Research::Filter do
fab!(:feature_bug_post) { Fabricate(:post, topic: feature_bug_topic, user: user) } fab!(:feature_bug_post) { Fabricate(:post, topic: feature_bug_topic, user: user) }
fab!(:no_tag_post) { Fabricate(:post, topic: no_tag_topic, user: user) } fab!(:no_tag_post) { Fabricate(:post, topic: no_tag_topic, user: user) }
describe "security filtering" do
fab!(:secure_group) { Fabricate(:group) }
fab!(:secure_category) { Fabricate(:category, name: "Secure") }
fab!(:secure_topic) do
secure_category.set_permissions(secure_group => :readonly)
secure_category.save!
Fabricate(
:topic,
category: secure_category,
user: user,
title: "This is a secret Secret Topic",
)
end
fab!(:secure_post) { Fabricate(:post, topic: secure_topic, user: user) }
fab!(:pm_topic) { Fabricate(:private_message_topic, user: user) }
fab!(:pm_post) { Fabricate(:post, topic: pm_topic, user: user) }
it "omits secure categories when no guardian is supplied" do
filter = described_class.new("")
expect(filter.search.pluck(:id)).not_to include(secure_post.id)
user.groups << secure_group
guardian = Guardian.new(user)
filter_with_guardian = described_class.new("", guardian: guardian)
expect(filter_with_guardian.search.pluck(:id)).to include(secure_post.id)
end
it "omits PMs unconditionally" do
filter = described_class.new("")
expect(filter.search.pluck(:id)).not_to include(pm_post.id)
guardian = Guardian.new(user)
filter_with_guardian = described_class.new("", guardian: guardian)
expect(filter_with_guardian.search.pluck(:id)).not_to include(pm_post.id)
end
end
describe "tag filtering" do describe "tag filtering" do
it "correctly filters posts by tags" do it "correctly filters posts by tags" do
filter = described_class.new("tag:feature") filter = described_class.new("tag:feature")
@ -76,6 +119,18 @@ describe DiscourseAi::Utils::Research::Filter do
filter = described_class.new("category:Announcements") filter = described_class.new("category:Announcements")
expect(filter.search.pluck(:id)).to contain_exactly(feature_post.id, bug_post.id) expect(filter.search.pluck(:id)).to contain_exactly(feature_post.id, bug_post.id)
# it can tack on topics
filter =
described_class.new(
"category:Announcements topic:#{feature_bug_post.topic.id},#{no_tag_post.topic.id}",
)
expect(filter.search.pluck(:id)).to contain_exactly(
feature_post.id,
bug_post.id,
feature_bug_post.id,
no_tag_post.id,
)
filter = described_class.new("category:Announcements,Feedback") filter = described_class.new("category:Announcements,Feedback")
expect(filter.search.pluck(:id)).to contain_exactly( expect(filter.search.pluck(:id)).to contain_exactly(
feature_post.id, feature_post.id,