From 4d07f43a2e8f4a535c429f707bf50b4575f3f8bc Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sat, 22 Aug 2026 23:47:42 +0200 Subject: [PATCH] Show related packages based on description embeddings Package pages get a "Related packages" section: a nearest neighbor query against the package's own stored embedding (no external service call), excluding the package itself. Packages without an embedding render no section. --- README.md | 4 ++++ app/controllers/packages_controller.rb | 3 +++ app/models/package.rb | 11 +++++++++++ app/views/packages/details.slim | 13 +++++++++++++ test/controllers/packages_controller_test.rb | 15 +++++++++++++++ 5 files changed, 46 insertions(+) diff --git a/README.md b/README.md index deaef3a..365d995 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ Queries that share no vocabulary with the package data (gibberish, keyboard mash) return an honest empty result instead of random semantic matches. +Package pages also show semantically **related packages**, computed as +a nearest neighbor query against the package's own description +embedding - no external service call involved. + ## Deployment Read the doc/README.Installation.md diff --git a/app/controllers/packages_controller.rb b/app/controllers/packages_controller.rb index 726f4f0..5c65c97 100644 --- a/app/controllers/packages_controller.rb +++ b/app/controllers/packages_controller.rb @@ -30,6 +30,9 @@ class PackagesController < ApplicationController @screenshots = @package.screenshots.accessible_by(current_ability, :view).paginate( page: @page, per_page: 6 ) + # Semantic recommendations based on the package's own description + # embedding - a plain nearest neighbor query, no service call. + @related_packages = @package.related_packages end end diff --git a/app/models/package.rb b/app/models/package.rb index e2e1b00..71b89c7 100644 --- a/app/models/package.rb +++ b/app/models/package.rb @@ -112,6 +112,16 @@ class Package < ApplicationRecord text.gsub(/[\\%_]/) { |char| "\\#{char}" } end + # Packages semantically similar to this one, based on the stored + # description embedding. Returns an empty relation for packages that + # have no embedding (yet). + def related_packages(limit: 6) + return Package.none unless embedding + + Package.nearest_to_vector(embedding, limit: limit + 1) + .where.not(id: id).limit(limit) + end + # Check whether the given text shares at least one lexeme with any # package name/description in the database. Queries without such an # overlap (gibberish, keyboard mash, unsupported languages) can @@ -209,3 +219,4 @@ class Package < ApplicationRecord end end end +# rubocop:enable Metrics/ClassLength diff --git a/app/views/packages/details.slim b/app/views/packages/details.slim index 03c9609..7443302 100644 --- a/app/views/packages/details.slim +++ b/app/views/packages/details.slim @@ -70,6 +70,19 @@ / #metadata is the anchor for this sticky container. = render(partial: 'details_rightbox', locals: {pkg: @package}) + / Semantically similar packages, based on the package's description embedding + - if @related_packages.any? + #related + h3 Related packages + .grid-x.grid-margin-x.medium-up-2.large-up-3 data-equalizer=true + - @related_packages.each do |pkg| + .cell.pkgcard data-equalizer-watch=true + a.black href=package_path(name: pkg.name) + .text.pkgname + = pkg.name + .text.small + = pkg.description + javascript: // Where to send POST requests for uploads let ajax_upload_url = '#{{upload_receive_json_path}}'; diff --git a/test/controllers/packages_controller_test.rb b/test/controllers/packages_controller_test.rb index 4d89d88..92a6ea1 100644 --- a/test/controllers/packages_controller_test.rb +++ b/test/controllers/packages_controller_test.rb @@ -73,6 +73,21 @@ class PackagesControllerTest < ActionController::TestCase end end + test 'details shows semantically related packages' do + packages(:vim).update_column(:embedding, Array.new(384, 0.25)) + packages(:firefox).update_column(:embedding, Array.new(384, 0.75)) + + get :details, params: { name: 'vim' } + assert_response :success + assert_select '#related .pkgname', text: 'firefox' + end + + test 'details without embedding renders no related section' do + get :details, params: { name: 'vim' } + assert_response :success + assert_select '#related', count: 0 + end + test 'search uses semantic (vector) results' do relation = Package.where(name: packages(:vim).name) with_stubbed_method(Package, :nearest_to_text, ->(_text, **_opts) { relation }) do