autocomplete: rework tag autocomplete behavior.

Reworks tag autocomplete to work the same way for all users. Previously
autocomplete for Builders worked differently than autocomplete for
regular users.

This is how it works now:

* If the search starts with a slash (/), then do a tag abbreviation
  match. For example, `/evth` matches eyebrows_visible_through_hair.
* Otherwise if the search contains a wildcard (*), then just do a simple
  wildcard search.
* Otherwise do a tag prefix match against tags and aliases. For example,
  `black` matches all tags or aliases beginning with `black`.
* If the tag prefix match returns no results, then do a autocorrect match.

The differences for regular users:

* You can abbreviate tags with a slash (/).

The differences for Builders:

* Now tag abbreviations have to start with a slash (/).
* Autocorrect isn't performed unless a regular search returns no results.
* Results are always sorted by tag count. Before different types of
  results (regular tag matches, alias matches, abbreviation matches,
  and autocorrect matches) were all mixed together based on a tag
  weighting scheme.
This commit is contained in:
evazion
2020-12-13 00:45:22 -06:00
parent adc1c2c2cc
commit b0be8ae456
5 changed files with 89 additions and 12 deletions

View File

@@ -3,12 +3,4 @@ module TagsHelper
return nil if tag.blank?
"tag-type-#{tag.category}"
end
def tag_alias_for_pattern(tag, pattern)
return nil if pattern.blank?
tag.consequent_aliases.find do |tag_alias|
!tag.name.ilike?(pattern) && tag_alias.antecedent_name.ilike?(pattern)
end
end
end

View File

@@ -63,10 +63,45 @@ class AutocompleteService
end
def autocomplete_tag(string)
tags = Tag.names_matches_with_aliases(string, limit)
if string.starts_with?("/")
string = string + "*" unless string.include?("*")
results = tag_matches(string)
results += tag_abbreviation_matches(string)
results = results.uniq.sort_by { |r| [r[:antecedent].length, -r[:post_count]] }.take(limit)
elsif string.include?("*")
results = tag_matches(string)
else
results = tag_matches(string + "*")
results = tag_autocorrect_matches(string) if results.blank?
end
results
end
def tag_matches(string)
name_matches = Tag.nonempty.name_matches(string).order(post_count: :desc).limit(limit)
alias_matches = Tag.nonempty.alias_matches(string).order(post_count: :desc).limit(limit)
union = "((#{name_matches.to_sql}) UNION (#{alias_matches.to_sql})) AS tags"
tags = Tag.from(union).order(post_count: :desc).limit(limit).includes(:consequent_aliases)
tags.map do |tag|
{ type: "tag", label: tag.name.tr("_", " "), value: tag.name, antecedent: tag.antecedent_name, category: tag.category, post_count: tag.post_count, source: nil, weight: nil }
{ type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: tag.tag_alias_for_pattern(string)&.antecedent_name }
end
end
def tag_abbreviation_matches(string)
tags = Tag.nonempty.abbreviation_matches(string).order(post_count: :desc).limit(limit)
tags.map do |tag|
{ type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: "/" + tag.abbreviation }
end
end
def tag_autocorrect_matches(string)
tags = Tag.nonempty.fuzzy_name_matches(string).order_similarity(string).limit(limit)
tags.map do |tag|
{ type: "tag", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count }
end
end

View File

@@ -1,4 +1,6 @@
class Tag < ApplicationRecord
ABBREVIATION_REGEXP = /([a-z0-9])[a-z0-9']*($|[^a-z0-9']+)/
has_one :wiki_page, :foreign_key => "title", :primary_key => "name"
has_one :artist, :foreign_key => "name", :primary_key => "name"
has_one :antecedent_alias, -> {active}, :class_name => "TagAlias", :foreign_key => "antecedent_name", :primary_key => "name"
@@ -249,7 +251,7 @@ class Tag < ApplicationRecord
end
def alias_matches(name)
where(name: TagAlias.active.where_ilike(:antecedent_name, normalize_name(name)).select(:consequent_name))
where(name: TagAlias.active.where_like(:antecedent_name, normalize_name(name)).select(:consequent_name))
end
def name_or_alias_matches(name)
@@ -260,6 +262,11 @@ class Tag < ApplicationRecord
nonempty.name_matches(tag).order(post_count: :desc, name: :asc).limit(limit).pluck(:name)
end
def abbreviation_matches(abbrev)
abbrev = abbrev.delete_prefix("/")
where("regexp_replace(tags.name, ?, '\\1', 'g') LIKE ?", ABBREVIATION_REGEXP.source, abbrev.to_escaped_for_sql_like)
end
def search(params)
q = super
@@ -352,6 +359,18 @@ class Tag < ApplicationRecord
Post.system_tag_match(name)
end
def abbreviation
name.gsub(ABBREVIATION_REGEXP, "\\1")
end
def tag_alias_for_pattern(pattern)
return nil if pattern.blank?
consequent_aliases.find do |tag_alias|
!name.ilike?(pattern) && tag_alias.antecedent_name.ilike?(pattern)
end
end
def self.model_restriction(table)
super.where(table[:post_count].gt(0))
end

View File

@@ -10,7 +10,7 @@
<%= link_to_wiki "?", tag.name, class: tag_class(tag) %>
<%= link_to tag.name, posts_path(tags: tag.name), class: tag_class(tag) %>
<% tag_alias = tag_alias_for_pattern(tag, params[:search][:name_or_alias_matches]) %>
<% tag_alias = tag.tag_alias_for_pattern(params[:search][:name_or_alias_matches]) %>
<% if tag_alias.present? %>
← <%= link_to tag_alias.antecedent_name, tag_alias, class: "fineprint" %>
<% end %>

View File

@@ -80,6 +80,37 @@ class AutocompleteServiceTest < ActiveSupport::TestCase
assert_autocomplete_includes("touhou", "~tou", :tag_query)
end
should "autocomplete tag abbreviations" do
create(:tag, name: "mole", post_count: 150)
create(:tag, name: "mole_under_eye", post_count: 100)
create(:tag, name: "mole_under_mouth", post_count: 50)
assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "/m", :tag_query)
assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "/mu", :tag_query)
assert_autocomplete_equals(%w[mole_under_mouth], "/mum", :tag_query)
assert_autocomplete_equals(%w[mole_under_eye], "/mue", :tag_query)
assert_autocomplete_equals(%w[mole_under_eye], "/*ue", :tag_query)
assert_autocomplete_includes("mole_under_eye", "-/mue", :tag_query)
assert_autocomplete_includes("mole_under_eye", "~/mue", :tag_query)
end
should "autocomplete wildcard searches" do
create(:tag, name: "mole", post_count: 150)
create(:tag, name: "mole_under_eye", post_count: 100)
create(:tag, name: "mole_under_mouth", post_count: 50)
assert_autocomplete_equals(%w[mole mole_under_eye mole_under_mouth], "mole*", :tag_query)
assert_autocomplete_equals(%w[mole_under_eye mole_under_mouth], "*under*", :tag_query)
assert_autocomplete_equals(%w[mole_under_eye], "*eye", :tag_query)
end
should "autocorrect misspelled tags" do
create(:tag, name: "touhou")
assert_autocomplete_equals(%w[touhou], "touhuo", :tag_query)
end
should "autocomplete static metatags" do
assert_autocomplete_equals(["status:active"], "status:act", :tag_query)
assert_autocomplete_equals(["parent:active"], "parent:act", :tag_query)