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:
Christoph Haas 2026-08-23 10:39:50 +02:00
parent 61b64fae25
commit 323b7004ed
6 changed files with 69 additions and 24 deletions

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 = []

View file

@ -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

View file

@ -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