FIX: Allow Ask AI to work with PMs (e.g. in:messages) and dates (#43158)

Ask AI currently excludes personal messages from retrieval, so it
cannot answer questions about conversations the user can see.

This PR adds keyword and semantic PM search. The query rewriter can
now turn natural-language requests into native Discourse filters such
as in:messages, order:views, and after:.

Semantic PM search requires ai_embeddings_generate_for_pms.
This commit is contained in:
Natalie Tay
2026-09-03 14:36:30 +08:00
committed by GitHub
parent 6802427901
commit 5e191ccc33
11 changed files with 357 additions and 59 deletions
@@ -39,12 +39,15 @@ module Jobs
table_name = DiscourseAi::Embeddings::Schema::TOPICS_TABLE
vector_def = vector.vdef
archetypes = [Archetype.default]
archetypes << Archetype.private_message if SiteSetting.ai_embeddings_generate_for_pms
topics =
Topic
.joins(
"LEFT JOIN #{table_name} ON #{table_name}.topic_id = topics.id AND #{table_name}.model_id = #{vector_def.id}",
)
.where(archetype: Archetype.default)
.where(archetype: archetypes)
.where(deleted_at: nil)
.order("topics.bumped_at DESC")
@@ -90,6 +93,7 @@ module Jobs
)
.where(deleted_at: nil)
.where(post_type: Post.types[:regular])
posts = posts.public_posts if !SiteSetting.ai_embeddings_generate_for_pms
# First, we'll try to backfill embeddings for posts that have none
posts
@@ -125,7 +125,7 @@ en:
ai_helper_chat_thread_title_agent: "The agent to use for generating chat thread titles."
ai_embeddings_selected_model: "Use the selected model for generating embeddings."
ai_embeddings_generate_for_pms: "Generate embeddings for personal messages."
ai_embeddings_generate_for_pms: "Generate embeddings for personal messages. This is also required for semantic personal-message results in Ask AI."
ai_embeddings_semantic_related_topics_enabled: "Use Semantic Search for related topics."
ai_embeddings_semantic_related_topics: "Maximum number of topics to show in related topic section."
ai_embeddings_backfill_batch_size: "Number of embeddings to backfill every 15 minutes."
@@ -167,7 +167,7 @@ en:
ai_ask_ai_agent: "Agent that selects sources and writes Ask AI answers."
ai_ask_ai_query_rewriter_agent: "Agent used to prepare separate keyword and semantic queries for Ask AI. If this agent or its model is unavailable, Ask AI searches with the user's original query."
ai_ask_ai_follow_up_agent: "Agent used when a user wants to follow up an Ask AI result. The selected agent must be enabled, allow personal messages, and be available to the user."
ai_ask_ai_allowed_groups: "Groups allowed to use Ask AI and its follow-up handoff. The Ask AI agent's permissions, the follow-up agent's permissions, and normal topic and category visibility still apply. Personal messages are always excluded from search. Content from restricted categories visible to an allowed user may be sent to the configured LLM provider."
ai_ask_ai_allowed_groups: "Groups allowed to use Ask AI and its follow-up handoff. The Ask AI agent's permissions, the follow-up agent's permissions, and normal topic, category, and personal-message visibility still apply. Semantic personal-message search requires {{setting:ai_embeddings_generate_for_pms}}. Content visible to an allowed user, including restricted topics and personal messages, may be sent to the configured LLM provider."
ai_google_custom_search_api_key: "API key for the Google Custom Search API see: https://developers.google.com/custom-search"
ai_google_custom_search_cx: "CX for Google Custom Search API"
@@ -26,6 +26,7 @@ module DiscourseAi
- Preserve the user's intent, product names, numbers, quoted text, and explicit Discourse search filters.
- If the query is not in the forum's default locale, translate it to that locale for the keyword search.
- Use native Discourse search operators when they express the request exactly. For example, use order:likes for the most-liked topics, l for the latest results, and @username to restrict results to an author.
- Use in:messages when the user asks to search their personal messages. Keep the essential subject terms before the operator, and combine it with other exact operators such as order:views when requested.
- Do not add a text term when an operator-only query expresses the complete request.
semantic_query is a natural-language description of the information that would answer the user.
@@ -33,6 +34,7 @@ module DiscourseAi
- Use the forum's default locale so it matches the forum's content.
- Keep it to one sentence and under twenty words.
- Return an empty string when the keyword query relies on native search operators for filtering, ordering, or live forum state. Semantic search cannot preserve those constraints.
- in:messages alone is an exception: describe the requested personal-message content without the operator. Ask AI applies the personal-message scope separately.
The two queries must not broaden, narrow, or reinterpret the user's request.
PROMPT
@@ -58,11 +60,11 @@ module DiscourseAi
],
[
{
query: "What are the 3 most popular topics on the forum?",
query: "What are the most popular topics since January 1, 2026?",
forum_default_locale: "en",
}.to_json,
{
keyword_query: "order:likes",
keyword_query: "after:2026-01-01 order:likes",
semantic_query: "",
original_query_locale: "en",
}.to_json,
@@ -75,6 +77,14 @@ module DiscourseAi
original_query_locale: "en",
}.to_json,
],
[
{ query: "Which of my PMs discuss anime?", forum_default_locale: "en" }.to_json,
{
keyword_query: "anime in:messages",
semantic_query: "private conversations about anime",
original_query_locale: "en",
}.to_json,
],
]
end
end
+2 -1
View File
@@ -200,7 +200,8 @@ module DiscourseAi
post = posts[source["post_id"]]
topic = post&.topic
visible =
topic && topic.id == source["topic_id"] && topic.archetype == Archetype.default &&
topic && topic.id == source["topic_id"] &&
[Archetype.default, Archetype.private_message].include?(topic.archetype) &&
topic.deleted_at.nil? && topic.visible? && guardian.can_see?(post) &&
topic.category_id == source["category_id"] &&
post.updated_at.iso8601(6) == source["post_updated_at"]
@@ -8,9 +8,10 @@ module DiscourseAi
SYNTHESIS_LIMIT = 50
SELECTED_SOURCE_LIMIT = 6
EXCERPT_LIMIT = 1200
SEMANTIC_PRIVATE_MESSAGE_FILTER = /(?:\A|\s)in:(?:messages|personal)(?=\s|\z)/i
Result =
Struct.new(:candidates, keyword_init: true) do
Struct.new(:candidates, :private_messages, keyword_init: true) do
def synthesis_candidates
candidates.first(Retrieval::SYNTHESIS_LIMIT)
end
@@ -20,28 +21,31 @@ module DiscourseAi
@user = user
@guardian = Guardian.new(user)
@lexical_retriever = lexical_retriever || method(:lexical_sources)
@semantic_retriever = semantic_retriever || method(:semantic_sources)
@semantic_retriever = semantic_retriever
end
def call(query, keyword_query: query, semantic_query: query)
return Result.new(candidates: []) if DiscourseAi::Discoveries.private_message_query?(query)
private_messages =
DiscourseAi::Discoveries.private_message_query?(query) ||
DiscourseAi::Discoveries.private_message_query?(keyword_query)
rankings =
if self.class.explicit_filters?(query)
if self.class.explicit_filters_except_private_messages?(query)
[retrieve(@lexical_retriever, query)]
elsif semantic_query.blank? || self.class.explicit_filters?(keyword_query)
elsif semantic_query.blank? ||
self.class.explicit_filters_except_private_messages?(keyword_query)
[retrieve(@lexical_retriever, keyword_query)]
else
retrieve_in_parallel(keyword_query, semantic_query)
retrieve_in_parallel(keyword_query, semantic_query, private_messages:)
end
candidates = reciprocal_rank_fusion(rankings)
candidates = revalidate_and_limit(candidates)
candidates = revalidate_and_limit(candidates, private_messages:)
candidates =
candidates.map.with_index do |candidate, index|
candidate.merge("source_ref" => "source_#{index + 1}")
end
Result.new(candidates:)
Result.new(candidates:, private_messages:)
end
def self.explicit_filters?(query)
@@ -53,7 +57,7 @@ module DiscourseAi
cleaned = word.delete("\"'")
direct_filter =
cleaned.match?(
/\A(?:[lr]|t|order:\w+|in:title|topic:\d+|in:all(?:-posts)?|include:(?:invisible|unlisted))\z/i,
/\A(?:[lr]|t|order:\w+|in:title|topic:\d+|in:all(?:-posts)?|include:(?:invisible|unlisted)|personal_messages:\S+)\z/i,
)
direct_filter ||
@@ -63,6 +67,10 @@ module DiscourseAi
end
end
def self.explicit_filters_except_private_messages?(query)
explicit_filters?(query.to_s.gsub(SEMANTIC_PRIVATE_MESSAGE_FILTER, " "))
end
def validated_sources(result, source_refs)
source_refs = Array(source_refs)
return [] if source_refs.empty? || source_refs.length > SELECTED_SOURCE_LIMIT
@@ -74,7 +82,7 @@ module DiscourseAi
selected = source_refs.filter_map { |source_ref| candidates_by_ref[source_ref] }
return [] if selected.length != source_refs.length
revalidated = revalidate_and_limit(selected)
revalidated = revalidate_and_limit(selected, private_messages: result.private_messages)
return [] if revalidated.length != selected.length
revalidated
@@ -101,9 +109,12 @@ module DiscourseAi
.first(CANDIDATE_LIMIT)
end
def retrieve_in_parallel(keyword_query, semantic_query)
def retrieve_in_parallel(keyword_query, semantic_query, private_messages:)
database = RailsMultisite::ConnectionManagement.current_db
searches = [[@lexical_retriever, keyword_query], [@semantic_retriever, semantic_query]]
searches = [
[@lexical_retriever, keyword_query],
[semantic_retriever(private_messages:), semantic_query],
]
threads =
searches.map do |retriever, search_query|
Thread.new do
@@ -140,11 +151,15 @@ module DiscourseAi
[[], error]
end
def semantic_sources(query)
def semantic_retriever(private_messages:)
@semantic_retriever || ->(query) { semantic_sources(query, private_messages:) }
end
def semantic_sources(query, private_messages:)
guardian = Guardian.new(@user)
DiscourseAi::Embeddings::SemanticSearch
.new(guardian)
.search_for_topics(query, 1, hyde: false)
.search_for_topics(query, 1, hyde: false, private_messages:)
.includes(:topic)
.to_a
.uniq(&:topic_id)
@@ -192,7 +207,7 @@ module DiscourseAi
source.slice("post_id", "post_number", "url", "excerpt", "post_updated_at")
end
def revalidate_and_limit(candidates)
def revalidate_and_limit(candidates, private_messages: false)
post_ids =
candidates.flat_map do |candidate|
candidate.fetch("passages", [candidate]).map { |passage| passage.fetch("post_id") }
@@ -203,14 +218,36 @@ module DiscourseAi
.includes(:user, topic: [{ category: :parent_category }, :tags])
.index_by(&:id)
hidden_tags = DiscourseTagging.hidden_tag_names if SiteSetting.tagging_enabled
private_message_topic_ids = Set.new
if private_messages && @user
private_message_topic_ids =
Topic
.private_messages_for_user(@user)
.where(id: candidates.pluck("topic_id"))
.pluck(:id)
.to_set
end
visible_post_types = Topic.visible_post_types(@user)
candidates
.filter_map do |candidate|
post = posts[candidate.fetch("post_id")]
topic = post&.topic
next if topic.nil? || topic.id != candidate.fetch("topic_id")
next if topic.archetype != Archetype.default || topic.deleted_at? || !topic.visible?
next if !@guardian.can_see?(post)
allowed_archetype =
if private_messages
topic.archetype == Archetype.private_message
else
topic.archetype == Archetype.default
end
next if !allowed_archetype
next if topic.deleted_at? || !topic.visible?
if topic.private_message?
next if !private_message_topic_ids.include?(topic.id)
next if post.hidden? || !visible_post_types.include?(post.post_type)
else
next if !@guardian.can_see?(post)
end
if candidate["post_updated_at"] &&
candidate["post_updated_at"] != post.updated_at.iso8601(6)
next
@@ -225,7 +262,13 @@ module DiscourseAi
candidate_passages.filter_map do |passage|
passage_post = posts[passage.fetch("post_id")]
next if passage_post.nil? || passage_post.topic_id != topic.id
next if !@guardian.can_see?(passage_post)
if topic.private_message?
if passage_post.hidden? || !visible_post_types.include?(passage_post.post_type)
next
end
elsif !@guardian.can_see?(passage_post)
next
end
if passage["post_updated_at"] &&
passage["post_updated_at"] != passage_post.updated_at.iso8601(6)
next
@@ -63,7 +63,7 @@ module DiscourseAi
# if the user filtered the results or index is a bit out of date
OVER_SELECTION_FACTOR = 4
def search_for_topics(query, page = 1, hyde: true)
def search_for_topics(query, page = 1, hyde: true, private_messages: false)
max_results_per_page = 100
limit = [Search.per_filter, max_results_per_page].min + 1
offset = (page - 1) * limit
@@ -73,6 +73,7 @@ module DiscourseAi
if search_term.blank? || search_term.length < SiteSetting.min_search_term_length
return Post.none
end
return Post.none if private_messages && guardian.user.nil?
search_embedding = nil
if hyde
@@ -86,24 +87,64 @@ module DiscourseAi
schema = DiscourseAi::Embeddings::Schema.for(Topic)
candidate_topic_ids =
schema.asymmetric_similarity_search(
search_embedding,
limit: over_selection_limit,
offset: offset,
).map(&:topic_id)
schema
.asymmetric_similarity_search(
search_embedding,
limit: over_selection_limit,
offset: offset,
) do |builder|
builder.join("topics ON topics.id = #{Schema::TOPICS_TABLE}.topic_id")
builder.where("topics.deleted_at IS NULL AND topics.visible")
if private_messages
builder.where(
<<~SQL,
topics.archetype = :private_message
AND (
topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_USER})
OR topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_GROUP})
)
SQL
private_message: Archetype.private_message,
user_id: guardian.user.id,
)
else
builder.where(
"topics.archetype <> :private_message",
private_message: Archetype.private_message,
)
end
end
.map(&:topic_id)
semantic_results =
::Post
.where(post_type: ::Topic.visible_post_types(guardian.user))
.public_posts
.joins(:topic)
.where("topics.visible")
.where(hidden: false)
.where(topic_id: candidate_topic_ids, post_number: 1)
.order("array_position(ARRAY#{candidate_topic_ids}, posts.topic_id)")
.limit(limit)
semantic_results =
if private_messages
semantic_results.where(
topics: {
archetype: Archetype.private_message,
},
).private_posts_for_user(guardian.user)
else
semantic_results.where.not(topics: { archetype: Archetype.private_message })
end
query_filter_results = search.apply_filters(semantic_results)
guardian.filter_allowed_categories(query_filter_results)
if private_messages
query_filter_results
else
guardian.filter_allowed_categories(query_filter_results)
end
end
def similar_topic_ids_to(query, candidates:)
@@ -106,4 +106,36 @@ RSpec.describe Jobs::EmbeddingsBackfill do
expect(topic_ids).to contain_exactly(first_topic.id, second_topic.id, third_topic.id)
end
it "backfills personal messages when personal-message embeddings are enabled" do
personal_message = Fabricate(:private_message_post)
SiteSetting.ai_embeddings_generate_for_pms = true
SiteSetting.ai_embeddings_backfill_batch_size = 100
described_class.new.execute({})
topic_ids =
DB.query_single("SELECT topic_id from #{DiscourseAi::Embeddings::Schema::TOPICS_TABLE}")
expect(topic_ids).to include(personal_message.topic_id)
post_ids =
DB.query_single("SELECT post_id from #{DiscourseAi::Embeddings::Schema::POSTS_TABLE}")
expect(post_ids).to include(personal_message.id)
end
it "does not backfill personal messages when personal-message embeddings are disabled" do
personal_message = Fabricate(:private_message_post)
SiteSetting.ai_embeddings_generate_for_pms = false
SiteSetting.ai_embeddings_backfill_batch_size = 100
described_class.new.execute({})
topic_ids =
DB.query_single("SELECT topic_id from #{DiscourseAi::Embeddings::Schema::TOPICS_TABLE}")
expect(topic_ids).not_to include(personal_message.topic_id)
post_ids =
DB.query_single("SELECT post_id from #{DiscourseAi::Embeddings::Schema::POSTS_TABLE}")
expect(post_ids).not_to include(personal_message.id)
end
end
@@ -13,25 +13,39 @@ describe DiscourseAi::Agents::AskAiQueryRewriter do
{ "key" => "original_query_locale", "type" => "string" },
],
)
expect(agent.examples).to include(
expect(agent.examples).to eq(
[
{ query: "怎么删除具备管理员权限的幽灵机器人用户?", forum_default_locale: "en" }.to_json,
{
keyword_query: "delete admin bot user",
semantic_query: "how to remove a bot account that has administrator permissions",
original_query_locale: "zh_CN",
}.to_json,
],
[
{
query: "What are the 3 most popular topics on the forum?",
forum_default_locale: "en",
}.to_json,
{ keyword_query: "order:likes", semantic_query: "", original_query_locale: "en" }.to_json,
],
[
{ query: "@nat l logs", forum_default_locale: "en" }.to_json,
{ keyword_query: "@nat l logs", semantic_query: "", original_query_locale: "en" }.to_json,
[
{ query: "怎么删除具备管理员权限的幽灵机器人用户?", forum_default_locale: "en" }.to_json,
{
keyword_query: "delete admin bot user",
semantic_query: "how to remove a bot account that has administrator permissions",
original_query_locale: "zh_CN",
}.to_json,
],
[
{
query: "What are the most popular topics since January 1, 2026?",
forum_default_locale: "en",
}.to_json,
{
keyword_query: "after:2026-01-01 order:likes",
semantic_query: "",
original_query_locale: "en",
}.to_json,
],
[
{ query: "@nat l logs", forum_default_locale: "en" }.to_json,
{ keyword_query: "@nat l logs", semantic_query: "", original_query_locale: "en" }.to_json,
],
[
{ query: "Which of my PMs discuss anime?", forum_default_locale: "en" }.to_json,
{
keyword_query: "anime in:messages",
semantic_query: "private conversations about anime",
original_query_locale: "en",
}.to_json,
],
],
)
end
@@ -170,15 +170,84 @@ describe DiscourseAi::Discoveries::Retrieval do
)
end
it "returns no candidates for a personal-message search" do
it "uses keyword and semantic retrieval for personal messages the user can see" do
personal_message = Fabricate(:private_message_post, recipient: user, raw: "Anime plans")
inaccessible_message = Fabricate(:private_message_post, raw: "Hidden anime plans")
lexical_retriever =
instance_spy(
Proc,
call: [source(personal_message), source(inaccessible_message), source(post_1)],
)
semantic_retriever =
instance_spy(
Proc,
call: [source(personal_message), source(inaccessible_message), source(post_1)],
)
result =
described_class.new(user:, lexical_retriever:, semantic_retriever:).call(
"Which PMs discuss anime?",
keyword_query: "anime in:messages",
semantic_query: "private conversations about anime",
)
expect(lexical_retriever).to have_received(:call).with("anime in:messages")
expect(semantic_retriever).to have_received(:call).with("private conversations about anime")
expect(result.candidates.map { |candidate| candidate.fetch("topic_id") }).to eq(
[personal_message.topic_id],
)
end
it "uses only keyword retrieval when a personal-message query has an ordering operator" do
personal_message = Fabricate(:private_message_post, recipient: user, raw: "Anime plans")
semantic_retriever = instance_spy(Proc, call: [source(post_2)])
result =
described_class.new(
user:,
lexical_retriever: ->(_query) { [source(post_1)] },
semantic_retriever: ->(_query) { [source(post_1)] },
).call("cats in:messages")
lexical_retriever: ->(_query) { [source(personal_message)] },
semantic_retriever:,
).call(
"My most viewed anime PM",
keyword_query: "anime in:messages order:views",
semantic_query: "",
)
expect(result.candidates).to eq([])
expect(semantic_retriever).not_to have_received(:call)
expect(result.candidates.map { |candidate| candidate.fetch("topic_id") }).to eq(
[personal_message.topic_id],
)
end
it "preserves a personal-message user filter without semantic broadening" do
matching_message = Fabricate(:private_message_post, recipient: user, raw: "Anime plans")
unrelated_message =
Fabricate(:private_message_post, recipient: user, raw: "Unrelated private plans")
query = "anime personal_messages:#{user.username}"
result =
described_class.new(
user:,
lexical_retriever: ->(_query) { [source(matching_message)] },
semantic_retriever: ->(_query) { [source(unrelated_message)] },
).call(query, keyword_query: query, semantic_query: "private conversations about anime")
expect(result.candidates.map { |candidate| candidate.fetch("topic_id") }).to eq(
[matching_message.topic_id],
)
end
it "excludes personal messages from a regular search" do
personal_message = Fabricate(:private_message_post, recipient: user, raw: "Anime plans")
result =
described_class.new(
user:,
lexical_retriever: ->(_query) { [source(post_1), source(personal_message)] },
semantic_retriever: ->(_query) { [] },
).call("anime")
expect(result.candidates.map { |candidate| candidate.fetch("topic_id") }).to eq([topic_1.id])
end
it "does not broaden explicit search filters through semantic retrieval" do
@@ -282,5 +351,20 @@ describe DiscourseAi::Discoveries::Retrieval do
child_post.topic_id,
)
end
it "finds only personal messages the user can see" do
token = "privateneedle#{SecureRandom.hex(6)}"
personal_message = Fabricate(:private_message_post, recipient: user, raw: token)
inaccessible_message = Fabricate(:private_message_post, raw: token)
[personal_message, inaccessible_message].each do |post|
SearchIndexer.index(post, force: true)
end
result = described_class.new(user:).call("#{token} in:messages", semantic_query: "")
expect(result.candidates.map { |candidate| candidate.fetch("topic_id") }).to eq(
[personal_message.topic_id],
)
end
end
end
@@ -218,5 +218,25 @@ describe DiscourseAi::Discoveries do
"url" => post.full_url,
)
end
it "returns a personal-message result only to a participant" do
personal_message = Fabricate(:private_message_post, recipient: user)
request_id = SecureRandom.uuid
described_class.store_result(
user_id: user.id,
request_id:,
query: "private plans",
answer: "A private answer.",
sources: [{ "post_id" => personal_message.id, "topic_id" => personal_message.topic_id }],
agent_id: ai_agent.id,
)
expect(described_class.cached_result_for(user:, request_id:)).to include(
"answer" => "A private answer.",
)
personal_message.topic.topic_allowed_users.find_by(user: user).destroy!
expect(described_class.cached_result_for(user:, request_id:)).to be_nil
end
end
end
@@ -23,13 +23,13 @@ RSpec.describe DiscourseAi::Embeddings::SemanticSearch do
after { described_class.clear_cache_for(query) }
def insert_candidate(candidate)
DiscourseAi::Embeddings::Schema.for(Topic).store(candidate, hyde_embedding, "digest")
def insert_candidate(candidate, embedding = hyde_embedding)
DiscourseAi::Embeddings::Schema.for(Topic).store(candidate, embedding, "digest")
end
def trigger_search(query)
def trigger_search(query, **options)
DiscourseAi::Completions::Llm.with_prepared_responses([hypothetical_post]) do
semantic_search.search_for_topics(query)
semantic_search.search_for_topics(query, **options)
end
end
@@ -78,6 +78,55 @@ RSpec.describe DiscourseAi::Embeddings::SemanticSearch do
end
end
context "when personal messages are requested" do
it "returns no results for an anonymous user" do
insert_candidate(Fabricate(:private_message_post).topic)
posts =
described_class.new(Guardian.new(nil)).search_for_topics(query, private_messages: true)
expect(posts).to be_empty
end
it "returns only personal messages the user can see" do
visible_pm = Fabricate(:private_message_post, recipient: user)
hidden_pm = Fabricate(:private_message_post)
insert_candidate(visible_pm.topic)
insert_candidate(hidden_pm.topic)
posts = trigger_search(query, private_messages: true)
expect(posts).to contain_exactly(visible_pm)
end
it "limits vector ranking to personal messages the user can see" do
SiteSetting.search_page_size = 0
visible_pm = Fabricate(:private_message_post, recipient: user)
distant_embedding = [1.0] + Array.new(vector_def.dimensions - 1, 0.0)
insert_candidate(visible_pm.topic, distant_embedding)
9.times { insert_candidate(Fabricate(:private_message_post).topic) }
posts = trigger_search(query, private_messages: true)
expect(posts).to contain_exactly(visible_pm)
end
end
context "when public topics are requested" do
it "excludes personal messages before vector ranking" do
SiteSetting.search_page_size = 0
distant_embedding = [1.0] + Array.new(vector_def.dimensions - 1, 0.0)
insert_candidate(post.topic, distant_embedding)
9.times { insert_candidate(Fabricate(:private_message_post).topic) }
posts = trigger_search(query)
expect(posts).to contain_exactly(post)
end
end
context "when the post type is not visible" do
it "returns an empty list" do
post.update!(post_type: Post.types[:whisper])