From 323b7004edfe3bdec334e51fed2f521c2efe3b95 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sun, 23 Aug 2026 10:39:50 +0200 Subject: [PATCH] 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. --- Gemfile | 2 + Gemfile.lock | 5 ++ app/controllers/packages_controller.rb | 55 +++++++++++--------- app/helpers/packages_helper.rb | 12 +++++ app/views/packages/browse.slim | 6 +++ test/controllers/packages_controller_test.rb | 13 +++++ 6 files changed, 69 insertions(+), 24 deletions(-) diff --git a/Gemfile b/Gemfile index 6e1b32d..c7a6922 100644 --- a/Gemfile +++ b/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 diff --git a/Gemfile.lock b/Gemfile.lock index 9cf75ac..65bbd8a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/packages_controller.rb b/app/controllers/packages_controller.rb index 5c65c97..eec639d 100644 --- a/app/controllers/packages_controller.rb +++ b/app/controllers/packages_controller.rb @@ -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 diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index d7dec2c..3bc7de0 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -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 = [] diff --git a/app/views/packages/browse.slim b/app/views/packages/browse.slim index 53009da..2dfdedc 100644 --- a/app/views/packages/browse.slim +++ b/app/views/packages/browse.slim @@ -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 diff --git a/test/controllers/packages_controller_test.rb b/test/controllers/packages_controller_test.rb index 92a6ea1..0c9c81c 100644 --- a/test/controllers/packages_controller_test.rb +++ b/test/controllers/packages_controller_test.rb @@ -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