media assets: add ability to search by AI tags.

Add ability to search the /media_assets index by AI tags. Multi-tag
searches are supported, including AND/OR/NOT operators, but metatags
aren't supported. Multi-tag searches will probably be slow.

The default AI tag confidence threshold is 50%. There's a hidden
search[min_score] URL param that lets you change this.
This commit is contained in:
evazion
2022-07-06 01:38:41 -05:00
parent 52ff12dffb
commit d7e08d1313
3 changed files with 90 additions and 9 deletions

View File

@@ -0,0 +1,57 @@
# frozen_string_literal: true
# An AITagQuery is a tag search performed on media assets using AI tags. Only
# basic tags are allowed, no metatags.
class AITagQuery
extend Memoist
attr_reader :search_string
delegate :to_infix, :to_pretty_string, to: :ast
alias_method :to_s, :to_infix
def initialize(search_string)
@search_string = search_string.to_s.strip
end
def self.search(search_string, **options)
new(search_string).search(**options)
end
def ast
@ast ||= PostQuery::Parser.parse(search_string)
end
def normalized_ast
@normalized_ast ||= ast.to_cnf.rewrite_opts.trim
end
def search(relation: MediaAsset.all, foreign_key: :id, score_range: (50..))
normalized_ast.visit do |node, *children|
case node.type
in :all
relation.all
in :none
relation.none
in :tag
ai_tag = AITag.named(node.name).where(score: score_range)
relation.where(ai_tag.where(AITag.arel_table[:media_asset_id].eq(relation.arel_table[foreign_key])).arel.exists)
in :metatag
relation.none
in :wildcard
relation.none
in :not
children.first.negate_relation
in :and
joins = children.flat_map(&:joins_values)
orders = children.flat_map(&:order_values)
nodes = children.map { |child| child.joins(joins).order(orders) }
nodes.reduce(&:and)
in :or
joins = children.flat_map(&:joins_values)
orders = children.flat_map(&:order_values)
nodes = children.map { |child| child.joins(joins).order(orders) }
nodes.reduce(&:or)
end
end
end
end

View File

@@ -195,13 +195,22 @@ class MediaAsset < ApplicationRecord
concerning :SearchMethods do
class_methods do
def ai_tags_match(tag_string, score_range: (50..))
AITagQuery.search(tag_string, relation: self, score_range: score_range)
end
def search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :md5, :file_ext, :file_size, :image_width, :image_height, :file_key, :is_public)
q = search_attributes(params, :id, :created_at, :updated_at, :status, :md5, :file_ext, :file_size, :image_width, :image_height, :file_key, :is_public)
if params[:metadata].present?
q = q.joins(:media_metadata).merge(MediaMetadata.search(metadata: params[:metadata]))
end
if params[:ai_tags_match].present?
min_score = params.fetch(:min_score, 50).to_i
q = q.ai_tags_match(params[:ai_tags_match], score_range: (min_score..))
end
if params[:is_posted].to_s.truthy?
#q = q.where.associated(:post)
q = q.where(Post.where("posts.md5 = media_assets.md5").arel.exists)
@@ -210,7 +219,16 @@ class MediaAsset < ApplicationRecord
q = q.where.not(Post.where("posts.md5 = media_assets.md5").arel.exists)
end
q.apply_default_order(params)
case params[:order]
when "id", "id_desc"
q = q.order(id: :desc)
when "id_asc"
q = q.order(id: :asc)
else
q = q.apply_default_order(params)
end
q
end
end
end

View File

@@ -2,22 +2,28 @@
<div id="a-index">
<h1 class="mb-2">All Uploads</h1>
<% if search_params[:metadata].present? %>
<%= search_form_for(media_assets_path) do |f| %>
<%= search_form_for(media_assets_path) do |f| %>
<%= f.input :ai_tags_match, label: "Tags", input_html: { value: params.dig(:search, :ai_tags_match), data: { autocomplete: "tag-query" } } %>
<%= f.input :status, collection: MediaAsset.statuses.keys.map(&:capitalize), include_blank: true, selected: params.dig(:search, :status) %>
<%= f.input :is_posted, as: :hidden, input_html: { value: params.dig(:search, :is_posted) } %>
<%= f.input :min_score, as: :hidden, input_html: { value: params.dig(:search, :min_score) } %>
<% if search_params[:metadata].present? %>
<%= f.simple_fields_for :metadata do |meta| %>
<% params.dig(:search, :metadata).to_h.each do |key, value| %>
<%= meta.input key, label: key, input_html: { value: value } %>
<% end %>
<% end %>
<%= f.submit "Search" %>
<% end %>
<%= f.input :order, collection: [%w[Newest id], %w[Oldest id_asc]], include_blank: true, selected: params[:search][:order] %>
<%= f.submit "Search" %>
<% end %>
<div class="border-b mb-4 flex flex-wrap gap-4">
<%= link_to "All", current_page_path(search: search_params.to_h.without("is_posted")), class: ["inline-block p-1 pb-2", (search_params[:is_posted].nil? ? "border-current border-b-2 -mb-px" : "inactive-link")] %>
<%= link_to "Posted", current_page_path(search: { is_posted: true }), class: ["inline-block p-1 pb-2", (search_params[:is_posted].to_s.truthy? ? "border-current border-b-2 -mb-px" : "inactive-link")] %>
<%= link_to "Unposted", current_page_path(search: { is_posted: false }), class: ["inline-block p-1 pb-2", (search_params[:is_posted].to_s.falsy? ? "border-current border-b-2 -mb-px" : "inactive-link")] %>
<%= link_to "All", current_page_path(search: search_params.merge(is_posted: nil)), class: ["inline-block p-1 pb-2", (search_params[:is_posted].nil? ? "border-current border-b-2 -mb-px" : "inactive-link")] %>
<%= link_to "Posted", current_page_path(search: search_params.merge(is_posted: true)), class: ["inline-block p-1 pb-2", (search_params[:is_posted].to_s.truthy? ? "border-current border-b-2 -mb-px" : "inactive-link")] %>
<%= link_to "Unposted", current_page_path(search: search_params.merge(is_posted: false)), class: ["inline-block p-1 pb-2", (search_params[:is_posted].to_s.falsy? ? "border-current border-b-2 -mb-px" : "inactive-link")] %>
<span class="flex-grow-1"></span>
<%= render PreviewSizeMenuComponent.new(current_size: @preview_size) %>
</div>