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]
|
||||
gem 'capybara'
|
||||
gem 'selenium-webdriver'
|
||||
# Provides the assigns() test helper extracted from modern Rails
|
||||
gem 'rails-controller-testing'
|
||||
end
|
||||
|
||||
# TODO… https://github.com/galetahub/simple-captcha
|
||||
|
|
|
|||
|
|
@ -381,6 +381,10 @@ GEM
|
|||
activesupport (= 8.1.3)
|
||||
bundler (>= 1.15.0)
|
||||
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)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -592,6 +596,7 @@ DEPENDENCIES
|
|||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.0)
|
||||
rails-controller-testing
|
||||
rails-erd
|
||||
rails-healthcheck
|
||||
rails_icons (~> 1.3)
|
||||
|
|
|
|||
|
|
@ -362,6 +362,11 @@ class PackagesController < ApplicationController
|
|||
def query_packages
|
||||
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
|
||||
if params[:search].present?
|
||||
logger.debug "Searching for #{params[:search]}"
|
||||
|
|
@ -393,15 +398,34 @@ class PackagesController < ApplicationController
|
|||
|
||||
def search_packages(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+/)
|
||||
name_matches = (
|
||||
Package.name_starts_with(query).limit(MAX_PROMOTED_NAME_MATCHES) +
|
||||
Package.name_contains_all(tokens).limit(MAX_PROMOTED_NAME_MATCHES)
|
||||
).uniq(&:id)
|
||||
prefix_matches = Package.name_starts_with(query).limit(MAX_PROMOTED_NAME_MATCHES).to_a
|
||||
token_matches = Package.name_contains_all(query.to_s.split(/\s+/)).limit(
|
||||
MAX_PROMOTED_NAME_MATCHES
|
||||
).to_a
|
||||
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
|
||||
# no meaningful results. Skip the vector service round-trip and show
|
||||
|
|
@ -409,23 +433,6 @@ class PackagesController < ApplicationController
|
|||
results.empty? ? Package.none : results
|
||||
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
|
||||
# for later identification.
|
||||
# def create_user_token
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
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
|
||||
def screenshot_caption(screenshot)
|
||||
str = []
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@
|
|||
= pkg.name
|
||||
.text
|
||||
= pkg.description
|
||||
- if @search_sources[pkg.id]
|
||||
.text.search-source
|
||||
span.label.secondary = search_source_label(@search_sources[pkg.id])
|
||||
- elsif @view_style==:list
|
||||
- @packages.to_a.each do |pkg|
|
||||
.grid-x.grid-margin-x.listview
|
||||
|
|
@ -37,6 +40,9 @@
|
|||
a href=package_path(name: pkg.name)
|
||||
=pkg.name
|
||||
p =pkg.description
|
||||
- if @search_sources[pkg.id]
|
||||
p
|
||||
span.label.secondary = search_source_label(@search_sources[pkg.id])
|
||||
.listview.longdescription
|
||||
= pkg.long_description_first_paragraph
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ class PackagesControllerTest < ActionController::TestCase
|
|||
get :grid, params: { search: 'vim' }
|
||||
assert_response :success
|
||||
assert_select 'div', 'vim'
|
||||
assert_equal({ packages(:vim).id => :exact }, assigns(:search_sources))
|
||||
assert_select '.search-source .label', 'exact match'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ class PackagesControllerTest < ActionController::TestCase
|
|||
# the grid renders every package in the database when no search
|
||||
# filter applies - so an empty result must not fall back to that
|
||||
assert_select 'div', text: /package-\d+/, count: 0
|
||||
assert_empty assigns(:search_sources)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -51,6 +54,9 @@ class PackagesControllerTest < ActionController::TestCase
|
|||
assert_select 'div', 'package-0'
|
||||
# ...and before any semantically found package
|
||||
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
|
||||
|
||||
|
|
@ -60,6 +66,8 @@ class PackagesControllerTest < ActionController::TestCase
|
|||
get :grid, params: { search: 'fire' }
|
||||
assert_response :success
|
||||
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
|
||||
|
||||
|
|
@ -70,6 +78,9 @@ class PackagesControllerTest < ActionController::TestCase
|
|||
get :grid, params: { search: 'fire fox' }
|
||||
assert_response :success
|
||||
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
|
||||
|
||||
|
|
@ -94,6 +105,7 @@ class PackagesControllerTest < ActionController::TestCase
|
|||
get :grid, params: { search: 'editor' }
|
||||
assert_response :success
|
||||
assert_select 'div', 'vim'
|
||||
assert_equal({ packages(:vim).id => :semantic }, assigns(:search_sources))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -109,6 +121,7 @@ class PackagesControllerTest < ActionController::TestCase
|
|||
assert_response :success
|
||||
assert_select 'div', 'vim'
|
||||
assert flash[:error].present?
|
||||
assert_equal({ packages(:vim).id => :fulltext }, assigns(:search_sources))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue