mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user