Expose which search strategy produced each result
query_packages now assigns @search_sources - a hash mapping package ids to the matching strategy that put them into the results: :exact, :name_prefix, :name_contains, :semantic or :fulltext. The grid and list views render a small badge with a humanized label per result, making the search tiers observable. semantic_results() was folded into search_packages() so each branch tags its own source. Adds the rails-controller-testing gem for assigns() in tests.
This commit is contained in:
parent
61b64fae25
commit
323b7004ed
6 changed files with 69 additions and 24 deletions
2
Gemfile
2
Gemfile
|
|
@ -108,6 +108,8 @@ group :test do
|
||||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||||
gem 'capybara'
|
gem 'capybara'
|
||||||
gem 'selenium-webdriver'
|
gem 'selenium-webdriver'
|
||||||
|
# Provides the assigns() test helper extracted from modern Rails
|
||||||
|
gem 'rails-controller-testing'
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO… https://github.com/galetahub/simple-captcha
|
# TODO… https://github.com/galetahub/simple-captcha
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,10 @@ GEM
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.3)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.3)
|
railties (= 8.1.3)
|
||||||
|
rails-controller-testing (1.0.5)
|
||||||
|
actionpack (>= 5.0.1.rc1)
|
||||||
|
actionview (>= 5.0.1.rc1)
|
||||||
|
activesupport (>= 5.0.1.rc1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -592,6 +596,7 @@ DEPENDENCIES
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.0)
|
rails (~> 8.1.0)
|
||||||
|
rails-controller-testing
|
||||||
rails-erd
|
rails-erd
|
||||||
rails-healthcheck
|
rails-healthcheck
|
||||||
rails_icons (~> 1.3)
|
rails_icons (~> 1.3)
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,11 @@ class PackagesController < ApplicationController
|
||||||
def query_packages
|
def query_packages
|
||||||
packages = Package # .order(visits: :desc)
|
packages = Package # .order(visits: :desc)
|
||||||
|
|
||||||
|
# Maps package id to the search strategy that put it into the
|
||||||
|
# result list: :exact, :name_prefix, :name_contains, :semantic or
|
||||||
|
# :fulltext. Empty when browsing without a search term.
|
||||||
|
@search_sources = {}
|
||||||
|
|
||||||
# text search
|
# text search
|
||||||
if params[:search].present?
|
if params[:search].present?
|
||||||
logger.debug "Searching for #{params[:search]}"
|
logger.debug "Searching for #{params[:search]}"
|
||||||
|
|
@ -393,15 +398,34 @@ class PackagesController < ApplicationController
|
||||||
|
|
||||||
def search_packages(query)
|
def search_packages(query)
|
||||||
exact_match = Package.find_by(name: query)
|
exact_match = Package.find_by(name: query)
|
||||||
return Package.where(id: exact_match.id) if exact_match
|
if exact_match
|
||||||
|
@search_sources[exact_match.id] = :exact
|
||||||
|
return Package.where(id: exact_match.id)
|
||||||
|
end
|
||||||
|
|
||||||
tokens = query.to_s.split(/\s+/)
|
prefix_matches = Package.name_starts_with(query).limit(MAX_PROMOTED_NAME_MATCHES).to_a
|
||||||
name_matches = (
|
token_matches = Package.name_contains_all(query.to_s.split(/\s+/)).limit(
|
||||||
Package.name_starts_with(query).limit(MAX_PROMOTED_NAME_MATCHES) +
|
MAX_PROMOTED_NAME_MATCHES
|
||||||
Package.name_contains_all(tokens).limit(MAX_PROMOTED_NAME_MATCHES)
|
).to_a
|
||||||
).uniq(&:id)
|
prefix_matches.each { |package| @search_sources[package.id] ||= :name_prefix }
|
||||||
|
token_matches.each { |package| @search_sources[package.id] ||= :name_contains }
|
||||||
|
|
||||||
results = (name_matches + semantic_results(query)).uniq(&:id)
|
semantic = []
|
||||||
|
if Package.lexical_overlap?(query)
|
||||||
|
begin
|
||||||
|
semantic = Package.nearest_to_text(query, limit: MAX_SEMANTIC_RESULTS).to_a
|
||||||
|
semantic.each { |package| @search_sources[package.id] ||= :semantic }
|
||||||
|
rescue Vectorizer::Error => e
|
||||||
|
logger.error "Semantic search failed: #{e.message}"
|
||||||
|
flash.now['error'] = 'Semantic search is unavailable right now. Showing full-text matches.'
|
||||||
|
semantic = Package.general_search(query).limit(100).to_a
|
||||||
|
semantic.each { |package| @search_sources[package.id] ||= :fulltext }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
logger.debug "Query '#{query}' has no lexical overlap with package data"
|
||||||
|
end
|
||||||
|
|
||||||
|
results = (prefix_matches + token_matches + semantic).uniq(&:id)
|
||||||
|
|
||||||
# Queries that share no vocabulary with our package data can produce
|
# Queries that share no vocabulary with our package data can produce
|
||||||
# no meaningful results. Skip the vector service round-trip and show
|
# no meaningful results. Skip the vector service round-trip and show
|
||||||
|
|
@ -409,23 +433,6 @@ class PackagesController < ApplicationController
|
||||||
results.empty? ? Package.none : results
|
results.empty? ? Package.none : results
|
||||||
end
|
end
|
||||||
|
|
||||||
# Semantic (vector) nearest neighbor search with a full-text fallback
|
|
||||||
# for when the external embedding service is unreachable.
|
|
||||||
def semantic_results(query)
|
|
||||||
unless Package.lexical_overlap?(query)
|
|
||||||
logger.debug "Query '#{query}' has no lexical overlap with package data"
|
|
||||||
return []
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
Package.nearest_to_text(query, limit: MAX_SEMANTIC_RESULTS).to_a
|
|
||||||
rescue Vectorizer::Error => e
|
|
||||||
logger.error "Semantic search failed: #{e.message}"
|
|
||||||
flash.now['error'] = 'Semantic search is unavailable right now. Showing full-text matches.'
|
|
||||||
Package.general_search(query).limit(100).to_a
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Store a random identifier and the client's IP address in the session
|
# Store a random identifier and the client's IP address in the session
|
||||||
# for later identification.
|
# for later identification.
|
||||||
# def create_user_token
|
# def create_user_token
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
module PackagesHelper
|
module PackagesHelper
|
||||||
|
# Human readable label for the search strategy that put a package
|
||||||
|
# into the search results (see PackagesController#search_packages)
|
||||||
|
def search_source_label(source)
|
||||||
|
{
|
||||||
|
exact: 'exact match',
|
||||||
|
name_prefix: 'name match',
|
||||||
|
name_contains: 'name match',
|
||||||
|
semantic: 'similar',
|
||||||
|
fulltext: 'full-text'
|
||||||
|
}[source]
|
||||||
|
end
|
||||||
|
|
||||||
# Return the description and/or the version of the package
|
# Return the description and/or the version of the package
|
||||||
def screenshot_caption(screenshot)
|
def screenshot_caption(screenshot)
|
||||||
str = []
|
str = []
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@
|
||||||
= pkg.name
|
= pkg.name
|
||||||
.text
|
.text
|
||||||
= pkg.description
|
= pkg.description
|
||||||
|
- if @search_sources[pkg.id]
|
||||||
|
.text.search-source
|
||||||
|
span.label.secondary = search_source_label(@search_sources[pkg.id])
|
||||||
- elsif @view_style==:list
|
- elsif @view_style==:list
|
||||||
- @packages.to_a.each do |pkg|
|
- @packages.to_a.each do |pkg|
|
||||||
.grid-x.grid-margin-x.listview
|
.grid-x.grid-margin-x.listview
|
||||||
|
|
@ -37,6 +40,9 @@
|
||||||
a href=package_path(name: pkg.name)
|
a href=package_path(name: pkg.name)
|
||||||
=pkg.name
|
=pkg.name
|
||||||
p =pkg.description
|
p =pkg.description
|
||||||
|
- if @search_sources[pkg.id]
|
||||||
|
p
|
||||||
|
span.label.secondary = search_source_label(@search_sources[pkg.id])
|
||||||
.listview.longdescription
|
.listview.longdescription
|
||||||
= pkg.long_description_first_paragraph
|
= pkg.long_description_first_paragraph
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ class PackagesControllerTest < ActionController::TestCase
|
||||||
get :grid, params: { search: 'vim' }
|
get :grid, params: { search: 'vim' }
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_select 'div', 'vim'
|
assert_select 'div', 'vim'
|
||||||
|
assert_equal({ packages(:vim).id => :exact }, assigns(:search_sources))
|
||||||
|
assert_select '.search-source .label', 'exact match'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -39,6 +41,7 @@ class PackagesControllerTest < ActionController::TestCase
|
||||||
# the grid renders every package in the database when no search
|
# the grid renders every package in the database when no search
|
||||||
# filter applies - so an empty result must not fall back to that
|
# filter applies - so an empty result must not fall back to that
|
||||||
assert_select 'div', text: /package-\d+/, count: 0
|
assert_select 'div', text: /package-\d+/, count: 0
|
||||||
|
assert_empty assigns(:search_sources)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -51,6 +54,9 @@ class PackagesControllerTest < ActionController::TestCase
|
||||||
assert_select 'div', 'package-0'
|
assert_select 'div', 'package-0'
|
||||||
# ...and before any semantically found package
|
# ...and before any semantically found package
|
||||||
assert response.body.index('package-0') < response.body.index('vim')
|
assert response.body.index('package-0') < response.body.index('vim')
|
||||||
|
sources = assigns(:search_sources)
|
||||||
|
assert_equal :name_prefix, sources[Package.find_by(name: 'package-0').id]
|
||||||
|
assert_equal :semantic, sources[packages(:vim).id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -60,6 +66,8 @@ class PackagesControllerTest < ActionController::TestCase
|
||||||
get :grid, params: { search: 'fire' }
|
get :grid, params: { search: 'fire' }
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_select 'div', text: 'firefox', count: 1
|
assert_select 'div', text: 'firefox', count: 1
|
||||||
|
# the higher tier wins the attribution
|
||||||
|
assert_equal :name_prefix, assigns(:search_sources)[packages(:firefox).id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -70,6 +78,9 @@ class PackagesControllerTest < ActionController::TestCase
|
||||||
get :grid, params: { search: 'fire fox' }
|
get :grid, params: { search: 'fire fox' }
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_select 'div', text: 'firefox', count: 1
|
assert_select 'div', text: 'firefox', count: 1
|
||||||
|
assert_equal({ packages(:firefox).id => :name_contains },
|
||||||
|
assigns(:search_sources))
|
||||||
|
assert_select '.search-source .label', 'name match'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -94,6 +105,7 @@ class PackagesControllerTest < ActionController::TestCase
|
||||||
get :grid, params: { search: 'editor' }
|
get :grid, params: { search: 'editor' }
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_select 'div', 'vim'
|
assert_select 'div', 'vim'
|
||||||
|
assert_equal({ packages(:vim).id => :semantic }, assigns(:search_sources))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -109,6 +121,7 @@ class PackagesControllerTest < ActionController::TestCase
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_select 'div', 'vim'
|
assert_select 'div', 'vim'
|
||||||
assert flash[:error].present?
|
assert flash[:error].present?
|
||||||
|
assert_equal({ packages(:vim).id => :fulltext }, assigns(:search_sources))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue