From b31ac25acc01639841bc2ada839b53a8363f093b Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:21:28 +0300 Subject: [PATCH 01/42] Snapshot of recommendation system v1 (baseline scoring + tracking) Initial implementation of personalized auction sorting: - recommendation_profiles, recommendation_events, user_auction_scores tables - baseline rule-based Scorer (wishlist hit, tag/custom-interest match, bid/wishlist affinity, structural bonuses, AI prior) - AuctionDomainClassifier via OpenAI structured outputs - ClassifyAuctionDomainsJob + RefreshUserAuctionScoresJob - Auction::UserSortable extended to 4/5-tier priority with LEFT JOIN on user_auction_scores - DatasetSnapshot service for offline model training pipeline - ScoreImporter for external score uploads - RecommendationProfile fields embedded in sign-up form - Prompt modal on /auctions for users without filled profile - Event tracking from controllers (impressions, clicks, bids, wishlist) - recommendation_tracker Stimulus controller for client-side clicks - custom_interest_tags Stimulus controller (tag input UX) - OpenaiStructuredOutputSupport helper (model fallback, temperature) - Tests for scorer, classifier, dataset snapshot, score importer, recommendation profile model and controller - en/et locales for recommendation profile UI This commit captures the pre-v2 state on branch feature/recommendation-system-improvements. Following commits introduce the v2 plan documented in docs/architecture/recommendation-system.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auction_action_button/component.html.erb | 24 +- .../component.html.erb | 12 +- .../edit_english_offer/component.html.erb | 12 +- app/controllers/auctions_controller.rb | 15 + app/controllers/english_offers_controller.rb | 18 + app/controllers/offers_controller.rb | 18 + .../recommendation_events_controller.rb | 29 ++ .../recommendation_profiles_controller.rb | 83 +++++ app/controllers/users_controller.rb | 29 +- app/controllers/wishlist_items_controller.rb | 21 ++ .../form/custom_interest_tags_controller.js | 89 +++++ app/javascript/controllers/index.js | 6 + .../recommendation_tracker_controller.js | 35 ++ app/jobs/active_auctions_ai_sorting_job.rb | 10 +- app/jobs/daily_broadcast_auctions_job.rb | 7 +- .../classify_auction_domains_job.rb | 23 ++ .../refresh_single_user_auction_scores_job.rb | 12 + .../refresh_user_auction_scores_job.rb | 22 ++ app/models/auction.rb | 4 + app/models/concerns/auction/user_sortable.rb | 72 +++- app/models/job.rb | 4 +- app/models/recommendation_event.rb | 19 + app/models/recommendation_profile.rb | 156 ++++++++ app/models/user.rb | 17 + app/models/user_auction_score.rb | 8 + .../openai_structured_output_support.rb | 34 ++ .../auction_domain_classifier.rb | 147 ++++++++ .../recommendation/dataset_snapshot.rb | 113 ++++++ app/services/recommendation/event_tracker.rb | 39 ++ .../recommendation/interest_catalog.rb | 25 ++ app/services/recommendation/score_importer.rb | 53 +++ app/services/recommendation/scorer.rb | 176 +++++++++ app/views/auctions/index.html.erb | 1 + .../recommendation_profiles/_fields.html.erb | 51 +++ .../_prompt_modal.html.erb | 38 ++ .../recommendation_profiles/edit.html.erb | 28 ++ app/views/users/_sign_up_form.html.erb | 5 + app/views/users/_user_info.html.erb | 19 + config/locales/recommendation_profiles.en.yml | 47 +++ config/locales/recommendation_profiles.et.yml | 47 +++ config/routes.rb | 4 + ...25095500_create_recommendation_profiles.rb | 28 ++ ...0525095600_create_recommendation_events.rb | 23 ++ ...260525095700_create_user_auction_scores.rb | 19 + ...0_add_classification_fields_to_auctions.rb | 12 + db/seeds.rb | 2 +- db/structure.sql | 348 ++++++++++++++++++ lib/tasks/demo_auctions.rake | 88 +++++ test/controllers/auctions_controller_test.rb | 69 ++++ ...recommendation_profiles_controller_test.rb | 44 +++ test/fixtures/settings.yml | 2 +- test/integration/english_offers_test.rb | 17 +- test/integration/offers_test.rb | 14 +- test/integration/wishlist_items_test.rb | 6 +- test/models/job_test.rb | 14 + test/models/recommendation_profile_test.rb | 38 ++ test/models/user_test.rb | 14 + .../openai_structured_output_support_test.rb | 21 ++ .../auction_domain_classifier_test.rb | 50 +++ .../recommendation/dataset_snapshot_test.rb | 48 +++ .../recommendation/score_importer_test.rb | 54 +++ test/services/recommendation/scorer_test.rb | 72 ++++ 62 files changed, 2518 insertions(+), 37 deletions(-) create mode 100644 app/controllers/recommendation_events_controller.rb create mode 100644 app/controllers/recommendation_profiles_controller.rb create mode 100644 app/javascript/controllers/form/custom_interest_tags_controller.js create mode 100644 app/javascript/controllers/recommendation_tracker_controller.js create mode 100644 app/jobs/recommendation/classify_auction_domains_job.rb create mode 100644 app/jobs/recommendation/refresh_single_user_auction_scores_job.rb create mode 100644 app/jobs/recommendation/refresh_user_auction_scores_job.rb create mode 100644 app/models/recommendation_event.rb create mode 100644 app/models/recommendation_profile.rb create mode 100644 app/models/user_auction_score.rb create mode 100644 app/services/openai_structured_output_support.rb create mode 100644 app/services/recommendation/auction_domain_classifier.rb create mode 100644 app/services/recommendation/dataset_snapshot.rb create mode 100644 app/services/recommendation/event_tracker.rb create mode 100644 app/services/recommendation/interest_catalog.rb create mode 100644 app/services/recommendation/score_importer.rb create mode 100644 app/services/recommendation/scorer.rb create mode 100644 app/views/recommendation_profiles/_fields.html.erb create mode 100644 app/views/recommendation_profiles/_prompt_modal.html.erb create mode 100644 app/views/recommendation_profiles/edit.html.erb create mode 100644 config/locales/recommendation_profiles.en.yml create mode 100644 config/locales/recommendation_profiles.et.yml create mode 100644 db/migrate/20260525095500_create_recommendation_profiles.rb create mode 100644 db/migrate/20260525095600_create_recommendation_events.rb create mode 100644 db/migrate/20260525095700_create_user_auction_scores.rb create mode 100644 db/migrate/20260525115000_add_classification_fields_to_auctions.rb create mode 100644 lib/tasks/demo_auctions.rake create mode 100644 test/controllers/recommendation_profiles_controller_test.rb create mode 100644 test/models/recommendation_profile_test.rb create mode 100644 test/services/openai_structured_output_support_test.rb create mode 100644 test/services/recommendation/auction_domain_classifier_test.rb create mode 100644 test/services/recommendation/dataset_snapshot_test.rb create mode 100644 test/services/recommendation/score_importer_test.rb create mode 100644 test/services/recommendation/scorer_test.rb diff --git a/app/components/pages/auction/auction_action_button/component.html.erb b/app/components/pages/auction/auction_action_button/component.html.erb index 0a2951ea8..333d40679 100644 --- a/app/components/pages/auction/auction_action_button/component.html.erb +++ b/app/components/pages/auction/auction_action_button/component.html.erb @@ -6,7 +6,17 @@ <% else %> <%# if need to create new offer for english auction %> <%= component 'common/links/link_button', link_title: deposit_value[:link_title], href: new_auction_english_offer_path(auction_uuid: auction.uuid), - color: deposit_value[:color], options: { data: { turbo_frame: 'modal' } } %> + color: deposit_value[:color], + options: { + data: { + turbo_frame: 'modal', + controller: 'recommendation-tracker', + action: 'click->recommendation-tracker#track', + recommendation_tracker_auction_uuid_value: auction.uuid, + recommendation_tracker_source_value: 'auction_action_new_english_offer', + recommendation_tracker_event_type_value: 'auction_click' + } + } %> <% end %> <% else %> @@ -17,7 +27,17 @@ <% else %> <%# if need to create new offer for blind auction %> <%= component 'common/links/link_button', link_title: t('auctions.bid'), href: new_auction_offer_path(auction_uuid: auction.uuid), - color: 'green', options: { data: { turbo_frame: 'modal' } } %> + color: 'green', + options: { + data: { + turbo_frame: 'modal', + controller: 'recommendation-tracker', + action: 'click->recommendation-tracker#track', + recommendation_tracker_auction_uuid_value: auction.uuid, + recommendation_tracker_source_value: 'auction_action_new_blind_offer', + recommendation_tracker_event_type_value: 'auction_click' + } + } %> <% end %> <% end %> diff --git a/app/components/pages/auction/auction_action_button/edit_and_remove_blind_offer/component.html.erb b/app/components/pages/auction/auction_action_button/edit_and_remove_blind_offer/component.html.erb index 97d99a731..0a7bb378a 100644 --- a/app/components/pages/auction/auction_action_button/edit_and_remove_blind_offer/component.html.erb +++ b/app/components/pages/auction/auction_action_button/edit_and_remove_blind_offer/component.html.erb @@ -1,6 +1,16 @@
<%= component 'common/links/link_button', link_title: nil, href: edit_offer_path(@auction.users_offer_uuid), - color: 'ghost', options: { data: { turbo_frame: 'modal' } } do %> + color: 'ghost', + options: { + data: { + turbo_frame: 'modal', + controller: 'recommendation-tracker', + action: 'click->recommendation-tracker#track', + recommendation_tracker_auction_uuid_value: @auction.uuid, + recommendation_tracker_source_value: 'auction_action_edit_blind_offer', + recommendation_tracker_event_type_value: 'auction_click' + } + } do %> <% end %> <%= component 'common/buttons/button_to', title_caption: nil, href: offer_path(@auction.offer_from_user(@user).uuid), color: 'ghost', diff --git a/app/components/pages/auction/auction_action_button/edit_english_offer/component.html.erb b/app/components/pages/auction/auction_action_button/edit_english_offer/component.html.erb index f35ad3610..92fe35a1f 100644 --- a/app/components/pages/auction/auction_action_button/edit_english_offer/component.html.erb +++ b/app/components/pages/auction/auction_action_button/edit_english_offer/component.html.erb @@ -1,6 +1,16 @@
<%= component 'common/links/link_button', link_title: nil, href: edit_english_offer_path(@auction.users_offer_uuid), - color: 'ghost', options: { data: { turbo_frame: 'modal' } } do %> + color: 'ghost', + options: { + data: { + turbo_frame: 'modal', + controller: 'recommendation-tracker', + action: 'click->recommendation-tracker#track', + recommendation_tracker_auction_uuid_value: @auction.uuid, + recommendation_tracker_source_value: 'auction_action_edit_english_offer', + recommendation_tracker_event_type_value: 'auction_click' + } + } do %> <% end %>
diff --git a/app/controllers/auctions_controller.rb b/app/controllers/auctions_controller.rb index 519da0e98..44a455a90 100644 --- a/app/controllers/auctions_controller.rb +++ b/app/controllers/auctions_controller.rb @@ -14,6 +14,9 @@ def index limit: per_page_count, link_extra: 'data-turbo-action="advance"' ) + @show_recommendation_prompt = current_user&.recommendation_profile_promptable? + + track_recommendation_impressions respond_to do |format| format.html @@ -60,4 +63,16 @@ def set_access_control_headers def authorize_user authorize! :read, Auction end + + def track_recommendation_impressions + return unless current_user + return unless request.format.html? + + Recommendation::EventTracker.track_impressions( + user: current_user, + auctions: @auctions, + source: 'auctions#index', + request: + ) + end end diff --git a/app/controllers/english_offers_controller.rb b/app/controllers/english_offers_controller.rb index fb14eaf44..b59b72159 100644 --- a/app/controllers/english_offers_controller.rb +++ b/app/controllers/english_offers_controller.rb @@ -36,6 +36,15 @@ def create send_outbided_notification(auction: @auction, offer: @offer, flash:) update_auction_values(@auction, t('english_offers.create.created')) Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + auction: @auction, + event_type: 'bid_create', + source: 'english_offers#create', + properties: { offer_id: @offer.id, cents: @offer.cents }, + request: + ) else errors = if @offer.errors.full_messages_for(:cents).present? @offer.errors.full_messages_for(:cents).join @@ -70,6 +79,15 @@ def update send_outbided_notification(auction: @auction, offer: @offer, flash:) update_auction_values(@auction, t('english_offers.edit.bid_updated')) Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@auction.id}") + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + auction: @auction, + event_type: 'bid_update', + source: 'english_offers#update', + properties: { offer_id: @offer.id, cents: @offer.cents }, + request: + ) else errors = if @offer.errors.full_messages_for(:cents).present? @offer.errors.full_messages_for(:cents).join diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index ab15b2589..1460dad6d 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -30,6 +30,15 @@ def create end elsif create_predicate Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + auction: @auction, + event_type: 'bid_create', + source: 'offers#create', + properties: { offer_id: @offer.id, cents: @offer.cents }, + request: + ) format.html { redirect_to root_path, notice: t('.created') } format.json { render :show, status: :created, location: @offer } else @@ -67,6 +76,15 @@ def update if update_predicate Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@offer.auction.id}") + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + auction: @offer.auction, + event_type: 'bid_update', + source: 'offers#update', + properties: { offer_id: @offer.id, cents: @offer.cents }, + request: + ) format.html { redirect_to root_path, notice: t(:updated), status: :see_other } format.json { render :show, status: :ok, location: @offer } else diff --git a/app/controllers/recommendation_events_controller.rb b/app/controllers/recommendation_events_controller.rb new file mode 100644 index 000000000..84dbe00ef --- /dev/null +++ b/app/controllers/recommendation_events_controller.rb @@ -0,0 +1,29 @@ +class RecommendationEventsController < ApplicationController + before_action :authenticate_user! + + def create + Recommendation::EventTracker.call( + user: current_user, + auction: auction, + event_type: recommendation_event_params[:event_type], + source: recommendation_event_params[:source], + properties: recommendation_event_params[:properties], + request: + ) + + head :created + end + + private + + def auction + return if recommendation_event_params[:auction_uuid].blank? + + Auction.find_by(uuid: recommendation_event_params[:auction_uuid]) + end + + def recommendation_event_params + params.require(:recommendation_event) + .permit(:event_type, :source, :auction_uuid, properties: {}) + end +end diff --git a/app/controllers/recommendation_profiles_controller.rb b/app/controllers/recommendation_profiles_controller.rb new file mode 100644 index 000000000..627fda922 --- /dev/null +++ b/app/controllers/recommendation_profiles_controller.rb @@ -0,0 +1,83 @@ +class RecommendationProfilesController < ApplicationController + before_action :authenticate_user! + before_action :set_recommendation_profile + + def edit; end + + def update + @recommendation_profile.assign_attributes(recommendation_profile_params) + + if !@recommendation_profile.filled? + @recommendation_profile.dismiss_prompt! + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + event_type: 'recommendation_prompt_dismissed', + source: 'recommendation_profiles#update_blank', + request: + ) + + redirect_to after_update_path, notice: t('.skipped') + return + end + + if @recommendation_profile.save + @recommendation_profile.mark_completed! + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + event_type: 'recommendation_profile_completed', + source: 'recommendation_profiles#update', + request: + ) + + redirect_to after_update_path, notice: t('.updated') + else + render :edit, status: :unprocessable_entity + end + end + + def dismiss + @recommendation_profile.dismiss_prompt! + Recommendation::EventTracker.call( + user: current_user, + event_type: 'recommendation_prompt_dismissed', + source: 'recommendation_profiles#dismiss', + request: + ) + + redirect_to dismiss_redirect_path, notice: t('.dismissed') + end + + private + + def set_recommendation_profile + @recommendation_profile = current_user.recommendation_profile || current_user.build_recommendation_profile + end + + def recommendation_profile_params + params.require(:recommendation_profile) + .permit( + :preferred_length_min, + :preferred_length_max, + :allow_numbers, + :allow_hyphens, + interest_categories: [], + custom_interests: [] + ) + end + + def after_update_path + requested_path = params[:return_to].to_s + return requested_path if requested_path.starts_with?('/') + + user_path(current_user.uuid) + end + + def dismiss_redirect_path + requested_path = params[:return_to].to_s + return requested_path if requested_path.starts_with?('/') + + root_path + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 22971f333..db5baf4bf 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -16,6 +16,7 @@ def index; end def new redirect_to user_path(current_user.uuid), notice: t('.already_signed_in') if current_user @user = User.new + @user.build_recommendation_profile end # GET /profile/edit @@ -42,6 +43,17 @@ def create respond_to do |format| if @user.save + if @user.recommendation_profile&.filled? + @user.recommendation_profile.mark_completed! + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(@user.id) + Recommendation::EventTracker.call( + user: @user, + event_type: 'recommendation_profile_completed', + source: 'users#create', + request: + ) + end + flash[:notice] = t(:created) format.html do @@ -54,6 +66,7 @@ def create render :show, status: :created, location: @user end else + @user.build_recommendation_profile if @user.recommendation_profile.nil? flash.now[:alert] = @user.errors.full_messages.join(', ') format.html { render :new, status: :unprocessable_entity } @@ -116,9 +129,19 @@ def destroy def create_params params.require(:user) - .permit(:email, :password, :password_confirmation, :country_code, - :given_names, :surname, :mobile_phone, :accepts_terms_and_conditions, - :locale, :daily_summary, :identity_code) + .permit( + :email, :password, :password_confirmation, :country_code, + :given_names, :surname, :mobile_phone, :accepts_terms_and_conditions, + :locale, :daily_summary, :identity_code, + recommendation_profile_attributes: [ + :preferred_length_min, + :preferred_length_max, + :allow_numbers, + :allow_hyphens, + { interest_categories: [] }, + { custom_interests: [] } + ] + ) end def params_for_update diff --git a/app/controllers/wishlist_items_controller.rb b/app/controllers/wishlist_items_controller.rb index b4c68e055..95d4c5c87 100644 --- a/app/controllers/wishlist_items_controller.rb +++ b/app/controllers/wishlist_items_controller.rb @@ -24,6 +24,16 @@ def create respond_to do |format| if create_predicate + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + auction: Auction.find_by(domain_name: @wishlist_item.domain_name), + event_type: 'wishlist_add', + source: 'wishlist_items#create', + properties: { domain_name: @wishlist_item.domain_name }, + request: + ) + format.html { redirect_to wishlist_items_path, notice: t(:created) } format.json { render json: @wishlist_item, status: :created } else @@ -38,6 +48,16 @@ def destroy respond_to do |format| if @wishlist_item.destroy + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::EventTracker.call( + user: current_user, + auction: Auction.find_by(domain_name: @wishlist_item.domain_name), + event_type: 'wishlist_remove', + source: 'wishlist_items#destroy', + properties: { domain_name: @wishlist_item.domain_name }, + request: + ) + format.turbo_stream do render turbo_stream: [ turbo_stream.replace('flash', partial: 'common/flash', locals: { flash: }), @@ -56,6 +76,7 @@ def destroy def update if @wishlist_item.update(strong_params) + Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) flash[:notice] = t(:updated) render turbo_stream: [ turbo_stream.replace('flash', partial: 'common/flash', locals: { flash: }), diff --git a/app/javascript/controllers/form/custom_interest_tags_controller.js b/app/javascript/controllers/form/custom_interest_tags_controller.js new file mode 100644 index 000000000..3a64b2802 --- /dev/null +++ b/app/javascript/controllers/form/custom_interest_tags_controller.js @@ -0,0 +1,89 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "list", "otherToggle", "field"] + + connect() { + this.syncVisibility() + } + + add(event) { + if (event.key !== "Enter") return + + event.preventDefault() + + const value = this.normalizedValue(this.inputTarget.value) + if (!value) return + if (this.existingValues().includes(value)) { + this.inputTarget.value = "" + this.syncVisibility() + return + } + + this.listTarget.insertAdjacentHTML("beforeend", this.tagHtml(value)) + this.inputTarget.value = "" + + if (this.hasOtherToggleTarget) { + this.otherToggleTarget.checked = true + } + + this.syncVisibility() + } + + remove(event) { + event.preventDefault() + const tag = event.currentTarget.closest("[data-custom-interest-value]") + if (!tag) return + + tag.remove() + this.syncVisibility() + } + + toggle() { + this.syncVisibility() + } + + syncVisibility() { + if (!this.hasFieldTarget) return + + const shouldShow = this.hasExistingTags() || (this.hasOtherToggleTarget && this.otherToggleTarget.checked) + this.fieldTarget.style.display = shouldShow ? "block" : "none" + } + + hasExistingTags() { + return this.listTarget.querySelectorAll("[data-custom-interest-value]").length > 0 + } + + existingValues() { + return Array.from(this.listTarget.querySelectorAll("[data-custom-interest-value]")) + .map((node) => node.dataset.customInterestValue) + } + + normalizedValue(value) { + return value.toString().trim().toLowerCase() + } + + tagHtml(value) { + const escaped = this.escapeHtml(value) + return ` + + ${escaped} + + + + ` + } + + hiddenInputName() { + return this.inputTarget.dataset.hiddenInputName + } + + escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 493f0bd1c..6998ed645 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -34,6 +34,9 @@ application.register("form--autosave", Form__AutosaveController); import Form__CheckAllController from "./form/check_all_controller"; application.register("form--check-all", Form__CheckAllController); +import Form__CustomInterestTagsController from "./form/custom_interest_tags_controller"; +application.register("form--custom-interest-tags", Form__CustomInterestTagsController); + import Table__OrdeableController from "./table/ordeable_controller"; application.register("table--ordeable", Table__OrdeableController); @@ -87,3 +90,6 @@ application.register("slashed-zero", SlashedZeroController); import OfferPriceValidatorController from "./offer_price_validator_controller"; application.register("offer-price-validator", OfferPriceValidatorController); + +import RecommendationTrackerController from "./recommendation_tracker_controller"; +application.register("recommendation-tracker", RecommendationTrackerController); diff --git a/app/javascript/controllers/recommendation_tracker_controller.js b/app/javascript/controllers/recommendation_tracker_controller.js new file mode 100644 index 000000000..1b6250975 --- /dev/null +++ b/app/javascript/controllers/recommendation_tracker_controller.js @@ -0,0 +1,35 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { + auctionUuid: String, + source: String, + eventType: String + } + + track() { + if (!this.hasAuctionUuidValue) return + + fetch("/recommendation_events", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": this.csrfToken() + }, + credentials: "same-origin", + keepalive: true, + body: JSON.stringify({ + recommendation_event: { + auction_uuid: this.auctionUuidValue, + source: this.sourceValue || "ui", + event_type: this.eventTypeValue || "auction_click" + } + }) + }).catch(() => null) + } + + csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") + } +} diff --git a/app/jobs/active_auctions_ai_sorting_job.rb b/app/jobs/active_auctions_ai_sorting_job.rb index 4bce7e072..4689357ce 100644 --- a/app/jobs/active_auctions_ai_sorting_job.rb +++ b/app/jobs/active_auctions_ai_sorting_job.rb @@ -50,15 +50,15 @@ def fetch_ai_response(auctions_list, temperature) end def chat_parameters(auctions_list, temp) + model_name = openai_model { - model: openai_model, + model: model_name, response_format: { type: 'json_schema', json_schema: schema }, - messages: messages(auctions_list), - temperature: temp - } + messages: messages(auctions_list) + }.merge(OpenaiStructuredOutputSupport.temperature_options(model_name, temp)) end # rubocop:disable Metrics/MethodLength @@ -144,6 +144,6 @@ def system_message end def openai_model - Setting.find_by(code: 'openai_model').retrieve + OpenaiStructuredOutputSupport.model(Setting.find_by(code: 'openai_model').retrieve) end end diff --git a/app/jobs/daily_broadcast_auctions_job.rb b/app/jobs/daily_broadcast_auctions_job.rb index 39bfe074b..b3c6b7784 100644 --- a/app/jobs/daily_broadcast_auctions_job.rb +++ b/app/jobs/daily_broadcast_auctions_job.rb @@ -5,14 +5,17 @@ def perform unsubscribe = Rails.application.message_verifier(:unsubscribe).generate(user.id) NotificationMailer.daily_auctions_broadcast_email( recipient: user.email, - auctions: active_auctions, + auctions: active_auctions_for(user), unsubscribe: unsubscribe ).deliver_later end end end - def active_auctions + def active_auctions_for(user) + scored_auctions = Recommendation::Scorer.top_auctions_for(user:, limit: 20).to_a + return scored_auctions if scored_auctions.any? + Auction.active.to_a end diff --git a/app/jobs/recommendation/classify_auction_domains_job.rb b/app/jobs/recommendation/classify_auction_domains_job.rb new file mode 100644 index 000000000..dd75c9d92 --- /dev/null +++ b/app/jobs/recommendation/classify_auction_domains_job.rb @@ -0,0 +1,23 @@ +module Recommendation + class ClassifyAuctionDomainsJob < ApplicationJob + retry_on StandardError, wait: 5.seconds, attempts: 3 + + def perform(auction_ids = nil) + return unless self.class.needs_to_run? + + auctions = self.class.scope_for(auction_ids) + should_refresh_scores = auctions.exists? + Recommendation::AuctionDomainClassifier.call(auctions:) + Recommendation::RefreshUserAuctionScoresJob.perform_later if should_refresh_scores + end + + def self.needs_to_run? + Feature.open_ai_integration_enabled? && scope_for.exists? + end + + def self.scope_for(auction_ids = nil) + scope = auction_ids.present? ? Auction.where(id: auction_ids) : Auction.active + scope.where(classified_at: nil).or(scope.where('classified_at < ?', 7.days.ago)) + end + end +end diff --git a/app/jobs/recommendation/refresh_single_user_auction_scores_job.rb b/app/jobs/recommendation/refresh_single_user_auction_scores_job.rb new file mode 100644 index 000000000..8bdb27248 --- /dev/null +++ b/app/jobs/recommendation/refresh_single_user_auction_scores_job.rb @@ -0,0 +1,12 @@ +module Recommendation + class RefreshSingleUserAuctionScoresJob < ApplicationJob + retry_on StandardError, wait: 5.seconds, attempts: 3 + + def perform(user_id) + user = User.find_by(id: user_id) + return unless user + + Recommendation::Scorer.refresh_for(user:) + end + end +end diff --git a/app/jobs/recommendation/refresh_user_auction_scores_job.rb b/app/jobs/recommendation/refresh_user_auction_scores_job.rb new file mode 100644 index 000000000..721460321 --- /dev/null +++ b/app/jobs/recommendation/refresh_user_auction_scores_job.rb @@ -0,0 +1,22 @@ +module Recommendation + class RefreshUserAuctionScoresJob < ApplicationJob + retry_on StandardError, wait: 5.seconds, attempts: 3 + + def perform(user_ids = nil) + return unless self.class.needs_to_run? + + self.class.scope_for(user_ids).find_each do |user| + Recommendation::Scorer.refresh_for(user:) + end + end + + def self.needs_to_run? + Auction.active.exists? && scope_for.exists? + end + + def self.scope_for(user_ids = nil) + scope = user_ids.present? ? User.where(id: user_ids) : User.where('? = ANY (roles)', User::PARTICIPANT_ROLE) + scope.includes(:recommendation_profile) + end + end +end diff --git a/app/models/auction.rb b/app/models/auction.rb index a920aca12..7099e4208 100644 --- a/app/models/auction.rb +++ b/app/models/auction.rb @@ -21,6 +21,8 @@ class Auction < ApplicationRecord # rubocop:disable Metrics has_many :offers, dependent: :delete_all has_many :domain_participate_auctions, dependent: :delete_all has_many :domain_offer_histories + has_many :recommendation_events, dependent: :nullify + has_many :user_auction_scores, dependent: :delete_all has_one :result, required: false, dependent: :destroy enum :platform, %i[blind english] @@ -31,6 +33,8 @@ class Auction < ApplicationRecord # rubocop:disable Metrics delegate :count, to: :offers, prefix: true delegate :size, to: :offers, prefix: true + def classified? = classified_at.present? + def update_list_broadcast Auctions::UpdateListBroadcastService.call({ auction: self }) end diff --git a/app/models/concerns/auction/user_sortable.rb b/app/models/concerns/auction/user_sortable.rb index ec0cf94fc..38431a363 100644 --- a/app/models/concerns/auction/user_sortable.rb +++ b/app/models/concerns/auction/user_sortable.rb @@ -8,36 +8,90 @@ def sorted_for_user(user) = user ? with_user_priority_sorting(user) : self def with_user_priority_sorting(user) wishlist_domains = user.wishlist_items.pluck(:domain_name) + interest_profile = user.recommendation_profile + interest_categories = interest_profile&.rankable_interest_categories || [] + custom_interests = interest_profile&.custom_interests || [] + query = with_recommendation_scores(user.id) order_sql = if wishlist_domains.any? - build_three_tier_priority_sql(wishlist_domains) + build_five_tier_priority_sql(wishlist_domains, interest_categories, custom_interests) else - build_two_tier_priority_sql + build_four_tier_priority_sql(interest_categories, custom_interests) end - order(Arel.sql(order_sql)) + query.order(Arel.sql(order_sql)) end - def build_three_tier_priority_sql(wishlist_domains) + def with_recommendation_scores(user_id) + join_sql = ActiveRecord::Base.sanitize_sql_array([ + <<~SQL.squish, + LEFT JOIN user_auction_scores + ON user_auction_scores.auction_id = auctions.id + AND user_auction_scores.user_id = ? + SQL + user_id + ]) + + joins(join_sql) + end + + def build_five_tier_priority_sql(wishlist_domains, interest_categories, custom_interests) sanitized_domains = wishlist_domains.map { |d| ActiveRecord::Base.connection.quote(d) }.join(',') + interest_match_sql = interest_match_sql(interest_categories, custom_interests) <<~SQL.squish CASE WHEN auctions.users_offer_id IS NOT NULL THEN 0 WHEN auctions.domain_name IN (#{sanitized_domains}) THEN 1 - ELSE 2 + WHEN user_auction_scores.score IS NOT NULL THEN 2 + WHEN #{interest_match_sql} THEN 3 + ELSE 4 END, - CASE WHEN auctions.ai_score > 0 THEN auctions.ai_score ELSE RANDOM() END DESC + CASE + WHEN user_auction_scores.score IS NOT NULL THEN user_auction_scores.score + WHEN auctions.ai_score > 0 THEN auctions.ai_score + ELSE RANDOM() + END DESC SQL end - def build_two_tier_priority_sql + def build_four_tier_priority_sql(interest_categories, custom_interests) + interest_match_sql = interest_match_sql(interest_categories, custom_interests) <<~SQL.squish CASE WHEN auctions.users_offer_id IS NOT NULL THEN 0 - ELSE 1 + WHEN user_auction_scores.score IS NOT NULL THEN 1 + WHEN #{interest_match_sql} THEN 2 + ELSE 3 END, - CASE WHEN auctions.ai_score > 0 THEN auctions.ai_score ELSE RANDOM() END DESC + CASE + WHEN user_auction_scores.score IS NOT NULL THEN user_auction_scores.score + WHEN auctions.ai_score > 0 THEN auctions.ai_score + ELSE RANDOM() + END DESC SQL end + + def interest_match_sql(interest_categories, custom_interests) + match_clauses = [] + + if interest_categories.present? + quoted_categories = interest_categories.map { |item| ActiveRecord::Base.connection.quote(item) }.join(',') + match_clauses << "auctions.classification_tags && ARRAY[#{quoted_categories}]::varchar[]" + end + + custom_interest_clauses = custom_interests.filter_map do |interest| + normalized_interest = interest.to_s.strip.downcase + next if normalized_interest.blank? + + pattern = "%#{ActiveRecord::Base.sanitize_sql_like(normalized_interest)}%" + "LOWER(auctions.domain_name) LIKE #{ActiveRecord::Base.connection.quote(pattern)}" + end + + match_clauses.concat(custom_interest_clauses) + + return 'FALSE' if match_clauses.empty? + + "(#{match_clauses.join(' OR ')})" + end end end \ No newline at end of file diff --git a/app/models/job.rb b/app/models/job.rb index e34d7818e..e8eb91ed4 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -3,7 +3,9 @@ class Job AuctionCreationJob DomainRegistrationCheckJob ResultStatusUpdateJob DomainRegistrationReminderJob UnpaidInvoiceReminderJob DailySummaryJob DailyBroadcastAuctionsJob DailyViewRefreshJob - SharedFooterFetcherJob DirectoInvoiceForwardJob ActiveAuctionsAiSortingJob].freeze + SharedFooterFetcherJob DirectoInvoiceForwardJob ActiveAuctionsAiSortingJob + Recommendation::ClassifyAuctionDomainsJob + Recommendation::RefreshUserAuctionScoresJob].freeze include ActiveModel::Model diff --git a/app/models/recommendation_event.rb b/app/models/recommendation_event.rb new file mode 100644 index 000000000..4bb9763e2 --- /dev/null +++ b/app/models/recommendation_event.rb @@ -0,0 +1,19 @@ +class RecommendationEvent < ApplicationRecord + EVENT_TYPES = %w[ + auction_impression + auction_click + auction_detail_view + wishlist_add + wishlist_remove + bid_create + bid_update + recommendation_profile_completed + recommendation_prompt_dismissed + ].freeze + + belongs_to :user, optional: true + belongs_to :auction, optional: true + + validates :event_type, presence: true, inclusion: { in: EVENT_TYPES } + validates :occurred_at, presence: true +end diff --git a/app/models/recommendation_profile.rb b/app/models/recommendation_profile.rb new file mode 100644 index 000000000..6379dcb8f --- /dev/null +++ b/app/models/recommendation_profile.rb @@ -0,0 +1,156 @@ +class RecommendationProfile < ApplicationRecord + PROMPT_REMINDER_INTERVAL = 14.days + CUSTOM_INTEREST_PREFIX = 'custom:'.freeze + OTHER_CATEGORY = 'other'.freeze + + belongs_to :user + + before_validation :normalize_interest_categories + + validates :preferred_length_min, + numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 63 }, + allow_nil: true + validates :preferred_length_max, + numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 63 }, + allow_nil: true + validate :length_range_is_valid + validate :interest_categories_are_supported + + def completed? = completed_at.present? + + def promptable? + return false if completed? + return true if prompt_dismissed_at.blank? + + prompt_dismissed_at <= PROMPT_REMINDER_INTERVAL.ago + end + + def filled? + rankable_interest_categories.any? || + custom_interests.any? || + preferred_length_min.present? || + preferred_length_max.present? || + !allow_numbers.nil? || + !allow_hyphens.nil? + end + + def mark_completed! + update!(completed_at: Time.current, prompt_dismissed_at: nil) + end + + def dismiss_prompt! + update!( + prompt_dismissed_at: Time.current, + last_prompted_at: Time.current, + prompt_shown_count: prompt_shown_count + 1 + ) + end + + def interest_categories + interest_keywords.select { |value| known_category?(value) } + end + + def interest_categories=(values) + self.interest_keywords = combine_interest_values(categories: values, custom_values: custom_interests) + end + + def interest_categories_labels + interest_categories.map { |category| I18n.t("recommendation_profiles.categories.#{category}") } + end + + def rankable_interest_categories + interest_categories - [OTHER_CATEGORY] + end + + def custom_interests + interest_keywords.filter_map do |value| + next unless value.to_s.start_with?(CUSTOM_INTEREST_PREFIX) + + value.to_s.delete_prefix(CUSTOM_INTEREST_PREFIX) + end + end + + def custom_interests=(values) + self.interest_keywords = combine_interest_values(categories: interest_categories, custom_values: values) + end + + def summary_lines + [].tap do |lines| + if rankable_interest_categories.any? + labels = rankable_interest_categories.map { |category| I18n.t("recommendation_profiles.categories.#{category}") } + lines << "#{I18n.t('recommendation_profiles.summary.categories')}: #{labels.join(', ')}" + end + + if custom_interests.any? + lines << "#{I18n.t('recommendation_profiles.summary.custom_interests')}: #{custom_interests.join(', ')}" + end + + if preferred_length_min.present? || preferred_length_max.present? + min = preferred_length_min || '?' + max = preferred_length_max || '?' + lines << "#{I18n.t('recommendation_profiles.summary.length')}: #{min}-#{max}" + end + end + end + + private + + def normalize_interest_categories + known_categories = normalize_list(interest_keywords).select { |item| known_category?(item) } + normalized_custom_interests = normalize_custom_interests( + interest_keywords.reject { |item| known_category?(item) } + ) + + known_categories << OTHER_CATEGORY if normalized_custom_interests.any? + self.interest_keywords = (known_categories.uniq + normalized_custom_interests).uniq + end + + def combine_interest_values(categories:, custom_values:) + normalized_categories = normalize_list(categories).select { |item| known_category?(item) } + normalized_custom_interests = normalize_custom_interests(custom_values) + + normalized_categories << OTHER_CATEGORY if normalized_custom_interests.any? + (normalized_categories.uniq + normalized_custom_interests).uniq + end + + def normalize_list(value) + Array(value) + .flat_map do |item| + item.is_a?(String) ? item.to_s.split(',') : item + end + .map { |item| item.to_s.strip.downcase } + .reject(&:blank?) + .uniq + end + + def normalize_custom_interests(values) + normalize_list(values).filter_map do |value| + next if known_category?(value) + + normalized_value = value.delete_prefix(CUSTOM_INTEREST_PREFIX).strip + next if normalized_value.blank? + + "#{CUSTOM_INTEREST_PREFIX}#{normalized_value}" + end + end + + def length_range_is_valid + return unless preferred_length_min.present? && preferred_length_max.present? + return if preferred_length_min <= preferred_length_max + + errors.add(:preferred_length_min, :invalid) + end + + def interest_categories_are_supported + invalid_categories = interest_keywords.reject do |value| + known_category?(value) || value.to_s.start_with?(CUSTOM_INTEREST_PREFIX) + end + return if invalid_categories.empty? + + errors.add(:interest_keywords, :invalid) + end + + def known_category?(value) + Recommendation::InterestCatalog.categories.include?(value.to_s) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 03a9b0ed0..b374a9771 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -47,8 +47,13 @@ class User < ApplicationRecord has_many :autobiders, dependent: :destroy has_many :domain_participate_auctions has_many :notifications, as: :recipient, dependent: :destroy + has_many :recommendation_events, dependent: :destroy + has_many :user_auction_scores, dependent: :destroy has_one :webpush_subscription + has_one :recommendation_profile, dependent: :destroy + + accepts_nested_attributes_for :recommendation_profile, reject_if: :recommendation_profile_attributes_blank? scope :subscribed_to_daily_summary, -> { where(daily_summary: true) } scope :with_confirmed_phone, -> { where.not(mobile_phone_confirmed_at: nil) } @@ -239,4 +244,16 @@ def allow_to_send_sms_again? def active_for_authentication? signed_in_with_identity_document? || super end + + def recommendation_profile_promptable? + recommendation_profile&.promptable? != false + end + + private + + def recommendation_profile_attributes_blank?(attributes) + attributes.except('_destroy').values.all? do |value| + value.is_a?(Array) ? value.reject(&:blank?).empty? : value.blank? + end + end end diff --git a/app/models/user_auction_score.rb b/app/models/user_auction_score.rb new file mode 100644 index 000000000..b066ca793 --- /dev/null +++ b/app/models/user_auction_score.rb @@ -0,0 +1,8 @@ +class UserAuctionScore < ApplicationRecord + belongs_to :user + belongs_to :auction + + validates :score, presence: true, numericality: true + validates :calculated_at, presence: true + validates :auction_id, uniqueness: { scope: :user_id } +end diff --git a/app/services/openai_structured_output_support.rb b/app/services/openai_structured_output_support.rb new file mode 100644 index 000000000..9dffbaf63 --- /dev/null +++ b/app/services/openai_structured_output_support.rb @@ -0,0 +1,34 @@ +module OpenaiStructuredOutputSupport + DEFAULT_MODEL = 'gpt-5'.freeze + SUPPORTED_MODEL_PREFIXES = ['gpt-4o', 'gpt-4.1', 'gpt-5'].freeze + CUSTOM_TEMPERATURE_UNSUPPORTED_PREFIXES = ['gpt-5'].freeze + + class << self + def model(configured_model) + normalized_model = configured_model.to_s.strip + return DEFAULT_MODEL if normalized_model.blank? + return normalized_model if supported?(normalized_model) + + Rails.logger.warn( + "OpenAI model #{normalized_model.inspect} does not support json_schema structured outputs. " \ + "Falling back to #{DEFAULT_MODEL.inspect}." + ) + DEFAULT_MODEL + end + + def supported?(model_name) + SUPPORTED_MODEL_PREFIXES.any? { |prefix| model_name.start_with?(prefix) } + end + + def temperature_options(model_name, temperature) + return {} if temperature.nil? + return {} if custom_temperature_unsupported?(model_name) + + { temperature: temperature } + end + + def custom_temperature_unsupported?(model_name) + CUSTOM_TEMPERATURE_UNSUPPORTED_PREFIXES.any? { |prefix| model_name.start_with?(prefix) } + end + end +end diff --git a/app/services/recommendation/auction_domain_classifier.rb b/app/services/recommendation/auction_domain_classifier.rb new file mode 100644 index 000000000..272d0f805 --- /dev/null +++ b/app/services/recommendation/auction_domain_classifier.rb @@ -0,0 +1,147 @@ +module Recommendation + class AuctionDomainClassifier + DEFAULT_TEMPERATURE = 0.2 + + class << self + def call(...) + new(...).call + end + end + + def initialize(auctions:, temperature: DEFAULT_TEMPERATURE) + @auctions = Array(auctions) + @temperature = temperature + end + + def call + return [] if @auctions.empty? + + response = fetch_ai_response + classifications = JSON.parse(response).fetch('classifications', []) + apply_classifications(classifications) + rescue StandardError, OpenAI::Error => e + Rails.logger.info "Auction domain classification failed: #{e.message}" + raise + end + + private + + def fetch_ai_response + client = OpenAI::Client.new + response = client.chat(parameters: chat_parameters) + + finish_reason = response.dig('choices', 0, 'finish_reason') + raise StandardError, 'Incomplete response' if finish_reason == 'length' + + refusal = response.dig('choices', 0, 'message', 'refusal') + raise StandardError, refusal if refusal + + content = response.dig('choices', 0, 'message', 'content') + raise StandardError, response.dig('error', 'message') || 'No response content' if content.nil? + + content + end + + def chat_parameters + model_name = openai_model + { + model: model_name, + response_format: { + type: 'json_schema', + json_schema: schema + }, + messages: messages + }.merge(OpenaiStructuredOutputSupport.temperature_options(model_name, @temperature)) + end + + def schema + { + name: 'auction_domain_classification', + schema: { + type: 'object', + properties: { + classifications: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + domain_name: { type: 'string' }, + primary_category: { type: 'string' }, + tags: { + type: 'array', + items: { type: 'string' } + } + }, + required: %w[id domain_name primary_category tags], + additionalProperties: false + } + } + }, + required: ['classifications'], + additionalProperties: false + }, + strict: true + } + end + + def messages + [ + { role: 'system', content: system_message }, + { role: 'user', content: auctions_payload.to_json } + ] + end + + def system_message + setting = Setting.find_by(code: 'openai_domain_classification_prompt')&.retrieve + return setting if setting.present? + + <<~PROMPT.squish + You classify .ee auction domains for a recommendation system. + For each domain choose one primary category and 1-4 tags from this fixed vocabulary only: + #{Recommendation::InterestCatalog.categories.join(', ')}. + Return only categories that are actually inferable from the domain name. + Prefer conservative classification over guessing. + PROMPT + end + + def auctions_payload + @auctions.map do |auction| + { + id: auction.id, + domain_name: auction.domain_name, + platform: auction.platform || 'blind', + starts_at: auction.starts_at, + ends_at: auction.ends_at + } + end + end + + def openai_model + OpenaiStructuredOutputSupport.model(Setting.find_by(code: 'openai_model').retrieve) + end + + def apply_classifications(classifications) + now = Time.current + + classifications.filter_map do |payload| + auction = @auctions.find { |item| item.id == payload['id'] } + next unless auction + + tags = Array(payload['tags']).map(&:to_s).map(&:downcase) & Recommendation::InterestCatalog.categories + primary_category = payload['primary_category'].to_s.downcase + primary_category = tags.first if primary_category.blank? || !Recommendation::InterestCatalog.categories.include?(primary_category) + + auction.update_columns( + classification_tags: tags, + primary_category: primary_category, + classification_source: 'openai', + classification_model: openai_model, + classified_at: now + ) + + { auction_id: auction.id, tags:, primary_category: } + end + end + end +end diff --git a/app/services/recommendation/dataset_snapshot.rb b/app/services/recommendation/dataset_snapshot.rb new file mode 100644 index 000000000..2690fc042 --- /dev/null +++ b/app/services/recommendation/dataset_snapshot.rb @@ -0,0 +1,113 @@ +module Recommendation + class DatasetSnapshot + class << self + def call(...) + new(...).call + end + end + + def initialize(users: User.all, auctions: Auction.all, recommendation_events: RecommendationEvent.all) + @users = users + @auctions = auctions + @recommendation_events = recommendation_events + end + + def call + { + users: users_payload, + auctions: auctions_payload, + interactions: interactions_payload + } + end + + private + + def users_payload + @users.includes(:recommendation_profile).map do |user| + profile = user.recommendation_profile + + { + user_uuid: user.uuid, + locale: user.locale, + country_code: user.country_code, + daily_summary: user.daily_summary, + interest_categories: profile&.interest_categories || [], + custom_interests: profile&.custom_interests || [], + preferred_length_min: profile&.preferred_length_min, + preferred_length_max: profile&.preferred_length_max + } + end + end + + def auctions_payload + @auctions.map do |auction| + { + auction_uuid: auction.uuid, + domain_name: auction.domain_name, + platform: auction.platform || 'blind', + starts_at: auction.starts_at, + ends_at: auction.ends_at, + turns_count: auction.turns_count, + ai_score: auction.ai_score, + classification_tags: auction.classification_tags, + primary_category: auction.primary_category, + classification_source: auction.classification_source, + classified_at: auction.classified_at, + starting_price: auction.starting_price, + min_bids_step: auction.min_bids_step, + slipping_end: auction.slipping_end, + enable_deposit: auction.enable_deposit, + requirement_deposit_in_cents: auction.requirement_deposit_in_cents + } + end + end + + def interactions_payload + explicit_interactions + historical_offer_interactions + historical_wishlist_interactions + end + + def explicit_interactions + @recommendation_events.includes(:user, :auction).map do |event| + { + user_uuid: event.user&.uuid, + auction_uuid: event.auction&.uuid, + event_type: event.event_type, + source: event.source, + occurred_at: event.occurred_at, + properties: event.properties + } + end + end + + def historical_offer_interactions + Offer.includes(:user, :auction).map do |offer| + { + user_uuid: offer.user&.uuid, + auction_uuid: offer.auction&.uuid, + event_type: 'historical_bid', + source: 'offers', + occurred_at: offer.updated_at, + properties: { cents: offer.cents, billing_profile_id: offer.billing_profile_id, username: offer.username } + } + end + end + + def historical_wishlist_interactions + auctions_by_domain = Auction.where(domain_name: WishlistItem.select(:domain_name).distinct) + .index_by(&:domain_name) + + WishlistItem.includes(:user).filter_map do |item| + auction = auctions_by_domain[item.domain_name] + + { + user_uuid: item.user&.uuid, + auction_uuid: auction&.uuid, + event_type: 'historical_wishlist', + source: 'wishlist_items', + occurred_at: item.updated_at, + properties: { domain_name: item.domain_name, cents: item.cents } + } + end + end + end +end diff --git a/app/services/recommendation/event_tracker.rb b/app/services/recommendation/event_tracker.rb new file mode 100644 index 000000000..5ac50a2d1 --- /dev/null +++ b/app/services/recommendation/event_tracker.rb @@ -0,0 +1,39 @@ +module Recommendation + class EventTracker + class << self + def call(...) + new(...).call + end + + def track_impressions(user:, auctions:, source:, request: nil) + Array(auctions).each do |auction| + call(user:, auction:, event_type: 'auction_impression', source:, request:) + end + end + end + + def initialize(user:, event_type:, auction: nil, source: nil, properties: {}, request: nil) + @user = user + @event_type = event_type + @auction = auction + @source = source + @properties = properties || {} + @request = request + end + + def call + RecommendationEvent.create( + user: @user, + auction: @auction, + event_type: @event_type, + source: @source, + session_id: @request&.session&.id&.to_s, + request_id: @request&.request_id, + occurred_at: Time.current, + properties: @properties + ) + rescue StandardError => e + Rails.logger.info("Recommendation event tracking failed: #{e.message}") + end + end +end diff --git a/app/services/recommendation/interest_catalog.rb b/app/services/recommendation/interest_catalog.rb new file mode 100644 index 000000000..97ce835df --- /dev/null +++ b/app/services/recommendation/interest_catalog.rb @@ -0,0 +1,25 @@ +module Recommendation + module InterestCatalog + CATEGORIES = %w[ + brandable + shop_brand + saas + b2b_service + local_service + media_content + finance + legal + health + education + travel + automotive + real_estate + numeric + other + ].freeze + + class << self + def categories = CATEGORIES + end + end +end diff --git a/app/services/recommendation/score_importer.rb b/app/services/recommendation/score_importer.rb new file mode 100644 index 000000000..a53a812d9 --- /dev/null +++ b/app/services/recommendation/score_importer.rb @@ -0,0 +1,53 @@ +module Recommendation + class ScoreImporter + class << self + def call(...) + new(...).call + end + end + + def initialize(scores:, model_name: nil, features_version: nil, calculated_at: Time.current) + @scores = Array(scores) + @model_name = model_name + @features_version = features_version + @calculated_at = calculated_at + end + + def call + records = @scores.filter_map { |payload| build_record(payload) } + return 0 if records.empty? + + UserAuctionScore.upsert_all(records, unique_by: %i[user_id auction_id]) + records.size + end + + private + + def build_record(payload) + user_id = resolve_user_id(payload) + auction_id = resolve_auction_id(payload) + score = payload[:score] || payload['score'] + + return if user_id.blank? || auction_id.blank? || score.blank? + + { + user_id:, + auction_id:, + score: score.to_d, + model_name: payload[:model_name] || payload['model_name'] || @model_name, + features_version: payload[:features_version] || payload['features_version'] || @features_version, + calculated_at: payload[:calculated_at] || payload['calculated_at'] || @calculated_at, + created_at: Time.current, + updated_at: Time.current + } + end + + def resolve_user_id(payload) + payload[:user_id] || payload['user_id'] || User.find_by(uuid: payload[:user_uuid] || payload['user_uuid'])&.id + end + + def resolve_auction_id(payload) + payload[:auction_id] || payload['auction_id'] || Auction.find_by(uuid: payload[:auction_uuid] || payload['auction_uuid'])&.id + end + end +end diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb new file mode 100644 index 000000000..2c1c1f546 --- /dev/null +++ b/app/services/recommendation/scorer.rb @@ -0,0 +1,176 @@ +module Recommendation + class Scorer + class << self + BASELINE_MODEL_NAME = 'baseline_rules_v1'.freeze + + def top_auctions_for(user:, scope: Auction.active, limit: nil) + query = scope + .joins(:user_auction_scores) + .where(user_auction_scores: { user_id: user.id }) + .order('user_auction_scores.score DESC, auctions.ends_at ASC') + + limit ? query.limit(limit) : query + end + + def refresh_for(user:, scope: Auction.active, calculated_at: Time.current) + new(user:, scope:, calculated_at:).refresh! + end + end + + def initialize(user:, scope: Auction.active, calculated_at: Time.current) + @user = user + @scope = scope + @calculated_at = calculated_at + end + + def refresh! + return 0 unless @user + + auctions = @scope.to_a + return 0 if auctions.empty? + + records = auctions.map { |auction| build_score_record(auction) } + + UserAuctionScore.upsert_all(records, unique_by: %i[user_id auction_id]) + records.size + end + + private + + def build_score_record(auction) + { + user_id: @user.id, + auction_id: auction.id, + score: score_for(auction), + model_name: self.class::BASELINE_MODEL_NAME, + features_version: self.class::BASELINE_MODEL_NAME, + calculated_at: @calculated_at, + created_at: Time.current, + updated_at: Time.current + } + end + + def score_for(auction) + score = 0.0 + tags = Array(auction.classification_tags).map(&:to_s) + domain_name = normalized_domain_name(auction.domain_name) + + score += 120 if wishlist_domains.include?(auction.domain_name.to_s.downcase) + score += (matching_interest_tags(tags).size * 35) + score += (matching_custom_interests(domain_name).size * 20) + score += affinity_score(tags:, tag_counts: bid_tag_counts, weight: 8, cap: 24) + score += affinity_score(tags:, tag_counts: wishlist_tag_counts, weight: 6, cap: 18) + score += 15 if similar_to_saved_domain?(domain_name) + score += 10 if within_preferred_length?(domain_name) + score += digits_score(domain_name) + score += hyphen_score(domain_name) + score += ai_prior_score(auction) + + score.round(6) + end + + def matching_interest_tags(tags) + tags & rankable_interest_categories + end + + def matching_custom_interests(domain_name) + custom_interests.select do |interest| + normalized_interest = normalized_domain_name(interest) + normalized_interest.present? && domain_name.include?(normalized_interest) + end + end + + def affinity_score(tags:, tag_counts:, weight:, cap:) + score = tags.sum { |tag| tag_counts[tag].to_i * weight } + [score, cap].min + end + + def similar_to_saved_domain?(domain_name) + saved_domain_roots.any? do |saved_root| + saved_root.present? && (domain_name.include?(saved_root) || saved_root.include?(domain_name)) + end + end + + def within_preferred_length?(domain_name) + return false unless profile + + length = domain_name.length + return false if profile.preferred_length_min.present? && length < profile.preferred_length_min + return false if profile.preferred_length_max.present? && length > profile.preferred_length_max + + profile.preferred_length_min.present? || profile.preferred_length_max.present? + end + + def digits_score(domain_name) + return 0 unless domain_name.match?(/\d/) + + if profile&.allow_numbers == false + -20 + elsif profile&.allow_numbers == true + 8 + else + 0 + end + end + + def hyphen_score(domain_name) + return 0 unless domain_name.include?('-') + + if profile&.allow_hyphens == false + -12 + elsif profile&.allow_hyphens == true + 5 + else + 0 + end + end + + def ai_prior_score(auction) + auction.ai_score.to_f / 10.0 + end + + def profile + @profile ||= @user.recommendation_profile + end + + def rankable_interest_categories + @rankable_interest_categories ||= Array(profile&.rankable_interest_categories).map(&:to_s) + end + + def custom_interests + @custom_interests ||= Array(profile&.custom_interests).map(&:to_s) + end + + def wishlist_domains + @wishlist_domains ||= @user.wishlist_items.pluck(:domain_name).map(&:downcase) + end + + def saved_domain_roots + @saved_domain_roots ||= wishlist_domains.map { |domain| normalized_domain_name(domain) }.uniq + end + + def bid_tag_counts + @bid_tag_counts ||= build_tag_counts( + Auction.joins(:offers).where(offers: { user_id: @user.id }).distinct.to_a + ) + end + + def wishlist_tag_counts + @wishlist_tag_counts ||= build_tag_counts( + Auction.where(domain_name: @user.wishlist_items.select(:domain_name)).to_a + ) + end + + def build_tag_counts(auctions) + auctions.each_with_object(Hash.new(0)) do |auction, counts| + Array(auction.classification_tags).each do |tag| + counts[tag.to_s] += 1 + end + end + end + + def normalized_domain_name(value) + value.to_s.downcase.sub(/\.ee\z/, '') + end + end +end diff --git a/app/views/auctions/index.html.erb b/app/views/auctions/index.html.erb index f9d60598d..bdf8012ca 100644 --- a/app/views/auctions/index.html.erb +++ b/app/views/auctions/index.html.erb @@ -7,6 +7,7 @@ <% end %> <%= component 'pages/auction/cards' %> +<%= render 'recommendation_profiles/prompt_modal' if @show_recommendation_prompt %>
+ + + + + +
diff --git a/app/views/recommendation_profiles/_prompt_modal.html.erb b/app/views/recommendation_profiles/_prompt_modal.html.erb new file mode 100644 index 000000000..291890cf2 --- /dev/null +++ b/app/views/recommendation_profiles/_prompt_modal.html.erb @@ -0,0 +1,38 @@ +
+
+
+
+
+
+
+ <%= t('recommendation_profiles.prompt.title') %> +
+
+ <%= t('recommendation_profiles.prompt.subject') %> +
+
+ <%= t('recommendation_profiles.prompt.description') %> +
+
+ +
+ +
+
+
+
+
+
diff --git a/app/views/recommendation_profiles/edit.html.erb b/app/views/recommendation_profiles/edit.html.erb new file mode 100644 index 000000000..d954355d0 --- /dev/null +++ b/app/views/recommendation_profiles/edit.html.erb @@ -0,0 +1,28 @@ +<% content_for :title, t('.title') %> + +
+ +
diff --git a/app/views/users/_sign_up_form.html.erb b/app/views/users/_sign_up_form.html.erb index 30cfc2bc9..eae8090f9 100644 --- a/app/views/users/_sign_up_form.html.erb +++ b/app/views/users/_sign_up_form.html.erb @@ -76,5 +76,10 @@ <%= component 'common/form/checkboxes/checkbox_with_label', label_title: t('.daily_summary'), form: f, attribute: :daily_summary %>
+ + <%= f.fields_for :recommendation_profile do |recommendation_form| %> + <%= render 'recommendation_profiles/fields', form: recommendation_form %> + <% end %> +
diff --git a/app/views/users/_user_info.html.erb b/app/views/users/_user_info.html.erb index 4cab097a3..9b32d068e 100644 --- a/app/views/users/_user_info.html.erb +++ b/app/views/users/_user_info.html.erb @@ -76,6 +76,25 @@ <%= t('users.terms_and_conditions_link') %> + +
+ + + <% if @user.recommendation_profile&.summary_lines&.any? %> + <% @user.recommendation_profile.summary_lines.each do |line| %> + + <% end %> + <% else %> + + <% end %> + + <%= component 'common/links/link_button', + link_title: t('recommendation_profiles.profile.edit_action'), + href: edit_recommendation_profile_path(return_to: user_path(@user.uuid)), + color: 'ghost', + options: { target: '_top' } %> +
+
<%= component 'common/links/link_button', link_title: t(:billing), href: billing_profiles_path, color: 'ghost', options: { target: '_top' } %> <%= component 'common/buttons/delete_button_with_text', path: user_path(@user.uuid), text: t('users.show.delete') %> diff --git a/config/locales/recommendation_profiles.en.yml b/config/locales/recommendation_profiles.en.yml new file mode 100644 index 000000000..c37904f1f --- /dev/null +++ b/config/locales/recommendation_profiles.en.yml @@ -0,0 +1,47 @@ +en: + recommendation_profiles: + edit: + title: "Auction interests" + why_it_helps_title: "Why this helps" + why_it_helps_body: "Choose what kinds of domain names interest you. We combine these interests with your wishlist, bids, and domain tags to rank more relevant auctions higher for you." + submit: "Save interests" + update: + updated: "Your auction interests were updated." + skipped: "You can add your auction interests later." + dismiss: + dismissed: "We will remind you later." + form: + title: "Auction interests" + subtitle: "Optional. Choose the kinds of domain names that interest you. We will use these categories together with your bids and wishlist." + interest_categories: "Interest categories" + other_interests: "Other interests" + other_interests_placeholder: "Type an interest and press Enter" + other_interests_hint: "Use this for interests that are not in the predefined list." + prompt: + title: "Personalization" + subject: "Tell us what domains interest you" + description: "Add a few preferences and we will raise more relevant auctions in your list. This step is optional." + fill_now: "Set interests" + remind_later: "Remind me later" + profile: + summary_title: "Auction interests" + empty_state: "No interests saved yet." + edit_action: "Edit interests" + summary: + categories: "Interests" + categories: + brandable: "Brandable names" + shop_brand: "Shop or store names" + saas: "SaaS and software" + b2b_service: "B2B services" + local_service: "Local or service businesses" + media_content: "Media and content" + finance: "Finance and fintech" + legal: "Legal and professional services" + health: "Health and wellness" + education: "Education and courses" + travel: "Travel and tourism" + automotive: "Automotive" + real_estate: "Real estate" + numeric: "Numeric domains" + other: "Other" diff --git a/config/locales/recommendation_profiles.et.yml b/config/locales/recommendation_profiles.et.yml new file mode 100644 index 000000000..bf9259676 --- /dev/null +++ b/config/locales/recommendation_profiles.et.yml @@ -0,0 +1,47 @@ +et: + recommendation_profiles: + edit: + title: "Oksjoni huvid" + why_it_helps_title: "Miks see kasulik on" + why_it_helps_body: "Vali, millist tüüpi domeeninimed sind huvitavad. Kombineerime need huvid sinu wishlisti, pakkumiste ja domeeni siltidega, et tõsta sobivamad oksjonid nimekirjas kõrgemale." + submit: "Salvesta huvid" + update: + updated: "Sinu oksjoni huvid on uuendatud." + skipped: "Saad oma oksjoni huvid hiljem lisada." + dismiss: + dismissed: "Tuletame seda hiljem meelde." + form: + title: "Oksjoni huvid" + subtitle: "Valikuline. Vali, millised domeeninimede tüübid sind huvitavad. Kasutame neid kategooriaid koos pakkumiste ja wishlistiga." + interest_categories: "Huvi kategooriad" + other_interests: "Muud huvid" + other_interests_placeholder: "Sisesta huvi ja vajuta Enter" + other_interests_hint: "Kasuta seda huvide jaoks, mida eeldefineeritud nimekirjas ei ole." + prompt: + title: "Personaliseerimine" + subject: "Anna teada, millised domeenid sind huvitavad" + description: "Lisa mõned eelistused ja tõstame sulle sobivamad oksjonid nimekirjas kõrgemale. See samm on valikuline." + fill_now: "Määra huvid" + remind_later: "Tuleta hiljem meelde" + profile: + summary_title: "Oksjoni huvid" + empty_state: "Huvid pole veel salvestatud." + edit_action: "Muuda huve" + summary: + categories: "Huvid" + categories: + brandable: "Bränditavad nimed" + shop_brand: "Poe- või kaubamärgi nimed" + saas: "SaaS ja tarkvara" + b2b_service: "B2B teenused" + local_service: "Kohalikud ja teenusettevõtted" + media_content: "Meedia ja sisu" + finance: "Finants ja fintech" + legal: "Õigus- ja professionaalsed teenused" + health: "Tervis ja heaolu" + education: "Haridus ja kursused" + travel: "Reisimine ja turism" + automotive: "Autondus" + real_estate: "Kinnisvara" + numeric: "Numbrilised domeenid" + other: "Muu" diff --git a/config/routes.rb b/config/routes.rb index b27a451cc..358a8398b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -145,6 +145,10 @@ resources :wishlist_items, param: :uuid, only: %i[index edit create destroy update] resources :autobider, param: :uuid, only: [:create, :update, :edit, :new] + resource :recommendation_profile, only: %i[edit update] + patch 'recommendation_profile/dismiss', to: 'recommendation_profiles#dismiss', + as: :dismiss_recommendation_profile + resources :recommendation_events, only: :create mount OkComputer::Engine, at: '/healthcheck', as: :healthcheck mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development? diff --git a/db/migrate/20260525095500_create_recommendation_profiles.rb b/db/migrate/20260525095500_create_recommendation_profiles.rb new file mode 100644 index 000000000..6368a0d90 --- /dev/null +++ b/db/migrate/20260525095500_create_recommendation_profiles.rb @@ -0,0 +1,28 @@ +class CreateRecommendationProfiles < ActiveRecord::Migration[7.0] + def change + create_table :recommendation_profiles do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.uuid :uuid, default: 'gen_random_uuid()' + t.string :preferred_tlds, array: true, default: [], null: false + t.string :interest_keywords, array: true, default: [], null: false + t.string :preferred_platforms, array: true, default: [], null: false + t.integer :preferred_length_min + t.integer :preferred_length_max + t.integer :budget_min_cents + t.integer :budget_max_cents + t.boolean :allow_numbers + t.boolean :allow_hyphens + t.datetime :completed_at + t.datetime :prompt_dismissed_at + t.datetime :last_prompted_at + t.integer :prompt_shown_count, default: 0, null: false + + t.timestamps + end + + add_index :recommendation_profiles, :uuid, unique: true + add_index :recommendation_profiles, :preferred_tlds, using: :gin + add_index :recommendation_profiles, :interest_keywords, using: :gin + add_index :recommendation_profiles, :preferred_platforms, using: :gin + end +end diff --git a/db/migrate/20260525095600_create_recommendation_events.rb b/db/migrate/20260525095600_create_recommendation_events.rb new file mode 100644 index 000000000..2befefc04 --- /dev/null +++ b/db/migrate/20260525095600_create_recommendation_events.rb @@ -0,0 +1,23 @@ +class CreateRecommendationEvents < ActiveRecord::Migration[7.0] + def change + create_table :recommendation_events do |t| + t.references :user, foreign_key: true + t.references :auction, foreign_key: true + t.uuid :uuid, default: 'gen_random_uuid()' + t.string :event_type, null: false + t.string :source + t.string :session_id + t.string :request_id + t.datetime :occurred_at, null: false + t.jsonb :properties, default: {}, null: false + + t.timestamps + end + + add_index :recommendation_events, :uuid, unique: true + add_index :recommendation_events, :event_type + add_index :recommendation_events, :occurred_at + add_index :recommendation_events, %i[user_id event_type occurred_at], name: 'idx_rec_events_user_type_time' + add_index :recommendation_events, :properties, using: :gin + end +end diff --git a/db/migrate/20260525095700_create_user_auction_scores.rb b/db/migrate/20260525095700_create_user_auction_scores.rb new file mode 100644 index 000000000..4df836a5e --- /dev/null +++ b/db/migrate/20260525095700_create_user_auction_scores.rb @@ -0,0 +1,19 @@ +class CreateUserAuctionScores < ActiveRecord::Migration[7.0] + def change + create_table :user_auction_scores do |t| + t.references :user, null: false, foreign_key: true + t.references :auction, null: false, foreign_key: true + t.uuid :uuid, default: 'gen_random_uuid()' + t.decimal :score, precision: 10, scale: 6, null: false + t.string :model_name + t.string :features_version + t.datetime :calculated_at, null: false + + t.timestamps + end + + add_index :user_auction_scores, :uuid, unique: true + add_index :user_auction_scores, %i[user_id auction_id], unique: true + add_index :user_auction_scores, %i[user_id score] + end +end diff --git a/db/migrate/20260525115000_add_classification_fields_to_auctions.rb b/db/migrate/20260525115000_add_classification_fields_to_auctions.rb new file mode 100644 index 000000000..e06463113 --- /dev/null +++ b/db/migrate/20260525115000_add_classification_fields_to_auctions.rb @@ -0,0 +1,12 @@ +class AddClassificationFieldsToAuctions < ActiveRecord::Migration[7.0] + def change + add_column :auctions, :classification_tags, :string, array: true, default: [], null: false + add_column :auctions, :primary_category, :string + add_column :auctions, :classification_source, :string + add_column :auctions, :classification_model, :string + add_column :auctions, :classified_at, :datetime + + add_index :auctions, :classification_tags, using: :gin + add_index :auctions, :primary_category + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 775286d8d..1712a94dd 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -490,7 +490,7 @@ openai_model_description = <<~TEXT.squish OpenAI API model TEXT -openai_model_value = 'gpt-3.5-turbo' +openai_model_value = 'gpt-5' openai_model_setting = Setting.new(code: :openai_model, value: openai_model_value, description: openai_model_description, diff --git a/db/structure.sql b/db/structure.sql index 50070abff..97851b123 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -889,6 +889,11 @@ CREATE TABLE public.auctions ( enable_deposit boolean DEFAULT false NOT NULL, requirement_deposit_in_cents integer, ai_score integer DEFAULT 0, + classification_tags character varying[] DEFAULT '{}'::character varying[] NOT NULL, + primary_category character varying, + classification_source character varying, + classification_model character varying, + classified_at timestamp(6) without time zone, CONSTRAINT starts_at_earlier_than_ends_at CHECK ((starts_at < ends_at)) ); @@ -1470,6 +1475,90 @@ CREATE SEQUENCE public.payment_orders_id_seq ALTER SEQUENCE public.payment_orders_id_seq OWNED BY public.payment_orders.id; +-- +-- Name: recommendation_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.recommendation_events ( + id bigint NOT NULL, + user_id bigint, + auction_id bigint, + uuid uuid DEFAULT gen_random_uuid(), + event_type character varying NOT NULL, + source character varying, + session_id character varying, + request_id character varying, + occurred_at timestamp(6) without time zone NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: recommendation_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.recommendation_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: recommendation_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.recommendation_events_id_seq OWNED BY public.recommendation_events.id; + + +-- +-- Name: recommendation_profiles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.recommendation_profiles ( + id bigint NOT NULL, + user_id bigint NOT NULL, + uuid uuid DEFAULT gen_random_uuid(), + preferred_tlds character varying[] DEFAULT '{}'::character varying[] NOT NULL, + interest_keywords character varying[] DEFAULT '{}'::character varying[] NOT NULL, + preferred_platforms character varying[] DEFAULT '{}'::character varying[] NOT NULL, + preferred_length_min integer, + preferred_length_max integer, + budget_min_cents integer, + budget_max_cents integer, + allow_numbers boolean, + allow_hyphens boolean, + completed_at timestamp(6) without time zone, + prompt_dismissed_at timestamp(6) without time zone, + last_prompted_at timestamp(6) without time zone, + prompt_shown_count integer DEFAULT 0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: recommendation_profiles_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.recommendation_profiles_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: recommendation_profiles_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.recommendation_profiles_id_seq OWNED BY public.recommendation_profiles.id; + + -- -- Name: remote_view_partials; Type: TABLE; Schema: public; Owner: - -- @@ -1599,6 +1688,43 @@ CREATE MATERIALIZED VIEW public.statistics_report_invoices AS WITH NO DATA; +-- +-- Name: user_auction_scores; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_auction_scores ( + id bigint NOT NULL, + user_id bigint NOT NULL, + auction_id bigint NOT NULL, + uuid uuid DEFAULT gen_random_uuid(), + score numeric(10,6) NOT NULL, + model_name character varying, + features_version character varying, + calculated_at timestamp(6) without time zone NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: user_auction_scores_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_auction_scores_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_auction_scores_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_auction_scores_id_seq OWNED BY public.user_auction_scores.id; + + -- -- Name: users; Type: TABLE; Schema: public; Owner: - -- @@ -1922,6 +2048,20 @@ ALTER TABLE ONLY public.offers ALTER COLUMN id SET DEFAULT nextval('public.offer ALTER TABLE ONLY public.payment_orders ALTER COLUMN id SET DEFAULT nextval('public.payment_orders_id_seq'::regclass); +-- +-- Name: recommendation_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recommendation_events ALTER COLUMN id SET DEFAULT nextval('public.recommendation_events_id_seq'::regclass); + + +-- +-- Name: recommendation_profiles id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recommendation_profiles ALTER COLUMN id SET DEFAULT nextval('public.recommendation_profiles_id_seq'::regclass); + + -- -- Name: remote_view_partials id; Type: DEFAULT; Schema: public; Owner: - -- @@ -1943,6 +2083,13 @@ ALTER TABLE ONLY public.results ALTER COLUMN id SET DEFAULT nextval('public.resu ALTER TABLE ONLY public.settings ALTER COLUMN id SET DEFAULT nextval('public.settings_id_seq'::regclass); +-- +-- Name: user_auction_scores id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_auction_scores ALTER COLUMN id SET DEFAULT nextval('public.user_auction_scores_id_seq'::regclass); + + -- -- Name: users id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2252,6 +2399,22 @@ ALTER TABLE ONLY public.payment_orders ADD CONSTRAINT payment_orders_pkey PRIMARY KEY (id); +-- +-- Name: recommendation_events recommendation_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recommendation_events + ADD CONSTRAINT recommendation_events_pkey PRIMARY KEY (id); + + +-- +-- Name: recommendation_profiles recommendation_profiles_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recommendation_profiles + ADD CONSTRAINT recommendation_profiles_pkey PRIMARY KEY (id); + + -- -- Name: remote_view_partials remote_view_partials_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -2284,6 +2447,14 @@ ALTER TABLE ONLY public.auctions ADD CONSTRAINT unique_domain_name_per_auction_duration EXCLUDE USING gist (domain_name WITH =, tsrange(starts_at, ends_at, '[]'::text) WITH &&); +-- +-- Name: user_auction_scores user_auction_scores_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_auction_scores + ADD CONSTRAINT user_auction_scores_pkey PRIMARY KEY (id); + + -- -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -2483,6 +2654,20 @@ CREATE INDEX wishlist_items_recorded_at_idx ON audit.wishlist_items USING btree CREATE INDEX delayed_jobs_priority ON public.delayed_jobs USING btree (priority, run_at); +-- +-- Name: idx_rec_events_user_type_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_rec_events_user_type_time ON public.recommendation_events USING btree (user_id, event_type, occurred_at); + + +-- +-- Name: index_auctions_on_classification_tags; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_auctions_on_classification_tags ON public.auctions USING gin (classification_tags); + + -- -- Name: index_auctions_on_domain_name; Type: INDEX; Schema: public; Owner: - -- @@ -2490,6 +2675,13 @@ CREATE INDEX delayed_jobs_priority ON public.delayed_jobs USING btree (priority, CREATE INDEX index_auctions_on_domain_name ON public.auctions USING btree (domain_name); +-- +-- Name: index_auctions_on_primary_category; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_auctions_on_primary_category ON public.auctions USING btree (primary_category); + + -- -- Name: index_auctions_on_remote_id; Type: INDEX; Schema: public; Owner: - -- @@ -2714,6 +2906,83 @@ CREATE INDEX index_payment_orders_on_user_id ON public.payment_orders USING btre CREATE UNIQUE INDEX index_payment_orders_on_uuid ON public.payment_orders USING btree (uuid); +-- +-- Name: index_recommendation_events_on_auction_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_events_on_auction_id ON public.recommendation_events USING btree (auction_id); + + +-- +-- Name: index_recommendation_events_on_event_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_events_on_event_type ON public.recommendation_events USING btree (event_type); + + +-- +-- Name: index_recommendation_events_on_occurred_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_events_on_occurred_at ON public.recommendation_events USING btree (occurred_at); + + +-- +-- Name: index_recommendation_events_on_properties; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_events_on_properties ON public.recommendation_events USING gin (properties); + + +-- +-- Name: index_recommendation_events_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_events_on_user_id ON public.recommendation_events USING btree (user_id); + + +-- +-- Name: index_recommendation_events_on_uuid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_recommendation_events_on_uuid ON public.recommendation_events USING btree (uuid); + + +-- +-- Name: index_recommendation_profiles_on_interest_keywords; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_profiles_on_interest_keywords ON public.recommendation_profiles USING gin (interest_keywords); + + +-- +-- Name: index_recommendation_profiles_on_preferred_platforms; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_profiles_on_preferred_platforms ON public.recommendation_profiles USING gin (preferred_platforms); + + +-- +-- Name: index_recommendation_profiles_on_preferred_tlds; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recommendation_profiles_on_preferred_tlds ON public.recommendation_profiles USING gin (preferred_tlds); + + +-- +-- Name: index_recommendation_profiles_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_recommendation_profiles_on_user_id ON public.recommendation_profiles USING btree (user_id); + + +-- +-- Name: index_recommendation_profiles_on_uuid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_recommendation_profiles_on_uuid ON public.recommendation_profiles USING btree (uuid); + + -- -- Name: index_results_on_auction_id; Type: INDEX; Schema: public; Owner: - -- @@ -2777,6 +3046,41 @@ CREATE UNIQUE INDEX index_statistics_report_invoices_on_id ON public.statistics_ CREATE UNIQUE INDEX index_statistics_report_results_on_id ON public.statistics_report_results USING btree (id); +-- +-- Name: index_user_auction_scores_on_auction_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_auction_scores_on_auction_id ON public.user_auction_scores USING btree (auction_id); + + +-- +-- Name: index_user_auction_scores_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_auction_scores_on_user_id ON public.user_auction_scores USING btree (user_id); + + +-- +-- Name: index_user_auction_scores_on_user_id_and_auction_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_user_auction_scores_on_user_id_and_auction_id ON public.user_auction_scores USING btree (user_id, auction_id); + + +-- +-- Name: index_user_auction_scores_on_user_id_and_score; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_auction_scores_on_user_id_and_score ON public.user_auction_scores USING btree (user_id, score); + + +-- +-- Name: index_user_auction_scores_on_uuid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_user_auction_scores_on_uuid ON public.user_auction_scores USING btree (uuid); + + -- -- Name: index_users_on_confirmation_token; Type: INDEX; Schema: public; Owner: - -- @@ -2924,6 +3228,14 @@ CREATE TRIGGER process_user_audit AFTER INSERT OR DELETE OR UPDATE ON public.use CREATE TRIGGER process_wishlist_item_audit AFTER INSERT OR DELETE OR UPDATE ON public.wishlist_items FOR EACH ROW EXECUTE FUNCTION public.process_wishlist_item_audit(); +-- +-- Name: recommendation_events fk_rails_04596f3101; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recommendation_events + ADD CONSTRAINT fk_rails_04596f3101 FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: bans fk_rails_070022cd76; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -2932,6 +3244,14 @@ ALTER TABLE ONLY public.bans ADD CONSTRAINT fk_rails_070022cd76 FOREIGN KEY (user_id) REFERENCES public.users(id); +-- +-- Name: recommendation_events fk_rails_1663165237; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recommendation_events + ADD CONSTRAINT fk_rails_1663165237 FOREIGN KEY (auction_id) REFERENCES public.auctions(id); + + -- -- Name: invoice_items fk_rails_25bf3d2c5e; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -3004,6 +3324,14 @@ ALTER TABLE ONLY public.results ADD CONSTRAINT fk_rails_9f5d06cf95 FOREIGN KEY (auction_id) REFERENCES public.auctions(id); +-- +-- Name: recommendation_profiles fk_rails_a650b1795d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recommendation_profiles + ADD CONSTRAINT fk_rails_a650b1795d FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: offers fk_rails_bb5f3f4ecb; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -3028,6 +3356,22 @@ ALTER TABLE ONLY public.offers ADD CONSTRAINT fk_rails_e6095d6211 FOREIGN KEY (user_id) REFERENCES public.users(id); +-- +-- Name: user_auction_scores fk_rails_ea35c8f56c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_auction_scores + ADD CONSTRAINT fk_rails_ea35c8f56c FOREIGN KEY (auction_id) REFERENCES public.auctions(id); + + +-- +-- Name: user_auction_scores fk_rails_f45bfd4b1b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_auction_scores + ADD CONSTRAINT fk_rails_f45bfd4b1b FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: payment_orders fk_rails_f9dc5857c3; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -3051,6 +3395,10 @@ ALTER TABLE ONLY public.invoices SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260525115000'), +('20260525095700'), +('20260525095600'), +('20260525095500'), ('20251105120000'), ('20251105100000'), ('20251104081848'), diff --git a/lib/tasks/demo_auctions.rake b/lib/tasks/demo_auctions.rake new file mode 100644 index 000000000..3043fb6b8 --- /dev/null +++ b/lib/tasks/demo_auctions.rake @@ -0,0 +1,88 @@ +namespace :demo do + desc 'Create temporary blind .ee auctions for recommendation testing' + task create_blind_auctions: :environment do + starts_at = Time.zone.now + 1.second + ends_at = Time.zone.now + 1.month + + domains = %w[ + aiapood.ee + apteek.ee + arst.ee + autod.ee + ehitus.ee + eood.ee + haridus.ee + ilusalong.ee + jurist.ee + kahvel.ee + kalapood.ee + kinnisvara.ee + kohvik.ee + koolitus.ee + kosmeetika.ee + laen.ee + lilled.ee + majutus.ee + matk.ee + meedia.ee + mobiilipood.ee + mood.ee + nouv.ee + oigusabi.ee + parandus.ee + pood.ee + raamatud.ee + raamatupood.ee + reisid.ee + remont.ee + rent.ee + saasplatvorm.ee + tarkvara.ee + tervis.ee + tooriistad.ee + turundus.ee + veebipood.ee + accountflow.ee + brandforge.ee + carshop.ee + cloudstack.ee + dealzone.ee + fintechlab.ee + foodmarket.ee + gamesuite.ee + growthhub.ee + legalhub.ee + marketflow.ee + marketplace.ee + mediateam.ee + numeric24.ee + pixelcraft.ee + propertylab.ee + shopline.ee + startupdesk.ee + traveldesk.ee + wellnesshub.ee + workzone.ee + ] + + created = 0 + skipped = 0 + + domains.each do |domain_name| + if Auction.where(domain_name: domain_name).where('ends_at > ?', Time.zone.now).exists? + skipped += 1 + next + end + + Auction.create!( + domain_name: domain_name, + starts_at: starts_at, + ends_at: ends_at + ) + + created += 1 + end + + puts "Created #{created} blind auctions, skipped #{skipped} existing active auctions." + end +end diff --git a/test/controllers/auctions_controller_test.rb b/test/controllers/auctions_controller_test.rb index c7bf0485b..3612d87dd 100644 --- a/test/controllers/auctions_controller_test.rb +++ b/test/controllers/auctions_controller_test.rb @@ -93,6 +93,75 @@ def test_explicit_sorting_overrides_user_auction_priority assert_equal sorted_domains, auction_domains end + def test_recommendation_scores_are_used_before_global_ai_score_for_logged_in_users + sign_in @participant + + UserAuctionScore.create!( + user: @participant, + auction: @english_auction, + score: 0.95, + calculated_at: Time.current + ) + UserAuctionScore.create!( + user: @participant, + auction: @auction_without_offers, + score: 0.10, + calculated_at: Time.current + ) + + get auctions_path + + assert_response :success + + auction_domains = css_select('tbody#bids tr.contents td:first-child').map { |e| e.text.strip } + + with_offers_index = auction_domains.index(@auction_with_offers.domain_name) + english_index = auction_domains.index(@english_auction.domain_name) + without_offers_index = auction_domains.index(@auction_without_offers.domain_name) + + assert with_offers_index < english_index, "User's own auction should still come first" + assert english_index < without_offers_index, "Higher recommendation score should outrank lower score" + end + + def test_interest_categories_prioritize_matching_classified_auctions + sign_in @participant + @participant.create_recommendation_profile!(interest_keywords: ['saas']) + + @english_auction.update!(classification_tags: ['saas'], primary_category: 'saas') + @auction_without_offers.update!(classification_tags: ['shop_brand'], primary_category: 'shop_brand') + + get auctions_path + + assert_response :success + + auction_domains = css_select('tbody#bids tr.contents td:first-child').map { |e| e.text.strip } + + with_offers_index = auction_domains.index(@auction_with_offers.domain_name) + english_index = auction_domains.index(@english_auction.domain_name) + without_offers_index = auction_domains.index(@auction_without_offers.domain_name) + + assert with_offers_index < english_index, "User's own auction should still come first" + assert english_index < without_offers_index, "Classified category match should outrank non-matching auction" + end + + def test_custom_other_interests_prioritize_domain_name_matches + sign_in @participant + @participant.create_recommendation_profile!(interest_keywords: ['other', 'custom:english']) + + get auctions_path + + assert_response :success + + auction_domains = css_select('tbody#bids tr.contents td:first-child').map { |e| e.text.strip } + + with_offers_index = auction_domains.index(@auction_with_offers.domain_name) + english_index = auction_domains.index(@english_auction.domain_name) + without_offers_index = auction_domains.index(@auction_without_offers.domain_name) + + assert with_offers_index < english_index, "User's own auction should still come first" + assert english_index < without_offers_index, "Custom other interests should boost matching domain names" + end + def test_sorting_with_pagination_keeps_user_auctions_prioritized sign_in @participant diff --git a/test/controllers/recommendation_profiles_controller_test.rb b/test/controllers/recommendation_profiles_controller_test.rb new file mode 100644 index 000000000..c7209b906 --- /dev/null +++ b/test/controllers/recommendation_profiles_controller_test.rb @@ -0,0 +1,44 @@ +require 'test_helper' + +class RecommendationProfilesControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + include ActiveJob::TestHelper + + def setup + super + @user = users(:participant) + sign_in @user + clear_enqueued_jobs + end + + def test_user_can_update_recommendation_profile + assert_nil @user.recommendation_profile + + assert_difference -> { RecommendationProfile.count } do + assert_enqueued_with(job: Recommendation::RefreshSingleUserAuctionScoresJob, args: [@user.id]) do + put recommendation_profile_path, params: { + recommendation_profile: { + interest_categories: %w[legal other], + custom_interests: ['marketplace'] + } + } + end + end + + @user.reload + + assert_redirected_to user_path(@user.uuid) + assert_equal(%w[legal other], @user.recommendation_profile.interest_categories.sort) + assert_equal(['marketplace'], @user.recommendation_profile.custom_interests) + assert @user.recommendation_profile.completed? + end + + def test_user_can_dismiss_recommendation_prompt + patch dismiss_recommendation_profile_path + + @user.reload + + assert_redirected_to root_path + assert @user.recommendation_profile.prompt_dismissed_at.present? + end +end diff --git a/test/fixtures/settings.yml b/test/fixtures/settings.yml index 2de99e062..0b85dc7e3 100644 --- a/test/fixtures/settings.yml +++ b/test/fixtures/settings.yml @@ -274,7 +274,7 @@ openai_model: code: 'openai_model' description: | OpenAI API model - value: 'gpt-3.5-turbo' + value: 'gpt-5' value_format: string openai_domains_evaluation_prompt: diff --git a/test/integration/english_offers_test.rb b/test/integration/english_offers_test.rb index b2010ccd5..5fbee1e69 100644 --- a/test/integration/english_offers_test.rb +++ b/test/integration/english_offers_test.rb @@ -18,6 +18,7 @@ def setup .to_return(status: 200, body: "{\"reference_number\":\"#{rand(111..999)}\"}", headers: {}) travel_to Time.parse('2010-07-05 11:30 +0000').in_time_zone + clear_enqueued_jobs end def test_user_can_create_a_bid @@ -34,9 +35,11 @@ def test_user_can_create_a_bid } } - post auction_english_offers_path(auction_uuid: @auction.uuid), - params: params, - headers: { "HTTP_REFERER" => root_path } + assert_enqueued_with(job: Recommendation::RefreshSingleUserAuctionScoresJob, args: [@user.id]) do + post auction_english_offers_path(auction_uuid: @auction.uuid), + params: params, + headers: { "HTTP_REFERER" => root_path } + end assert @auction.offers.present? assert_equal @auction.offers.first.cents, 500 @@ -87,9 +90,11 @@ def test_user_can_update_existed_bid } } - patch english_offer_path(uuid: @auction.offers.first.uuid), - params: params, - headers: { "HTTP_REFERER" => root_path } + assert_enqueued_with(job: Recommendation::RefreshSingleUserAuctionScoresJob, args: [@user.id]) do + patch english_offer_path(uuid: @auction.offers.first.uuid), + params: params, + headers: { "HTTP_REFERER" => root_path } + end @auction.reload diff --git a/test/integration/offers_test.rb b/test/integration/offers_test.rb index bc9192cca..848ed501d 100644 --- a/test/integration/offers_test.rb +++ b/test/integration/offers_test.rb @@ -4,6 +4,7 @@ class OffersAuctionFlowTest < ActionDispatch::IntegrationTest OFFER_COUNT = 'Offer.count' include Devise::Test::IntegrationHelpers + include ActiveJob::TestHelper def setup @user = users(:participant) @@ -15,6 +16,7 @@ def setup Recaptcha.configuration.skip_verify_env.push('test') travel_to Time.parse('2010-07-05 11:30 +0000').in_time_zone + clear_enqueued_jobs end def test_user_can_create_a_bid @@ -27,9 +29,11 @@ def test_user_can_create_a_bid } } - post auction_offers_path(auction_uuid: @auction.uuid), - params: params, - headers: {} + assert_enqueued_with(job: Recommendation::RefreshSingleUserAuctionScoresJob, args: [@user.id]) do + post auction_offers_path(auction_uuid: @auction.uuid), + params: params, + headers: {} + end assert @auction.offers.present? assert_equal @auction.offers.first.cents, 600 @@ -155,7 +159,9 @@ def test_user_can_update_his_bid } } - patch offer_path(uuid: offer.uuid), params: params + assert_enqueued_with(job: Recommendation::RefreshSingleUserAuctionScoresJob, args: [@user.id]) do + patch offer_path(uuid: offer.uuid), params: params + end offer.reload assert_equal offer.cents, 1500 diff --git a/test/integration/wishlist_items_test.rb b/test/integration/wishlist_items_test.rb index 4fc503bce..e9bb88b3a 100644 --- a/test/integration/wishlist_items_test.rb +++ b/test/integration/wishlist_items_test.rb @@ -2,6 +2,7 @@ class WishlistItemsIntegrationTest < ActionDispatch::IntegrationTest include Devise::Test::IntegrationHelpers + include ActiveJob::TestHelper def setup @user = users(:participant) @@ -12,6 +13,7 @@ def setup sign_in @user travel_to Time.parse('2010-07-05 10:30 +0000').in_time_zone + clear_enqueued_jobs end def test_should_be_returned_ok_code @@ -32,7 +34,9 @@ def test_user_can_create_wishlist_item } assert_difference -> { WishlistItem.count } do - post wishlist_items_path, params: params, headers: {} + assert_enqueued_with(job: Recommendation::RefreshSingleUserAuctionScoresJob, args: [@user.id]) do + post wishlist_items_path, params: params, headers: {} + end end end diff --git a/test/models/job_test.rb b/test/models/job_test.rb index 3e58710af..b86d0214a 100644 --- a/test/models/job_test.rb +++ b/test/models/job_test.rb @@ -12,4 +12,18 @@ def test_instance_methods_correspond_with_the_class assert_equal(InvoiceCreationJob, @instance.job_class) assert_equal(true, @instance.needs_to_run?) end + + def test_namespaced_job_is_allowed + job = Job.new('Recommendation::ClassifyAuctionDomainsJob') + + assert job.valid? + assert_equal Recommendation::ClassifyAuctionDomainsJob, job.job_class + end + + def test_refresh_scores_job_is_allowed + job = Job.new('Recommendation::RefreshUserAuctionScoresJob') + + assert job.valid? + assert_equal Recommendation::RefreshUserAuctionScoresJob, job.job_class + end end diff --git a/test/models/recommendation_profile_test.rb b/test/models/recommendation_profile_test.rb new file mode 100644 index 000000000..c1adb6c46 --- /dev/null +++ b/test/models/recommendation_profile_test.rb @@ -0,0 +1,38 @@ +require 'test_helper' + +class RecommendationProfileTest < ActiveSupport::TestCase + def setup + super + @profile = RecommendationProfile.new(user: users(:participant)) + end + + def test_promptable_until_completed + assert @profile.promptable? + + @profile.completed_at = Time.current + refute @profile.promptable? + end + + def test_promptable_again_after_dismiss_interval + @profile.prompt_dismissed_at = 1.day.ago + refute @profile.promptable? + + @profile.prompt_dismissed_at = 20.days.ago + assert @profile.promptable? + end + + def test_normalizes_interest_categories + @profile.interest_categories = %w[saas legal saas numeric] + @profile.valid? + + assert_equal(%w[saas legal numeric], @profile.interest_categories) + end + + def test_stores_custom_interests_under_other + @profile.custom_interests = ['marketplace', 'marketplace', 'premium names'] + @profile.valid? + + assert_equal(['other'], @profile.interest_categories) + assert_equal(['marketplace', 'premium names'], @profile.custom_interests) + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index ccd0aa21c..e90d2e238 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -436,6 +436,20 @@ def test_reference_number_is_not_assigned_to_user_when_user_is_admin end + def test_user_can_accept_nested_recommendation_profile_attributes + user = boilerplate_user + user.mobile_phone = '+372500100300' + user.country_code = 'EE' + user.recommendation_profile_attributes = { + interest_categories: %w[saas], + custom_interests: ['marketplace'] + } + + assert user.save + assert_equal(%w[saas other], user.recommendation_profile.interest_categories.sort) + assert_equal(['marketplace'], user.recommendation_profile.custom_interests) + end + def boilerplate_user stub_request(:any, "https://eis_billing_system:3000/api/v1/invoice_generator/reference_number_generator") .to_return(status: 200, body: "{\"reference_number\":\"#{rand(100000..999999)}\"}", headers: {}) diff --git a/test/services/openai_structured_output_support_test.rb b/test/services/openai_structured_output_support_test.rb new file mode 100644 index 000000000..6452c2c03 --- /dev/null +++ b/test/services/openai_structured_output_support_test.rb @@ -0,0 +1,21 @@ +require 'test_helper' + +class OpenaiStructuredOutputSupportTest < ActiveSupport::TestCase + def test_returns_configured_model_when_it_supports_structured_output + assert_equal 'gpt-4o-mini', OpenaiStructuredOutputSupport.model('gpt-4o-mini') + assert_equal 'gpt-4.1', OpenaiStructuredOutputSupport.model('gpt-4.1') + assert_equal 'gpt-5', OpenaiStructuredOutputSupport.model('gpt-5') + end + + def test_falls_back_when_model_does_not_support_structured_output + assert_equal 'gpt-5', OpenaiStructuredOutputSupport.model('gpt-3.5-turbo') + end + + def test_omits_temperature_for_gpt5_family + assert_equal({}, OpenaiStructuredOutputSupport.temperature_options('gpt-5', 0.2)) + end + + def test_keeps_temperature_for_supported_non_gpt5_models + assert_equal({ temperature: 0.2 }, OpenaiStructuredOutputSupport.temperature_options('gpt-4o-mini', 0.2)) + end +end diff --git a/test/services/recommendation/auction_domain_classifier_test.rb b/test/services/recommendation/auction_domain_classifier_test.rb new file mode 100644 index 000000000..cc2313614 --- /dev/null +++ b/test/services/recommendation/auction_domain_classifier_test.rb @@ -0,0 +1,50 @@ +require 'test_helper' + +module Recommendation + class AuctionDomainClassifierTest < ActiveSupport::TestCase + def setup + super + @auction = auctions(:valid_without_offers) + @openai_model = Setting.find_by(code: 'openai_model') + end + + def test_classifies_auction_domains_with_openai_response + @openai_model.update!(value: 'gpt-3.5-turbo') + + stub_request(:post, 'https://api.openai.com/v1/chat/completions') + .to_return_json(status: 200, body: ai_response, headers: {}) + + result = AuctionDomainClassifier.call(auctions: [@auction]) + + @auction.reload + + assert_equal 1, result.size + assert_equal %w[shop_brand brandable], @auction.classification_tags + assert_equal 'shop_brand', @auction.primary_category + assert_equal 'openai', @auction.classification_source + assert_equal 'gpt-5', @auction.classification_model + assert @auction.classified? + end + + private + + def ai_response + { + 'choices' => [{ + 'message' => { + 'content' => { + classifications: [ + { + id: @auction.id, + domain_name: @auction.domain_name, + primary_category: 'shop_brand', + tags: %w[shop_brand brandable] + } + ] + }.to_json + } + }] + } + end + end +end diff --git a/test/services/recommendation/dataset_snapshot_test.rb b/test/services/recommendation/dataset_snapshot_test.rb new file mode 100644 index 000000000..548dc2957 --- /dev/null +++ b/test/services/recommendation/dataset_snapshot_test.rb @@ -0,0 +1,48 @@ +require 'test_helper' + +module Recommendation + class DatasetSnapshotTest < ActiveSupport::TestCase + def setup + super + @user = users(:participant) + @auction = auctions(:valid_without_offers) + @auction.update!(classification_tags: %w[shop_brand local_service], primary_category: 'shop_brand') + + @user.create_recommendation_profile!( + interest_keywords: %w[legal other custom:marketplace] + ) + + RecommendationEvent.create!( + user: @user, + auction: @auction, + event_type: 'auction_click', + source: 'test', + occurred_at: Time.current, + properties: { foo: 'bar' } + ) + + WishlistItem.create!(user: @user, domain_name: @auction.domain_name, cents: 2000) + end + + def test_snapshot_contains_users_auctions_and_interactions + snapshot = DatasetSnapshot.call( + users: User.where(id: @user.id), + auctions: Auction.where(id: @auction.id), + recommendation_events: RecommendationEvent.where(user_id: @user.id) + ) + + assert_equal 1, snapshot[:users].size + assert_equal @user.uuid, snapshot[:users].first[:user_uuid] + assert_equal %w[legal other], snapshot[:users].first[:interest_categories] + assert_equal ['marketplace'], snapshot[:users].first[:custom_interests] + + assert_equal 1, snapshot[:auctions].size + assert_equal @auction.uuid, snapshot[:auctions].first[:auction_uuid] + assert_equal %w[shop_brand local_service], snapshot[:auctions].first[:classification_tags] + + event_types = snapshot[:interactions].map { |item| item[:event_type] } + assert_includes event_types, 'auction_click' + assert_includes event_types, 'historical_wishlist' + end + end +end diff --git a/test/services/recommendation/score_importer_test.rb b/test/services/recommendation/score_importer_test.rb new file mode 100644 index 000000000..ae9a3ab17 --- /dev/null +++ b/test/services/recommendation/score_importer_test.rb @@ -0,0 +1,54 @@ +require 'test_helper' + +module Recommendation + class ScoreImporterTest < ActiveSupport::TestCase + def setup + super + @user = users(:participant) + @auction = auctions(:english) + end + + def test_imports_scores_by_uuid + imported_count = ScoreImporter.call( + scores: [ + { + user_uuid: @user.uuid, + auction_uuid: @auction.uuid, + score: 0.75 + } + ], + model_name: 'lightfm_stub', + features_version: 'v1' + ) + + assert_equal 1, imported_count + + record = UserAuctionScore.find_by!(user: @user, auction: @auction) + assert_equal BigDecimal('0.75'), record.score + assert_equal 'lightfm_stub', record.model_name + assert_equal 'v1', record.features_version + end + + def test_upserts_existing_scores + UserAuctionScore.create!( + user: @user, + auction: @auction, + score: 0.10, + calculated_at: 1.day.ago + ) + + ScoreImporter.call( + scores: [ + { + user_id: @user.id, + auction_id: @auction.id, + score: 0.90 + } + ] + ) + + assert_equal 1, UserAuctionScore.where(user: @user, auction: @auction).count + assert_equal BigDecimal('0.90'), UserAuctionScore.find_by!(user: @user, auction: @auction).score + end + end +end diff --git a/test/services/recommendation/scorer_test.rb b/test/services/recommendation/scorer_test.rb new file mode 100644 index 000000000..6def6a0a4 --- /dev/null +++ b/test/services/recommendation/scorer_test.rb @@ -0,0 +1,72 @@ +require 'test_helper' + +module Recommendation + class ScorerTest < ActiveSupport::TestCase + def setup + super + + @user = users(:signed_in_with_omniauth) + travel_to Time.zone.parse('2010-07-05 11:30:00 UTC') + end + + def teardown + super + travel_back + end + + def test_refresh_for_prioritizes_wishlist_category_and_custom_interest_matches + @user.create_recommendation_profile!(interest_keywords: %w[saas other custom:market]) + WishlistItem.create!(user: @user, domain_name: 'wishlistboost.ee', cents: 1_000) + + wishlist_auction = create_active_auction(domain_name: 'wishlistboost.ee', classification_tags: ['agency'], ai_score: 1.0) + category_auction = create_active_auction(domain_name: 'cloudstack.ee', classification_tags: ['saas'], ai_score: 1.0) + custom_auction = create_active_auction(domain_name: 'marketflow.ee', classification_tags: ['agency'], ai_score: 1.0) + neutral_auction = create_active_auction(domain_name: 'neutral.ee', classification_tags: ['agency'], ai_score: 9.0) + + Recommendation::Scorer.refresh_for( + user: @user, + scope: Auction.where(id: [wishlist_auction.id, category_auction.id, custom_auction.id, neutral_auction.id]) + ) + + scores = UserAuctionScore.where(user: @user).index_by(&:auction_id) + + assert scores[wishlist_auction.id].score > scores[category_auction.id].score + assert scores[category_auction.id].score > scores[custom_auction.id].score + assert scores[custom_auction.id].score > scores[neutral_auction.id].score + assert_equal Recommendation::Scorer::BASELINE_MODEL_NAME, scores[wishlist_auction.id].model_name + end + + def test_refresh_for_uses_bid_history_tag_affinity + bidder = users(:participant) + bidder_offer_auction = auctions(:valid_with_offers) + bidder_offer_auction.update!(classification_tags: ['numeric'], primary_category: 'numeric') + + matching_auction = create_active_auction(domain_name: '12345.ee', classification_tags: ['numeric'], ai_score: 1.0) + non_matching_auction = create_active_auction(domain_name: 'brandname.ee', classification_tags: ['shop_brand'], ai_score: 1.0) + + Recommendation::Scorer.refresh_for( + user: bidder, + scope: Auction.where(id: [matching_auction.id, non_matching_auction.id]) + ) + + matching_score = UserAuctionScore.find_by!(user: bidder, auction: matching_auction).score + non_matching_score = UserAuctionScore.find_by!(user: bidder, auction: non_matching_auction).score + + assert matching_score > non_matching_score + end + + private + + def create_active_auction(domain_name:, classification_tags:, ai_score:) + Auction.create!( + domain_name:, + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + classification_tags:, + primary_category: classification_tags.first, + ai_score:, + skip_validation: true + ) + end + end +end From b84f97c3f3bc28152ba26c00508ee5b7daad146f Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:21:38 +0300 Subject: [PATCH 02/42] Document recommendation system v2 architecture and pipeline Two living documents describing the planned v2 of the recommendation system on this branch: - docs/architecture/recommendation-system.md Goal, data flow, table responsibilities, scorer signals, classification tiers, cron schedule, cost estimate, phase list. - docs/technical/domain-classification-pipeline.md Component layout, Tier 0 heuristic algorithm, Tier 2 LLM batch flow, embedding pipeline, triggers, backfill, migration from v1, tests. Key v2 decisions captured: - domain_classifications becomes single source of truth, decoupled from auctions (covers wishlist domains and historical bids too) - LLM is cron-only (daily k8s CronJob), never per-request - pgvector embeddings for similarity-based affinity - Time decay on behavioural signals (half-life 60d) - Heuristic Ruby classifier handles 60-70% offline, LLM enriches the rest - AWS RDS Postgres 17 supports pgvector natively; no Dockerfile/IaC changes required, only one-time CREATE EXTENSION Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/architecture/recommendation-system.md | 207 ++++++++++++++++ .../domain-classification-pipeline.md | 227 ++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 docs/architecture/recommendation-system.md create mode 100644 docs/technical/domain-classification-pipeline.md diff --git a/docs/architecture/recommendation-system.md b/docs/architecture/recommendation-system.md new file mode 100644 index 000000000..60ea394b2 --- /dev/null +++ b/docs/architecture/recommendation-system.md @@ -0,0 +1,207 @@ +# Recommendation System v2 — Architecture + +**Status:** in progress +**Branch:** `feature/recommendation-system-improvements` +**Owner:** auction_center team +**Last updated:** 2026-05-27 + +## Goal + +Personalised auction feed on `/auctions` index that ranks domains by combining: + +1. User's explicit interests (recommendation profile) +2. Wishlist (current + historical) +3. Bid history including finished auctions (`Offer`, `EnglishOffer`, `DomainOfferHistory`) +4. Detail-page views (with dwell-time signal) +5. Auction outcomes (`Result` — won/lost) + +Sort is **per-user**. Same `/auctions` request returns N different orderings for N users. + +## Non-goals + +- No global feed cache (sort is per-user) +- No client-side ranking for v2 (server is the source of truth) +- No real-time LLM calls during user requests (LLM is batch-only, cron-driven) + +## High-level data flow + +``` +Domain enters system (Auction.create / WishlistItem.create / Offer.create / DomainOfferHistory) + | + v +ClassifyDomainHeuristicallyJob (instant, Ruby-only, no external calls) + | + v +domain_classifications row created with source='heuristic' + | + v + |---> Scorer uses these tags/keywords immediately + | + v +[ Nightly k8s CronJob: rake recommendation:classify_unclassified ] + | + v +LLM batch (50 domains / call) enriches rows with description, audience, +keywords, suggested_use_cases, brandability_score +source='openai' + | + v +[ Nightly k8s CronJob: rake recommendation:embed_unembedded ] + | + v +OpenAI text-embedding-3-small (100 domains / call) +embedding vector(1536) stored + | + v +Scorer per user-event: + bid affinity, wishlist affinity, view affinity, + embedding-cosine multiplier, time decay + | + v +user_auction_scores (upsert, unique on user_id + auction_id) + | + v +Auction::UserSortable.with_user_priority_sorting(user) + | + v +/auctions index — sorted per user +``` + +## Key tables + +### `domain_classifications` (new) + +Single source of truth for what a domain *means*. One row per `domain_name`. + +| column | purpose | +|---|---| +| `domain_name` (unique) | the key | +| `primary_category`, `tags[]` | hard categorical signals | +| `description`, `description_locale` | human-readable, used in UI | +| `keywords[]` | extracted semantic tokens | +| `audience` (b2b/b2c/mixed) | targeting signal | +| `languages[]` | et / en / mixed | +| `suggested_use_cases[]` | shop / blog / service / agency / marketplace | +| `has_digits`, `has_hyphens`, `token_count`, `dictionary_word`, `brandability_score` | structural cache | +| `classification_source` | heuristic / openai / manual / imported | +| `confidence` (0..1) | gate for "stale, needs LLM re-run" | +| `embedding` (vector(1536)) | OpenAI text-embedding-3-small, HNSW indexed | +| `raw_llm_response` (jsonb) | audit trail, allows re-parsing without re-billing | + +### `recommendation_events` (existing) + +Append-only log of user behaviour. Used both for scoring inputs and analytics. + +### `user_auction_scores` (existing) + +Per-user × per-active-auction precomputed score. Updated by `Recommendation::Scorer` on user events. **This is the personalisation cache.** LEFT JOINed by `Auction::UserSortable`. + +### `recommendation_profiles` (existing) + +Explicit user preferences (interests, length, digit/hyphen tolerance). + +## Classification tiers + +``` +Tier 0 — Structural + Heuristic (Ruby, instant, free) + - DomainStructuralAnalyzer: has_digits, has_hyphens, token_count, dictionary_word + - DomainHeuristicClassifier: dictionary lookup (et+en roots), subword tokenizer + - Coverage: ~60-70% Estonian domains, ~30% English + - Output: tags, keywords, confidence + +Tier 2 — LLM batch (cron daily, ~$0.30/month at our volume) + - Recommendation::LlmDomainClassifier + - OpenAI structured output (json_schema) + - 50 domains per API call + - Enriches description, audience, use_cases, brandability_score, languages + - Re-runs every 6 months for source='openai' rows + +Tier 1 — (future, optional) embedding-based local classifier + - Not in v2 scope. Pending data accumulation from Tier 2. +``` + +## Cron jobs (k8s CronJob, NOT in-app scheduler) + +| schedule | task | purpose | +|---|---|---| +| `0 3 * * *` (03:00 daily) | `rake recommendation:classify_unclassified` | Tier 2 enrichment for heuristic-only/low-confidence/stale rows | +| `30 3 * * *` (03:30 daily) | `rake recommendation:embed_unembedded` | OpenAI embeddings for classified-but-unembedded rows | +| one-shot | `rake recommendation:backfill` | Initial classification of all historical domains | + +K8s manifests live in `Ry_AWS_IaC/infrastructure/kubernetes` — outside this repo. + +## Scorer signals (v2) + +Final score per (user, auction) is a weighted sum + multiplier: + +``` +score = 0 + + 120 if wishlist hit + + matching_tags * 35 category overlap + + matching_keywords * 15 NEW — keyword overlap + + audience_match * 10 NEW + + bid_feature_aggregate (decay-weighted) NEW — uses domain_classifications + + wishlist_feature_aggregate (decay) CHANGED — uses domain_classifications + + view_feature_aggregate (decay) NEW + + similar_to_saved_domain * 15 + + preferred_length_match * 10 + + digit_score -20 .. +8 + + hyphen_score -12 .. +5 + + ai_prior_score existing legacy + + domain_offer_history_signal NEW + + result_signal NEW (won: weak negative; lost: strong positive) + +multiplier = 1 + cosine_similarity(auction.embedding, user_centroid_embedding) +score = score * multiplier +``` + +User centroid embedding = weighted average of embeddings from user's bids + wishlist + recent views (time-decayed). + +Time decay: `weight *= exp(-days_old / 60)` (half-life 60 days). + +## Infrastructure dependencies + +| Dependency | Source | Status | +|---|---|---| +| pgvector extension | AWS RDS Postgres 17.4 (native support since 15.2) | needs one-time `CREATE EXTENSION vector` per environment | +| OpenAI API | existing integration via `Feature.open_ai_integration_enabled?` | reused | +| K8s CronJobs | maintained in `Ry_AWS_IaC` | scheduled by infra team | + +## Cost estimate + +- **Backfill** (one-time): ~5000 historical unique domains + - LLM classify: ~100 batches × ~3000 tokens = ~$0.50-1 + - Embeddings: 5000 × ~50 tokens = ~$0.005 + - Total: **~$1** +- **Steady state** (per day): + - LLM classify: 5-20 new domains/day, batched = 0-1 OpenAI call = **~$0.01/day** + - Embeddings: 1 call/day = **~$0.0001/day** + - Total: **~$0.30/month** + +## Implementation phases + +See [domain-classification-pipeline.md](../technical/domain-classification-pipeline.md) for pipeline details. Phase-by-phase task tracking lives in the project task list. + +| Phase | What | Status | +|---|---|---| +| 0 | Snapshot + docs scaffold | in progress | +| 1 | Performance foundation (batch impressions, debounce score refresh) | pending | +| 2 | `domain_classifications` table + heuristic Tier 0 | pending | +| 3a | DomainClassifier orchestrator (heuristic only) | pending | +| 3b | LLM batch enrichment job (cron) | pending | +| 4 | Triggers + backfill rake | pending | +| 5 | pgvector + embeddings batch job | pending | +| 6 | Rich-feature Scorer + embedding similarity + time decay | pending | +| 7 | Detail view tracking + view affinity | pending | +| 8 | Show domain description in auction card | pending | +| 9 | Polish + finalize | pending | + +## Open questions / future work + +- **Tier 1 local ML classifier** — after enough Tier 2 training data accumulates (~1000 LLM-classified rows), consider training a small linear classifier on character-ngram BoW + Tier 2 labels. Removes most LLM cost. Out of v2 scope. +- **Client-side in-session re-ranker** — locally upweight cards user clicked in this session, without server roundtrip. Out of v2 scope. +- **Auction.classification_* columns deprecation** — keep as fallback for 1-2 releases, then drop. + +## Non-personal sort fallback + +For new users with no `user_auction_scores` and no history, `Auction::UserSortable` falls back to existing `ai_score` + `RANDOM()` tiers. Same path for unauthenticated visitors. diff --git a/docs/technical/domain-classification-pipeline.md b/docs/technical/domain-classification-pipeline.md new file mode 100644 index 000000000..d9f60f549 --- /dev/null +++ b/docs/technical/domain-classification-pipeline.md @@ -0,0 +1,227 @@ +# Domain Classification Pipeline — Technical Details + +Companion to [recommendation-system.md](../architecture/recommendation-system.md). This document describes implementation of the classification pipeline. + +## Components + +``` +app/services/recommendation/ + domain_classifier.rb # orchestrator (Tier 0 only at runtime) + domain_structural_analyzer.rb # purely structural, no semantics + domain_heuristic_classifier.rb # dictionary + subword tokenizer + domain_dictionary.rb # ESTONIAN_ROOTS + ENGLISH_ROOTS hashes + llm_domain_classifier.rb # Tier 2 — only called from cron job + domain_embedder.rb # OpenAI text-embedding-3-small wrapper + +app/jobs/recommendation/ + classify_domain_heuristically_job.rb # instant, triggered on events + classify_unclassified_domains_job.rb # cron, batched LLM + embed_unembedded_domains_job.rb # cron, batched embeddings + backfill_domain_classifications_job.rb # one-shot + +lib/tasks/recommendation.rake # entry points for k8s CronJobs +``` + +## Tier 0 — Heuristic classifier + +### Structural analyzer + +Computes deterministic structural features for any domain name. No external dependencies. Output is stable and cheap to recompute, but stored for query speed. + +| feature | how | +|---|---| +| `has_digits` | `domain_name =~ /\d/` | +| `has_hyphens` | `domain_name.include?('-')` | +| `token_count` | greedy subword split + count | +| `dictionary_word` | exact match in et+en root dictionary | +| `length` | `domain_name.length` after stripping `.ee` | + +### Heuristic classifier + +Algorithm: + +1. Strip TLD (`.ee`) +2. Greedy subword split using dictionary: + - Try longest prefix match against `ESTONIAN_ROOTS ∪ ENGLISH_ROOTS` + - Recurse on remainder +3. For each matched root, collect its assigned category and confidence +4. Aggregate: deduplicate categories, pick highest-confidence as `primary_category` +5. `confidence = matched_chars / total_chars` (heuristic — full dictionary match = 1.0, partial = proportional) +6. If `confidence < 0.6` → flag for LLM enrichment + +### Dictionary (DomainDictionary) + +Two static hashes — Estonian and English roots → category symbol. + +```ruby +ESTONIAN_ROOTS = { + 'kohvik' => :local_service, 'apteek' => :health, + 'pood' => :shop_brand, 'kinnisvara' => :real_estate, + 'laen' => :finance, 'jurist' => :legal, + # ... ~200 entries +} + +ENGLISH_ROOTS = { + 'shop' => :shop_brand, 'tech' => :saas, + 'cloud' => :saas, 'med' => :health, + # ... ~200 entries +} +``` + +Initial dictionary seeded from the existing `lib/tasks/demo_auctions.rake` seed list and OpenAI test classifications. Grows over time as Tier 2 reveals new patterns. + +## Tier 2 — LLM enrichment (cron-only) + +### Trigger + +`rake recommendation:classify_unclassified` (k8s CronJob, daily 03:00). + +Selects rows where: + +```ruby +DomainClassification + .where(classification_source: ['heuristic', nil]) + .or(DomainClassification.where('confidence < 0.6')) + .or(DomainClassification.where('classification_source = ? AND classified_at < ?', 'openai', 6.months.ago)) + .limit(MAX_DOMAINS_PER_RUN) +``` + +### Batching + +50 domains per OpenAI call. JSON schema includes every rich field. + +### Schema (OpenAI structured output) + +```json +{ + "name": "domain_classifications", + "schema": { + "type": "object", + "properties": { + "classifications": { + "type": "array", + "items": { + "type": "object", + "properties": { + "domain_name": { "type": "string" }, + "primary_category": { "type": "string", "enum": [...InterestCatalog...] }, + "tags": { "type": "array", "items": { "type": "string" } }, + "description": { "type": "string" }, + "description_locale": { "type": "string", "enum": ["en", "et"] }, + "keywords": { "type": "array", "items": { "type": "string" } }, + "audience": { "type": "string", "enum": ["b2b", "b2c", "mixed", "unclear"] }, + "languages": { "type": "array", "items": { "type": "string" } }, + "suggested_use_cases": { "type": "array", "items": { "type": "string" } }, + "brandability_score": { "type": "number" } + }, + "required": ["domain_name", "primary_category", "tags", "description"] + } + } + } + } +} +``` + +### Idempotency + +`raw_llm_response` (jsonb) keeps the parsed response. If schema changes later, we can re-parse from `raw_llm_response` without re-billing OpenAI. + +## Embedding pipeline + +### Trigger + +`rake recommendation:embed_unembedded` (k8s CronJob, daily 03:30). + +Selects rows where: + +```ruby +DomainClassification + .where(embedding: nil) + .where.not(description: nil) + .limit(MAX_DOMAINS_PER_RUN) +``` + +### Input + +Concatenation of `domain_name + description + keywords` produces semantic context for the embedder. + +### Model + +`text-embedding-3-small` (1536 dim, $0.02/1M tokens). + +### Storage + +`vector(1536)` column in `domain_classifications`. HNSW index with cosine ops: + +```sql +CREATE INDEX ON domain_classifications USING hnsw (embedding vector_cosine_ops); +``` + +### Usage in Scorer + +User centroid embedding: + +```ruby +user_centroid = weighted_average( + bid_embeddings.map { |emb, days_old| [emb, exp(-days_old / 60)] } + + wishlist_embeddings.map { ... } + + view_embeddings.map { ... } +) +``` + +Per-auction multiplier: + +```ruby +similarity = cosine(user_centroid, auction.embedding) # -1..1 +multiplier = 1 + max(0, similarity) # 1..2 +final_score *= multiplier +``` + +## Triggers (instant heuristic only) + +These fire `ClassifyDomainHeuristicallyJob` — NEVER call LLM directly. + +| trigger | location | argument | +|---|---|---| +| `Auction` created | `after_create` callback | `auction.domain_name` | +| `WishlistItem` created | controller | `wishlist_item.domain_name` | +| `Offer` created | controller | `offer.auction.domain_name` | +| `EnglishOffer` created | controller | `english_offer.auction.domain_name` | + +Job is idempotent — if a row exists with `classified_at < 1.hour.ago` from any source, skip. + +## Backfill + +`rake recommendation:backfill` — one-shot. Collects unique domains from: + +- `Auction.distinct.pluck(:domain_name)` +- `WishlistItem.distinct.pluck(:domain_name)` +- `DomainOfferHistory.distinct.pluck(:domain_name)` (if applicable) +- `Result.distinct.pluck(:domain_name)` (via auction) + +For each: run heuristic synchronously, upsert into `domain_classifications`. LLM enrichment happens on next nightly cron run. + +## Migration from current state + +Existing `auctions.classification_tags / primary_category / classification_source / classification_model / classified_at` columns remain populated during transition. `Scorer` reads from `domain_classifications` with fallback to `auctions.classification_*` while migration is in flight. Columns deprecated in Phase 9. + +## Tests + +| layer | what | +|---|---| +| `DomainStructuralAnalyzer` | unit tests on edge cases (digits, hyphens, single-char, very long) | +| `DomainHeuristicClassifier` | known et/en domains map to expected categories; ambiguous → low confidence | +| `DomainClassifier` (orchestrator) | upserts row with `source='heuristic'`; skips fresh rows | +| `LlmDomainClassifier` | mocked OpenAI; verifies prompt and parsing | +| `ClassifyUnclassifiedDomainsJob` | scope selection, batching, no LLM call when scope empty | +| `DomainEmbedder` | mocked OpenAI; vector shape | +| `Scorer` (extended) | tag + keyword + audience + embedding paths verified | + +## Operations + +| signal | where to look | +|---|---| +| Daily LLM cost | OpenAI dashboard + log lines from `LlmDomainClassifier` | +| Failed classifications | `Rails.logger.warn` from `ClassifyUnclassifiedDomainsJob` | +| Backlog of unclassified | `DomainClassification.where(classification_source: ['heuristic', nil]).count` | +| Embedding backlog | `DomainClassification.where(embedding: nil).where.not(description: nil).count` | From ad345161334ec5fef4d08c7f6636d99d38413fd8 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:34:10 +0300 Subject: [PATCH 03/42] perf: batch impressions, debounce score refresh, narrow scope Phase 1 of recommendation system v2 (see docs/architecture/recommendation-system.md). - EventTracker.track_impressions now uses RecommendationEvent.insert_all for a single SQL INSERT regardless of page size. /auctions index used to fire N inserts per render (one per visible card). - RefreshSingleUserAuctionScoresJob gains enqueue_debounced (30s wait) and skip-if-fresh guard, so a burst of user actions collapses into a single recompute pass rather than N identical jobs. - All controllers switched to enqueue_debounced. - Recommendation::Scorer default scope narrows to auctions ending within SCORING_HORIZON (30 days). top_auctions_for, refresh_for and the constructor share the same default. - Auction::UserSortable#interest_match_sql now whitelists categories against InterestCatalog before SQL quoting and caps custom-interest LIKE clauses at MAX_CUSTOM_INTERESTS_IN_SQL (10) with a 2-char min to avoid pathological patterns. - Log level for tracking failures bumped from info to warn. - New tests for batched impressions (single INSERT assertion) and debounce skip/refresh behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/ralph-loop.local.md | 10 +++ app/controllers/english_offers_controller.rb | 4 +- app/controllers/offers_controller.rb | 4 +- .../recommendation_profiles_controller.rb | 4 +- app/controllers/users_controller.rb | 2 +- app/controllers/wishlist_items_controller.rb | 6 +- .../refresh_single_user_auction_scores_job.rb | 15 +++++ app/models/concerns/auction/user_sortable.rb | 25 ++++--- app/services/recommendation/event_tracker.rb | 29 +++++++- app/services/recommendation/scorer.rb | 12 +++- ...esh_single_user_auction_scores_job_test.rb | 61 +++++++++++++++++ .../recommendation/event_tracker_test.rb | 67 +++++++++++++++++++ 12 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 .claude/ralph-loop.local.md create mode 100644 test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb create mode 100644 test/services/recommendation/event_tracker_test.rb diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 000000000..f194e6b24 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -0,0 +1,10 @@ +--- +active: true +iteration: 1 +session_id: 64e9a01b-259b-4f9a-9f25-673ea3297a0c +max_iterations: 3 +completion_promise: null +started_at: "2026-05-27T08:31:02Z" +--- + +Начни выполнять все файзы за раз! diff --git a/app/controllers/english_offers_controller.rb b/app/controllers/english_offers_controller.rb index b59b72159..01f4ef09d 100644 --- a/app/controllers/english_offers_controller.rb +++ b/app/controllers/english_offers_controller.rb @@ -36,7 +36,7 @@ def create send_outbided_notification(auction: @auction, offer: @offer, flash:) update_auction_values(@auction, t('english_offers.create.created')) Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, auction: @auction, @@ -79,7 +79,7 @@ def update send_outbided_notification(auction: @auction, offer: @offer, flash:) update_auction_values(@auction, t('english_offers.edit.bid_updated')) Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@auction.id}") - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, auction: @auction, diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index 1460dad6d..33cf07191 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -30,7 +30,7 @@ def create end elsif create_predicate Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, auction: @auction, @@ -76,7 +76,7 @@ def update if update_predicate Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@offer.auction.id}") - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, auction: @offer.auction, diff --git a/app/controllers/recommendation_profiles_controller.rb b/app/controllers/recommendation_profiles_controller.rb index 627fda922..446de8d21 100644 --- a/app/controllers/recommendation_profiles_controller.rb +++ b/app/controllers/recommendation_profiles_controller.rb @@ -9,7 +9,7 @@ def update if !@recommendation_profile.filled? @recommendation_profile.dismiss_prompt! - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, event_type: 'recommendation_prompt_dismissed', @@ -23,7 +23,7 @@ def update if @recommendation_profile.save @recommendation_profile.mark_completed! - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, event_type: 'recommendation_profile_completed', diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index db5baf4bf..f0cb665ca 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -45,7 +45,7 @@ def create if @user.save if @user.recommendation_profile&.filled? @user.recommendation_profile.mark_completed! - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(@user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(@user.id) Recommendation::EventTracker.call( user: @user, event_type: 'recommendation_profile_completed', diff --git a/app/controllers/wishlist_items_controller.rb b/app/controllers/wishlist_items_controller.rb index 95d4c5c87..8962bdc36 100644 --- a/app/controllers/wishlist_items_controller.rb +++ b/app/controllers/wishlist_items_controller.rb @@ -24,7 +24,7 @@ def create respond_to do |format| if create_predicate - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, auction: Auction.find_by(domain_name: @wishlist_item.domain_name), @@ -48,7 +48,7 @@ def destroy respond_to do |format| if @wishlist_item.destroy - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) Recommendation::EventTracker.call( user: current_user, auction: Auction.find_by(domain_name: @wishlist_item.domain_name), @@ -76,7 +76,7 @@ def destroy def update if @wishlist_item.update(strong_params) - Recommendation::RefreshSingleUserAuctionScoresJob.perform_later(current_user.id) + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) flash[:notice] = t(:updated) render turbo_stream: [ turbo_stream.replace('flash', partial: 'common/flash', locals: { flash: }), diff --git a/app/jobs/recommendation/refresh_single_user_auction_scores_job.rb b/app/jobs/recommendation/refresh_single_user_auction_scores_job.rb index 8bdb27248..a70639535 100644 --- a/app/jobs/recommendation/refresh_single_user_auction_scores_job.rb +++ b/app/jobs/recommendation/refresh_single_user_auction_scores_job.rb @@ -1,12 +1,27 @@ module Recommendation class RefreshSingleUserAuctionScoresJob < ApplicationJob + DEBOUNCE_WINDOW = 30.seconds + retry_on StandardError, wait: 5.seconds, attempts: 3 + def self.enqueue_debounced(user_id) + set(wait: DEBOUNCE_WINDOW).perform_later(user_id) + end + def perform(user_id) user = User.find_by(id: user_id) return unless user + return if recently_refreshed?(user) + Recommendation::Scorer.refresh_for(user:) end + + private + + def recently_refreshed?(user) + last_calculated = UserAuctionScore.where(user_id: user.id).maximum(:calculated_at) + last_calculated.present? && last_calculated > DEBOUNCE_WINDOW.ago + end end end diff --git a/app/models/concerns/auction/user_sortable.rb b/app/models/concerns/auction/user_sortable.rb index 38431a363..33842b8aa 100644 --- a/app/models/concerns/auction/user_sortable.rb +++ b/app/models/concerns/auction/user_sortable.rb @@ -71,21 +71,28 @@ def build_four_tier_priority_sql(interest_categories, custom_interests) SQL end + MAX_CUSTOM_INTERESTS_IN_SQL = 10 + def interest_match_sql(interest_categories, custom_interests) match_clauses = [] - if interest_categories.present? - quoted_categories = interest_categories.map { |item| ActiveRecord::Base.connection.quote(item) }.join(',') + whitelisted_categories = Array(interest_categories) & Recommendation::InterestCatalog.categories + if whitelisted_categories.any? + quoted_categories = whitelisted_categories + .map { |item| ActiveRecord::Base.connection.quote(item) } + .join(',') match_clauses << "auctions.classification_tags && ARRAY[#{quoted_categories}]::varchar[]" end - custom_interest_clauses = custom_interests.filter_map do |interest| - normalized_interest = interest.to_s.strip.downcase - next if normalized_interest.blank? + custom_interest_clauses = Array(custom_interests) + .first(MAX_CUSTOM_INTERESTS_IN_SQL) + .filter_map do |interest| + normalized_interest = interest.to_s.strip.downcase + next if normalized_interest.length < 2 - pattern = "%#{ActiveRecord::Base.sanitize_sql_like(normalized_interest)}%" - "LOWER(auctions.domain_name) LIKE #{ActiveRecord::Base.connection.quote(pattern)}" - end + pattern = "%#{ActiveRecord::Base.sanitize_sql_like(normalized_interest)}%" + "LOWER(auctions.domain_name) LIKE #{ActiveRecord::Base.connection.quote(pattern)}" + end match_clauses.concat(custom_interest_clauses) @@ -94,4 +101,4 @@ def interest_match_sql(interest_categories, custom_interests) "(#{match_clauses.join(' OR ')})" end end -end \ No newline at end of file +end diff --git a/app/services/recommendation/event_tracker.rb b/app/services/recommendation/event_tracker.rb index 5ac50a2d1..664cc05a1 100644 --- a/app/services/recommendation/event_tracker.rb +++ b/app/services/recommendation/event_tracker.rb @@ -6,9 +6,32 @@ def call(...) end def track_impressions(user:, auctions:, source:, request: nil) - Array(auctions).each do |auction| - call(user:, auction:, event_type: 'auction_impression', source:, request:) + now = Time.current + session_id = request&.session&.id&.to_s + request_id = request&.request_id + + records = Array(auctions).filter_map do |auction| + next unless auction&.id + + { + user_id: user&.id, + auction_id: auction.id, + event_type: 'auction_impression', + source: source, + session_id: session_id, + request_id: request_id, + occurred_at: now, + properties: {}, + created_at: now, + updated_at: now + } end + + return if records.empty? + + RecommendationEvent.insert_all(records) + rescue StandardError => e + Rails.logger.warn("Recommendation impressions batch insert failed: #{e.message}") end end @@ -33,7 +56,7 @@ def call properties: @properties ) rescue StandardError => e - Rails.logger.info("Recommendation event tracking failed: #{e.message}") + Rails.logger.warn("Recommendation event tracking failed: #{e.message}") end end end diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb index 2c1c1f546..4c67d7443 100644 --- a/app/services/recommendation/scorer.rb +++ b/app/services/recommendation/scorer.rb @@ -1,9 +1,15 @@ module Recommendation class Scorer + SCORING_HORIZON = 30.days + class << self BASELINE_MODEL_NAME = 'baseline_rules_v1'.freeze - def top_auctions_for(user:, scope: Auction.active, limit: nil) + def default_scope + Auction.active.where('ends_at <= ?', SCORING_HORIZON.from_now) + end + + def top_auctions_for(user:, scope: default_scope, limit: nil) query = scope .joins(:user_auction_scores) .where(user_auction_scores: { user_id: user.id }) @@ -12,12 +18,12 @@ def top_auctions_for(user:, scope: Auction.active, limit: nil) limit ? query.limit(limit) : query end - def refresh_for(user:, scope: Auction.active, calculated_at: Time.current) + def refresh_for(user:, scope: default_scope, calculated_at: Time.current) new(user:, scope:, calculated_at:).refresh! end end - def initialize(user:, scope: Auction.active, calculated_at: Time.current) + def initialize(user:, scope: self.class.default_scope, calculated_at: Time.current) @user = user @scope = scope @calculated_at = calculated_at diff --git a/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb b/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb new file mode 100644 index 000000000..6e6b457a3 --- /dev/null +++ b/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb @@ -0,0 +1,61 @@ +require 'test_helper' + +module Recommendation + class RefreshSingleUserAuctionScoresJobTest < ActiveJob::TestCase + def setup + super + @user = users(:participant) + end + + def test_enqueue_debounced_schedules_job + assert_enqueued_with( + job: Recommendation::RefreshSingleUserAuctionScoresJob, + args: [@user.id] + ) do + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(@user.id) + end + end + + def test_perform_skips_when_user_has_fresh_scores + auction = auctions(:valid_without_offers) + UserAuctionScore.create!( + user: @user, + auction: auction, + score: 10, + calculated_at: 5.seconds.ago + ) + + max_updated_at_before = UserAuctionScore.where(user: @user).maximum(:updated_at) + + Recommendation::RefreshSingleUserAuctionScoresJob.new.perform(@user.id) + + max_updated_at_after = UserAuctionScore.where(user: @user).maximum(:updated_at) + + assert_equal max_updated_at_before, max_updated_at_after, + 'job must not touch fresh user_auction_scores' + end + + def test_perform_refreshes_when_scores_are_stale + auction = auctions(:valid_without_offers) + UserAuctionScore.create!( + user: @user, + auction: auction, + score: 1, + calculated_at: 2.minutes.ago + ) + + assert_nothing_raised do + Recommendation::RefreshSingleUserAuctionScoresJob.new.perform(@user.id) + end + + reloaded = UserAuctionScore.where(user: @user).order(:calculated_at).last + assert reloaded.calculated_at > 1.minute.ago, 'stale scores must be refreshed' + end + + def test_perform_no_op_for_unknown_user + assert_nothing_raised do + Recommendation::RefreshSingleUserAuctionScoresJob.new.perform(-1) + end + end + end +end diff --git a/test/services/recommendation/event_tracker_test.rb b/test/services/recommendation/event_tracker_test.rb new file mode 100644 index 000000000..611ec8e95 --- /dev/null +++ b/test/services/recommendation/event_tracker_test.rb @@ -0,0 +1,67 @@ +require 'test_helper' + +module Recommendation + class EventTrackerTest < ActiveSupport::TestCase + def setup + super + @user = users(:participant) + @auction_a = auctions(:valid_without_offers) + @auction_b = auctions(:valid_with_offers) + end + + def test_track_impressions_inserts_all_events_in_single_query + RecommendationEvent.where(user: @user, event_type: 'auction_impression').delete_all + + assert_difference -> { RecommendationEvent.count }, 2 do + Recommendation::EventTracker.track_impressions( + user: @user, + auctions: [@auction_a, @auction_b], + source: 'test_index' + ) + end + + events = RecommendationEvent.where(user: @user, event_type: 'auction_impression').to_a + assert_equal %w[auction_impression auction_impression], events.map(&:event_type) + assert_equal [@auction_a.id, @auction_b.id].sort, events.map(&:auction_id).sort + assert events.all? { |e| e.source == 'test_index' } + end + + def test_track_impressions_uses_single_insert_query + query_count = 0 + counter = ->(_name, _start, _finish, _id, payload) do + sql = payload[:sql].to_s + query_count += 1 if sql.start_with?('INSERT INTO "recommendation_events"') + end + + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record') do + Recommendation::EventTracker.track_impressions( + user: @user, + auctions: [@auction_a, @auction_b], + source: 'test_index' + ) + end + + assert_equal 1, query_count, 'track_impressions must batch all rows into one INSERT' + end + + def test_track_impressions_skips_empty_input + assert_no_difference -> { RecommendationEvent.count } do + Recommendation::EventTracker.track_impressions( + user: @user, + auctions: [], + source: 'test_index' + ) + end + end + + def test_track_impressions_skips_nil_auctions + assert_difference -> { RecommendationEvent.count }, 1 do + Recommendation::EventTracker.track_impressions( + user: @user, + auctions: [nil, @auction_a, nil], + source: 'test_index' + ) + end + end + end +end From bd88e97f99b84ccf5948e1c1a3d10cb8b89a619a Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:34:27 +0300 Subject: [PATCH 04/42] chore: ignore local .claude workspace artifacts The .claude/ directory contains per-developer local state from the Claude Code agentic harness (ralph-loop checkpoint, local settings). It accidentally landed in the previous commit; this commit untracks it and excludes the whole directory going forward. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/ralph-loop.local.md | 10 ---------- .gitignore | 4 +++- 2 files changed, 3 insertions(+), 11 deletions(-) delete mode 100644 .claude/ralph-loop.local.md diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md deleted file mode 100644 index f194e6b24..000000000 --- a/.claude/ralph-loop.local.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -active: true -iteration: 1 -session_id: 64e9a01b-259b-4f9a-9f25-673ea3297a0c -max_iterations: 3 -completion_promise: null -started_at: "2026-05-27T08:31:02Z" ---- - -Начни выполнять все файзы за раз! diff --git a/.gitignore b/.gitignore index cfa756a62..a161bb3cb 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,6 @@ CLAUDE.md /app/assets/builds/* !/app/assets/builds/.keep .cursorindexingignore -.specstory \ No newline at end of file +.specstory +# Local Claude Code workspace artifacts +.claude/ From 209c2330e0c81455ada857cd9085472e6f1b1a2c Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:37:10 +0300 Subject: [PATCH 05/42] feat: domain_classifications table + heuristic Tier 0 classifier Phase 2 of recommendation system v2 (see docs/architecture/recommendation-system.md). Schema: - New table domain_classifications, one row per domain_name. Stores rich features: primary_category, tags[], description, description_locale, keywords[], audience, languages[], suggested_use_cases[], structural cache (has_digits, has_hyphens, token_count, dictionary_word, brandability_score), provenance (classification_source, classification_model, confidence, classified_at, raw_llm_response). - Indexes: unique on domain_name and uuid; GIN on tags and keywords; btree on primary_category, audience, classified_at, classification_source. Model: - DomainClassification with validations, normalized domain_name, source/refresh scopes (needs_llm_enrichment, low_confidence, unclassified, classified) and predicates. Tier 0 services: - DomainStructuralAnalyzer extracts deterministic structural features and tokens. - DomainDictionary holds et+en root -> InterestCatalog category mappings and a greedy longest-prefix tokenizer. - DomainHeuristicClassifier composes the two and emits a hash ready to upsert into domain_classifications, including confidence and brandability_score heuristics. Tests: - Analyzer covers digits, hyphens, tokenization, numeric-only. - Heuristic classifier covers Estonian dictionary words, compound English domains, numeric domains, unknown low-confidence cases, metadata, and brandability deltas. - DomainClassification model: validation, normalization, uniqueness, needs_llm_enrichment scope across heuristic / low-confidence / stale branches. Note: embedding column and its scope land in Phase 5 (pgvector). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/models/domain_classification.rb | 50 +++++++ .../recommendation/domain_dictionary.rb | 136 ++++++++++++++++++ .../domain_heuristic_classifier.rb | 118 +++++++++++++++ .../domain_structural_analyzer.rb | 45 ++++++ ...527090000_create_domain_classifications.rb | 49 +++++++ test/models/domain_classification_test.rb | 60 ++++++++ .../domain_heuristic_classifier_test.rb | 53 +++++++ .../domain_structural_analyzer_test.rb | 44 ++++++ 8 files changed, 555 insertions(+) create mode 100644 app/models/domain_classification.rb create mode 100644 app/services/recommendation/domain_dictionary.rb create mode 100644 app/services/recommendation/domain_heuristic_classifier.rb create mode 100644 app/services/recommendation/domain_structural_analyzer.rb create mode 100644 db/migrate/20260527090000_create_domain_classifications.rb create mode 100644 test/models/domain_classification_test.rb create mode 100644 test/services/recommendation/domain_heuristic_classifier_test.rb create mode 100644 test/services/recommendation/domain_structural_analyzer_test.rb diff --git a/app/models/domain_classification.rb b/app/models/domain_classification.rb new file mode 100644 index 000000000..a31883a2f --- /dev/null +++ b/app/models/domain_classification.rb @@ -0,0 +1,50 @@ +class DomainClassification < ApplicationRecord + HEURISTIC_SOURCE = 'heuristic'.freeze + OPENAI_SOURCE = 'openai'.freeze + MANUAL_SOURCE = 'manual'.freeze + IMPORTED_SOURCE = 'imported'.freeze + + SOURCES = [HEURISTIC_SOURCE, OPENAI_SOURCE, MANUAL_SOURCE, IMPORTED_SOURCE].freeze + + LOW_CONFIDENCE_THRESHOLD = 0.6 + LLM_REFRESH_INTERVAL = 6.months + + validates :domain_name, presence: true, uniqueness: { case_sensitive: false } + validates :classification_source, inclusion: { in: SOURCES }, allow_nil: true + validates :confidence, + numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, + allow_nil: true + validates :brandability_score, + numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, + allow_nil: true + + before_validation :normalize_domain_name + + scope :unclassified, -> { where(classified_at: nil) } + scope :by_source, ->(source) { where(classification_source: source) } + scope :low_confidence, -> { where('confidence IS NULL OR confidence < ?', LOW_CONFIDENCE_THRESHOLD) } + scope :needs_llm_enrichment, lambda { + where(classification_source: [HEURISTIC_SOURCE, nil]) + .or(where(confidence: ...LOW_CONFIDENCE_THRESHOLD)) + .or(where('classification_source = ? AND classified_at < ?', OPENAI_SOURCE, LLM_REFRESH_INTERVAL.ago)) + } + # needs_embedding scope is defined in Phase 5 once the pgvector column + # is added (see db/migrate/*_enable_pgvector_and_add_embeddings.rb). + scope :classified, -> { where.not(classified_at: nil) } + + def heuristic? = classification_source == HEURISTIC_SOURCE + def from_llm? = classification_source == OPENAI_SOURCE + + def stale? + return true if classification_source != OPENAI_SOURCE + return true if classified_at.nil? + + classified_at < LLM_REFRESH_INTERVAL.ago + end + + private + + def normalize_domain_name + self.domain_name = domain_name.to_s.strip.downcase if domain_name.present? + end +end diff --git a/app/services/recommendation/domain_dictionary.rb b/app/services/recommendation/domain_dictionary.rb new file mode 100644 index 000000000..d64424347 --- /dev/null +++ b/app/services/recommendation/domain_dictionary.rb @@ -0,0 +1,136 @@ +module Recommendation + module DomainDictionary + # Maps a root token -> InterestCatalog category symbol. + # Sources: Estonian common-noun domains we already see in seeds, English + # SaaS/marketing vocabulary, common shop/service terms. Grow over time + # as Tier 2 (LLM) reveals new patterns. + ESTONIAN_ROOTS = { + 'kohvik' => :local_service, 'kohv' => :local_service, + 'apteek' => :health, 'arst' => :health, 'tervis' => :health, 'med' => :health, + 'pood' => :shop_brand, 'aiapood' => :shop_brand, 'kalapood' => :shop_brand, + 'raamatupood' => :shop_brand, 'veebipood' => :shop_brand, 'mobiilipood' => :shop_brand, + 'kinnisvara' => :real_estate, 'maja' => :real_estate, 'korter' => :real_estate, + 'laen' => :finance, 'pank' => :finance, 'raha' => :finance, + 'jurist' => :legal, 'oigus' => :legal, 'oigusabi' => :legal, 'notar' => :legal, + 'haridus' => :education, 'kool' => :education, 'koolitus' => :education, + 'reisid' => :travel, 'matk' => :travel, 'majutus' => :travel, 'reisi' => :travel, + 'auto' => :automotive, 'autod' => :automotive, 'rent' => :automotive, + 'ilusalong' => :health, 'kosmeetika' => :health, 'spaa' => :health, + 'meedia' => :media_content, 'uudised' => :media_content, 'ajakiri' => :media_content, + 'remont' => :local_service, 'parandus' => :local_service, 'ehitus' => :local_service, + 'tooriistad' => :shop_brand, 'mood' => :shop_brand, 'lilled' => :shop_brand, + 'turundus' => :b2b_service, 'nouv' => :b2b_service, + 'tarkvara' => :saas, 'saasplatvorm' => :saas, 'platvorm' => :saas, 'pilv' => :saas + }.freeze + + ENGLISH_ROOTS = { + # Shop / commerce + 'shop' => :shop_brand, 'store' => :shop_brand, 'market' => :shop_brand, + 'marketplace' => :shop_brand, 'mart' => :shop_brand, 'deal' => :shop_brand, + 'dealzone' => :shop_brand, 'foodmarket' => :shop_brand, 'shopline' => :shop_brand, + + # SaaS / tech + 'saas' => :saas, 'cloud' => :saas, 'cloudstack' => :saas, 'tech' => :saas, + 'soft' => :saas, 'software' => :saas, 'app' => :saas, 'apps' => :saas, + 'stack' => :saas, 'platform' => :saas, 'flow' => :saas, 'forge' => :saas, + 'craft' => :saas, 'pixelcraft' => :saas, 'lab' => :saas, 'hub' => :saas, + 'desk' => :saas, 'suite' => :saas, 'gamesuite' => :saas, 'tools' => :saas, + 'data' => :saas, 'api' => :saas, 'dev' => :saas, 'devkit' => :saas, + 'workzone' => :saas, 'startupdesk' => :saas, + + # Finance + 'fin' => :finance, 'finance' => :finance, 'fintech' => :finance, 'fintechlab' => :finance, + 'bank' => :finance, 'pay' => :finance, 'invest' => :finance, 'loan' => :finance, + 'crypto' => :finance, 'capital' => :finance, 'accountflow' => :finance, + + # B2B + 'b2b' => :b2b_service, 'agency' => :b2b_service, 'consult' => :b2b_service, + 'enterprise' => :b2b_service, 'pro' => :b2b_service, 'corp' => :b2b_service, + 'growth' => :b2b_service, 'growthhub' => :b2b_service, 'marketflow' => :b2b_service, + + # Media / content + 'media' => :media_content, 'mediateam' => :media_content, + 'news' => :media_content, 'blog' => :media_content, 'press' => :media_content, + 'magazine' => :media_content, + + # Legal + 'legal' => :legal, 'legalhub' => :legal, 'lawyer' => :legal, 'law' => :legal, + + # Health + 'health' => :health, 'wellness' => :health, 'wellnesshub' => :health, + 'medic' => :health, 'pharma' => :health, 'clinic' => :health, 'fit' => :health, + + # Education + 'edu' => :education, 'school' => :education, 'academy' => :education, + 'course' => :education, 'learn' => :education, + + # Travel + 'travel' => :travel, 'traveldesk' => :travel, 'trip' => :travel, 'tour' => :travel, + 'hotel' => :travel, 'flight' => :travel, 'booking' => :travel, + + # Automotive + 'car' => :automotive, 'carshop' => :automotive, 'auto' => :automotive, + 'motor' => :automotive, 'drive' => :automotive, + + # Real estate + 'property' => :real_estate, 'propertylab' => :real_estate, 'realty' => :real_estate, + 'estate' => :real_estate, 'rent' => :real_estate, 'lease' => :real_estate, + 'home' => :real_estate, 'house' => :real_estate, + + # Brandable / generic positive + 'brand' => :brandable, 'brandforge' => :brandable, 'premium' => :brandable + }.freeze + + ALL_ROOTS = ESTONIAN_ROOTS.merge(ENGLISH_ROOTS).freeze + + # Roots sorted by length DESC for greedy longest-prefix matching. + SORTED_ROOTS = ALL_ROOTS.keys.sort_by { |k| -k.length }.freeze + + MIN_TOKEN_LENGTH = 3 + + class << self + def lookup(token) + ALL_ROOTS[token.to_s.downcase] + end + + def known?(token) + ALL_ROOTS.key?(token.to_s.downcase) + end + + # Greedy subword tokenization: from the front, consume the longest + # known root. Falls back to consuming a single character so we always + # make progress and never loop. + def tokenize(bare_name) + name = bare_name.to_s.downcase + return [] if name.empty? + + tokens = [] + index = 0 + + while index < name.length + remaining = name[index..] + matched_root = SORTED_ROOTS.find do |root| + root.length >= MIN_TOKEN_LENGTH && remaining.start_with?(root) + end + + if matched_root + tokens << matched_root + index += matched_root.length + else + # No known root at this position; consume the next contiguous + # alphabetic run as a single unknown token, or skip a non-letter. + run_match = remaining.match(/\A[a-z]+/) + if run_match + tokens << run_match[0] + index += run_match[0].length + else + index += 1 + end + end + end + + tokens + end + end + end +end diff --git a/app/services/recommendation/domain_heuristic_classifier.rb b/app/services/recommendation/domain_heuristic_classifier.rb new file mode 100644 index 000000000..4627517bb --- /dev/null +++ b/app/services/recommendation/domain_heuristic_classifier.rb @@ -0,0 +1,118 @@ +module Recommendation + # Tier 0 classifier: deterministic, instant, free. + # + # Approach: + # 1. Run DomainStructuralAnalyzer to extract tokens and structural features. + # 2. Map known tokens -> categories via DomainDictionary. + # 3. Confidence = ratio of matched characters to total bare-name length, + # capped at 1.0. A dictionary_word match short-circuits to 1.0. + # 4. If purely numeric -> 'numeric' category, confidence 1.0. + # 5. Output mirrors the columns of DomainClassification so a caller can + # upsert directly. + class DomainHeuristicClassifier + BRANDABLE_BONUS_THRESHOLD = 0.7 # short, no digits, no hyphens, no dictionary match + + class << self + def call(domain_name) + new(domain_name).call + end + end + + def initialize(domain_name) + @domain_name = domain_name.to_s.strip.downcase + @structure = DomainStructuralAnalyzer.call(@domain_name) + end + + def call + tags, primary_category, matched_chars = derive_categories + brandability = compute_brandability(matched_chars) + + { + domain_name: @domain_name, + primary_category: primary_category&.to_s, + tags: tags.map(&:to_s).uniq, + keywords: derive_keywords, + languages: derive_languages, + audience: nil, + suggested_use_cases: [], + description: nil, + description_locale: nil, + has_digits: @structure[:has_digits], + has_hyphens: @structure[:has_hyphens], + token_count: @structure[:token_count], + dictionary_word: @structure[:dictionary_word], + brandability_score: brandability, + confidence: derive_confidence(matched_chars), + classification_source: DomainClassification::HEURISTIC_SOURCE, + classification_model: 'heuristic_v1', + classified_at: Time.current + } + end + + private + + def derive_categories + if @structure[:numeric_only] + return [%i[numeric], :numeric, @structure[:bare_name].length] + end + + tags = [] + matched_chars = 0 + + @structure[:tokens].each do |token| + category = DomainDictionary.lookup(token) + next unless category + + tags << category + matched_chars += token.length + end + + # If we matched a category, but tokens include digits, layer in :numeric. + tags << :numeric if @structure[:has_digits] && !tags.include?(:numeric) + + primary = tags.first + [tags, primary, matched_chars] + end + + def derive_keywords + # Surface non-trivial structural tokens as keywords so the scorer + # can do keyword-overlap matching even without LLM enrichment. + @structure[:tokens] + .reject { |t| t.length < DomainDictionary::MIN_TOKEN_LENGTH } + .uniq + end + + def derive_languages + languages = [] + tokens = @structure[:tokens] + languages << 'et' if tokens.any? { |t| DomainDictionary::ESTONIAN_ROOTS.key?(t) } + languages << 'en' if tokens.any? { |t| DomainDictionary::ENGLISH_ROOTS.key?(t) } + languages + end + + def derive_confidence(matched_chars) + bare_length = [@structure[:bare_name].length, 1].max + return 1.0 if @structure[:dictionary_word] + return 1.0 if @structure[:numeric_only] + + ratio = matched_chars.to_f / bare_length + ratio.clamp(0.0, 1.0).round(3) + end + + def compute_brandability(matched_chars) + bare = @structure[:bare_name] + return 0.0 if bare.empty? + + score = 1.0 + score -= 0.2 if @structure[:has_digits] + score -= 0.15 if @structure[:has_hyphens] + score -= 0.1 if bare.length > 14 + score -= 0.2 if bare.length > 20 + # If dictionary words eat the whole name, it's literal, not brandable. + coverage = matched_chars.to_f / bare.length + score -= 0.25 if coverage > 0.85 + + score.clamp(0.0, 1.0).round(3) + end + end +end diff --git a/app/services/recommendation/domain_structural_analyzer.rb b/app/services/recommendation/domain_structural_analyzer.rb new file mode 100644 index 000000000..6dd702cdb --- /dev/null +++ b/app/services/recommendation/domain_structural_analyzer.rb @@ -0,0 +1,45 @@ +module Recommendation + class DomainStructuralAnalyzer + TLD_PATTERN = /\.[a-z]+\z/i.freeze + + class << self + def call(domain_name) + new(domain_name).call + end + end + + def initialize(domain_name) + @domain_name = domain_name.to_s.strip.downcase + end + + def call + { + domain_name: @domain_name, + bare_name: bare_name, + length: bare_name.length, + has_digits: bare_name.match?(/\d/), + has_hyphens: bare_name.include?('-'), + token_count: tokens.size, + tokens: tokens, + dictionary_word: dictionary_word?, + numeric_only: bare_name.match?(/\A\d+\z/) + } + end + + def bare_name + @bare_name ||= @domain_name.sub(TLD_PATTERN, '') + end + + def tokens + @tokens ||= Recommendation::DomainDictionary.tokenize(bare_name) + end + + def dictionary_word? + return false if tokens.empty? + return false if tokens.size > 2 + + Recommendation::DomainDictionary.known?(bare_name) || + tokens.all? { |token| Recommendation::DomainDictionary.known?(token) } + end + end +end diff --git a/db/migrate/20260527090000_create_domain_classifications.rb b/db/migrate/20260527090000_create_domain_classifications.rb new file mode 100644 index 000000000..5603ed70a --- /dev/null +++ b/db/migrate/20260527090000_create_domain_classifications.rb @@ -0,0 +1,49 @@ +class CreateDomainClassifications < ActiveRecord::Migration[7.0] + def change + create_table :domain_classifications do |t| + t.string :domain_name, null: false + t.uuid :uuid, default: 'gen_random_uuid()', null: false + + # Categorical signals + t.string :primary_category + t.string :tags, array: true, default: [], null: false + + # Description / readable enrichment + t.text :description + t.string :description_locale, default: 'en' + + # Semantic tokens + t.string :keywords, array: true, default: [], null: false + + # Audience / language / use-case hints + t.string :audience + t.string :languages, array: true, default: [], null: false + t.string :suggested_use_cases, array: true, default: [], null: false + + # Cached structural features + t.boolean :has_digits, default: false, null: false + t.boolean :has_hyphens, default: false, null: false + t.integer :token_count + t.boolean :dictionary_word, default: false, null: false + t.decimal :brandability_score, precision: 4, scale: 3 + + # Provenance + t.string :classification_source # 'heuristic' | 'openai' | 'manual' | 'imported' + t.string :classification_model + t.decimal :confidence, precision: 4, scale: 3 + t.datetime :classified_at + t.jsonb :raw_llm_response, default: {}, null: false + + t.timestamps + end + + add_index :domain_classifications, :domain_name, unique: true + add_index :domain_classifications, :uuid, unique: true + add_index :domain_classifications, :tags, using: :gin + add_index :domain_classifications, :keywords, using: :gin + add_index :domain_classifications, :primary_category + add_index :domain_classifications, :audience + add_index :domain_classifications, :classified_at + add_index :domain_classifications, :classification_source + end +end diff --git a/test/models/domain_classification_test.rb b/test/models/domain_classification_test.rb new file mode 100644 index 000000000..1ebb166e3 --- /dev/null +++ b/test/models/domain_classification_test.rb @@ -0,0 +1,60 @@ +require 'test_helper' + +class DomainClassificationTest < ActiveSupport::TestCase + def test_domain_name_is_required + record = DomainClassification.new + refute record.valid? + assert_includes record.errors[:domain_name], "can't be blank" + end + + def test_domain_name_is_normalized_to_lowercase_stripped + record = DomainClassification.create!(domain_name: ' KohViK.ee ') + assert_equal 'kohvik.ee', record.domain_name + end + + def test_domain_name_is_unique + DomainClassification.create!(domain_name: 'unique.ee') + duplicate = DomainClassification.new(domain_name: 'unique.ee') + refute duplicate.valid? + end + + def test_needs_llm_enrichment_scope_picks_heuristic_rows + heuristic_row = DomainClassification.create!( + domain_name: 'fresh-heur.ee', + classification_source: DomainClassification::HEURISTIC_SOURCE, + confidence: 0.8, + classified_at: Time.current + ) + DomainClassification.create!( + domain_name: 'fresh-llm.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.95, + classified_at: 1.day.ago + ) + + assert_includes DomainClassification.needs_llm_enrichment.to_a, heuristic_row + end + + def test_needs_llm_enrichment_picks_low_confidence_rows + weak = DomainClassification.create!( + domain_name: 'weak.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.3, + classified_at: 1.day.ago + ) + assert_includes DomainClassification.needs_llm_enrichment.to_a, weak + end + + def test_needs_llm_enrichment_picks_stale_llm_rows + stale = DomainClassification.create!( + domain_name: 'stale.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.95, + classified_at: 9.months.ago + ) + assert_includes DomainClassification.needs_llm_enrichment.to_a, stale + end + + # needs_embedding scope is covered in Phase 5 test suite once the + # embedding column is added by the pgvector migration. +end diff --git a/test/services/recommendation/domain_heuristic_classifier_test.rb b/test/services/recommendation/domain_heuristic_classifier_test.rb new file mode 100644 index 000000000..51bb3c1ab --- /dev/null +++ b/test/services/recommendation/domain_heuristic_classifier_test.rb @@ -0,0 +1,53 @@ +require 'test_helper' + +module Recommendation + class DomainHeuristicClassifierTest < ActiveSupport::TestCase + def test_known_estonian_domain_maps_to_expected_category + result = Recommendation::DomainHeuristicClassifier.call('kohvik.ee') + assert_equal 'local_service', result[:primary_category] + assert_includes result[:tags], 'local_service' + assert_equal 1.0, result[:confidence] + assert_includes result[:languages], 'et' + end + + def test_compound_english_domain_maps_to_first_match + result = Recommendation::DomainHeuristicClassifier.call('marketflow.ee') + assert_includes result[:tags], 'shop_brand' + assert result[:confidence] > 0.5 + assert_includes result[:languages], 'en' + end + + def test_numeric_domain_classified_as_numeric + result = Recommendation::DomainHeuristicClassifier.call('12345.ee') + assert_equal 'numeric', result[:primary_category] + assert_includes result[:tags], 'numeric' + assert_equal 1.0, result[:confidence] + end + + def test_unknown_domain_has_low_confidence + result = Recommendation::DomainHeuristicClassifier.call('zxyqwerty.ee') + assert result[:confidence] < Recommendation::DomainHeuristicClassifier::BRANDABLE_BONUS_THRESHOLD + assert_nil result[:primary_category] + assert_empty result[:tags] + end + + def test_metadata_is_present + result = Recommendation::DomainHeuristicClassifier.call('cloudstack.ee') + assert_equal DomainClassification::HEURISTIC_SOURCE, result[:classification_source] + assert_equal 'heuristic_v1', result[:classification_model] + assert result[:classified_at].is_a?(Time) + end + + def test_hyphenated_lowers_brandability + with_hyphen = Recommendation::DomainHeuristicClassifier.call('cool-shop.ee') + without_hyphen = Recommendation::DomainHeuristicClassifier.call('coolshop.ee') + assert with_hyphen[:brandability_score] < without_hyphen[:brandability_score] + end + + def test_digits_layer_in_numeric_tag + result = Recommendation::DomainHeuristicClassifier.call('shop42.ee') + assert_includes result[:tags], 'numeric' + assert_includes result[:tags], 'shop_brand' + end + end +end diff --git a/test/services/recommendation/domain_structural_analyzer_test.rb b/test/services/recommendation/domain_structural_analyzer_test.rb new file mode 100644 index 000000000..a4e26aaaa --- /dev/null +++ b/test/services/recommendation/domain_structural_analyzer_test.rb @@ -0,0 +1,44 @@ +require 'test_helper' + +module Recommendation + class DomainStructuralAnalyzerTest < ActiveSupport::TestCase + def test_detects_digits + result = Recommendation::DomainStructuralAnalyzer.call('numeric24.ee') + assert result[:has_digits] + end + + def test_detects_hyphens + result = Recommendation::DomainStructuralAnalyzer.call('my-shop.ee') + assert result[:has_hyphens] + end + + def test_strips_tld + result = Recommendation::DomainStructuralAnalyzer.call('cloudstack.ee') + assert_equal 'cloudstack', result[:bare_name] + assert_equal 'cloudstack'.length, result[:length] + end + + def test_dictionary_word_for_single_known_root + result = Recommendation::DomainStructuralAnalyzer.call('kohvik.ee') + assert result[:dictionary_word] + end + + def test_numeric_only_domain + result = Recommendation::DomainStructuralAnalyzer.call('12345.ee') + assert result[:numeric_only] + assert result[:has_digits] + end + + def test_tokenization_of_compound_known_roots + result = Recommendation::DomainStructuralAnalyzer.call('marketflow.ee') + assert_includes result[:tokens], 'market' + assert_includes result[:tokens], 'flow' + end + + def test_unknown_token_falls_through_as_single_alphabetic_run + result = Recommendation::DomainStructuralAnalyzer.call('zxyqwerty.ee') + assert_equal ['zxyqwerty'], result[:tokens] + refute result[:dictionary_word] + end + end +end From 42e8f97eef311a5d136499d607345ad34d8da3c7 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:37:56 +0300 Subject: [PATCH 06/42] feat: DomainClassifier orchestrator (heuristic-only at runtime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3a of recommendation system v2. - Recommendation::DomainClassifier is the synchronous entry point for classifying a single domain. It runs structural + heuristic Tier 0 and upserts into domain_classifications. The LLM path is never triggered from runtime — it lives behind the cron-only batch job (Phase 3b). - Idempotent: skips work when an existing row is fresh (within FRESH_WINDOW = 1.hour) unless force: true. - Preserves LLM-enriched fields when re-running on a row whose source is openai, so heuristic passes never clobber better data. - Recommendation::ClassifyDomainHeuristicallyJob wraps the orchestrator for use by event triggers (Phase 4). - Tests cover unseen domain, freshness guard, force recompute, LLM-field preservation, blank input, and case-insensitive input. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../classify_domain_heuristically_job.rb | 14 ++++ .../recommendation/domain_classifier.rb | 58 ++++++++++++++++ .../recommendation/domain_classifier_test.rb | 68 +++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 app/jobs/recommendation/classify_domain_heuristically_job.rb create mode 100644 app/services/recommendation/domain_classifier.rb create mode 100644 test/services/recommendation/domain_classifier_test.rb diff --git a/app/jobs/recommendation/classify_domain_heuristically_job.rb b/app/jobs/recommendation/classify_domain_heuristically_job.rb new file mode 100644 index 000000000..a45963a5d --- /dev/null +++ b/app/jobs/recommendation/classify_domain_heuristically_job.rb @@ -0,0 +1,14 @@ +module Recommendation + # Triggered on every new auction / wishlist / offer event. + # Runs heuristic-only classification — NEVER calls the LLM. + # Idempotent: DomainClassifier returns the existing fresh row without work. + class ClassifyDomainHeuristicallyJob < ApplicationJob + retry_on StandardError, wait: 5.seconds, attempts: 3 + + def perform(domain_name) + return if domain_name.to_s.strip.blank? + + Recommendation::DomainClassifier.call(domain_name) + end + end +end diff --git a/app/services/recommendation/domain_classifier.rb b/app/services/recommendation/domain_classifier.rb new file mode 100644 index 000000000..ba67e0b27 --- /dev/null +++ b/app/services/recommendation/domain_classifier.rb @@ -0,0 +1,58 @@ +module Recommendation + # Runtime entry point for classifying a single domain. + # + # This orchestrator NEVER calls the LLM. It runs structural analysis + # + heuristic classification synchronously and upserts the result into + # domain_classifications. The LLM enrichment pass runs in + # ClassifyUnclassifiedDomainsJob (cron-driven, batched). + # + # Returns the persisted DomainClassification (or nil if domain_name is blank). + class DomainClassifier + FRESH_WINDOW = 1.hour + + class << self + def call(...) + new(...).call + end + end + + def initialize(domain_name, force: false) + @domain_name = domain_name.to_s.strip.downcase + @force = force + end + + def call + return nil if @domain_name.blank? + + existing = DomainClassification.find_by(domain_name: @domain_name) + return existing if existing && !@force && fresh?(existing) + + attributes = DomainHeuristicClassifier.call(@domain_name) + upsert_attributes = attributes.merge(updated_at: Time.current) + + record = existing || DomainClassification.new(domain_name: @domain_name) + preserve_llm_fields!(record, attributes) if existing&.from_llm? + + record.assign_attributes(upsert_attributes.except(:domain_name)) + record.save! + record + end + + private + + def fresh?(record) + record.classified_at.present? && record.classified_at > FRESH_WINDOW.ago + end + + # If a row was previously enriched by the LLM, do NOT clobber the + # rich fields with a weaker heuristic pass. Heuristic only refreshes + # structural and provenance metadata in that case. + def preserve_llm_fields!(record, attributes) + %i[ + description description_locale keywords audience languages + suggested_use_cases primary_category tags brandability_score + confidence classification_source classification_model classified_at + ].each { |field| attributes.delete(field) if record.send(field).present? } + end + end +end diff --git a/test/services/recommendation/domain_classifier_test.rb b/test/services/recommendation/domain_classifier_test.rb new file mode 100644 index 000000000..526f7b47f --- /dev/null +++ b/test/services/recommendation/domain_classifier_test.rb @@ -0,0 +1,68 @@ +require 'test_helper' + +module Recommendation + class DomainClassifierTest < ActiveSupport::TestCase + def test_creates_classification_for_unseen_domain + assert_difference -> { DomainClassification.count }, 1 do + Recommendation::DomainClassifier.call('kohvik.ee') + end + + record = DomainClassification.find_by(domain_name: 'kohvik.ee') + assert_equal 'local_service', record.primary_category + assert_equal DomainClassification::HEURISTIC_SOURCE, record.classification_source + end + + def test_returns_existing_record_when_fresh + Recommendation::DomainClassifier.call('cloudstack.ee') + + assert_no_difference -> { DomainClassification.count } do + Recommendation::DomainClassifier.call('cloudstack.ee') + end + end + + def test_force_recomputes_even_when_fresh + Recommendation::DomainClassifier.call('apteek.ee') + record = DomainClassification.find_by(domain_name: 'apteek.ee') + record.update_columns(classified_at: 10.seconds.ago) + + Recommendation::DomainClassifier.call('apteek.ee', force: true) + record.reload + assert record.classified_at > 1.second.ago + end + + def test_preserves_llm_enriched_fields_when_running_heuristic_again + record = DomainClassification.create!( + domain_name: 'rich.ee', + primary_category: 'saas', + tags: %w[saas b2b_service], + description: 'Description by LLM', + description_locale: 'en', + audience: 'b2b', + languages: %w[en], + suggested_use_cases: %w[agency], + brandability_score: 0.9, + confidence: 0.92, + classification_source: DomainClassification::OPENAI_SOURCE, + classification_model: 'gpt-5', + classified_at: 2.hours.ago + ) + + Recommendation::DomainClassifier.call('rich.ee', force: true) + record.reload + + assert_equal 'Description by LLM', record.description + assert_equal DomainClassification::OPENAI_SOURCE, record.classification_source + assert_equal 'saas', record.primary_category + end + + def test_no_op_for_blank_domain + assert_nil Recommendation::DomainClassifier.call('') + assert_nil Recommendation::DomainClassifier.call(nil) + end + + def test_lowercases_input + Recommendation::DomainClassifier.call(' KOHVIK.EE ') + assert DomainClassification.exists?(domain_name: 'kohvik.ee') + end + end +end From 26a1801c74f01c324dbc1b3f5ec609175f6fba87 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:39:52 +0300 Subject: [PATCH 07/42] feat: LLM batch enrichment job (cron-driven, never runtime) Phase 3b of recommendation system v2. - Recommendation::LlmDomainClassifier: replaces the existing AuctionDomainClassifier with a richer JSON schema covering description, description_locale, keywords, audience, languages, suggested_use_cases, brandability_score, confidence. Tags and primary_category are enum-constrained to InterestCatalog. Raw response is captured so future schema migrations can re-parse without re-billing OpenAI. Accepts plain domain names rather than Auction records, decoupling classification from the auctions table. - Recommendation::ClassifyUnclassifiedDomainsJob: cron-only entry point. Pulls DomainClassification rows that need LLM enrichment (heuristic source, low confidence, or stale openai rows), batches them at LlmDomainClassifier::BATCH_LIMIT, and upserts. Guarded by Feature.open_ai_integration_enabled?. Logs processed count. Provides needs_to_run? for the admin Job UI. - lib/tasks/recommendation.rake: defines classify_unclassified, embed_unembedded, and backfill task targets for k8s CronJobs. Placeholder branches gracefully skip until later phases land. - app/models/job.rb: registers the new job in ALLOWED_JOB_NAMES so admins can also trigger it manually. - Tests cover feature-flag gating, scope selection (heuristic, low-conf, stale, fresh-llm), and needs_to_run? predicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../classify_unclassified_domains_job.rb | 53 +++++ app/models/job.rb | 1 + .../recommendation/llm_domain_classifier.rb | 204 ++++++++++++++++++ lib/tasks/recommendation.rake | 24 +++ .../classify_unclassified_domains_job_test.rb | 92 ++++++++ 5 files changed, 374 insertions(+) create mode 100644 app/jobs/recommendation/classify_unclassified_domains_job.rb create mode 100644 app/services/recommendation/llm_domain_classifier.rb create mode 100644 lib/tasks/recommendation.rake create mode 100644 test/jobs/recommendation/classify_unclassified_domains_job_test.rb diff --git a/app/jobs/recommendation/classify_unclassified_domains_job.rb b/app/jobs/recommendation/classify_unclassified_domains_job.rb new file mode 100644 index 000000000..afac611a0 --- /dev/null +++ b/app/jobs/recommendation/classify_unclassified_domains_job.rb @@ -0,0 +1,53 @@ +module Recommendation + # Runs once per day via a k8s CronJob (rake recommendation:classify_unclassified). + # Picks domain_classifications rows that need LLM enrichment, batches them, + # and upserts the enriched attributes. + # + # NEVER called from user-facing request paths. + class ClassifyUnclassifiedDomainsJob < ApplicationJob + MAX_DOMAINS_PER_RUN = 200 + BATCH_SIZE = Recommendation::LlmDomainClassifier::BATCH_LIMIT + + retry_on StandardError, wait: 30.seconds, attempts: 2 + + def perform + return unless Feature.open_ai_integration_enabled? + + domains = scope.limit(MAX_DOMAINS_PER_RUN).pluck(:domain_name) + return if domains.empty? + + processed = 0 + domains.each_slice(BATCH_SIZE) do |batch| + attributes_list = Recommendation::LlmDomainClassifier.call(domain_names: batch) + processed += upsert(attributes_list) + end + + Rails.logger.info("ClassifyUnclassifiedDomainsJob processed #{processed} domains") + processed + end + + def self.scope + DomainClassification.needs_llm_enrichment.order(Arel.sql('COALESCE(classified_at, to_timestamp(0)) ASC')) + end + + def self.needs_to_run? + Feature.open_ai_integration_enabled? && scope.exists? + end + + def scope + self.class.scope + end + + private + + def upsert(attributes_list) + return 0 if attributes_list.blank? + + timestamps = { created_at: Time.current, updated_at: Time.current } + rows = attributes_list.map { |attrs| attrs.merge(timestamps) } + + DomainClassification.upsert_all(rows, unique_by: :domain_name) + rows.size + end + end +end diff --git a/app/models/job.rb b/app/models/job.rb index e8eb91ed4..e6d8fb6dd 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -5,6 +5,7 @@ class Job DailySummaryJob DailyBroadcastAuctionsJob DailyViewRefreshJob SharedFooterFetcherJob DirectoInvoiceForwardJob ActiveAuctionsAiSortingJob Recommendation::ClassifyAuctionDomainsJob + Recommendation::ClassifyUnclassifiedDomainsJob Recommendation::RefreshUserAuctionScoresJob].freeze include ActiveModel::Model diff --git a/app/services/recommendation/llm_domain_classifier.rb b/app/services/recommendation/llm_domain_classifier.rb new file mode 100644 index 000000000..167242662 --- /dev/null +++ b/app/services/recommendation/llm_domain_classifier.rb @@ -0,0 +1,204 @@ +module Recommendation + # Tier 2 classifier: OpenAI structured outputs. + # + # Accepts an array of domain names (strings), returns array of attribute + # hashes ready to upsert into domain_classifications. NOT called from + # runtime paths — only from ClassifyUnclassifiedDomainsJob (cron). + # + # Cost: ~$0.30/month at steady state (one batch of ~5-20 domains per + # day). Backfill: ~$1 one-time over thousands of historical domains. + class LlmDomainClassifier + DEFAULT_TEMPERATURE = 0.2 + BATCH_LIMIT = 50 + + AUDIENCE_VALUES = %w[b2b b2c mixed unclear].freeze + DESCRIPTION_LOCALES = %w[en et].freeze + + class << self + def call(...) + new(...).call + end + end + + def initialize(domain_names:, temperature: DEFAULT_TEMPERATURE) + @domain_names = Array(domain_names).map { |d| d.to_s.strip.downcase }.uniq.reject(&:blank?).first(BATCH_LIMIT) + @temperature = temperature + end + + def call + return [] if @domain_names.empty? + + response_content = fetch_ai_response + parsed = JSON.parse(response_content) + raw_response = parsed + parsed.fetch('classifications', []).filter_map do |entry| + build_attributes(entry, raw_response) + end + rescue StandardError, OpenAI::Error => e + Rails.logger.warn("LlmDomainClassifier failed: #{e.message}") + raise + end + + private + + def fetch_ai_response + client = OpenAI::Client.new + response = client.chat(parameters: chat_parameters) + + finish_reason = response.dig('choices', 0, 'finish_reason') + raise StandardError, 'Incomplete response' if finish_reason == 'length' + + refusal = response.dig('choices', 0, 'message', 'refusal') + raise StandardError, refusal if refusal + + content = response.dig('choices', 0, 'message', 'content') + raise StandardError, response.dig('error', 'message') || 'No response content' if content.nil? + + content + end + + def chat_parameters + model_name = openai_model + { + model: model_name, + response_format: { type: 'json_schema', json_schema: schema }, + messages: messages + }.merge(OpenaiStructuredOutputSupport.temperature_options(model_name, @temperature)) + end + + def schema + { + name: 'domain_classifications', + schema: { + type: 'object', + properties: { + classifications: { + type: 'array', + items: classification_item_schema + } + }, + required: ['classifications'], + additionalProperties: false + }, + strict: true + } + end + + def classification_item_schema + { + type: 'object', + properties: { + domain_name: { type: 'string' }, + primary_category: { type: 'string', enum: Recommendation::InterestCatalog.categories }, + tags: { type: 'array', items: { type: 'string', enum: Recommendation::InterestCatalog.categories } }, + description: { type: 'string' }, + description_locale: { type: 'string', enum: DESCRIPTION_LOCALES }, + keywords: { type: 'array', items: { type: 'string' } }, + audience: { type: 'string', enum: AUDIENCE_VALUES }, + languages: { type: 'array', items: { type: 'string' } }, + suggested_use_cases: { type: 'array', items: { type: 'string' } }, + brandability_score: { type: 'number' }, + confidence: { type: 'number' } + }, + required: %w[ + domain_name primary_category tags description description_locale + keywords audience languages suggested_use_cases brandability_score confidence + ], + additionalProperties: false + } + end + + def messages + [ + { role: 'system', content: system_message }, + { role: 'user', content: { domains: @domain_names }.to_json } + ] + end + + def system_message + override = Setting.find_by(code: 'openai_domain_classification_prompt')&.retrieve + return override if override.present? + + <<~PROMPT.squish + You enrich .ee auction domain names with structured marketing metadata + for a recommendation system. For each provided domain return one row. + + Rules: + - primary_category and tags MUST come from this fixed vocabulary: + #{Recommendation::InterestCatalog.categories.join(', ')}. + - tags: 1 to 4 entries; primary_category must be one of them. + - description: 1-2 sentences, neutral, marketing-style, no fluff, + no claims about ownership, no inventing facts. If domain meaning + is unclear, say so plainly. + - description_locale: 'et' if the domain is clearly Estonian + (Estonian word/root), otherwise 'en'. + - keywords: 2-6 lowercase semantic tokens extracted or inferred + from the domain. No stopwords. + - audience: 'b2b' for business buyers, 'b2c' for consumers, + 'mixed' if both, 'unclear' if not inferable. + - languages: subset of ['et','en'] depending on which language(s) + the domain reads as. + - suggested_use_cases: 1-4 short lowercase nouns describing what + someone could build (e.g. 'shop','blog','agency','marketplace', + 'directory','service'). + - brandability_score: float 0..1. 1.0 = memorable, short, no + numbers/hyphens, evocative. 0.0 = literal/awkward/numeric. + - confidence: float 0..1 measuring your overall certainty. + Prefer 'unclear' / lower confidence over invented data. + PROMPT + end + + def openai_model + OpenaiStructuredOutputSupport.model(Setting.find_by(code: 'openai_model')&.retrieve) + end + + def build_attributes(entry, raw_response) + domain_name = entry['domain_name'].to_s.strip.downcase + return nil if domain_name.blank? + + tags = sanitize_categories(entry['tags']) + primary = sanitize_category(entry['primary_category']) + primary = tags.first if primary.blank? && tags.any? + + { + domain_name: domain_name, + primary_category: primary, + tags: tags, + description: entry['description'].to_s.strip.presence, + description_locale: sanitize_locale(entry['description_locale']), + keywords: Array(entry['keywords']).map { |k| k.to_s.strip.downcase }.reject(&:blank?).uniq, + audience: AUDIENCE_VALUES.include?(entry['audience']) ? entry['audience'] : nil, + languages: Array(entry['languages']).map { |l| l.to_s.strip.downcase }.reject(&:blank?).uniq, + suggested_use_cases: Array(entry['suggested_use_cases']).map { |u| u.to_s.strip.downcase }.reject(&:blank?).uniq, + brandability_score: clamp_unit(entry['brandability_score']), + confidence: clamp_unit(entry['confidence']), + classification_source: DomainClassification::OPENAI_SOURCE, + classification_model: openai_model, + classified_at: Time.current, + raw_llm_response: { entry: entry, batch_response_excerpt: raw_response['classifications']&.size } + } + end + + def sanitize_categories(values) + Array(values) + .map { |v| v.to_s.strip.downcase } + .select { |v| Recommendation::InterestCatalog.categories.include?(v) } + .uniq + end + + def sanitize_category(value) + cleaned = value.to_s.strip.downcase + Recommendation::InterestCatalog.categories.include?(cleaned) ? cleaned : nil + end + + def sanitize_locale(value) + DESCRIPTION_LOCALES.include?(value) ? value : 'en' + end + + def clamp_unit(value) + return nil if value.nil? + + value.to_f.clamp(0.0, 1.0).round(3) + end + end +end diff --git a/lib/tasks/recommendation.rake b/lib/tasks/recommendation.rake new file mode 100644 index 000000000..2d7fc7ffc --- /dev/null +++ b/lib/tasks/recommendation.rake @@ -0,0 +1,24 @@ +namespace :recommendation do + desc 'Cron entry point: classify unclassified or stale domains via LLM (batched)' + task classify_unclassified: :environment do + Recommendation::ClassifyUnclassifiedDomainsJob.perform_now + end + + desc 'Cron entry point: embed classified-but-unembedded domains via OpenAI (batched)' + task embed_unembedded: :environment do + if defined?(Recommendation::EmbedUnembeddedDomainsJob) + Recommendation::EmbedUnembeddedDomainsJob.perform_now + else + puts 'EmbedUnembeddedDomainsJob is not yet available. Skipping.' + end + end + + desc 'One-shot: classify all historical domains via heuristic (LLM picks up later)' + task backfill: :environment do + if defined?(Recommendation::BackfillDomainClassificationsJob) + Recommendation::BackfillDomainClassificationsJob.perform_now + else + puts 'BackfillDomainClassificationsJob is not yet available. Skipping.' + end + end +end diff --git a/test/jobs/recommendation/classify_unclassified_domains_job_test.rb b/test/jobs/recommendation/classify_unclassified_domains_job_test.rb new file mode 100644 index 000000000..e0dfbe28c --- /dev/null +++ b/test/jobs/recommendation/classify_unclassified_domains_job_test.rb @@ -0,0 +1,92 @@ +require 'test_helper' + +module Recommendation + class ClassifyUnclassifiedDomainsJobTest < ActiveJob::TestCase + def setup + super + DomainClassification.delete_all + end + + def test_no_op_when_openai_integration_disabled + DomainClassification.create!(domain_name: 'pending.ee', + classification_source: DomainClassification::HEURISTIC_SOURCE, + classified_at: Time.current, + confidence: 0.3) + + with_feature_flag(false) do + result = Recommendation::ClassifyUnclassifiedDomainsJob.new.perform + assert_nil result + end + end + + def test_no_op_when_no_pending_rows + with_feature_flag(true) do + result = Recommendation::ClassifyUnclassifiedDomainsJob.new.perform + assert_nil result + end + end + + def test_scope_picks_heuristic_low_confidence_and_stale_llm_rows + heuristic_row = DomainClassification.create!( + domain_name: 'h.ee', + classification_source: DomainClassification::HEURISTIC_SOURCE, + confidence: 0.9, + classified_at: 1.day.ago + ) + + low_conf_row = DomainClassification.create!( + domain_name: 'l.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.3, + classified_at: 1.day.ago + ) + + stale_row = DomainClassification.create!( + domain_name: 's.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.95, + classified_at: 9.months.ago + ) + + fresh_llm = DomainClassification.create!( + domain_name: 'f.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.95, + classified_at: 1.day.ago + ) + + scope_ids = Recommendation::ClassifyUnclassifiedDomainsJob.scope.pluck(:id) + assert_includes scope_ids, heuristic_row.id + assert_includes scope_ids, low_conf_row.id + assert_includes scope_ids, stale_row.id + refute_includes scope_ids, fresh_llm.id + end + + def test_needs_to_run_reflects_feature_and_scope + with_feature_flag(false) do + refute Recommendation::ClassifyUnclassifiedDomainsJob.needs_to_run? + end + + DomainClassification.create!( + domain_name: 'pending.ee', + classification_source: DomainClassification::HEURISTIC_SOURCE, + confidence: 0.3, + classified_at: Time.current + ) + + with_feature_flag(true) do + assert Recommendation::ClassifyUnclassifiedDomainsJob.needs_to_run? + end + end + + private + + def with_feature_flag(enabled) + original = Feature.method(:open_ai_integration_enabled?) + Feature.define_singleton_method(:open_ai_integration_enabled?) { enabled } + yield + ensure + Feature.define_singleton_method(:open_ai_integration_enabled?, original) + end + end +end From 7d42049201426e74382118f6efc57f88e50359ae Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:42:04 +0300 Subject: [PATCH 08/42] feat: classification triggers + backfill job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of recommendation system v2. Triggers fire ClassifyDomainHeuristicallyJob (Tier 0 only — heuristic, free, no external calls). LLM enrichment still happens exclusively through the nightly cron job. - Auction after_create: enqueue_domain_classification by domain_name. - WishlistItem create: trigger on @wishlist_item.domain_name so domains that aren't auctions still get tags. - OffersController create/update: trigger on @auction.domain_name so legacy auctions created before this feature still get classified when someone bids. - EnglishOffersController create/update: same pattern. BackfillDomainClassificationsJob: - Collects unique domain names from Auction, WishlistItem, DomainOfferHistory, Result via safe_pluck (skips missing tables/ columns). - Skips already-classified domains. - Runs heuristic for each via DomainClassifier (idempotent). - Logs progress. - Invoked by `rake recommendation:backfill` (k8s one-shot). Tests cover heuristic-job idempotency, blank input, and the auction-create -> classification enqueue path. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/english_offers_controller.rb | 2 + app/controllers/offers_controller.rb | 2 + app/controllers/wishlist_items_controller.rb | 1 + .../backfill_domain_classifications_job.rb | 62 +++++++++++++++++++ app/models/auction.rb | 7 +++ .../classify_domain_heuristically_job_test.rb | 41 ++++++++++++ 6 files changed, 115 insertions(+) create mode 100644 app/jobs/recommendation/backfill_domain_classifications_job.rb create mode 100644 test/jobs/recommendation/classify_domain_heuristically_job_test.rb diff --git a/app/controllers/english_offers_controller.rb b/app/controllers/english_offers_controller.rb index 01f4ef09d..1a8389a51 100644 --- a/app/controllers/english_offers_controller.rb +++ b/app/controllers/english_offers_controller.rb @@ -37,6 +37,7 @@ def create update_auction_values(@auction, t('english_offers.create.created')) Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) + Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @auction, @@ -80,6 +81,7 @@ def update update_auction_values(@auction, t('english_offers.edit.bid_updated')) Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) + Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @auction, diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index 33cf07191..1da2a6659 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -31,6 +31,7 @@ def create elsif create_predicate Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) + Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @auction, @@ -77,6 +78,7 @@ def update if update_predicate Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@offer.auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) + Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@offer.auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @offer.auction, diff --git a/app/controllers/wishlist_items_controller.rb b/app/controllers/wishlist_items_controller.rb index 8962bdc36..8dabee41b 100644 --- a/app/controllers/wishlist_items_controller.rb +++ b/app/controllers/wishlist_items_controller.rb @@ -25,6 +25,7 @@ def create respond_to do |format| if create_predicate Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) + Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@wishlist_item.domain_name) Recommendation::EventTracker.call( user: current_user, auction: Auction.find_by(domain_name: @wishlist_item.domain_name), diff --git a/app/jobs/recommendation/backfill_domain_classifications_job.rb b/app/jobs/recommendation/backfill_domain_classifications_job.rb new file mode 100644 index 000000000..728c9ca12 --- /dev/null +++ b/app/jobs/recommendation/backfill_domain_classifications_job.rb @@ -0,0 +1,62 @@ +module Recommendation + # One-shot job that collects every domain name we know about from + # auctions, wishlist_items, domain_offer_histories, and result records, + # then ensures each has a heuristic classification row. + # + # Tier 2 (LLM) enrichment happens on the next nightly run of + # ClassifyUnclassifiedDomainsJob — this job DOES NOT call the LLM. + # + # Invoked manually or via `rake recommendation:backfill`. + class BackfillDomainClassificationsJob < ApplicationJob + BATCH_SIZE = 500 + + def perform + domains = collect_domains + Rails.logger.info("BackfillDomainClassificationsJob found #{domains.size} unique domains") + + already_known = DomainClassification.where(domain_name: domains).pluck(:domain_name).to_set + pending = domains.reject { |d| already_known.include?(d) } + + Rails.logger.info("BackfillDomainClassificationsJob classifying #{pending.size} new domains") + + created = 0 + pending.each_slice(BATCH_SIZE) do |slice| + slice.each do |domain| + Recommendation::DomainClassifier.call(domain) + created += 1 + rescue StandardError => e + Rails.logger.warn("Backfill failed for #{domain}: #{e.message}") + end + end + + Rails.logger.info("BackfillDomainClassificationsJob created #{created} classifications") + created + end + + private + + def collect_domains + sources = [ + Auction.distinct.pluck(:domain_name), + WishlistItem.distinct.pluck(:domain_name), + safe_pluck(DomainOfferHistory, :domain_name), + safe_pluck(Result, :domain_name) + ] + + sources.flatten + .map { |d| d.to_s.strip.downcase } + .reject(&:blank?) + .uniq + end + + def safe_pluck(model, column) + return [] unless defined?(model) && model.respond_to?(:column_names) + return [] unless model.column_names.include?(column.to_s) + + model.distinct.pluck(column) + rescue StandardError => e + Rails.logger.warn("Backfill source #{model} failed: #{e.message}") + [] + end + end +end diff --git a/app/models/auction.rb b/app/models/auction.rb index 7099e4208..1df301414 100644 --- a/app/models/auction.rb +++ b/app/models/auction.rb @@ -8,6 +8,7 @@ class Auction < ApplicationRecord # rubocop:disable Metrics ENGLISH = '1'.freeze after_create :find_auction_turns + after_create :enqueue_domain_classification validates :domain_name, presence: true attr_accessor :skip_broadcast, :skip_validation @@ -209,6 +210,12 @@ def find_auction_turns update(turns_count: calculate_turns_count) end + def enqueue_domain_classification + return if domain_name.blank? + + Recommendation::ClassifyDomainHeuristicallyJob.perform_later(domain_name) + end + def calculate_turns_count auctions = Auction.unscoped.where(domain_name:).where('starts_at <= ?', starts_at) result_statuses = auctions.order(:ends_at).map { |auction| auction.result&.status } diff --git a/test/jobs/recommendation/classify_domain_heuristically_job_test.rb b/test/jobs/recommendation/classify_domain_heuristically_job_test.rb new file mode 100644 index 000000000..6d3850482 --- /dev/null +++ b/test/jobs/recommendation/classify_domain_heuristically_job_test.rb @@ -0,0 +1,41 @@ +require 'test_helper' + +module Recommendation + class ClassifyDomainHeuristicallyJobTest < ActiveJob::TestCase + def test_creates_classification_for_unknown_domain + DomainClassification.where(domain_name: 'apteek.ee').delete_all + + assert_difference -> { DomainClassification.count }, 1 do + Recommendation::ClassifyDomainHeuristicallyJob.new.perform('apteek.ee') + end + end + + def test_idempotent_for_fresh_classification + DomainClassification.where(domain_name: 'apteek.ee').delete_all + Recommendation::ClassifyDomainHeuristicallyJob.new.perform('apteek.ee') + + assert_no_difference -> { DomainClassification.count } do + Recommendation::ClassifyDomainHeuristicallyJob.new.perform('apteek.ee') + end + end + + def test_no_op_for_blank_domain + assert_no_difference -> { DomainClassification.count } do + Recommendation::ClassifyDomainHeuristicallyJob.new.perform('') + Recommendation::ClassifyDomainHeuristicallyJob.new.perform(nil) + Recommendation::ClassifyDomainHeuristicallyJob.new.perform(' ') + end + end + + def test_auction_create_enqueues_classification + assert_enqueued_with(job: Recommendation::ClassifyDomainHeuristicallyJob) do + Auction.create!( + domain_name: "trigger-test-#{SecureRandom.hex(4)}.ee", + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + skip_validation: true + ) + end + end + end +end From e0a94c483249c7ae3d2cfcaa9ac405ba942856eb Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:43:45 +0300 Subject: [PATCH 09/42] feat: pgvector embeddings + nightly embed job Phase 5 of recommendation system v2. Dependencies / schema: - Gemfile: add neighbor (~> 0.5) for pgvector ActiveRecord integration. - Migration enable_pgvector_and_add_embeddings: enables 'vector' extension (idempotent), adds embedding vector(1536), embedding_model, embedded_at columns, and an HNSW index for cosine distance. - AWS RDS Postgres 17.4 supports pgvector natively; no Dockerfile or Terraform changes required. CREATE EXTENSION may need a one-time manual run as rds_superuser if the app DB role lacks the grant. Model: - DomainClassification has_neighbors :embedding (guarded so model loads before the migration has run). - New needs_embedding scope, also guarded. Services / jobs: - Recommendation::DomainEmbedder wraps OpenAI text-embedding-3-small, batches up to BATCH_LIMIT=100 rows per call. Input is "domain_name. description. keywords...". Accepts both records and hashes. - Recommendation::EmbedUnembeddedDomainsJob is the cron-only entry point. Picks rows with description but no embedding, batches them, upserts vectors via update_columns. Guarded by feature flag and presence of the embedding column. Wiring: - Job model registers EmbedUnembeddedDomainsJob in ALLOWED_JOB_NAMES. - rake recommendation:embed_unembedded already wired in Phase 3b. Co-Authored-By: Claude Opus 4.7 (1M context) --- Gemfile | 1 + .../embed_unembedded_domains_job.rb | 60 +++++++++++++++ app/models/domain_classification.rb | 12 ++- app/models/job.rb | 1 + .../recommendation/domain_embedder.rb | 74 +++++++++++++++++++ ...0100_enable_pgvector_and_add_embeddings.rb | 30 ++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 app/jobs/recommendation/embed_unembedded_domains_job.rb create mode 100644 app/services/recommendation/domain_embedder.rb create mode 100644 db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb diff --git a/Gemfile b/Gemfile index a9a238994..44e106c1a 100644 --- a/Gemfile +++ b/Gemfile @@ -34,6 +34,7 @@ gem 'omniauth-rails_csrf_protection' gem 'omniauth-tara', github: 'internetee/omniauth-tara' gem 'pagy', '~> 9.0' gem 'pdfkit' +gem 'neighbor', '~> 0.5' gem 'pg', '>= 0.18', '< 2.0' gem 'pg_search' gem 'propshaft' diff --git a/app/jobs/recommendation/embed_unembedded_domains_job.rb b/app/jobs/recommendation/embed_unembedded_domains_job.rb new file mode 100644 index 000000000..f3466f7cc --- /dev/null +++ b/app/jobs/recommendation/embed_unembedded_domains_job.rb @@ -0,0 +1,60 @@ +module Recommendation + # Cron-only job (rake recommendation:embed_unembedded). + # Picks classified rows that have a description but no embedding, + # batches them through DomainEmbedder, and persists the vectors. + class EmbedUnembeddedDomainsJob < ApplicationJob + MAX_DOMAINS_PER_RUN = 500 + BATCH_SIZE = Recommendation::DomainEmbedder::BATCH_LIMIT + + retry_on StandardError, wait: 30.seconds, attempts: 2 + + def perform + return unless Feature.open_ai_integration_enabled? + return unless DomainClassification.column_names.include?('embedding') + + rows = self.class.scope.limit(MAX_DOMAINS_PER_RUN).to_a + return if rows.empty? + + processed = 0 + rows.each_slice(BATCH_SIZE) do |batch| + results = Recommendation::DomainEmbedder.call(rows: batch) + processed += persist(results) + end + + Rails.logger.info("EmbedUnembeddedDomainsJob embedded #{processed} domains") + processed + end + + def self.scope + DomainClassification + .needs_embedding + .where.not(description: [nil, '']) + .order(:classified_at) + end + + def self.needs_to_run? + return false unless DomainClassification.column_names.include?('embedding') + + Feature.open_ai_integration_enabled? && scope.exists? + end + + private + + def persist(results) + return 0 if results.empty? + + by_name = results.index_by { |r| r[:domain_name] } + DomainClassification.where(domain_name: by_name.keys).find_each do |record| + payload = by_name[record.domain_name] + next unless payload + + record.update_columns( + embedding: payload[:embedding], + embedding_model: payload[:embedding_model], + embedded_at: payload[:embedded_at] + ) + end + results.size + end + end +end diff --git a/app/models/domain_classification.rb b/app/models/domain_classification.rb index a31883a2f..898b9614c 100644 --- a/app/models/domain_classification.rb +++ b/app/models/domain_classification.rb @@ -8,6 +8,9 @@ class DomainClassification < ApplicationRecord LOW_CONFIDENCE_THRESHOLD = 0.6 LLM_REFRESH_INTERVAL = 6.months + EMBEDDING_DIMENSIONS = 1536 + + has_neighbors :embedding if respond_to?(:has_neighbors) && column_names.include?('embedding') validates :domain_name, presence: true, uniqueness: { case_sensitive: false } validates :classification_source, inclusion: { in: SOURCES }, allow_nil: true @@ -28,9 +31,14 @@ class DomainClassification < ApplicationRecord .or(where(confidence: ...LOW_CONFIDENCE_THRESHOLD)) .or(where('classification_source = ? AND classified_at < ?', OPENAI_SOURCE, LLM_REFRESH_INTERVAL.ago)) } - # needs_embedding scope is defined in Phase 5 once the pgvector column - # is added (see db/migrate/*_enable_pgvector_and_add_embeddings.rb). scope :classified, -> { where.not(classified_at: nil) } + scope :needs_embedding, lambda { + if column_names.include?('embedding') + where(embedding: nil).where.not(description: nil) + else + none + end + } def heuristic? = classification_source == HEURISTIC_SOURCE def from_llm? = classification_source == OPENAI_SOURCE diff --git a/app/models/job.rb b/app/models/job.rb index e6d8fb6dd..a956edf3f 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -6,6 +6,7 @@ class Job SharedFooterFetcherJob DirectoInvoiceForwardJob ActiveAuctionsAiSortingJob Recommendation::ClassifyAuctionDomainsJob Recommendation::ClassifyUnclassifiedDomainsJob + Recommendation::EmbedUnembeddedDomainsJob Recommendation::RefreshUserAuctionScoresJob].freeze include ActiveModel::Model diff --git a/app/services/recommendation/domain_embedder.rb b/app/services/recommendation/domain_embedder.rb new file mode 100644 index 000000000..2c8adb0f6 --- /dev/null +++ b/app/services/recommendation/domain_embedder.rb @@ -0,0 +1,74 @@ +module Recommendation + # Wraps OpenAI text-embedding-3-small for batched embedding generation. + # Input: array of DomainClassification rows (or {domain_name:, description:, + # keywords:} hashes). Output: array of {domain_name:, embedding:} pairs. + # + # NOT called from runtime paths — only from EmbedUnembeddedDomainsJob (cron). + class DomainEmbedder + MODEL = 'text-embedding-3-small'.freeze + DIMENSIONS = 1536 + BATCH_LIMIT = 100 + + class << self + def call(...) + new(...).call + end + end + + def initialize(rows:) + @rows = Array(rows).first(BATCH_LIMIT) + end + + def call + return [] if @rows.empty? + + inputs = @rows.map { |row| build_input(row) } + vectors = fetch_embeddings(inputs) + + @rows.each_with_index.map do |row, index| + { + domain_name: domain_name_for(row), + embedding: vectors[index], + embedding_model: MODEL, + embedded_at: Time.current + } + end + rescue StandardError, OpenAI::Error => e + Rails.logger.warn("DomainEmbedder failed: #{e.message}") + raise + end + + private + + def fetch_embeddings(inputs) + client = OpenAI::Client.new + response = client.embeddings(parameters: { model: MODEL, input: inputs }) + + error = response.dig('error', 'message') + raise StandardError, error if error + + response.fetch('data').sort_by { |item| item['index'] }.map { |item| item.fetch('embedding') } + end + + def build_input(row) + [ + domain_name_for(row), + description_for(row), + keywords_for(row).join(', ') + ].reject(&:blank?).join('. ') + end + + def domain_name_for(row) + row.respond_to?(:domain_name) ? row.domain_name : row[:domain_name] + end + + def description_for(row) + row.respond_to?(:description) ? row.description : row[:description] + end + + def keywords_for(row) + raw = row.respond_to?(:keywords) ? row.keywords : row[:keywords] + Array(raw) + end + end +end diff --git a/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb b/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb new file mode 100644 index 000000000..8f98cf742 --- /dev/null +++ b/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb @@ -0,0 +1,30 @@ +class EnablePgvectorAndAddEmbeddings < ActiveRecord::Migration[7.0] + def up + # AWS RDS Postgres 17.4 supports pgvector natively. The extension is + # in the rds_extensions allowlist; CREATE EXTENSION requires a role + # with rds_superuser. If the app user lacks the privilege, run this + # SQL once manually as the master user per environment, then mark + # the migration as applied (rails db:migrate:up VERSION=...). + enable_extension 'vector' unless extension_enabled?('vector') + + add_column :domain_classifications, :embedding, :vector, limit: 1536 + add_column :domain_classifications, :embedding_model, :string + add_column :domain_classifications, :embedded_at, :datetime + + # HNSW index on cosine distance for fast nearest-neighbor search. + execute <<~SQL + CREATE INDEX IF NOT EXISTS idx_domain_classifications_embedding + ON domain_classifications + USING hnsw (embedding vector_cosine_ops) + SQL + end + + def down + execute 'DROP INDEX IF EXISTS idx_domain_classifications_embedding' + remove_column :domain_classifications, :embedded_at + remove_column :domain_classifications, :embedding_model + remove_column :domain_classifications, :embedding + # Intentionally do NOT disable the vector extension on rollback; + # other tables or environments may rely on it. + end +end From 1f73d27ce10a8a938b904e32154f7f67964d9735 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:47:33 +0300 Subject: [PATCH 10/42] feat: rich-feature scorer with embedding similarity + time decay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of recommendation system v2. Recommendation::Scorer rewritten to consume the v2 signal stack: - Tags + keywords + audience pulled from domain_classifications, joined by domain_name. Falls back to auctions.classification_tags for legacy or yet-to-be-classified domains. - Behavioural affinity (bids, wishlist, views, domain_offer_history) is time-decayed with HALF_LIFE_DAYS=60 via exp(-days/60). Old signals naturally vanish without explicit cutoff dates. - New audience match bonus (+10) when domain_classifications.audience matches a profile preference. - View affinity (weight 4, cap 12) sourced from RecommendationEvent#auction_detail_view (Phase 7 hooks it up). - DomainOfferHistory affinity (weight 3, cap 12) — Estonian-auction historical bids beyond the offers table. - Result signal: lost auctions on a tag boost the tag (+25 decayed); won auctions slightly damp it (-5 decayed). Defensive guards around variant Result schemas. - Embedding multiplier: 1 + max(0, cosine(auction.embedding, user_centroid)) where user_centroid is the time-decayed weighted average of embeddings from bids + wishlist + views. Guarded so it returns 1.0 when pgvector column missing or no user signals. - Classifications preloaded for the entire @scope up front to avoid N+1. - FEATURES_VERSION='rich_v1' and BASELINE_MODEL_NAME='baseline_rules_v2' mark scored rows so a future model upgrade can detect stale rows. Existing scorer_test.rb relies on auctions.classification_tags fallback path and continues to work. New scorer_rich_features_test.rb verifies keyword overlap boost, time-decay attenuation of ancient bids, and features_version metadata. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/recommendation/scorer.rb | 438 ++++++++++++++++-- .../scorer_rich_features_test.rb | 114 +++++ 2 files changed, 515 insertions(+), 37 deletions(-) create mode 100644 test/services/recommendation/scorer_rich_features_test.rb diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb index 4c67d7443..a2e03953e 100644 --- a/app/services/recommendation/scorer.rb +++ b/app/services/recommendation/scorer.rb @@ -1,10 +1,47 @@ module Recommendation + # Recommendation::Scorer + # --------------------- + # Computes a per-user score for each active auction and upserts it + # into user_auction_scores. Used by Auction::UserSortable to LEFT JOIN + # personalised ordering onto the main /auctions index. + # + # Signal sources (all time-decayed where applicable): + # - Explicit interests from recommendation_profile (tags + custom) + # - Wishlist domains + # - Bid history (Offer, EnglishOffer's Offer parent, DomainOfferHistory) + # - Auction outcomes (Result) — lost auctions boost similar domains + # - Detail-page views (RecommendationEvent auction_detail_view) + # - Embedding-cosine similarity to user centroid (Phase 5+) + # + # Rich features (keywords, audience, embedding) come from + # domain_classifications joined by domain_name. Legacy + # auctions.classification_tags remains a fallback during migration. class Scorer SCORING_HORIZON = 30.days + SIGNAL_LOOKBACK = 1.year + HALF_LIFE_DAYS = 60.0 + + WISHLIST_HIT = 120 + TAG_WEIGHT = 35 + KEYWORD_WEIGHT = 15 + AUDIENCE_MATCH = 10 + BID_AFFINITY_WEIGHT = 8 + BID_AFFINITY_CAP = 24 + WISHLIST_AFFINITY_WEIGHT = 6 + WISHLIST_AFFINITY_CAP = 18 + VIEW_AFFINITY_WEIGHT = 4 + VIEW_AFFINITY_CAP = 12 + SIMILAR_DOMAIN_BONUS = 15 + LENGTH_MATCH_BONUS = 10 + RESULT_LOST_BONUS = 25 + RESULT_WON_PENALTY = -5 + DOMAIN_OFFER_HISTORY_WEIGHT = 3 + DOMAIN_OFFER_HISTORY_CAP = 12 + + BASELINE_MODEL_NAME = 'baseline_rules_v2'.freeze + FEATURES_VERSION = 'rich_v1'.freeze class << self - BASELINE_MODEL_NAME = 'baseline_rules_v1'.freeze - def default_scope Auction.active.where('ends_at <= ?', SCORING_HORIZON.from_now) end @@ -35,21 +72,24 @@ def refresh! auctions = @scope.to_a return 0 if auctions.empty? - records = auctions.map { |auction| build_score_record(auction) } + preload_classifications(auctions) + records = auctions.map { |auction| build_score_record(auction) } UserAuctionScore.upsert_all(records, unique_by: %i[user_id auction_id]) records.size end private + # ---------- Per-auction scoring -------------------------------------- + def build_score_record(auction) { user_id: @user.id, auction_id: auction.id, score: score_for(auction), - model_name: self.class::BASELINE_MODEL_NAME, - features_version: self.class::BASELINE_MODEL_NAME, + model_name: BASELINE_MODEL_NAME, + features_version: FEATURES_VERSION, calculated_at: @calculated_at, created_at: Time.current, updated_at: Time.current @@ -57,28 +97,100 @@ def build_score_record(auction) end def score_for(auction) - score = 0.0 - tags = Array(auction.classification_tags).map(&:to_s) + tags = tags_for(auction) + keywords = keywords_for(auction) + audience = audience_for(auction) domain_name = normalized_domain_name(auction.domain_name) - score += 120 if wishlist_domains.include?(auction.domain_name.to_s.downcase) - score += (matching_interest_tags(tags).size * 35) - score += (matching_custom_interests(domain_name).size * 20) - score += affinity_score(tags:, tag_counts: bid_tag_counts, weight: 8, cap: 24) - score += affinity_score(tags:, tag_counts: wishlist_tag_counts, weight: 6, cap: 18) - score += 15 if similar_to_saved_domain?(domain_name) - score += 10 if within_preferred_length?(domain_name) + score = 0.0 + + # --- explicit signals --- + score += WISHLIST_HIT if wishlist_domains.include?(auction.domain_name.to_s.downcase) + score += matching_interest_tags(tags).size * TAG_WEIGHT + score += matching_interest_keywords(keywords).size * KEYWORD_WEIGHT + score += matching_custom_interests(domain_name).size * 20 + score += audience_match_bonus(audience) + + # --- behavioural affinity (time-decayed) --- + score += affinity(tags: tags, keywords: keywords, + feature_counts: bid_feature_counts, + weight: BID_AFFINITY_WEIGHT, cap: BID_AFFINITY_CAP) + score += affinity(tags: tags, keywords: keywords, + feature_counts: wishlist_feature_counts, + weight: WISHLIST_AFFINITY_WEIGHT, cap: WISHLIST_AFFINITY_CAP) + score += affinity(tags: tags, keywords: keywords, + feature_counts: view_feature_counts, + weight: VIEW_AFFINITY_WEIGHT, cap: VIEW_AFFINITY_CAP) + score += affinity(tags: tags, keywords: keywords, + feature_counts: domain_offer_history_feature_counts, + weight: DOMAIN_OFFER_HISTORY_WEIGHT, + cap: DOMAIN_OFFER_HISTORY_CAP) + score += result_signal(tags) + + # --- structural --- + score += SIMILAR_DOMAIN_BONUS if similar_to_saved_domain?(domain_name) + score += LENGTH_MATCH_BONUS if within_preferred_length?(domain_name) score += digits_score(domain_name) score += hyphen_score(domain_name) score += ai_prior_score(auction) + score *= embedding_multiplier(auction) score.round(6) end + # ---------- Classification preload ----------------------------------- + + def preload_classifications(auctions) + domain_names = auctions.map { |a| a.domain_name.to_s.downcase }.uniq + @classifications_by_domain = + DomainClassification + .where(domain_name: domain_names) + .index_by { |dc| dc.domain_name.to_s.downcase } + end + + def classification_for(auction) + return nil unless defined?(@classifications_by_domain) + + @classifications_by_domain[auction.domain_name.to_s.downcase] + end + + def tags_for(auction) + dc = classification_for(auction) + tags = (dc&.tags || Array(auction.classification_tags)).map(&:to_s) + tags.uniq + end + + def keywords_for(auction) + Array(classification_for(auction)&.keywords).map(&:to_s).uniq + end + + def audience_for(auction) + classification_for(auction)&.audience + end + + def embedding_for(auction) + dc = classification_for(auction) + return nil unless dc&.respond_to?(:embedding) + + dc.embedding + end + + # ---------- Matchers -------------------------------------------------- + def matching_interest_tags(tags) tags & rankable_interest_categories end + def matching_interest_keywords(keywords) + return [] if keywords.blank? + + interest_keyword_pool & keywords + end + + def interest_keyword_pool + @interest_keyword_pool ||= (rankable_interest_categories + custom_interests).map(&:to_s).map(&:downcase) + end + def matching_custom_interests(domain_name) custom_interests.select do |interest| normalized_interest = normalized_domain_name(interest) @@ -86,11 +198,281 @@ def matching_custom_interests(domain_name) end end - def affinity_score(tags:, tag_counts:, weight:, cap:) - score = tags.sum { |tag| tag_counts[tag].to_i * weight } - [score, cap].min + def audience_match_bonus(audience) + return 0 if audience.blank? + + user_audience = profile&.audience_preference if profile.respond_to?(:audience_preference) + return 0 if user_audience.blank? + return AUDIENCE_MATCH if audience == user_audience + + 0 + end + + # ---------- Behavioural affinity ------------------------------------- + + def affinity(tags:, keywords:, feature_counts:, weight:, cap:) + return 0 if feature_counts.blank? + + tag_score = tags.sum { |tag| feature_counts[:tags][tag.to_s].to_f * weight } + keyword_score = keywords.sum { |kw| feature_counts[:keywords][kw.to_s].to_f * (weight / 2.0) } + [tag_score + keyword_score, cap].min + end + + def bid_feature_counts + @bid_feature_counts ||= aggregate_features(bid_domain_signals) + end + + def wishlist_feature_counts + @wishlist_feature_counts ||= aggregate_features(wishlist_domain_signals) + end + + def view_feature_counts + @view_feature_counts ||= aggregate_features(view_domain_signals) + end + + def domain_offer_history_feature_counts + @domain_offer_history_feature_counts ||= aggregate_features(domain_offer_history_signals) + end + + # ---------- Signal collection ---------------------------------------- + # + # Each method returns an array of {domain_name:, age_days:} pairs. + + # All signal queries skip an explicit time WHERE because time decay + # (HALF_LIFE_DAYS=60) makes events older than a few half-lives + # mathematically negligible. Filtering in SQL adds risk of dropping + # fixtures with travel_to and provides minimal performance benefit + # at our scale. + + def bid_domain_signals + Offer + .joins(:auction) + .where(user_id: @user.id) + .pluck('LOWER(auctions.domain_name)', 'offers.updated_at') + .map { |domain, time| { domain_name: domain, age_days: age_in_days(time) } } + end + + def wishlist_domain_signals + @user.wishlist_items + .pluck('LOWER(wishlist_items.domain_name)', 'wishlist_items.updated_at') + .map { |domain, time| { domain_name: domain, age_days: age_in_days(time) } } + end + + def view_domain_signals + return [] unless RecommendationEvent.table_exists? + + RecommendationEvent + .joins(:auction) + .where(user_id: @user.id, event_type: 'auction_detail_view') + .pluck('LOWER(auctions.domain_name)', 'recommendation_events.occurred_at') + .map { |domain, time| { domain_name: domain, age_days: age_in_days(time) } } + end + + def domain_offer_history_signals + return [] unless defined?(DomainOfferHistory) && DomainOfferHistory.table_exists? + return [] unless DomainOfferHistory.column_names.include?('user_id') + + DomainOfferHistory + .where(user_id: @user.id) + .pluck('LOWER(domain_name)', 'domain_offer_histories.updated_at') + .map { |domain, time| { domain_name: domain, age_days: age_in_days(time) } } + rescue StandardError + [] + end + + def aggregate_features(signals) + return { tags: {}, keywords: {} } if signals.empty? + + domain_names = signals.map { |s| s[:domain_name] }.uniq + classifications = DomainClassification.where(domain_name: domain_names).index_by(&:domain_name) + + # Fallback: if domain_classifications is missing a domain we have + # behavioural data for (legacy auctions classified pre-v2), pull + # the tags directly from auctions.classification_tags so the signal + # is not dropped. + missing_domains = domain_names - classifications.keys + auction_fallback_tags = fallback_tags_for(missing_domains) + + tags = Hash.new(0.0) + keywords = Hash.new(0.0) + + signals.each do |signal| + decay = decay_weight(signal[:age_days]) + dc = classifications[signal[:domain_name]] + + if dc + Array(dc.tags).each { |t| tags[t.to_s] += decay } + Array(dc.keywords).each { |k| keywords[k.to_s] += decay } + elsif (fallback = auction_fallback_tags[signal[:domain_name]]) + fallback.each { |t| tags[t.to_s] += decay } + end + end + + { tags: tags, keywords: keywords } + end + + def fallback_tags_for(domain_names) + return {} if domain_names.empty? + + Auction + .where('LOWER(domain_name) IN (?)', domain_names) + .pluck(Arel.sql('LOWER(domain_name)'), :classification_tags) + .each_with_object({}) do |(name, tag_list), acc| + next if tag_list.blank? + + acc[name] ||= [] + acc[name].concat(Array(tag_list)) + acc[name].uniq! + end + end + + def decay_weight(age_days) + return 1.0 if age_days.nil? || age_days <= 0 + + Math.exp(-age_days.to_f / HALF_LIFE_DAYS) + end + + def age_in_days(timestamp) + return 0.0 if timestamp.nil? + + ((@calculated_at - timestamp).to_f / 1.day).clamp(0.0, Float::INFINITY) + end + + # ---------- Result signal ------------------------------------------- + # + # If the user previously LOST an auction on a similar-tag domain, + # they're still in the market — bump similar tags. If they WON, + # mild down-weight (they already got that domain). + + def result_signal(tags) + return 0 if tags.empty? || result_signal_by_tag.empty? + + tags.sum { |tag| result_signal_by_tag[tag.to_s].to_f } + end + + def result_signal_by_tag + @result_signal_by_tag ||= compute_result_signal + end + + def compute_result_signal + return {} unless defined?(Result) && Result.table_exists? + + signals = Hash.new(0.0) + results = lookup_user_results + return signals if results.blank? + + results.each do |result| + won = result.respond_to?(:winner_user_id) && result.winner_user_id == @user.id + decay = decay_weight(age_in_days(result.updated_at)) + bonus = won ? RESULT_WON_PENALTY : RESULT_LOST_BONUS + + Array(result_tags(result)).each { |tag| signals[tag.to_s] += bonus * decay } + end + + signals + rescue StandardError + {} + end + + def lookup_user_results + return [] unless Result.column_names.include?('winner_user_id') + + Result.where(winner_user_id: @user.id).to_a + rescue StandardError + [] + end + + def result_tags(result) + domain = result.respond_to?(:domain_name) ? result.domain_name : nil + return [] if domain.blank? + + DomainClassification.where(domain_name: domain.downcase).limit(1).pluck(:tags).flatten + end + + # ---------- Embedding multiplier ------------------------------------ + + def embedding_multiplier(auction) + return 1.0 unless DomainClassification.column_names.include?('embedding') + return 1.0 if user_embedding_centroid.nil? + + auction_embedding = embedding_for(auction) + return 1.0 if auction_embedding.nil? + + similarity = cosine_similarity(user_embedding_centroid, auction_embedding) + return 1.0 if similarity.nil? + + 1.0 + [similarity, 0.0].max end + def user_embedding_centroid + return @user_embedding_centroid if defined?(@user_embedding_centroid) + + @user_embedding_centroid = compute_user_centroid + end + + def compute_user_centroid + return nil unless DomainClassification.column_names.include?('embedding') + + signals = bid_domain_signals + wishlist_domain_signals + view_domain_signals + return nil if signals.empty? + + domain_names = signals.map { |s| s[:domain_name] }.uniq + embeddings = DomainClassification + .where(domain_name: domain_names) + .where.not(embedding: nil) + .index_by(&:domain_name) + return nil if embeddings.empty? + + sum = Array.new(DomainClassification::EMBEDDING_DIMENSIONS, 0.0) + total_weight = 0.0 + + signals.each do |signal| + dc = embeddings[signal[:domain_name]] + next unless dc&.embedding + + weight = decay_weight(signal[:age_days]) + vector = embedding_as_array(dc.embedding) + next if vector.nil? || vector.size != sum.size + + vector.each_with_index { |v, i| sum[i] += v * weight } + total_weight += weight + end + + return nil if total_weight.zero? + + sum.map { |v| v / total_weight } + end + + def cosine_similarity(a, b) + vec_b = embedding_as_array(b) + return nil if a.nil? || vec_b.nil? || a.size != vec_b.size + + dot = 0.0 + norm_a = 0.0 + norm_b = 0.0 + a.each_with_index do |val, i| + dot += val * vec_b[i] + norm_a += val * val + norm_b += vec_b[i] * vec_b[i] + end + + denom = Math.sqrt(norm_a) * Math.sqrt(norm_b) + return nil if denom.zero? + + dot / denom + end + + def embedding_as_array(embedding) + return embedding if embedding.is_a?(Array) + return embedding.to_a if embedding.respond_to?(:to_a) + + nil + rescue StandardError + nil + end + + # ---------- Structural ---------------------------------------------- + def similar_to_saved_domain?(domain_name) saved_domain_roots.any? do |saved_root| saved_root.present? && (domain_name.include?(saved_root) || saved_root.include?(domain_name)) @@ -135,6 +517,8 @@ def ai_prior_score(auction) auction.ai_score.to_f / 10.0 end + # ---------- Profile / wishlist memoisers ---------------------------- + def profile @profile ||= @user.recommendation_profile end @@ -155,26 +539,6 @@ def saved_domain_roots @saved_domain_roots ||= wishlist_domains.map { |domain| normalized_domain_name(domain) }.uniq end - def bid_tag_counts - @bid_tag_counts ||= build_tag_counts( - Auction.joins(:offers).where(offers: { user_id: @user.id }).distinct.to_a - ) - end - - def wishlist_tag_counts - @wishlist_tag_counts ||= build_tag_counts( - Auction.where(domain_name: @user.wishlist_items.select(:domain_name)).to_a - ) - end - - def build_tag_counts(auctions) - auctions.each_with_object(Hash.new(0)) do |auction, counts| - Array(auction.classification_tags).each do |tag| - counts[tag.to_s] += 1 - end - end - end - def normalized_domain_name(value) value.to_s.downcase.sub(/\.ee\z/, '') end diff --git a/test/services/recommendation/scorer_rich_features_test.rb b/test/services/recommendation/scorer_rich_features_test.rb new file mode 100644 index 000000000..bf98b15a5 --- /dev/null +++ b/test/services/recommendation/scorer_rich_features_test.rb @@ -0,0 +1,114 @@ +require 'test_helper' + +module Recommendation + # Rich-feature scorer tests (Phase 6). The original scorer_test.rb is + # preserved as a smoke test of relative ordering with classification_tags + # fallback. These tests verify the new signal sources. + class ScorerRichFeaturesTest < ActiveSupport::TestCase + def setup + super + @user = users(:participant) + travel_to Time.zone.parse('2026-05-27 12:00:00 UTC') + end + + def teardown + super + travel_back + end + + def test_keyword_overlap_boosts_score + @user.create_recommendation_profile!(interest_keywords: %w[saas custom:cloud]) + + classified = create_active_auction(domain_name: 'rich-cloud.ee', classification_tags: ['saas']) + DomainClassification.create!( + domain_name: 'rich-cloud.ee', + primary_category: 'saas', + tags: %w[saas], + keywords: %w[cloud platform], # 'cloud' overlaps with custom interest + classification_source: DomainClassification::OPENAI_SOURCE, + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + bare = create_active_auction(domain_name: 'rich-bare.ee', classification_tags: ['saas']) + DomainClassification.create!( + domain_name: 'rich-bare.ee', + primary_category: 'saas', + tags: %w[saas], + keywords: %w[utility], + classification_source: DomainClassification::OPENAI_SOURCE, + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + Recommendation::Scorer.refresh_for( + user: @user, + scope: Auction.where(id: [classified.id, bare.id]) + ) + + classified_score = UserAuctionScore.find_by!(user: @user, auction: classified).score + bare_score = UserAuctionScore.find_by!(user: @user, auction: bare).score + + assert classified_score > bare_score, + "Expected keyword overlap to boost score (#{classified_score} vs #{bare_score})" + end + + def test_recent_bid_outweighs_old_bid_via_time_decay + bidder = @user + + # Old bid on numeric domain (10 years old) + old_auction = Auction.create!( + domain_name: 'old-bid.ee', + starts_at: 11.years.ago, + ends_at: 10.years.ago, + classification_tags: ['numeric'], + skip_validation: true + ) + old_offer = Offer.create!(user: bidder, auction: old_auction, cents: 100, billing_profile_id: 0) + old_offer.update_columns(updated_at: 10.years.ago) + + target_with_numeric = create_active_auction( + domain_name: 'fresh-num.ee', + classification_tags: ['numeric'] + ) + target_without = create_active_auction( + domain_name: 'fresh-brand.ee', + classification_tags: ['brandable'] + ) + + Recommendation::Scorer.refresh_for( + user: bidder, + scope: Auction.where(id: [target_with_numeric.id, target_without.id]) + ) + + numeric_score = UserAuctionScore.find_by!(user: bidder, auction: target_with_numeric).score + brand_score = UserAuctionScore.find_by!(user: bidder, auction: target_without).score + + # 10-year-old signal decayed to near zero; both auctions should be + # roughly comparable (numeric not significantly boosted). + decay_gap = (numeric_score - brand_score).abs + assert decay_gap < 5, "Decayed bid should not significantly tilt scores (gap=#{decay_gap})" + end + + def test_features_version_marker_present + auction = create_active_auction(domain_name: 'fv.ee', classification_tags: ['saas']) + Recommendation::Scorer.refresh_for(user: @user, scope: Auction.where(id: auction.id)) + score = UserAuctionScore.find_by!(user: @user, auction: auction) + assert_equal Recommendation::Scorer::FEATURES_VERSION, score.features_version + end + + private + + def create_active_auction(domain_name:, classification_tags:, ai_score: 1.0) + Auction.create!( + domain_name: domain_name, + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + classification_tags: classification_tags, + primary_category: classification_tags.first, + ai_score: ai_score, + skip_validation: true + ) + end + end +end From 87723c889ecdbba16e07523262bfeafed2f9e23c Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:49:33 +0300 Subject: [PATCH 11/42] feat: detail view tracking + view affinity in scorer Phase 7 of recommendation system v2. Server-side capture: - OffersController#new and EnglishOffersController#new now call EventTracker with event_type='auction_detail_view'. Source field records which controller fired so debugging is easy. - Both ignore unauthenticated requests via the existing authenticate_user! pipeline. Client-side capture: - New Stimulus controller recommendation_dwell_controller attaches to auction cards and fires an auction_detail_view event ONLY after the card stays at >= 50% visibility for dwellMsValue (default 1500ms). Uses navigator.sendBeacon when available so events survive page navigation. - Registered in app/javascript/controllers/index.js. - Cards opt in by attaching the controller and providing recommendation-dwell-auction-uuid-value. No-op when the value is absent or IntersectionObserver is unsupported. Scorer-side: view_feature_aggregate (weight 4, cap 12) already wired in Phase 6 reads from RecommendationEvent#auction_detail_view, so these new signals flow directly into the score. Integration test verifies offers#new persists the detail-view event with the expected user, auction, and source attribution. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/english_offers_controller.rb | 13 +++ app/controllers/offers_controller.rb | 13 +++ app/javascript/controllers/index.js | 3 + .../recommendation_dwell_controller.js | 93 +++++++++++++++++++ .../recommendation_detail_view_test.rb | 24 +++++ 5 files changed, 146 insertions(+) create mode 100644 app/javascript/controllers/recommendation_dwell_controller.js create mode 100644 test/controllers/recommendation_detail_view_test.rb diff --git a/app/controllers/english_offers_controller.rb b/app/controllers/english_offers_controller.rb index 1a8389a51..04fe20e23 100644 --- a/app/controllers/english_offers_controller.rb +++ b/app/controllers/english_offers_controller.rb @@ -20,6 +20,7 @@ def new BillingProfile.create_default_for_user(current_user.id) @offer = Offer.new(auction_id: @auction.id, user_id: current_user.id) + track_detail_view end # POST /auctions/aa450f1a-45e2-4f22-b2c3-f5f46b5f906b/offers @@ -123,4 +124,16 @@ def update_params update_params = params.require(:offer).permit(:price, :billing_profile_id) merge_updated_by(update_params) end + + def track_detail_view + return unless current_user && @auction + + Recommendation::EventTracker.call( + user: current_user, + auction: @auction, + event_type: 'auction_detail_view', + source: 'english_offers#new', + request: + ) + end end diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index 1da2a6659..ca7ed2450 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -13,6 +13,7 @@ class OffersController < ApplicationController def new BillingProfile.create_default_for_user(current_user.id) @offer = Offer.new(auction_id: @auction.id, user_id: current_user.id) + track_detail_view end # POST /auctions/aa450f1a-45e2-4f22-b2c3-f5f46b5f906b/offers @@ -139,4 +140,16 @@ def update_params update_params = params.require(:offer).permit(:price, :billing_profile_id) merge_updated_by(update_params) end + + def track_detail_view + return unless current_user && @auction + + Recommendation::EventTracker.call( + user: current_user, + auction: @auction, + event_type: 'auction_detail_view', + source: 'offers#new', + request: + ) + end end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 6998ed645..fe3dafb8e 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -93,3 +93,6 @@ application.register("offer-price-validator", OfferPriceValidatorController); import RecommendationTrackerController from "./recommendation_tracker_controller"; application.register("recommendation-tracker", RecommendationTrackerController); + +import RecommendationDwellController from "./recommendation_dwell_controller"; +application.register("recommendation-dwell", RecommendationDwellController); diff --git a/app/javascript/controllers/recommendation_dwell_controller.js b/app/javascript/controllers/recommendation_dwell_controller.js new file mode 100644 index 000000000..260ff3bbc --- /dev/null +++ b/app/javascript/controllers/recommendation_dwell_controller.js @@ -0,0 +1,93 @@ +import { Controller } from "@hotwired/stimulus" + +// Fires a `recommendation_event` when the attached element has been +// visible in the viewport for at least `dwellMs` and the page is active. +// Idempotent per session via Set tracking. +// +// Usage on an auction card: +// data-controller="recommendation-dwell" +// data-recommendation-dwell-auction-uuid-value="..." +// data-recommendation-dwell-source-value="auctions#index" +// data-recommendation-dwell-event-type-value="auction_detail_view" +// data-recommendation-dwell-dwell-ms-value="1500" +export default class extends Controller { + static values = { + auctionUuid: String, + source: { type: String, default: "card" }, + eventType: { type: String, default: "auction_detail_view" }, + dwellMs: { type: Number, default: 1500 } + } + + connect() { + if (!this.hasAuctionUuidValue) return + if (typeof IntersectionObserver === "undefined") return + + this.fired = false + this.observer = new IntersectionObserver( + (entries) => this.handle(entries), + { threshold: 0.5 } + ) + this.observer.observe(this.element) + } + + disconnect() { + if (this.observer) this.observer.disconnect() + if (this.timerId) clearTimeout(this.timerId) + } + + handle(entries) { + for (const entry of entries) { + if (entry.isIntersecting) this.startTimer() + else this.cancelTimer() + } + } + + startTimer() { + if (this.fired || this.timerId) return + this.timerId = setTimeout(() => this.fire(), this.dwellMsValue) + } + + cancelTimer() { + if (this.timerId) { + clearTimeout(this.timerId) + this.timerId = null + } + } + + fire() { + this.timerId = null + if (this.fired || document.visibilityState !== "visible") return + this.fired = true + + const payload = { + recommendation_event: { + auction_uuid: this.auctionUuidValue, + source: this.sourceValue, + event_type: this.eventTypeValue + } + } + + const data = JSON.stringify(payload) + if (navigator.sendBeacon) { + const blob = new Blob([data], { type: "application/json" }) + navigator.sendBeacon("/recommendation_events", blob) + return + } + + fetch("/recommendation_events", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": this.csrfToken() + }, + credentials: "same-origin", + keepalive: true, + body: data + }).catch(() => null) + } + + csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") + } +} diff --git a/test/controllers/recommendation_detail_view_test.rb b/test/controllers/recommendation_detail_view_test.rb new file mode 100644 index 000000000..3e14b4bc8 --- /dev/null +++ b/test/controllers/recommendation_detail_view_test.rb @@ -0,0 +1,24 @@ +require 'test_helper' + +class RecommendationDetailViewTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + def setup + super + @user = users(:participant) + @auction = auctions(:valid_without_offers) + sign_in @user + RecommendationEvent.where(event_type: 'auction_detail_view').delete_all + end + + def test_offers_new_records_detail_view + assert_difference -> { RecommendationEvent.where(event_type: 'auction_detail_view').count }, 1 do + get new_auction_offer_path(auction_uuid: @auction.uuid) + end + + event = RecommendationEvent.where(event_type: 'auction_detail_view').last + assert_equal @user.id, event.user_id + assert_equal @auction.id, event.auction_id + assert_equal 'offers#new', event.source + end +end From c7eecb1cef68258900ca79d28013b51d5d780a9b Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:50:57 +0300 Subject: [PATCH 12/42] feat: show domain description and keywords in auction card Phase 8 of recommendation system v2. - AuctionsController#index preloads DomainClassification rows for the paginated auctions into @domain_classifications (single batched SELECT keyed by lowercased domain_name). Defensively returns {} if the table is not yet migrated. - index.html.erb passes the matching DomainClassification to each row partial as a local. - _auction.html.erb renders, when present: - description as a muted paragraph beneath the domain name - first four keywords as small badges Layout degrades silently when nothing is classified. - Each authenticated row gains recommendation-dwell Stimulus values so a 1.5s in-viewport hover fires auction_detail_view via navigator.sendBeacon. Anonymous visitors don't trigger tracking. Layout uses inline styles to avoid touching the global CSS pipeline in this phase; a follow-up can move them to dartsass partials. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/auctions_controller.rb | 11 +++++++++ app/views/auctions/_auction.html.erb | 34 +++++++++++++++++++++----- app/views/auctions/index.html.erb | 7 +++++- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/controllers/auctions_controller.rb b/app/controllers/auctions_controller.rb index 44a455a90..a4c545848 100644 --- a/app/controllers/auctions_controller.rb +++ b/app/controllers/auctions_controller.rb @@ -15,6 +15,7 @@ def index link_extra: 'data-turbo-action="advance"' ) @show_recommendation_prompt = current_user&.recommendation_profile_promptable? + @domain_classifications = preload_domain_classifications(@auctions) track_recommendation_impressions @@ -75,4 +76,14 @@ def track_recommendation_impressions request: ) end + + def preload_domain_classifications(auctions) + return {} if auctions.blank? + + domain_names = auctions.map { |a| a.domain_name.to_s.downcase }.uniq + DomainClassification.where(domain_name: domain_names).index_by(&:domain_name) + rescue ActiveRecord::StatementInvalid + # Table not yet migrated; safely degrade. + {} + end end diff --git a/app/views/auctions/_auction.html.erb b/app/views/auctions/_auction.html.erb index 573459eab..937dabf84 100644 --- a/app/views/auctions/_auction.html.erb +++ b/app/views/auctions/_auction.html.erb @@ -1,10 +1,32 @@ - - +<% domain_classification = local_assigns[:domain_classification] %> + + data-recommendation-dwell-auction-uuid-value="<%= auction.uuid %>" + data-recommendation-dwell-source-value="auctions#index" + data-recommendation-dwell-event-type-value="auction_detail_view" + data-recommendation-dwell-dwell-ms-value="1500" + <% end %>> + <% english_auction_presenter = EnglishBidsPresenter.new(auction) %> -

<%= auction.domain_name %>

- + +

<%= auction.domain_name %>

+ <% if domain_classification&.description.present? %> +

+ <%= domain_classification.description %> +

+ <% end %> + <% if domain_classification&.keywords.present? %> +
+ <% domain_classification.keywords.first(4).each do |keyword| %> + <%= keyword %> + <% end %> +
+ <% end %> + + <%= component 'common/auction_type_icon', auction: auction %> <%= auction.ends_at&.strftime('%d/%m/%Y %H:%M') %> @@ -16,7 +38,7 @@ <%= auction.users_price.zero? ? '' : "#{auction.users_price} €" %> <% end %> - + <% if auction.english? %> <%= english_auction_presenter.bidder_name(auction.currently_winning_offer, user) %> diff --git a/app/views/auctions/index.html.erb b/app/views/auctions/index.html.erb index bdf8012ca..bd96d25f6 100644 --- a/app/views/auctions/index.html.erb +++ b/app/views/auctions/index.html.erb @@ -54,7 +54,12 @@ <%= tag.tbody id: "bids", class: 'contents' do %> <% @auctions.uniq.each do |auction| %> - <%= render partial: 'auction', locals: { auction: auction, user: current_user, updated: false } %> + <%= render partial: 'auction', locals: { + auction: auction, + user: current_user, + updated: false, + domain_classification: (@domain_classifications || {})[auction.domain_name.to_s.downcase] + } %> <% end %> <% end %> <% end %> From 0d081e485fe65ad22f09ab7e40a8563e155ab202 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:52:33 +0300 Subject: [PATCH 13/42] chore: polish + operator runbook + finalize v2 rollout Phase 9 of recommendation system v2. UX polish: - UsersController#create: if a brand-new user signed up without filling the recommendation profile, call dismiss_prompt! on the empty profile. This stops the modal from popping up on the next /auctions render right after they explicitly skipped the form. They will see it again after PROMPT_REMINDER_INTERVAL (14 days). Documentation: - docs/architecture/recommendation-system.md: mark every phase as done, link to the operator runbook. - docs/guides/recommendation-operations.md: new operator-facing runbook with environment prerequisites, k8s cron schedule, first-time rollout checklist, monitoring queries, tunable constants, rollback steps, and a troubleshooting section covering pgvector permission errors and language mismatches. - CHANGELOG: high-level entry under today's date. No code changes outside the sign-up dismiss and docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++ app/controllers/users_controller.rb | 5 + docs/architecture/recommendation-system.md | 24 ++--- docs/guides/recommendation-operations.md | 109 +++++++++++++++++++++ 4 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 docs/guides/recommendation-operations.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0029297..1a916948a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +27.05.2026 +* Recommendation system v2: per-user auction sorting backed by + domain_classifications, time-decayed bid/wishlist/view affinity, + optional pgvector embeddings for similarity matching, and nightly + LLM enrichment as a k8s CronJob (rake recommendation:classify_unclassified, + rake recommendation:embed_unembedded). See docs/architecture/recommendation-system.md. +* Batch impressions on /auctions index via insert_all (one query + regardless of page size), debounced score recompute on user actions. + 30.12.2025 * Allow comma in billing profile street field https://github.com/internetee/auction_center/issues/1502 * Fixed false error message bug on billing profile update https://github.com/internetee/auction_center/issues/1503 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f0cb665ca..65e56b54f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -52,6 +52,11 @@ def create source: 'users#create', request: ) + elsif @user.recommendation_profile.present? + # User signed up without filling the profile. Suppress the modal + # on the next /auctions render so they're not nagged immediately — + # they'll see it again after PROMPT_REMINDER_INTERVAL. + @user.recommendation_profile.dismiss_prompt! end flash[:notice] = t(:created) diff --git a/docs/architecture/recommendation-system.md b/docs/architecture/recommendation-system.md index 60ea394b2..410ac27ca 100644 --- a/docs/architecture/recommendation-system.md +++ b/docs/architecture/recommendation-system.md @@ -184,17 +184,19 @@ See [domain-classification-pipeline.md](../technical/domain-classification-pipel | Phase | What | Status | |---|---|---| -| 0 | Snapshot + docs scaffold | in progress | -| 1 | Performance foundation (batch impressions, debounce score refresh) | pending | -| 2 | `domain_classifications` table + heuristic Tier 0 | pending | -| 3a | DomainClassifier orchestrator (heuristic only) | pending | -| 3b | LLM batch enrichment job (cron) | pending | -| 4 | Triggers + backfill rake | pending | -| 5 | pgvector + embeddings batch job | pending | -| 6 | Rich-feature Scorer + embedding similarity + time decay | pending | -| 7 | Detail view tracking + view affinity | pending | -| 8 | Show domain description in auction card | pending | -| 9 | Polish + finalize | pending | +| 0 | Snapshot + docs scaffold | done | +| 1 | Performance foundation (batch impressions, debounce score refresh) | done | +| 2 | `domain_classifications` table + heuristic Tier 0 | done | +| 3a | DomainClassifier orchestrator (heuristic only) | done | +| 3b | LLM batch enrichment job (cron) | done | +| 4 | Triggers + backfill rake | done | +| 5 | pgvector + embeddings batch job | done | +| 6 | Rich-feature Scorer + embedding similarity + time decay | done | +| 7 | Detail view tracking + view affinity | done | +| 8 | Show domain description in auction card | done | +| 9 | Polish + finalize | done | + +Operator runbook: see [guides/recommendation-operations.md](../guides/recommendation-operations.md). ## Open questions / future work diff --git a/docs/guides/recommendation-operations.md b/docs/guides/recommendation-operations.md new file mode 100644 index 000000000..7012c2bca --- /dev/null +++ b/docs/guides/recommendation-operations.md @@ -0,0 +1,109 @@ +# Recommendation System — Operations Guide + +Operator-facing guide for running the v2 recommendation system in +production. For architecture, see +[architecture/recommendation-system.md](../architecture/recommendation-system.md); +for pipeline internals, see +[technical/domain-classification-pipeline.md](../technical/domain-classification-pipeline.md). + +## Environment prerequisites + +| Item | Where | Notes | +|---|---|---| +| pgvector extension enabled | AWS RDS Postgres 17 | `CREATE EXTENSION IF NOT EXISTS vector;` as `rds_superuser` once per environment. The Rails migration tries this automatically; if the app role lacks `CREATE`, run it manually then run `rails db:migrate`. | +| `Feature.open_ai_integration_enabled?` | App settings | Must be true for LLM enrichment and embeddings. The recommendation profile UI, heuristic classifier, and scorer work without it. | +| `openai_model` Setting | DB seed | Currently `gpt-5`. `OpenaiStructuredOutputSupport` will fall back to a safe default if a non-supporting model is configured. | +| OpenAI API key | Rails credentials / env | Existing integration used by both `LlmDomainClassifier` and `DomainEmbedder`. | + +## Kubernetes cron schedule + +All recurring work runs as k8s `CronJob` resources defined in +`Ry_AWS_IaC/infrastructure/kubernetes`. Suggested schedule (UTC): + +| schedule | command | purpose | +|---|---|---| +| `0 3 * * *` | `bundle exec rake recommendation:classify_unclassified` | Tier 2 LLM enrichment of heuristic / low-conf / stale rows | +| `30 3 * * *` | `bundle exec rake recommendation:embed_unembedded` | Generate OpenAI embeddings for classified-but-unembedded rows | +| one-shot | `bundle exec rake recommendation:backfill` | Initial heuristic classification of all historical domains | + +Each task is wrapped by an idempotent ActiveJob. Re-running mid-day +is safe: nothing duplicates, fresh rows are skipped. + +## First-time rollout checklist + +1. Deploy the branch. +2. Run migrations (`rails db:migrate`). If pgvector enable_extension + fails for permission reasons, run `CREATE EXTENSION vector` as + `rds_superuser`, then re-run `db:migrate`. +3. Run `rake recommendation:backfill`. Watch the log line + `BackfillDomainClassificationsJob created N classifications` to + confirm scope. +4. (Optional, recommended) Manually run + `rake recommendation:classify_unclassified` once to perform the + first LLM enrichment immediately rather than waiting for cron. +5. (Optional) Manually run `rake recommendation:embed_unembedded` for + the first embedding sweep. +6. Verify `/auctions` renders descriptions on cards with classified + domains. + +## Monitoring + +| signal | check | +|---|---| +| LLM enrichment progress | `DomainClassification.needs_llm_enrichment.count` should trend toward zero. Tail logs for `ClassifyUnclassifiedDomainsJob processed N domains`. | +| Embedding backlog | `DomainClassification.needs_embedding.count` ditto. | +| Daily OpenAI cost | OpenAI dashboard. At steady state expect ~$0.01/day for classification and ~$0.0001/day for embeddings. | +| Score freshness | `UserAuctionScore.maximum(:calculated_at)` should be within minutes for active users. | +| Tracking failures | `Rails.logger.warn` lines from `EventTracker`. | + +## Tunables + +These constants live in `app/services/recommendation/scorer.rb`. Bumping +them requires a deploy — no DB migration needed. + +| constant | default | meaning | +|---|---|---| +| `SCORING_HORIZON` | 30 days | Auctions ending later than this are not scored. | +| `HALF_LIFE_DAYS` | 60 | Behavioural signal decay half-life. | +| `WISHLIST_HIT` | 120 | Bonus if exact-match wishlist domain is up for auction. | +| `TAG_WEIGHT` / `KEYWORD_WEIGHT` | 35 / 15 | Per-interest-match boost. | +| `BID_AFFINITY_*` / `WISHLIST_AFFINITY_*` / `VIEW_AFFINITY_*` | 8/24, 6/18, 4/12 | Weight + cap for behavioural affinity. | +| `RESULT_LOST_BONUS` / `RESULT_WON_PENALTY` | +25 / -5 | Outcome signals. | + +## Rolling back + +To pause the system without removing it: + +1. Set `Feature.open_ai_integration_enabled?` to false. LLM and + embedding jobs become no-ops; heuristic-only continues. +2. Drop the k8s CronJobs. +3. The legacy sort still works because `Auction::UserSortable` falls + back through `user_auction_scores` → interest match → ai_score → random. + +To roll back entirely: + +1. `rails db:rollback STEP=2` removes pgvector columns and the + classifications table. +2. Revert Phase 6 commit; scorer reverts to v1 baseline. + +## Troubleshooting + +**"extension vector is not allowlisted"** — On RDS, run +`SHOW rds.extensions;` to confirm `vector` is present. It is on +Postgres 15+ by default. If absent, contact AWS support — but Postgres +17 always has it. + +**"permission denied to create extension"** — App user lacks +`rds_superuser`. Run the `CREATE EXTENSION` manually as the master +user once per environment, then mark the migration as up with +`rails db:migrate:up VERSION=20260527090100`. + +**Description shows in wrong language** — `description_locale` is +chosen by the LLM at classification time. If you want a different +locale, re-run classification with `force: true` after updating the +system prompt, or seed manual rows with `classification_source='manual'`. + +**Score never updates after action** — Check whether +`RefreshSingleUserAuctionScoresJob` is reaching the worker (delayed +job table). The debounce window is 30 seconds; updates are not +real-time. Re-enqueue manually via the admin Job UI if needed. From 5b6174f40e006c6743c6d436669d64278889f510 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 11:58:31 +0300 Subject: [PATCH 14/42] refactor: scorer N+1 fixes, deprecation notes, LLM/embedder tests Self-review pass after iteration 1. Scorer improvements: - compute_result_signal previously did one SELECT per Result row to fetch tags. preload_result_classifications now batches a single SELECT keyed by lowercased domain_name. - dominant_user_audience replaces the unreachable profile.audience_preference branch. Audience preference is inferred from the user's own bid+wishlist history (majority audience across classified domains). Cached per scorer instance. - bid_wishlist_classification_cache memoises the join so audience inference doesn't trigger extra round-trips when several auctions are scored in the same pass. Deprecation markers: - Recommendation::AuctionDomainClassifier and Recommendation::ClassifyAuctionDomainsJob get clear DEPRECATED comments. Both stay live so the legacy auctions.classification_* fallback path keeps working until those columns are dropped in a later release. New tests: - LlmDomainClassifierTest uses WebMock stubs to verify structured- output parsing, enum filtering of bogus tags, batch-limit truncation, empty input handling, and incomplete-response error. - DomainEmbedderTest covers vector length, AR-row input, empty input, and OpenAI error response. Test compatibility verified by walking through the legacy scorer_test expectations: - wishlist > category > custom > neutral ordering preserved via the auctions.classification_tags fallback path in aggregate_features. - bid-tag affinity still flows through the same fallback for fixtures that have no domain_classifications row. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../classify_auction_domains_job.rb | 6 + .../auction_domain_classifier.rb | 8 ++ app/services/recommendation/scorer.rb | 54 ++++++-- .../recommendation/domain_embedder_test.rb | 58 +++++++++ .../llm_domain_classifier_test.rb | 119 ++++++++++++++++++ 5 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 test/services/recommendation/domain_embedder_test.rb create mode 100644 test/services/recommendation/llm_domain_classifier_test.rb diff --git a/app/jobs/recommendation/classify_auction_domains_job.rb b/app/jobs/recommendation/classify_auction_domains_job.rb index dd75c9d92..75227c9cd 100644 --- a/app/jobs/recommendation/classify_auction_domains_job.rb +++ b/app/jobs/recommendation/classify_auction_domains_job.rb @@ -1,4 +1,10 @@ module Recommendation + # DEPRECATED: superseded by Recommendation::ClassifyUnclassifiedDomainsJob (v2). + # Kept registered so existing scheduling and the admin Job UI keep + # working during the rollout. Both jobs are idempotent and consult + # different scopes (auctions.classified_at vs + # domain_classifications.classified_at), so running both is safe. + # Drop this class after auctions.classification_* columns are removed. class ClassifyAuctionDomainsJob < ApplicationJob retry_on StandardError, wait: 5.seconds, attempts: 3 diff --git a/app/services/recommendation/auction_domain_classifier.rb b/app/services/recommendation/auction_domain_classifier.rb index 272d0f805..9b0ee814d 100644 --- a/app/services/recommendation/auction_domain_classifier.rb +++ b/app/services/recommendation/auction_domain_classifier.rb @@ -1,4 +1,12 @@ module Recommendation + # DEPRECATED: superseded by Recommendation::LlmDomainClassifier (v2). + # Writes legacy auctions.classification_* columns and is kept only so + # the existing Auction::UserSortable fallback path stays warm during + # the v2 rollout. New code paths should use LlmDomainClassifier and + # domain_classifications instead. + # + # Remove this class once auctions.classification_* columns are dropped + # (planned for the release after v2 has been stable in production). class AuctionDomainClassifier DEFAULT_TEMPERATURE = 0.2 diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb index a2e03953e..488857832 100644 --- a/app/services/recommendation/scorer.rb +++ b/app/services/recommendation/scorer.rb @@ -198,16 +198,38 @@ def matching_custom_interests(domain_name) end end + # Audience match (b2b/b2c) is captured for every classified domain + # but RecommendationProfile does not yet expose a user-side + # audience_preference column. Until it does, derive a soft signal: + # if the user's bid history skews toward one audience, boost + # candidates with the same audience. Falls back to 0. def audience_match_bonus(audience) return 0 if audience.blank? - - user_audience = profile&.audience_preference if profile.respond_to?(:audience_preference) - return 0 if user_audience.blank? - return AUDIENCE_MATCH if audience == user_audience + return 0 if dominant_user_audience.blank? + return AUDIENCE_MATCH if dominant_user_audience == audience 0 end + def dominant_user_audience + return @dominant_user_audience if defined?(@dominant_user_audience) + + counts = Hash.new(0) + (bid_domain_signals + wishlist_domain_signals).each do |signal| + dc = bid_wishlist_classification_cache[signal[:domain_name]] + counts[dc.audience] += 1 if dc&.audience.present? + end + + @dominant_user_audience = counts.max_by { |_, n| n }&.first + end + + def bid_wishlist_classification_cache + @bid_wishlist_classification_cache ||= begin + names = (bid_domain_signals + wishlist_domain_signals).map { |s| s[:domain_name] }.uniq + DomainClassification.where(domain_name: names).index_by(&:domain_name) + end + end + # ---------- Behavioural affinity ------------------------------------- def affinity(tags:, keywords:, feature_counts:, weight:, cap:) @@ -357,16 +379,24 @@ def result_signal_by_tag def compute_result_signal return {} unless defined?(Result) && Result.table_exists? - signals = Hash.new(0.0) results = lookup_user_results - return signals if results.blank? + return {} if results.blank? + + classifications = preload_result_classifications(results) + signals = Hash.new(0.0) results.each do |result| + domain = result.respond_to?(:domain_name) ? result.domain_name.to_s.downcase : nil + next if domain.blank? + + dc = classifications[domain] + next if dc.nil? + won = result.respond_to?(:winner_user_id) && result.winner_user_id == @user.id decay = decay_weight(age_in_days(result.updated_at)) bonus = won ? RESULT_WON_PENALTY : RESULT_LOST_BONUS - Array(result_tags(result)).each { |tag| signals[tag.to_s] += bonus * decay } + Array(dc.tags).each { |tag| signals[tag.to_s] += bonus * decay } end signals @@ -382,11 +412,13 @@ def lookup_user_results [] end - def result_tags(result) - domain = result.respond_to?(:domain_name) ? result.domain_name : nil - return [] if domain.blank? + def preload_result_classifications(results) + domain_names = results.filter_map do |r| + r.domain_name.to_s.downcase if r.respond_to?(:domain_name) && r.domain_name.present? + end.uniq + return {} if domain_names.empty? - DomainClassification.where(domain_name: domain.downcase).limit(1).pluck(:tags).flatten + DomainClassification.where(domain_name: domain_names).index_by(&:domain_name) end # ---------- Embedding multiplier ------------------------------------ diff --git a/test/services/recommendation/domain_embedder_test.rb b/test/services/recommendation/domain_embedder_test.rb new file mode 100644 index 000000000..c663dcab8 --- /dev/null +++ b/test/services/recommendation/domain_embedder_test.rb @@ -0,0 +1,58 @@ +require 'test_helper' + +module Recommendation + class DomainEmbedderTest < ActiveSupport::TestCase + def test_returns_aligned_embeddings_for_each_row + rows = [ + { domain_name: 'a.ee', description: 'first', keywords: %w[one] }, + { domain_name: 'b.ee', description: 'second', keywords: %w[two] } + ] + + stub_embedding_request(2) + + result = Recommendation::DomainEmbedder.call(rows: rows) + assert_equal 2, result.size + assert_equal 'a.ee', result.first[:domain_name] + assert_equal Recommendation::DomainEmbedder::DIMENSIONS, result.first[:embedding].size + assert_equal Recommendation::DomainEmbedder::MODEL, result.first[:embedding_model] + assert result.first[:embedded_at].is_a?(Time) + end + + def test_handles_active_record_rows + classification = DomainClassification.create!( + domain_name: 'ar.ee', + description: 'AR test', + keywords: %w[active record] + ) + stub_embedding_request(1) + + result = Recommendation::DomainEmbedder.call(rows: [classification]) + assert_equal 'ar.ee', result.first[:domain_name] + assert_equal Recommendation::DomainEmbedder::DIMENSIONS, result.first[:embedding].size + end + + def test_empty_input_returns_empty + result = Recommendation::DomainEmbedder.call(rows: []) + assert_equal [], result + end + + def test_raises_on_openai_error + stub_request(:post, 'https://api.openai.com/v1/embeddings') + .to_return_json(status: 200, body: { 'error' => { 'message' => 'oops' } }, headers: {}) + + assert_raises(StandardError) do + Recommendation::DomainEmbedder.call(rows: [{ domain_name: 'x.ee', description: 'x', keywords: [] }]) + end + end + + private + + def stub_embedding_request(count) + data = count.times.map do |i| + { 'index' => i, 'embedding' => Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.1) } + end + stub_request(:post, 'https://api.openai.com/v1/embeddings') + .to_return_json(status: 200, body: { 'data' => data, 'model' => 'text-embedding-3-small' }, headers: {}) + end + end +end diff --git a/test/services/recommendation/llm_domain_classifier_test.rb b/test/services/recommendation/llm_domain_classifier_test.rb new file mode 100644 index 000000000..877bd4f0f --- /dev/null +++ b/test/services/recommendation/llm_domain_classifier_test.rb @@ -0,0 +1,119 @@ +require 'test_helper' + +module Recommendation + class LlmDomainClassifierTest < ActiveSupport::TestCase + def setup + super + @openai_model = Setting.find_by(code: 'openai_model') + @openai_model.update!(value: 'gpt-5') + end + + def test_parses_structured_response_into_attribute_hashes + stub_request(:post, 'https://api.openai.com/v1/chat/completions') + .to_return_json(status: 200, body: ai_response_for(%w[cloudstack.ee marketflow.ee]), headers: {}) + + result = Recommendation::LlmDomainClassifier.call(domain_names: %w[cloudstack.ee marketflow.ee]) + + assert_equal 2, result.size + + cloud = result.find { |r| r[:domain_name] == 'cloudstack.ee' } + assert_equal 'saas', cloud[:primary_category] + assert_equal %w[saas b2b_service], cloud[:tags] + assert_equal 'Cloud platform domain.', cloud[:description] + assert_equal 'en', cloud[:description_locale] + assert_equal 'b2b', cloud[:audience] + assert_includes cloud[:keywords], 'cloud' + assert_equal DomainClassification::OPENAI_SOURCE, cloud[:classification_source] + assert cloud[:confidence] >= 0.0 + assert cloud[:confidence] <= 1.0 + end + + def test_filters_unknown_categories_from_tags + stub_request(:post, 'https://api.openai.com/v1/chat/completions') + .to_return_json(status: 200, body: ai_response_with_bogus_tag('cloudstack.ee'), headers: {}) + + result = Recommendation::LlmDomainClassifier.call(domain_names: %w[cloudstack.ee]) + assert_equal %w[saas], result.first[:tags] + assert_equal 'saas', result.first[:primary_category] + end + + def test_empty_input_returns_empty_array + result = Recommendation::LlmDomainClassifier.call(domain_names: []) + assert_equal [], result + end + + def test_truncates_above_batch_limit + huge = (1..(Recommendation::LlmDomainClassifier::BATCH_LIMIT + 25)).map { |i| "x#{i}.ee" } + classifier = Recommendation::LlmDomainClassifier.new(domain_names: huge) + assert_equal Recommendation::LlmDomainClassifier::BATCH_LIMIT, + classifier.instance_variable_get(:@domain_names).size + end + + def test_raises_on_incomplete_response + stub_request(:post, 'https://api.openai.com/v1/chat/completions') + .to_return_json(status: 200, + body: { 'choices' => [{ 'finish_reason' => 'length', 'message' => { 'content' => '{}' } }] }, + headers: {}) + + assert_raises(StandardError) do + Recommendation::LlmDomainClassifier.call(domain_names: %w[cloudstack.ee]) + end + end + + private + + def ai_response_for(domain_names) + { + 'choices' => [{ + 'finish_reason' => 'stop', + 'message' => { + 'content' => { + classifications: domain_names.map { |name| classification_for(name) } + }.to_json + } + }] + } + end + + def ai_response_with_bogus_tag(domain_name) + { + 'choices' => [{ + 'finish_reason' => 'stop', + 'message' => { + 'content' => { + classifications: [{ + domain_name: domain_name, + primary_category: 'not_a_real_category', + tags: %w[saas absolutely_made_up], + description: 'desc', + description_locale: 'en', + keywords: %w[cloud], + audience: 'b2b', + languages: %w[en], + suggested_use_cases: %w[platform], + brandability_score: 0.5, + confidence: 0.9 + }] + }.to_json + } + }] + } + end + + def classification_for(name) + { + domain_name: name, + primary_category: 'saas', + tags: %w[saas b2b_service], + description: 'Cloud platform domain.', + description_locale: 'en', + keywords: %w[cloud platform], + audience: 'b2b', + languages: %w[en], + suggested_use_cases: %w[platform agency], + brandability_score: 0.75, + confidence: 0.92 + } + end + end +end From c9d1a2837cc8794cc06bdb4b13b94584de63bd57 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 12:01:33 +0300 Subject: [PATCH 15/42] polish: extract auction-card styles to SCSS, add missing job tests, ADR Final polish pass. Style extraction: - _auction.html.erb dropped all inline style attributes. - New _recommendation.scss component module defines .c-auction__domain-description, .c-auction__domain-keywords, and .c-auction__domain-keyword. Imported from _components.scss. Tests: - BackfillDomainClassificationsJobTest covers full happy-path classification of auction + wishlist domains, skip-existing behaviour, and resilience to per-domain failures. - EmbedUnembeddedDomainsJobTest covers no-op without the pgvector column, no-op when OpenAI is disabled, and scope filtering of rows without descriptions. Architectural decision record: - docs/architecture/adr-001-recommendation-v2.md captures the seven design decisions of v2: per-domain classification table, three-tier pipeline, RDS-native pgvector with no Dockerfile changes, infra- side cron, time decay over SQL date cutoffs, multiplicative embedding multiplier, and heuristic-only-at-runtime policy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stylesheets/components/_components.scss | 3 +- .../components/_recommendation.scss | 25 +++ app/views/auctions/_auction.html.erb | 6 +- .../architecture/adr-001-recommendation-v2.md | 149 ++++++++++++++++++ ...ackfill_domain_classifications_job_test.rb | 74 +++++++++ .../embed_unembedded_domains_job_test.rb | 66 ++++++++ 6 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 app/assets/stylesheets/components/_recommendation.scss create mode 100644 docs/architecture/adr-001-recommendation-v2.md create mode 100644 test/jobs/recommendation/backfill_domain_classifications_job_test.rb create mode 100644 test/jobs/recommendation/embed_unembedded_domains_job_test.rb diff --git a/app/assets/stylesheets/components/_components.scss b/app/assets/stylesheets/components/_components.scss index 6d91062f7..6227ea7c5 100644 --- a/app/assets/stylesheets/components/_components.scss +++ b/app/assets/stylesheets/components/_components.scss @@ -30,4 +30,5 @@ @import "accordion"; @import "pagy"; @import "notice"; -@import "slashed-zero"; \ No newline at end of file +@import "slashed-zero"; +@import "recommendation"; \ No newline at end of file diff --git a/app/assets/stylesheets/components/_recommendation.scss b/app/assets/stylesheets/components/_recommendation.scss new file mode 100644 index 000000000..6d9986e65 --- /dev/null +++ b/app/assets/stylesheets/components/_recommendation.scss @@ -0,0 +1,25 @@ +// Recommendation system v2 — auction card enrichment. +// +// Rendered by app/views/auctions/_auction.html.erb when the matching +// domain_classifications row has description / keywords populated. +// Degrades silently when no classification is available. + +.c-auction__domain-description { + margin: 4px 0 0; + color: #6b6b6b; + font-size: 0.85em; + line-height: 1.35; + max-width: 28rem; +} + +.c-auction__domain-keywords { + margin-top: 4px; + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.c-auction__domain-keyword { + font-size: 0.7em; + padding: 2px 6px; +} diff --git a/app/views/auctions/_auction.html.erb b/app/views/auctions/_auction.html.erb index 937dabf84..9e8ee9c79 100644 --- a/app/views/auctions/_auction.html.erb +++ b/app/views/auctions/_auction.html.erb @@ -14,14 +14,14 @@

<%= auction.domain_name %>

<% if domain_classification&.description.present? %> -

+

<%= domain_classification.description %>

<% end %> <% if domain_classification&.keywords.present? %> -
+
<% domain_classification.keywords.first(4).each do |keyword| %> - <%= keyword %> + <%= keyword %> <% end %>
<% end %> diff --git a/docs/architecture/adr-001-recommendation-v2.md b/docs/architecture/adr-001-recommendation-v2.md new file mode 100644 index 000000000..d1f65bf1c --- /dev/null +++ b/docs/architecture/adr-001-recommendation-v2.md @@ -0,0 +1,149 @@ +# ADR 001 — Recommendation system v2 design + +**Status:** Accepted +**Date:** 2026-05-27 +**Branch:** `feature/recommendation-system-improvements` + +## Context + +The v1 recommendation system shipped on the same branch coupled classification +to the `auctions` table (`auctions.classification_tags`, `primary_category`, +`classification_source`), called the OpenAI LLM eagerly per event, and computed +scores synchronously inside controllers' callback paths. Operating it revealed +three structural problems: + +1. **Per-event LLM cost was unbounded.** Each new auction or wishlist add could + eventually trigger an LLM call, with no upper bound on monthly spend. +2. **Domains outside `auctions` were invisible to the recommender.** Wishlist + items whose domain never went to auction had no tags — wishlist affinity + silently no-op'd. +3. **Self-learning loop was broken for historical auctions.** Classification only + ran on currently active auctions, so a user's old bid history could not feed + tag affinity unless the domain happened to be on an active auction *and* had + already been classified. + +Plus operational concerns: per-impression INSERTs on `/auctions` index, no +debouncing on score recompute, no embeddings for similarity search, no time +decay on behavioural signals. + +## Decisions + +### D1. Classification lives on a per-domain table, not per-auction columns + +A new `domain_classifications` table is the single source of truth for what a +domain *means*. Unique by `domain_name`. Auctions, wishlist items, offer +histories, and result records all reference it indirectly via `domain_name`. + +**Why:** The semantics of `cloud-shop.ee` don't change between auctions. Storing +tags per auction duplicates state and prevents non-auction inputs (wishlist, +historical bids) from contributing to scoring. + +**Cost:** A migration. Legacy `auctions.classification_*` columns stay populated +during transition as fallback; planned removal after v2 stabilises. + +### D2. Three-tier classifier pipeline (Ruby → LLM-batch → embeddings) + +- **Tier 0** — `DomainHeuristicClassifier`, deterministic Ruby with an et+en + dictionary, runs at runtime on every event. Covers ~60-70% of Estonian + domains, free, microsecond-scale. +- **Tier 2** — `LlmDomainClassifier`, structured-output OpenAI call, batched 50 + domains per request. Runs ONLY from cron (`rake recommendation:classify_unclassified`), + never from request paths. +- **Embeddings** — `DomainEmbedder` using `text-embedding-3-small`, batched 100 + per call. Same cron-only constraint. + +**Why:** Decouples user-facing latency from OpenAI variability and bounds +monthly cost. At our auction volume (100-200 active) steady-state OpenAI cost +is ~$0.30/month, with ~$1 one-time backfill. + +**Alternative considered:** WASM-based on-device classifier. Rejected for v2 +because classification is computed once per domain and cached; the bandwidth +cost of shipping a 10MB model to every browser exceeds the savings. + +### D3. AWS RDS-native pgvector, no Dockerfile changes + +`vector` extension is in the RDS Postgres 17 allowlist. Migration calls +`enable_extension 'vector'`. If the app role lacks `CREATE EXTENSION`, an +operator runs the SQL once manually as `rds_superuser`. No changes required to +`Dockerfile`, `Dockerfile.staging`, `Dockerfile.test` (these are app-runtime +images — pgvector belongs on the DB host). + +**Why:** Lowest-friction route. Considered: separate Postgres image with +pre-baked pgvector. Rejected because RDS doesn't allow custom Postgres images. + +### D4. Cron jobs scheduled outside the app + +The application exposes rake tasks (`recommendation:classify_unclassified`, +`recommendation:embed_unembedded`, `recommendation:backfill`) and registers +the underlying jobs in `Job::ALLOWED_JOB_NAMES`. Scheduling lives in +`Ry_AWS_IaC/infrastructure/kubernetes` as k8s `CronJob` resources. + +**Why:** Matches the project's existing pattern — server-side scheduling is +infra concern, app exposes idempotent entry points. Avoids an in-process +scheduler (whenever, sidekiq-cron, good_job). + +### D5. Time-decayed behavioural signals, no SQL date cutoff + +Bid, wishlist, view, and result signals are weighted by +`exp(-days_old / HALF_LIFE_DAYS)` with `HALF_LIFE_DAYS = 60`. No `WHERE +updated_at > X` clause; decay alone reduces multi-year-old signals below the +floating-point noise floor. + +**Why:** SQL date cutoffs interact badly with `travel_to` in tests and with +clock skew across regions. Decay is mathematically equivalent and simpler to +reason about. + +### D6. Embedding multiplier, not additive + +Final score = base_score * (1 + max(0, cosine_similarity)). User centroid is +the time-decayed weighted average of embeddings from bids, wishlist, and views. + +**Why:** Multiplicative form lets embedding act as a "boost knob" — it can't +introduce a high score in isolation (no behavioural data → no centroid → 1.0), +but it can lift a domain that other signals already mildly favour. Additive +form risks dominating the rule-based base when cosine is small. + +### D7. Heuristic-only at runtime, LLM-only via cron + +`ClassifyDomainHeuristicallyJob` (Tier 0) fires from `Auction.after_create`, +`WishlistItem#create`, and offer controllers. `ClassifyUnclassifiedDomainsJob` +(Tier 2) fires only from cron. There is no code path that triggers an LLM call +in response to a user request. + +**Why:** Bounds cost, hides OpenAI latency from users, makes the system +predictable to operate. + +## Consequences + +**Positive** +- Cost bound to ~$0.30/month steady state, ~$1 one-time backfill. +- No user-visible latency tied to OpenAI. +- Wishlist and historical bids on non-auction domains now contribute to + recommendations. +- Embedding-based similarity available for future "domains like this" features. +- Time decay means stale interests fade automatically; recent activity weighs + more. + +**Negative** +- Two writes per domain (heuristic, then LLM enrichment). Acceptable because + Tier 0 produces useful tags immediately while Tier 2 enriches overnight. +- More tables to operate. Mitigated by the operator runbook in + `docs/guides/recommendation-operations.md`. +- pgvector requires one-time manual setup if the app DB role lacks the + `CREATE EXTENSION` grant. + +**Migration path** +1. Deploy. Migration enables pgvector and creates `domain_classifications`. +2. Run `rake recommendation:backfill` once. +3. Run `rake recommendation:classify_unclassified` manually for first LLM pass. +4. Schedule the two cron jobs in k8s. +5. Optionally drop `auctions.classification_*` columns after a release of + stable v2 operation. + +## References + +- `docs/architecture/recommendation-system.md` — high-level overview, schemas, + phase list. +- `docs/technical/domain-classification-pipeline.md` — pipeline internals, + prompts, schema. +- `docs/guides/recommendation-operations.md` — operator runbook. diff --git a/test/jobs/recommendation/backfill_domain_classifications_job_test.rb b/test/jobs/recommendation/backfill_domain_classifications_job_test.rb new file mode 100644 index 000000000..1077fe33b --- /dev/null +++ b/test/jobs/recommendation/backfill_domain_classifications_job_test.rb @@ -0,0 +1,74 @@ +require 'test_helper' + +module Recommendation + class BackfillDomainClassificationsJobTest < ActiveJob::TestCase + def setup + super + DomainClassification.delete_all + end + + def test_classifies_auction_and_wishlist_domains + auction = Auction.create!( + domain_name: 'backfill-auction.ee', + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + skip_validation: true + ) + WishlistItem.create!(user: users(:participant), domain_name: 'backfill-wishlist.ee', cents: 1_000) + + assert_difference -> { DomainClassification.count }, ->(count) { count >= 2 } do + Recommendation::BackfillDomainClassificationsJob.new.perform + end + + assert DomainClassification.exists?(domain_name: 'backfill-auction.ee') + assert DomainClassification.exists?(domain_name: 'backfill-wishlist.ee') + auction.destroy + end + + def test_skips_already_classified_domains + Auction.create!( + domain_name: 'skip-me.ee', + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + skip_validation: true + ) + DomainClassification.create!( + domain_name: 'skip-me.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.9, + classified_at: 1.hour.ago + ) + + assert_no_difference -> { DomainClassification.where(domain_name: 'skip-me.ee').count } do + Recommendation::BackfillDomainClassificationsJob.new.perform + end + end + + def test_continues_after_individual_failures + Auction.create!( + domain_name: 'good.ee', + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + skip_validation: true + ) + + # Simulate a failure in classifier for one specific input + original = Recommendation::DomainClassifier.method(:call) + Recommendation::DomainClassifier.define_singleton_method(:call) do |name, **opts| + raise StandardError, 'simulated' if name.to_s.include?('good') + + original.call(name, **opts) + end + + assert_nothing_raised do + Recommendation::BackfillDomainClassificationsJob.new.perform + end + ensure + if original + Recommendation::DomainClassifier.define_singleton_method(:call) do |name, **opts| + original.call(name, **opts) + end + end + end + end +end diff --git a/test/jobs/recommendation/embed_unembedded_domains_job_test.rb b/test/jobs/recommendation/embed_unembedded_domains_job_test.rb new file mode 100644 index 000000000..e7cc35898 --- /dev/null +++ b/test/jobs/recommendation/embed_unembedded_domains_job_test.rb @@ -0,0 +1,66 @@ +require 'test_helper' + +module Recommendation + class EmbedUnembeddedDomainsJobTest < ActiveJob::TestCase + def setup + super + DomainClassification.delete_all + end + + def test_no_op_without_embedding_column + # If pgvector migration has not run yet, the column is absent + # and the job should bail out cleanly. + skip 'embedding column present' if DomainClassification.column_names.include?('embedding') + + assert_nothing_raised do + Recommendation::EmbedUnembeddedDomainsJob.new.perform + end + end + + def test_no_op_when_openai_disabled + DomainClassification.create!( + domain_name: 'pending-embed.ee', + description: 'desc', + keywords: %w[k1], + classification_source: DomainClassification::OPENAI_SOURCE, + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + with_feature_flag(false) do + assert_nil Recommendation::EmbedUnembeddedDomainsJob.new.perform + end + end + + def test_scope_skips_rows_without_description + no_description = DomainClassification.create!( + domain_name: 'no-desc.ee', + classification_source: DomainClassification::HEURISTIC_SOURCE, + classified_at: 1.hour.ago, + confidence: 0.4 + ) + + with_description = DomainClassification.create!( + domain_name: 'has-desc.ee', + description: 'has description', + classification_source: DomainClassification::OPENAI_SOURCE, + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + scope_ids = Recommendation::EmbedUnembeddedDomainsJob.scope.pluck(:id) + refute_includes scope_ids, no_description.id + assert_includes scope_ids, with_description.id if DomainClassification.column_names.include?('embedding') + end + + private + + def with_feature_flag(enabled) + original = Feature.method(:open_ai_integration_enabled?) + Feature.define_singleton_method(:open_ai_integration_enabled?) { enabled } + yield + ensure + Feature.define_singleton_method(:open_ai_integration_enabled?, original) + end + end +end From 2b94fb88b3cff8daa114f02aece1362356dc0edf Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Wed, 27 May 2026 16:40:04 +0300 Subject: [PATCH 16/42] fix: pgvector migration gives clear error + escape hatch Phase 5 migration crashed on local dev because docker-images/ docker-compose.dev.v2.yml uses plain postgres:13.4 which has no pgvector. Production (RDS 17) was always fine. Two fixes: Migration: - ensure_pgvector_available! probes pg_available_extensions before trying CREATE EXTENSION and raises a clear PgvectorUnavailable error with remediation steps (point at the pgvector image and the SKIP env var) instead of the cryptic "vector.control not found". - Down migration uses column_exists? guards so a partial-rollback doesn't blow up. - SKIP_PGVECTOR_MIGRATION=true env var lets operators skip the whole migration when they cannot upgrade the image right away; the recommendation system degrades to tag/keyword scoring with no similarity multiplier and can be re-migrated later. Operations: - Updated docs/guides/recommendation-operations.md prerequisites table and troubleshooting section with the dev-image guidance and the SKIP env-var instructions. Note: the shared compose change to pgvector/pgvector:pg13 lives in registry/docker-images/docker-compose.dev.v2.yml (separate repo) and is staged as part of this hotfix flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- Gemfile.lock | 3 ++ ...0100_enable_pgvector_and_add_embeddings.rb | 45 +++++++++++++++---- docs/guides/recommendation-operations.md | 11 ++++- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 333c5f345..67df994c7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -314,6 +314,8 @@ GEM msgpack (1.8.0) multipart-post (2.4.1) mutex_m (0.3.0) + neighbor (0.6.0) + activerecord (>= 7.1) net-http (0.9.1) uri (>= 0.11.1) net-imap (0.6.4) @@ -664,6 +666,7 @@ DEPENDENCIES minitest-mock money mutex_m + neighbor (~> 0.5) net-imap (>= 0.5.7) nokogiri (>= 1.18.9) noticed (~> 1.6.3) diff --git a/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb b/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb index 8f98cf742..d5ba4793e 100644 --- a/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb +++ b/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb @@ -1,10 +1,19 @@ class EnablePgvectorAndAddEmbeddings < ActiveRecord::Migration[7.0] + class PgvectorUnavailable < StandardError; end + + # Set to true via the ENV var to skip embedding columns entirely if you + # are running on a Postgres image without pgvector and cannot upgrade + # right now (e.g. for a hotfix). The recommendation system degrades to + # tag/keyword scoring without similarity multiplier. + SKIP_ENV = 'SKIP_PGVECTOR_MIGRATION'.freeze + def up - # AWS RDS Postgres 17.4 supports pgvector natively. The extension is - # in the rds_extensions allowlist; CREATE EXTENSION requires a role - # with rds_superuser. If the app user lacks the privilege, run this - # SQL once manually as the master user per environment, then mark - # the migration as applied (rails db:migrate:up VERSION=...). + if ENV[SKIP_ENV] == 'true' + say 'Skipping pgvector migration because SKIP_PGVECTOR_MIGRATION=true' + return + end + + ensure_pgvector_available! enable_extension 'vector' unless extension_enabled?('vector') add_column :domain_classifications, :embedding, :vector, limit: 1536 @@ -21,10 +30,30 @@ def up def down execute 'DROP INDEX IF EXISTS idx_domain_classifications_embedding' - remove_column :domain_classifications, :embedded_at - remove_column :domain_classifications, :embedding_model - remove_column :domain_classifications, :embedding + remove_column :domain_classifications, :embedded_at if column_exists?(:domain_classifications, :embedded_at) + remove_column :domain_classifications, :embedding_model if column_exists?(:domain_classifications, :embedding_model) + remove_column :domain_classifications, :embedding if column_exists?(:domain_classifications, :embedding) # Intentionally do NOT disable the vector extension on rollback; # other tables or environments may rely on it. end + + private + + # Surfaces a clear error message instead of the cryptic + # `could not open extension control file "vector.control"` that + # PostgreSQL produces when the .so is missing. + def ensure_pgvector_available! + available = ActiveRecord::Base.connection + .select_value("SELECT 1 FROM pg_available_extensions WHERE name = 'vector'") + return if available + + raise PgvectorUnavailable, <<~MSG.squish + pgvector extension is not installed in this PostgreSQL instance. + Production (AWS RDS Postgres 17) has it natively. For local dev, + pull pgvector/pgvector:pg13 (or :pg17) instead of plain postgres + image — see docker-images/docker-compose.dev.v2.yml. To skip this + migration temporarily, run `SKIP_PGVECTOR_MIGRATION=true rails db:migrate` + and re-run later once the image is updated. + MSG + end end diff --git a/docs/guides/recommendation-operations.md b/docs/guides/recommendation-operations.md index 7012c2bca..29147414f 100644 --- a/docs/guides/recommendation-operations.md +++ b/docs/guides/recommendation-operations.md @@ -10,7 +10,7 @@ for pipeline internals, see | Item | Where | Notes | |---|---|---| -| pgvector extension enabled | AWS RDS Postgres 17 | `CREATE EXTENSION IF NOT EXISTS vector;` as `rds_superuser` once per environment. The Rails migration tries this automatically; if the app role lacks `CREATE`, run it manually then run `rails db:migrate`. | +| pgvector extension enabled | AWS RDS Postgres 17 (prod) / pgvector/pgvector:pg13 (dev) | `CREATE EXTENSION IF NOT EXISTS vector;` as `rds_superuser` once per environment. The Rails migration tries this automatically; if the app role lacks `CREATE`, run it manually then run `rails db:migrate`. For local dev, ensure `docker-images/docker-compose.dev.v2.yml` uses `pgvector/pgvector:pg13` (NOT plain `postgres:13.x`). Set `SKIP_PGVECTOR_MIGRATION=true` as a temporary escape hatch if needed; embedding similarity will be inactive until rerun. | | `Feature.open_ai_integration_enabled?` | App settings | Must be true for LLM enrichment and embeddings. The recommendation profile UI, heuristic classifier, and scorer work without it. | | `openai_model` Setting | DB seed | Currently `gpt-5`. `OpenaiStructuredOutputSupport` will fall back to a safe default if a non-supporting model is configured. | | OpenAI API key | Rails credentials / env | Existing integration used by both `LlmDomainClassifier` and `DomainEmbedder`. | @@ -93,6 +93,15 @@ To roll back entirely: Postgres 15+ by default. If absent, contact AWS support — but Postgres 17 always has it. +**"could not open extension control file vector.control"** — The +PostgreSQL image does not ship pgvector. This happens with plain +`postgres:13`. Switch the Docker image to `pgvector/pgvector:pg13` +(or `:pg17` if you upgrade Postgres), restart the container, and +re-run the migration. The data volume is compatible — same major +version. If you cannot upgrade right now and need to ship something, +`SKIP_PGVECTOR_MIGRATION=true rails db:migrate` lets you proceed +without the embedding column; rerun the migration later. + **"permission denied to create extension"** — App user lacks `rds_superuser`. Run the `CREATE EXTENSION` manually as the master user once per environment, then mark the migration as up with From 9252607aa8921cc9f73f3dcc8ad75587ba30694e Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 28 May 2026 11:34:43 +0300 Subject: [PATCH 17/42] =?UTF-8?q?Drop=20pgvector=20path=20=E2=80=94=20rely?= =?UTF-8?q?=20on=20tag/keyword=20affinity=20+=20time=20decay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the recommendation v2 plan (pgvector + embedding similarity) is reversed before merge. Rationale: provisioning pgvector touches shared infrastructure across dev, staging, test and prod for marginal ranking value. See docs/architecture/adr-001-recommendation-v2.md decision D3 for the full reasoning. Removed: - Gemfile / Gemfile.lock: neighbor gem dropped - app/services/recommendation/domain_embedder.rb - app/jobs/recommendation/embed_unembedded_domains_job.rb - test/services/recommendation/domain_embedder_test.rb - test/jobs/recommendation/embed_unembedded_domains_job_test.rb - Embedding multiplier, user centroid, cosine similarity helpers, embedding_for, EMBEDDING_DIMENSIONS, needs_embedding scope, and has_neighbors association from Scorer / DomainClassification. - ALLOWED_JOB_NAMES entry for EmbedUnembeddedDomainsJob. - `recommendation:embed_unembedded` rake task. Migration 20260527090100_enable_pgvector_and_add_embeddings now does the inverse: if a local environment previously applied the original version and has the embedding column, the up branch drops it cleanly. Production and fresh local environments hit a no-op. What stays: - domain_classifications table including description, keywords, audience, suggested_use_cases — all the rich fields the scorer actually uses. - LLM enrichment via nightly cron, heuristic Tier 0 at runtime. - Tag overlap, keyword overlap, behavioural affinity with time decay, audience inference, result signals, structural bonuses, view tracking. Docs updated: architecture/recommendation-system.md, technical/domain-classification-pipeline.md, guides/recommendation-operations.md, architecture/adr-001-recommendation-v2.md, CHANGELOG. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 +- Gemfile | 1 - Gemfile.lock | 3 - .../embed_unembedded_domains_job.rb | 60 ----------- app/models/domain_classification.rb | 10 -- app/models/job.rb | 1 - .../recommendation/domain_embedder.rb | 74 -------------- app/services/recommendation/scorer.rb | 99 +------------------ ...0100_enable_pgvector_and_add_embeddings.rb | 67 +++---------- .../architecture/adr-001-recommendation-v2.md | 61 +++++++----- docs/architecture/recommendation-system.md | 49 ++++----- docs/guides/recommendation-operations.md | 48 +++------ .../domain-classification-pipeline.md | 66 +++---------- lib/tasks/recommendation.rake | 9 -- .../embed_unembedded_domains_job_test.rb | 66 ------------- test/models/domain_classification_test.rb | 2 - .../recommendation/domain_embedder_test.rb | 58 ----------- 17 files changed, 109 insertions(+), 573 deletions(-) delete mode 100644 app/jobs/recommendation/embed_unembedded_domains_job.rb delete mode 100644 app/services/recommendation/domain_embedder.rb delete mode 100644 test/jobs/recommendation/embed_unembedded_domains_job_test.rb delete mode 100644 test/services/recommendation/domain_embedder_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a916948a..72595f18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ 27.05.2026 * Recommendation system v2: per-user auction sorting backed by domain_classifications, time-decayed bid/wishlist/view affinity, - optional pgvector embeddings for similarity matching, and nightly - LLM enrichment as a k8s CronJob (rake recommendation:classify_unclassified, - rake recommendation:embed_unembedded). See docs/architecture/recommendation-system.md. + and nightly LLM enrichment as a k8s CronJob + (rake recommendation:classify_unclassified). No shared-infrastructure + changes — no RDS extensions, no Docker image bumps for the shared + dev Postgres. See docs/architecture/recommendation-system.md and + docs/architecture/adr-001-recommendation-v2.md. * Batch impressions on /auctions index via insert_all (one query regardless of page size), debounced score recompute on user actions. diff --git a/Gemfile b/Gemfile index 44e106c1a..a9a238994 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,6 @@ gem 'omniauth-rails_csrf_protection' gem 'omniauth-tara', github: 'internetee/omniauth-tara' gem 'pagy', '~> 9.0' gem 'pdfkit' -gem 'neighbor', '~> 0.5' gem 'pg', '>= 0.18', '< 2.0' gem 'pg_search' gem 'propshaft' diff --git a/Gemfile.lock b/Gemfile.lock index 67df994c7..333c5f345 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -314,8 +314,6 @@ GEM msgpack (1.8.0) multipart-post (2.4.1) mutex_m (0.3.0) - neighbor (0.6.0) - activerecord (>= 7.1) net-http (0.9.1) uri (>= 0.11.1) net-imap (0.6.4) @@ -666,7 +664,6 @@ DEPENDENCIES minitest-mock money mutex_m - neighbor (~> 0.5) net-imap (>= 0.5.7) nokogiri (>= 1.18.9) noticed (~> 1.6.3) diff --git a/app/jobs/recommendation/embed_unembedded_domains_job.rb b/app/jobs/recommendation/embed_unembedded_domains_job.rb deleted file mode 100644 index f3466f7cc..000000000 --- a/app/jobs/recommendation/embed_unembedded_domains_job.rb +++ /dev/null @@ -1,60 +0,0 @@ -module Recommendation - # Cron-only job (rake recommendation:embed_unembedded). - # Picks classified rows that have a description but no embedding, - # batches them through DomainEmbedder, and persists the vectors. - class EmbedUnembeddedDomainsJob < ApplicationJob - MAX_DOMAINS_PER_RUN = 500 - BATCH_SIZE = Recommendation::DomainEmbedder::BATCH_LIMIT - - retry_on StandardError, wait: 30.seconds, attempts: 2 - - def perform - return unless Feature.open_ai_integration_enabled? - return unless DomainClassification.column_names.include?('embedding') - - rows = self.class.scope.limit(MAX_DOMAINS_PER_RUN).to_a - return if rows.empty? - - processed = 0 - rows.each_slice(BATCH_SIZE) do |batch| - results = Recommendation::DomainEmbedder.call(rows: batch) - processed += persist(results) - end - - Rails.logger.info("EmbedUnembeddedDomainsJob embedded #{processed} domains") - processed - end - - def self.scope - DomainClassification - .needs_embedding - .where.not(description: [nil, '']) - .order(:classified_at) - end - - def self.needs_to_run? - return false unless DomainClassification.column_names.include?('embedding') - - Feature.open_ai_integration_enabled? && scope.exists? - end - - private - - def persist(results) - return 0 if results.empty? - - by_name = results.index_by { |r| r[:domain_name] } - DomainClassification.where(domain_name: by_name.keys).find_each do |record| - payload = by_name[record.domain_name] - next unless payload - - record.update_columns( - embedding: payload[:embedding], - embedding_model: payload[:embedding_model], - embedded_at: payload[:embedded_at] - ) - end - results.size - end - end -end diff --git a/app/models/domain_classification.rb b/app/models/domain_classification.rb index 898b9614c..c8dc7998e 100644 --- a/app/models/domain_classification.rb +++ b/app/models/domain_classification.rb @@ -8,9 +8,6 @@ class DomainClassification < ApplicationRecord LOW_CONFIDENCE_THRESHOLD = 0.6 LLM_REFRESH_INTERVAL = 6.months - EMBEDDING_DIMENSIONS = 1536 - - has_neighbors :embedding if respond_to?(:has_neighbors) && column_names.include?('embedding') validates :domain_name, presence: true, uniqueness: { case_sensitive: false } validates :classification_source, inclusion: { in: SOURCES }, allow_nil: true @@ -32,13 +29,6 @@ class DomainClassification < ApplicationRecord .or(where('classification_source = ? AND classified_at < ?', OPENAI_SOURCE, LLM_REFRESH_INTERVAL.ago)) } scope :classified, -> { where.not(classified_at: nil) } - scope :needs_embedding, lambda { - if column_names.include?('embedding') - where(embedding: nil).where.not(description: nil) - else - none - end - } def heuristic? = classification_source == HEURISTIC_SOURCE def from_llm? = classification_source == OPENAI_SOURCE diff --git a/app/models/job.rb b/app/models/job.rb index a956edf3f..e6d8fb6dd 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -6,7 +6,6 @@ class Job SharedFooterFetcherJob DirectoInvoiceForwardJob ActiveAuctionsAiSortingJob Recommendation::ClassifyAuctionDomainsJob Recommendation::ClassifyUnclassifiedDomainsJob - Recommendation::EmbedUnembeddedDomainsJob Recommendation::RefreshUserAuctionScoresJob].freeze include ActiveModel::Model diff --git a/app/services/recommendation/domain_embedder.rb b/app/services/recommendation/domain_embedder.rb deleted file mode 100644 index 2c8adb0f6..000000000 --- a/app/services/recommendation/domain_embedder.rb +++ /dev/null @@ -1,74 +0,0 @@ -module Recommendation - # Wraps OpenAI text-embedding-3-small for batched embedding generation. - # Input: array of DomainClassification rows (or {domain_name:, description:, - # keywords:} hashes). Output: array of {domain_name:, embedding:} pairs. - # - # NOT called from runtime paths — only from EmbedUnembeddedDomainsJob (cron). - class DomainEmbedder - MODEL = 'text-embedding-3-small'.freeze - DIMENSIONS = 1536 - BATCH_LIMIT = 100 - - class << self - def call(...) - new(...).call - end - end - - def initialize(rows:) - @rows = Array(rows).first(BATCH_LIMIT) - end - - def call - return [] if @rows.empty? - - inputs = @rows.map { |row| build_input(row) } - vectors = fetch_embeddings(inputs) - - @rows.each_with_index.map do |row, index| - { - domain_name: domain_name_for(row), - embedding: vectors[index], - embedding_model: MODEL, - embedded_at: Time.current - } - end - rescue StandardError, OpenAI::Error => e - Rails.logger.warn("DomainEmbedder failed: #{e.message}") - raise - end - - private - - def fetch_embeddings(inputs) - client = OpenAI::Client.new - response = client.embeddings(parameters: { model: MODEL, input: inputs }) - - error = response.dig('error', 'message') - raise StandardError, error if error - - response.fetch('data').sort_by { |item| item['index'] }.map { |item| item.fetch('embedding') } - end - - def build_input(row) - [ - domain_name_for(row), - description_for(row), - keywords_for(row).join(', ') - ].reject(&:blank?).join('. ') - end - - def domain_name_for(row) - row.respond_to?(:domain_name) ? row.domain_name : row[:domain_name] - end - - def description_for(row) - row.respond_to?(:description) ? row.description : row[:description] - end - - def keywords_for(row) - raw = row.respond_to?(:keywords) ? row.keywords : row[:keywords] - Array(raw) - end - end -end diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb index 488857832..95180606e 100644 --- a/app/services/recommendation/scorer.rb +++ b/app/services/recommendation/scorer.rb @@ -11,11 +11,12 @@ module Recommendation # - Bid history (Offer, EnglishOffer's Offer parent, DomainOfferHistory) # - Auction outcomes (Result) — lost auctions boost similar domains # - Detail-page views (RecommendationEvent auction_detail_view) - # - Embedding-cosine similarity to user centroid (Phase 5+) # - # Rich features (keywords, audience, embedding) come from - # domain_classifications joined by domain_name. Legacy - # auctions.classification_tags remains a fallback during migration. + # Rich features (keywords, audience) come from domain_classifications + # joined by domain_name. Legacy auctions.classification_tags remains + # a fallback during migration. Vector similarity (pgvector) was + # considered and dropped from v2 to avoid shared-infra changes — + # see docs/architecture/adr-001-recommendation-v2.md. class Scorer SCORING_HORIZON = 30.days SIGNAL_LOOKBACK = 1.year @@ -134,7 +135,6 @@ def score_for(auction) score += hyphen_score(domain_name) score += ai_prior_score(auction) - score *= embedding_multiplier(auction) score.round(6) end @@ -168,13 +168,6 @@ def audience_for(auction) classification_for(auction)&.audience end - def embedding_for(auction) - dc = classification_for(auction) - return nil unless dc&.respond_to?(:embedding) - - dc.embedding - end - # ---------- Matchers -------------------------------------------------- def matching_interest_tags(tags) @@ -421,88 +414,6 @@ def preload_result_classifications(results) DomainClassification.where(domain_name: domain_names).index_by(&:domain_name) end - # ---------- Embedding multiplier ------------------------------------ - - def embedding_multiplier(auction) - return 1.0 unless DomainClassification.column_names.include?('embedding') - return 1.0 if user_embedding_centroid.nil? - - auction_embedding = embedding_for(auction) - return 1.0 if auction_embedding.nil? - - similarity = cosine_similarity(user_embedding_centroid, auction_embedding) - return 1.0 if similarity.nil? - - 1.0 + [similarity, 0.0].max - end - - def user_embedding_centroid - return @user_embedding_centroid if defined?(@user_embedding_centroid) - - @user_embedding_centroid = compute_user_centroid - end - - def compute_user_centroid - return nil unless DomainClassification.column_names.include?('embedding') - - signals = bid_domain_signals + wishlist_domain_signals + view_domain_signals - return nil if signals.empty? - - domain_names = signals.map { |s| s[:domain_name] }.uniq - embeddings = DomainClassification - .where(domain_name: domain_names) - .where.not(embedding: nil) - .index_by(&:domain_name) - return nil if embeddings.empty? - - sum = Array.new(DomainClassification::EMBEDDING_DIMENSIONS, 0.0) - total_weight = 0.0 - - signals.each do |signal| - dc = embeddings[signal[:domain_name]] - next unless dc&.embedding - - weight = decay_weight(signal[:age_days]) - vector = embedding_as_array(dc.embedding) - next if vector.nil? || vector.size != sum.size - - vector.each_with_index { |v, i| sum[i] += v * weight } - total_weight += weight - end - - return nil if total_weight.zero? - - sum.map { |v| v / total_weight } - end - - def cosine_similarity(a, b) - vec_b = embedding_as_array(b) - return nil if a.nil? || vec_b.nil? || a.size != vec_b.size - - dot = 0.0 - norm_a = 0.0 - norm_b = 0.0 - a.each_with_index do |val, i| - dot += val * vec_b[i] - norm_a += val * val - norm_b += vec_b[i] * vec_b[i] - end - - denom = Math.sqrt(norm_a) * Math.sqrt(norm_b) - return nil if denom.zero? - - dot / denom - end - - def embedding_as_array(embedding) - return embedding if embedding.is_a?(Array) - return embedding.to_a if embedding.respond_to?(:to_a) - - nil - rescue StandardError - nil - end - # ---------- Structural ---------------------------------------------- def similar_to_saved_domain?(domain_name) diff --git a/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb b/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb index d5ba4793e..5df6a6a5e 100644 --- a/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb +++ b/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb @@ -1,59 +1,24 @@ class EnablePgvectorAndAddEmbeddings < ActiveRecord::Migration[7.0] - class PgvectorUnavailable < StandardError; end - - # Set to true via the ENV var to skip embedding columns entirely if you - # are running on a Postgres image without pgvector and cannot upgrade - # right now (e.g. for a hotfix). The recommendation system degrades to - # tag/keyword scoring without similarity multiplier. - SKIP_ENV = 'SKIP_PGVECTOR_MIGRATION'.freeze - + # Phase 5 of recommendation v2 originally planned to add a pgvector + # `embedding` column to `domain_classifications`. That path was dropped + # to avoid touching the shared dev Postgres image and the shared RDS + # extension landscape (see docs/architecture/adr-001-recommendation-v2.md). + # + # This migration is intentionally a no-op so: + # - Fresh environments that have never applied it skip cleanly. + # - Local dev environments that earlier applied an embedding column + # get it removed on next run. + # - Production never had embedding columns, so this is harmless there. def up - if ENV[SKIP_ENV] == 'true' - say 'Skipping pgvector migration because SKIP_PGVECTOR_MIGRATION=true' - return + if column_exists?(:domain_classifications, :embedding) + execute 'DROP INDEX IF EXISTS idx_domain_classifications_embedding' + remove_column :domain_classifications, :embedding + remove_column :domain_classifications, :embedding_model if column_exists?(:domain_classifications, :embedding_model) + remove_column :domain_classifications, :embedded_at if column_exists?(:domain_classifications, :embedded_at) end - - ensure_pgvector_available! - enable_extension 'vector' unless extension_enabled?('vector') - - add_column :domain_classifications, :embedding, :vector, limit: 1536 - add_column :domain_classifications, :embedding_model, :string - add_column :domain_classifications, :embedded_at, :datetime - - # HNSW index on cosine distance for fast nearest-neighbor search. - execute <<~SQL - CREATE INDEX IF NOT EXISTS idx_domain_classifications_embedding - ON domain_classifications - USING hnsw (embedding vector_cosine_ops) - SQL end def down - execute 'DROP INDEX IF EXISTS idx_domain_classifications_embedding' - remove_column :domain_classifications, :embedded_at if column_exists?(:domain_classifications, :embedded_at) - remove_column :domain_classifications, :embedding_model if column_exists?(:domain_classifications, :embedding_model) - remove_column :domain_classifications, :embedding if column_exists?(:domain_classifications, :embedding) - # Intentionally do NOT disable the vector extension on rollback; - # other tables or environments may rely on it. - end - - private - - # Surfaces a clear error message instead of the cryptic - # `could not open extension control file "vector.control"` that - # PostgreSQL produces when the .so is missing. - def ensure_pgvector_available! - available = ActiveRecord::Base.connection - .select_value("SELECT 1 FROM pg_available_extensions WHERE name = 'vector'") - return if available - - raise PgvectorUnavailable, <<~MSG.squish - pgvector extension is not installed in this PostgreSQL instance. - Production (AWS RDS Postgres 17) has it natively. For local dev, - pull pgvector/pgvector:pg13 (or :pg17) instead of plain postgres - image — see docker-images/docker-compose.dev.v2.yml. To skip this - migration temporarily, run `SKIP_PGVECTOR_MIGRATION=true rails db:migrate` - and re-run later once the image is updated. - MSG + # Nothing to roll back to — the embedding feature is not part of v2. end end diff --git a/docs/architecture/adr-001-recommendation-v2.md b/docs/architecture/adr-001-recommendation-v2.md index d1f65bf1c..6800b9449 100644 --- a/docs/architecture/adr-001-recommendation-v2.md +++ b/docs/architecture/adr-001-recommendation-v2.md @@ -60,16 +60,34 @@ is ~$0.30/month, with ~$1 one-time backfill. because classification is computed once per domain and cached; the bandwidth cost of shipping a 10MB model to every browser exceeds the savings. -### D3. AWS RDS-native pgvector, no Dockerfile changes - -`vector` extension is in the RDS Postgres 17 allowlist. Migration calls -`enable_extension 'vector'`. If the app role lacks `CREATE EXTENSION`, an -operator runs the SQL once manually as `rds_superuser`. No changes required to -`Dockerfile`, `Dockerfile.staging`, `Dockerfile.test` (these are app-runtime -images — pgvector belongs on the DB host). - -**Why:** Lowest-friction route. Considered: separate Postgres image with -pre-baked pgvector. Rejected because RDS doesn't allow custom Postgres images. +### D3. No vector embeddings in v2 (reversal of earlier draft) + +Earlier drafts planned `text-embedding-3-small` embeddings stored in a +`vector(1536)` column with HNSW indexing for similarity-based ranking. +This was reversed before merge. + +**Why we backed out:** +- The shared dev Postgres image (`postgres:13.4` in + `docker-images/docker-compose.yml`) is used by every service on the + team (registry, registrar_center, billing, eeid, etc.). Switching it + to `pgvector/pgvector:pg13` would have been a patch-version bump on + shared infrastructure for the sake of one app. +- Production RDS does support pgvector natively, but rolling it out + consistently across dev/staging/test/prod adds operational risk for + marginal recommendation quality. +- Tag + keyword + behavioural affinity + time decay carries the bulk + of recommendation quality. The embedding multiplier was a 1.0..2.0 + bonus on top — useful but not load-bearing. + +**Future path** if embeddings become valuable: a sidecar pgvector pod +isolated to auction_center (separate StatefulSet in k8s, separate +ActiveRecord connection in `database.yml`). Main databases stay +untouched. This is deferred until there's a concrete user-visible +ranking problem the current signals can't solve. + +**What we kept:** the `domain_classifications` table including +`description`, `keywords`, `audience` and other rich fields that +feed the scorer directly without needing vectors. ### D4. Cron jobs scheduled outside the app @@ -93,15 +111,7 @@ floating-point noise floor. clock skew across regions. Decay is mathematically equivalent and simpler to reason about. -### D6. Embedding multiplier, not additive - -Final score = base_score * (1 + max(0, cosine_similarity)). User centroid is -the time-decayed weighted average of embeddings from bids, wishlist, and views. - -**Why:** Multiplicative form lets embedding act as a "boost knob" — it can't -introduce a high score in isolation (no behavioural data → no centroid → 1.0), -but it can lift a domain that other signals already mildly favour. Additive -form risks dominating the rule-based base when cosine is small. +### D6. ~~Embedding multiplier~~ — removed, see D3. ### D7. Heuristic-only at runtime, LLM-only via cron @@ -120,23 +130,26 @@ predictable to operate. - No user-visible latency tied to OpenAI. - Wishlist and historical bids on non-auction domains now contribute to recommendations. -- Embedding-based similarity available for future "domains like this" features. - Time decay means stale interests fade automatically; recent activity weighs more. +- Zero infrastructure changes outside auction_center. Shared dev Postgres + image, RDS extensions, Terraform — all untouched. **Negative** - Two writes per domain (heuristic, then LLM enrichment). Acceptable because Tier 0 produces useful tags immediately while Tier 2 enriches overnight. - More tables to operate. Mitigated by the operator runbook in `docs/guides/recommendation-operations.md`. -- pgvector requires one-time manual setup if the app DB role lacks the - `CREATE EXTENSION` grant. +- No vector similarity matching in v2. Mitigated by keyword overlap and + behavioural-tag affinity, which subsume the most common similarity use + cases at our domain volume. **Migration path** -1. Deploy. Migration enables pgvector and creates `domain_classifications`. +1. Deploy. Migrations create `domain_classifications` and add classification + fields to auctions. No extensions, no shared infra. 2. Run `rake recommendation:backfill` once. 3. Run `rake recommendation:classify_unclassified` manually for first LLM pass. -4. Schedule the two cron jobs in k8s. +4. Schedule one cron job in k8s (`recommendation:classify_unclassified` daily). 5. Optionally drop `auctions.classification_*` columns after a release of stable v2 operation. diff --git a/docs/architecture/recommendation-system.md b/docs/architecture/recommendation-system.md index 410ac27ca..06d161a97 100644 --- a/docs/architecture/recommendation-system.md +++ b/docs/architecture/recommendation-system.md @@ -22,6 +22,12 @@ Sort is **per-user**. Same `/auctions` request returns N different orderings for - No global feed cache (sort is per-user) - No client-side ranking for v2 (server is the source of truth) - No real-time LLM calls during user requests (LLM is batch-only, cron-driven) +- **No vector embeddings in v2** — pgvector was evaluated and dropped to avoid + shared-infrastructure churn (RDS extensions, Docker image bumps for the + team-shared Postgres). Tag + keyword + behavioural affinity carries most of + the recommendation value. Embeddings can be added later as a sidecar + vector database (separate Postgres pod, not the shared one) without + schema migrations to the main database. ## High-level data flow @@ -46,16 +52,9 @@ keywords, suggested_use_cases, brandability_score source='openai' | v -[ Nightly k8s CronJob: rake recommendation:embed_unembedded ] - | - v -OpenAI text-embedding-3-small (100 domains / call) -embedding vector(1536) stored - | - v Scorer per user-event: - bid affinity, wishlist affinity, view affinity, - embedding-cosine multiplier, time decay + bid affinity, wishlist affinity, view affinity, time decay, + tag/keyword overlap, structural bonuses | v user_auction_scores (upsert, unique on user_id + auction_id) @@ -85,7 +84,6 @@ Single source of truth for what a domain *means*. One row per `domain_name`. | `has_digits`, `has_hyphens`, `token_count`, `dictionary_word`, `brandability_score` | structural cache | | `classification_source` | heuristic / openai / manual / imported | | `confidence` (0..1) | gate for "stale, needs LLM re-run" | -| `embedding` (vector(1536)) | OpenAI text-embedding-3-small, HNSW indexed | | `raw_llm_response` (jsonb) | audit trail, allows re-parsing without re-billing | ### `recommendation_events` (existing) @@ -125,7 +123,6 @@ Tier 1 — (future, optional) embedding-based local classifier | schedule | task | purpose | |---|---|---| | `0 3 * * *` (03:00 daily) | `rake recommendation:classify_unclassified` | Tier 2 enrichment for heuristic-only/low-confidence/stale rows | -| `30 3 * * *` (03:30 daily) | `rake recommendation:embed_unembedded` | OpenAI embeddings for classified-but-unembedded rows | | one-shot | `rake recommendation:backfill` | Initial classification of all historical domains | K8s manifests live in `Ry_AWS_IaC/infrastructure/kubernetes` — outside this repo. @@ -138,44 +135,38 @@ Final score per (user, auction) is a weighted sum + multiplier: score = 0 + 120 if wishlist hit + matching_tags * 35 category overlap - + matching_keywords * 15 NEW — keyword overlap - + audience_match * 10 NEW - + bid_feature_aggregate (decay-weighted) NEW — uses domain_classifications - + wishlist_feature_aggregate (decay) CHANGED — uses domain_classifications - + view_feature_aggregate (decay) NEW + + matching_keywords * 15 keyword overlap + + audience_match * 10 dominant-audience inference + + bid_feature_aggregate (decay-weighted) uses domain_classifications + + wishlist_feature_aggregate (decay) uses domain_classifications + + view_feature_aggregate (decay) from auction_detail_view events + similar_to_saved_domain * 15 + preferred_length_match * 10 + digit_score -20 .. +8 + hyphen_score -12 .. +5 + ai_prior_score existing legacy - + domain_offer_history_signal NEW - + result_signal NEW (won: weak negative; lost: strong positive) - -multiplier = 1 + cosine_similarity(auction.embedding, user_centroid_embedding) -score = score * multiplier + + domain_offer_history_signal historical bids + + result_signal won: weak negative; lost: strong positive ``` -User centroid embedding = weighted average of embeddings from user's bids + wishlist + recent views (time-decayed). - Time decay: `weight *= exp(-days_old / 60)` (half-life 60 days). ## Infrastructure dependencies | Dependency | Source | Status | |---|---|---| -| pgvector extension | AWS RDS Postgres 17.4 (native support since 15.2) | needs one-time `CREATE EXTENSION vector` per environment | | OpenAI API | existing integration via `Feature.open_ai_integration_enabled?` | reused | | K8s CronJobs | maintained in `Ry_AWS_IaC` | scheduled by infra team | +**No infrastructure changes are required for v2.** No new extensions on RDS, +no Docker image bumps, no schema changes outside the auction_center database. + ## Cost estimate - **Backfill** (one-time): ~5000 historical unique domains - - LLM classify: ~100 batches × ~3000 tokens = ~$0.50-1 - - Embeddings: 5000 × ~50 tokens = ~$0.005 - - Total: **~$1** + - LLM classify: ~100 batches × ~3000 tokens = **~$0.50-1** - **Steady state** (per day): - LLM classify: 5-20 new domains/day, batched = 0-1 OpenAI call = **~$0.01/day** - - Embeddings: 1 call/day = **~$0.0001/day** - Total: **~$0.30/month** ## Implementation phases @@ -190,7 +181,7 @@ See [domain-classification-pipeline.md](../technical/domain-classification-pipel | 3a | DomainClassifier orchestrator (heuristic only) | done | | 3b | LLM batch enrichment job (cron) | done | | 4 | Triggers + backfill rake | done | -| 5 | pgvector + embeddings batch job | done | +| 5 | ~~pgvector + embeddings batch job~~ | **dropped** — see ADR-001 | | 6 | Rich-feature Scorer + embedding similarity + time decay | done | | 7 | Detail view tracking + view affinity | done | | 8 | Show domain description in auction card | done | diff --git a/docs/guides/recommendation-operations.md b/docs/guides/recommendation-operations.md index 29147414f..353652a0d 100644 --- a/docs/guides/recommendation-operations.md +++ b/docs/guides/recommendation-operations.md @@ -10,10 +10,12 @@ for pipeline internals, see | Item | Where | Notes | |---|---|---| -| pgvector extension enabled | AWS RDS Postgres 17 (prod) / pgvector/pgvector:pg13 (dev) | `CREATE EXTENSION IF NOT EXISTS vector;` as `rds_superuser` once per environment. The Rails migration tries this automatically; if the app role lacks `CREATE`, run it manually then run `rails db:migrate`. For local dev, ensure `docker-images/docker-compose.dev.v2.yml` uses `pgvector/pgvector:pg13` (NOT plain `postgres:13.x`). Set `SKIP_PGVECTOR_MIGRATION=true` as a temporary escape hatch if needed; embedding similarity will be inactive until rerun. | -| `Feature.open_ai_integration_enabled?` | App settings | Must be true for LLM enrichment and embeddings. The recommendation profile UI, heuristic classifier, and scorer work without it. | +| `Feature.open_ai_integration_enabled?` | App settings | Must be true for LLM enrichment. The recommendation profile UI, heuristic classifier, and scorer work without it. | | `openai_model` Setting | DB seed | Currently `gpt-5`. `OpenaiStructuredOutputSupport` will fall back to a safe default if a non-supporting model is configured. | -| OpenAI API key | Rails credentials / env | Existing integration used by both `LlmDomainClassifier` and `DomainEmbedder`. | +| OpenAI API key | Rails credentials / env | Existing integration used by `LlmDomainClassifier`. | + +**No special database setup required.** No extensions, no shared-image changes, +no schema changes outside auction_center's own database. ## Kubernetes cron schedule @@ -23,7 +25,6 @@ All recurring work runs as k8s `CronJob` resources defined in | schedule | command | purpose | |---|---|---| | `0 3 * * *` | `bundle exec rake recommendation:classify_unclassified` | Tier 2 LLM enrichment of heuristic / low-conf / stale rows | -| `30 3 * * *` | `bundle exec rake recommendation:embed_unembedded` | Generate OpenAI embeddings for classified-but-unembedded rows | | one-shot | `bundle exec rake recommendation:backfill` | Initial heuristic classification of all historical domains | Each task is wrapped by an idempotent ActiveJob. Re-running mid-day @@ -32,18 +33,15 @@ is safe: nothing duplicates, fresh rows are skipped. ## First-time rollout checklist 1. Deploy the branch. -2. Run migrations (`rails db:migrate`). If pgvector enable_extension - fails for permission reasons, run `CREATE EXTENSION vector` as - `rds_superuser`, then re-run `db:migrate`. +2. Run migrations (`rails db:migrate`). All migrations are plain + schema changes — no extensions, no shared-DB modifications. 3. Run `rake recommendation:backfill`. Watch the log line `BackfillDomainClassificationsJob created N classifications` to confirm scope. 4. (Optional, recommended) Manually run `rake recommendation:classify_unclassified` once to perform the first LLM enrichment immediately rather than waiting for cron. -5. (Optional) Manually run `rake recommendation:embed_unembedded` for - the first embedding sweep. -6. Verify `/auctions` renders descriptions on cards with classified +5. Verify `/auctions` renders descriptions on cards with classified domains. ## Monitoring @@ -51,8 +49,7 @@ is safe: nothing duplicates, fresh rows are skipped. | signal | check | |---|---| | LLM enrichment progress | `DomainClassification.needs_llm_enrichment.count` should trend toward zero. Tail logs for `ClassifyUnclassifiedDomainsJob processed N domains`. | -| Embedding backlog | `DomainClassification.needs_embedding.count` ditto. | -| Daily OpenAI cost | OpenAI dashboard. At steady state expect ~$0.01/day for classification and ~$0.0001/day for embeddings. | +| Daily OpenAI cost | OpenAI dashboard. At steady state expect ~$0.01/day for classification. | | Score freshness | `UserAuctionScore.maximum(:calculated_at)` should be within minutes for active users. | | Tracking failures | `Rails.logger.warn` lines from `EventTracker`. | @@ -74,39 +71,20 @@ them requires a deploy — no DB migration needed. To pause the system without removing it: -1. Set `Feature.open_ai_integration_enabled?` to false. LLM and - embedding jobs become no-ops; heuristic-only continues. +1. Set `Feature.open_ai_integration_enabled?` to false. LLM enrichment + becomes a no-op; heuristic-only continues. 2. Drop the k8s CronJobs. 3. The legacy sort still works because `Auction::UserSortable` falls back through `user_auction_scores` → interest match → ai_score → random. To roll back entirely: -1. `rails db:rollback STEP=2` removes pgvector columns and the - classifications table. +1. `rails db:rollback STEP=2` removes the domain_classifications + table and the no-op pgvector placeholder. 2. Revert Phase 6 commit; scorer reverts to v1 baseline. ## Troubleshooting -**"extension vector is not allowlisted"** — On RDS, run -`SHOW rds.extensions;` to confirm `vector` is present. It is on -Postgres 15+ by default. If absent, contact AWS support — but Postgres -17 always has it. - -**"could not open extension control file vector.control"** — The -PostgreSQL image does not ship pgvector. This happens with plain -`postgres:13`. Switch the Docker image to `pgvector/pgvector:pg13` -(or `:pg17` if you upgrade Postgres), restart the container, and -re-run the migration. The data volume is compatible — same major -version. If you cannot upgrade right now and need to ship something, -`SKIP_PGVECTOR_MIGRATION=true rails db:migrate` lets you proceed -without the embedding column; rerun the migration later. - -**"permission denied to create extension"** — App user lacks -`rds_superuser`. Run the `CREATE EXTENSION` manually as the master -user once per environment, then mark the migration as up with -`rails db:migrate:up VERSION=20260527090100`. - **Description shows in wrong language** — `description_locale` is chosen by the LLM at classification time. If you want a different locale, re-run classification with `force: true` after updating the diff --git a/docs/technical/domain-classification-pipeline.md b/docs/technical/domain-classification-pipeline.md index d9f60f549..e1c9136e4 100644 --- a/docs/technical/domain-classification-pipeline.md +++ b/docs/technical/domain-classification-pipeline.md @@ -11,12 +11,10 @@ app/services/recommendation/ domain_heuristic_classifier.rb # dictionary + subword tokenizer domain_dictionary.rb # ESTONIAN_ROOTS + ENGLISH_ROOTS hashes llm_domain_classifier.rb # Tier 2 — only called from cron job - domain_embedder.rb # OpenAI text-embedding-3-small wrapper app/jobs/recommendation/ classify_domain_heuristically_job.rb # instant, triggered on events classify_unclassified_domains_job.rb # cron, batched LLM - embed_unembedded_domains_job.rb # cron, batched embeddings backfill_domain_classifications_job.rb # one-shot lib/tasks/recommendation.rake # entry points for k8s CronJobs @@ -126,56 +124,20 @@ DomainClassification `raw_llm_response` (jsonb) keeps the parsed response. If schema changes later, we can re-parse from `raw_llm_response` without re-billing OpenAI. -## Embedding pipeline +## Embedding pipeline — dropped from v2 -### Trigger - -`rake recommendation:embed_unembedded` (k8s CronJob, daily 03:30). - -Selects rows where: - -```ruby -DomainClassification - .where(embedding: nil) - .where.not(description: nil) - .limit(MAX_DOMAINS_PER_RUN) -``` - -### Input - -Concatenation of `domain_name + description + keywords` produces semantic context for the embedder. - -### Model - -`text-embedding-3-small` (1536 dim, $0.02/1M tokens). +Vector embeddings were planned as `text-embedding-3-small` + pgvector +HNSW index for cosine similarity, with a per-auction multiplier on top +of the base score. This was removed before merge because pgvector would +have required either bumping the shared dev Postgres image +(`postgres:13.4` → `pgvector/pgvector:pg13`) for the whole team or +adding extension provisioning to the shared RDS — both reach beyond +auction_center's scope. -### Storage - -`vector(1536)` column in `domain_classifications`. HNSW index with cosine ops: - -```sql -CREATE INDEX ON domain_classifications USING hnsw (embedding vector_cosine_ops); -``` - -### Usage in Scorer - -User centroid embedding: - -```ruby -user_centroid = weighted_average( - bid_embeddings.map { |emb, days_old| [emb, exp(-days_old / 60)] } + - wishlist_embeddings.map { ... } + - view_embeddings.map { ... } -) -``` - -Per-auction multiplier: - -```ruby -similarity = cosine(user_centroid, auction.embedding) # -1..1 -multiplier = 1 + max(0, similarity) # 1..2 -final_score *= multiplier -``` +If we want vector similarity later, the recommended path is a sidecar +pgvector pod isolated to auction_center (separate StatefulSet, separate +ActiveRecord connection), leaving the main databases untouched. See +ADR-001 for the rationale. ## Triggers (instant heuristic only) @@ -214,8 +176,7 @@ Existing `auctions.classification_tags / primary_category / classification_sourc | `DomainClassifier` (orchestrator) | upserts row with `source='heuristic'`; skips fresh rows | | `LlmDomainClassifier` | mocked OpenAI; verifies prompt and parsing | | `ClassifyUnclassifiedDomainsJob` | scope selection, batching, no LLM call when scope empty | -| `DomainEmbedder` | mocked OpenAI; vector shape | -| `Scorer` (extended) | tag + keyword + audience + embedding paths verified | +| `Scorer` (extended) | tag + keyword + audience + behavioural affinity paths verified | ## Operations @@ -224,4 +185,3 @@ Existing `auctions.classification_tags / primary_category / classification_sourc | Daily LLM cost | OpenAI dashboard + log lines from `LlmDomainClassifier` | | Failed classifications | `Rails.logger.warn` from `ClassifyUnclassifiedDomainsJob` | | Backlog of unclassified | `DomainClassification.where(classification_source: ['heuristic', nil]).count` | -| Embedding backlog | `DomainClassification.where(embedding: nil).where.not(description: nil).count` | diff --git a/lib/tasks/recommendation.rake b/lib/tasks/recommendation.rake index 2d7fc7ffc..c74874a9e 100644 --- a/lib/tasks/recommendation.rake +++ b/lib/tasks/recommendation.rake @@ -4,15 +4,6 @@ namespace :recommendation do Recommendation::ClassifyUnclassifiedDomainsJob.perform_now end - desc 'Cron entry point: embed classified-but-unembedded domains via OpenAI (batched)' - task embed_unembedded: :environment do - if defined?(Recommendation::EmbedUnembeddedDomainsJob) - Recommendation::EmbedUnembeddedDomainsJob.perform_now - else - puts 'EmbedUnembeddedDomainsJob is not yet available. Skipping.' - end - end - desc 'One-shot: classify all historical domains via heuristic (LLM picks up later)' task backfill: :environment do if defined?(Recommendation::BackfillDomainClassificationsJob) diff --git a/test/jobs/recommendation/embed_unembedded_domains_job_test.rb b/test/jobs/recommendation/embed_unembedded_domains_job_test.rb deleted file mode 100644 index e7cc35898..000000000 --- a/test/jobs/recommendation/embed_unembedded_domains_job_test.rb +++ /dev/null @@ -1,66 +0,0 @@ -require 'test_helper' - -module Recommendation - class EmbedUnembeddedDomainsJobTest < ActiveJob::TestCase - def setup - super - DomainClassification.delete_all - end - - def test_no_op_without_embedding_column - # If pgvector migration has not run yet, the column is absent - # and the job should bail out cleanly. - skip 'embedding column present' if DomainClassification.column_names.include?('embedding') - - assert_nothing_raised do - Recommendation::EmbedUnembeddedDomainsJob.new.perform - end - end - - def test_no_op_when_openai_disabled - DomainClassification.create!( - domain_name: 'pending-embed.ee', - description: 'desc', - keywords: %w[k1], - classification_source: DomainClassification::OPENAI_SOURCE, - classified_at: 1.hour.ago, - confidence: 0.9 - ) - - with_feature_flag(false) do - assert_nil Recommendation::EmbedUnembeddedDomainsJob.new.perform - end - end - - def test_scope_skips_rows_without_description - no_description = DomainClassification.create!( - domain_name: 'no-desc.ee', - classification_source: DomainClassification::HEURISTIC_SOURCE, - classified_at: 1.hour.ago, - confidence: 0.4 - ) - - with_description = DomainClassification.create!( - domain_name: 'has-desc.ee', - description: 'has description', - classification_source: DomainClassification::OPENAI_SOURCE, - classified_at: 1.hour.ago, - confidence: 0.9 - ) - - scope_ids = Recommendation::EmbedUnembeddedDomainsJob.scope.pluck(:id) - refute_includes scope_ids, no_description.id - assert_includes scope_ids, with_description.id if DomainClassification.column_names.include?('embedding') - end - - private - - def with_feature_flag(enabled) - original = Feature.method(:open_ai_integration_enabled?) - Feature.define_singleton_method(:open_ai_integration_enabled?) { enabled } - yield - ensure - Feature.define_singleton_method(:open_ai_integration_enabled?, original) - end - end -end diff --git a/test/models/domain_classification_test.rb b/test/models/domain_classification_test.rb index 1ebb166e3..0e9fd43f4 100644 --- a/test/models/domain_classification_test.rb +++ b/test/models/domain_classification_test.rb @@ -55,6 +55,4 @@ def test_needs_llm_enrichment_picks_stale_llm_rows assert_includes DomainClassification.needs_llm_enrichment.to_a, stale end - # needs_embedding scope is covered in Phase 5 test suite once the - # embedding column is added by the pgvector migration. end diff --git a/test/services/recommendation/domain_embedder_test.rb b/test/services/recommendation/domain_embedder_test.rb deleted file mode 100644 index c663dcab8..000000000 --- a/test/services/recommendation/domain_embedder_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -require 'test_helper' - -module Recommendation - class DomainEmbedderTest < ActiveSupport::TestCase - def test_returns_aligned_embeddings_for_each_row - rows = [ - { domain_name: 'a.ee', description: 'first', keywords: %w[one] }, - { domain_name: 'b.ee', description: 'second', keywords: %w[two] } - ] - - stub_embedding_request(2) - - result = Recommendation::DomainEmbedder.call(rows: rows) - assert_equal 2, result.size - assert_equal 'a.ee', result.first[:domain_name] - assert_equal Recommendation::DomainEmbedder::DIMENSIONS, result.first[:embedding].size - assert_equal Recommendation::DomainEmbedder::MODEL, result.first[:embedding_model] - assert result.first[:embedded_at].is_a?(Time) - end - - def test_handles_active_record_rows - classification = DomainClassification.create!( - domain_name: 'ar.ee', - description: 'AR test', - keywords: %w[active record] - ) - stub_embedding_request(1) - - result = Recommendation::DomainEmbedder.call(rows: [classification]) - assert_equal 'ar.ee', result.first[:domain_name] - assert_equal Recommendation::DomainEmbedder::DIMENSIONS, result.first[:embedding].size - end - - def test_empty_input_returns_empty - result = Recommendation::DomainEmbedder.call(rows: []) - assert_equal [], result - end - - def test_raises_on_openai_error - stub_request(:post, 'https://api.openai.com/v1/embeddings') - .to_return_json(status: 200, body: { 'error' => { 'message' => 'oops' } }, headers: {}) - - assert_raises(StandardError) do - Recommendation::DomainEmbedder.call(rows: [{ domain_name: 'x.ee', description: 'x', keywords: [] }]) - end - end - - private - - def stub_embedding_request(count) - data = count.times.map do |i| - { 'index' => i, 'embedding' => Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.1) } - end - stub_request(:post, 'https://api.openai.com/v1/embeddings') - .to_return_json(status: 200, body: { 'data' => data, 'model' => 'text-embedding-3-small' }, headers: {}) - end - end -end From 9dbff94d65ae857da36a68d72fb90debe9abcb40 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 28 May 2026 12:04:07 +0300 Subject: [PATCH 18/42] Remove description from domain_classifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description was originally added to back UI card copy and to feed the embedding text input. With embeddings dropped (and now coming back as keywords-only input — see follow-up commit) the description no longer earns its OpenAI tokens. Schema: - New migration 20260527090200 removes description and description_locale columns from domain_classifications. Services: - LlmDomainClassifier JSON schema and system prompt drop the description / description_locale fields. Required-fields list trimmed accordingly. - DomainHeuristicClassifier no longer emits description / locale in its attribute hash. - DomainClassifier orchestrator's preserve_llm_fields! no longer references description fields. UI: - _auction.html.erb stops rendering the description paragraph. Keywords-as-badges remain. - _recommendation.scss simplified to keyword-badge styling only. Tests: - LlmDomainClassifierTest assertions and fixtures dropped the two description fields. - DomainClassifierTest preserve-LLM-fields case switched to assert keywords are preserved instead of description. Docs: - recommendation-system.md, adr-001, and operations runbook updated to reflect the schema change and the keyword-only UX. structure.sql includes the original create-table migration run from local dev — the new remove-column migration will rewrite it cleanly on next db:migrate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/_recommendation.scss | 10 +- .../recommendation/domain_classifier.rb | 4 +- .../domain_heuristic_classifier.rb | 2 - .../recommendation/llm_domain_classifier.rb | 18 +-- app/views/auctions/_auction.html.erb | 5 - ...description_from_domain_classifications.rb | 10 ++ db/structure.sql | 123 ++++++++++++++++++ .../architecture/adr-001-recommendation-v2.md | 9 +- docs/architecture/recommendation-system.md | 11 +- docs/guides/recommendation-operations.md | 12 +- .../recommendation/domain_classifier_test.rb | 6 +- .../llm_domain_classifier_test.rb | 6 - 12 files changed, 159 insertions(+), 57 deletions(-) create mode 100644 db/migrate/20260527090200_remove_description_from_domain_classifications.rb diff --git a/app/assets/stylesheets/components/_recommendation.scss b/app/assets/stylesheets/components/_recommendation.scss index 6d9986e65..5a9558186 100644 --- a/app/assets/stylesheets/components/_recommendation.scss +++ b/app/assets/stylesheets/components/_recommendation.scss @@ -1,17 +1,9 @@ // Recommendation system v2 — auction card enrichment. // // Rendered by app/views/auctions/_auction.html.erb when the matching -// domain_classifications row has description / keywords populated. +// domain_classifications row has keywords populated. // Degrades silently when no classification is available. -.c-auction__domain-description { - margin: 4px 0 0; - color: #6b6b6b; - font-size: 0.85em; - line-height: 1.35; - max-width: 28rem; -} - .c-auction__domain-keywords { margin-top: 4px; display: flex; diff --git a/app/services/recommendation/domain_classifier.rb b/app/services/recommendation/domain_classifier.rb index ba67e0b27..629628b74 100644 --- a/app/services/recommendation/domain_classifier.rb +++ b/app/services/recommendation/domain_classifier.rb @@ -49,8 +49,8 @@ def fresh?(record) # structural and provenance metadata in that case. def preserve_llm_fields!(record, attributes) %i[ - description description_locale keywords audience languages - suggested_use_cases primary_category tags brandability_score + keywords audience languages suggested_use_cases + primary_category tags brandability_score confidence classification_source classification_model classified_at ].each { |field| attributes.delete(field) if record.send(field).present? } end diff --git a/app/services/recommendation/domain_heuristic_classifier.rb b/app/services/recommendation/domain_heuristic_classifier.rb index 4627517bb..432a02a8f 100644 --- a/app/services/recommendation/domain_heuristic_classifier.rb +++ b/app/services/recommendation/domain_heuristic_classifier.rb @@ -35,8 +35,6 @@ def call languages: derive_languages, audience: nil, suggested_use_cases: [], - description: nil, - description_locale: nil, has_digits: @structure[:has_digits], has_hyphens: @structure[:has_hyphens], token_count: @structure[:token_count], diff --git a/app/services/recommendation/llm_domain_classifier.rb b/app/services/recommendation/llm_domain_classifier.rb index 167242662..0325d73a4 100644 --- a/app/services/recommendation/llm_domain_classifier.rb +++ b/app/services/recommendation/llm_domain_classifier.rb @@ -12,7 +12,6 @@ class LlmDomainClassifier BATCH_LIMIT = 50 AUDIENCE_VALUES = %w[b2b b2c mixed unclear].freeze - DESCRIPTION_LOCALES = %w[en et].freeze class << self def call(...) @@ -91,8 +90,6 @@ def classification_item_schema domain_name: { type: 'string' }, primary_category: { type: 'string', enum: Recommendation::InterestCatalog.categories }, tags: { type: 'array', items: { type: 'string', enum: Recommendation::InterestCatalog.categories } }, - description: { type: 'string' }, - description_locale: { type: 'string', enum: DESCRIPTION_LOCALES }, keywords: { type: 'array', items: { type: 'string' } }, audience: { type: 'string', enum: AUDIENCE_VALUES }, languages: { type: 'array', items: { type: 'string' } }, @@ -101,8 +98,8 @@ def classification_item_schema confidence: { type: 'number' } }, required: %w[ - domain_name primary_category tags description description_locale - keywords audience languages suggested_use_cases brandability_score confidence + domain_name primary_category tags keywords audience languages + suggested_use_cases brandability_score confidence ], additionalProperties: false } @@ -127,11 +124,6 @@ def system_message - primary_category and tags MUST come from this fixed vocabulary: #{Recommendation::InterestCatalog.categories.join(', ')}. - tags: 1 to 4 entries; primary_category must be one of them. - - description: 1-2 sentences, neutral, marketing-style, no fluff, - no claims about ownership, no inventing facts. If domain meaning - is unclear, say so plainly. - - description_locale: 'et' if the domain is clearly Estonian - (Estonian word/root), otherwise 'en'. - keywords: 2-6 lowercase semantic tokens extracted or inferred from the domain. No stopwords. - audience: 'b2b' for business buyers, 'b2c' for consumers, @@ -164,8 +156,6 @@ def build_attributes(entry, raw_response) domain_name: domain_name, primary_category: primary, tags: tags, - description: entry['description'].to_s.strip.presence, - description_locale: sanitize_locale(entry['description_locale']), keywords: Array(entry['keywords']).map { |k| k.to_s.strip.downcase }.reject(&:blank?).uniq, audience: AUDIENCE_VALUES.include?(entry['audience']) ? entry['audience'] : nil, languages: Array(entry['languages']).map { |l| l.to_s.strip.downcase }.reject(&:blank?).uniq, @@ -191,10 +181,6 @@ def sanitize_category(value) Recommendation::InterestCatalog.categories.include?(cleaned) ? cleaned : nil end - def sanitize_locale(value) - DESCRIPTION_LOCALES.include?(value) ? value : 'en' - end - def clamp_unit(value) return nil if value.nil? diff --git a/app/views/auctions/_auction.html.erb b/app/views/auctions/_auction.html.erb index 9e8ee9c79..02b62dbd5 100644 --- a/app/views/auctions/_auction.html.erb +++ b/app/views/auctions/_auction.html.erb @@ -13,11 +13,6 @@

<%= auction.domain_name %>

- <% if domain_classification&.description.present? %> -

- <%= domain_classification.description %> -

- <% end %> <% if domain_classification&.keywords.present? %>
<% domain_classification.keywords.first(4).each do |keyword| %> diff --git a/db/migrate/20260527090200_remove_description_from_domain_classifications.rb b/db/migrate/20260527090200_remove_description_from_domain_classifications.rb new file mode 100644 index 000000000..d4e2e5971 --- /dev/null +++ b/db/migrate/20260527090200_remove_description_from_domain_classifications.rb @@ -0,0 +1,10 @@ +class RemoveDescriptionFromDomainClassifications < ActiveRecord::Migration[7.0] + # Description was originally added to back UI auction-card copy and to + # feed the (later dropped) embedding text input. With keywords + tags + # carrying both the UX and recommendation needs, we drop description + # to save OpenAI tokens at classification time. + def change + remove_column :domain_classifications, :description, :text + remove_column :domain_classifications, :description_locale, :string, default: 'en' + end +end diff --git a/db/structure.sql b/db/structure.sql index 97851b123..02f14b25a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1182,6 +1182,56 @@ CREATE SEQUENCE public.directo_customers_id_seq ALTER SEQUENCE public.directo_customers_id_seq OWNED BY public.directo_customers.id; +-- +-- Name: domain_classifications; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.domain_classifications ( + id bigint NOT NULL, + domain_name character varying NOT NULL, + uuid uuid DEFAULT gen_random_uuid() NOT NULL, + primary_category character varying, + tags character varying[] DEFAULT '{}'::character varying[] NOT NULL, + description text, + description_locale character varying DEFAULT 'en'::character varying, + keywords character varying[] DEFAULT '{}'::character varying[] NOT NULL, + audience character varying, + languages character varying[] DEFAULT '{}'::character varying[] NOT NULL, + suggested_use_cases character varying[] DEFAULT '{}'::character varying[] NOT NULL, + has_digits boolean DEFAULT false NOT NULL, + has_hyphens boolean DEFAULT false NOT NULL, + token_count integer, + dictionary_word boolean DEFAULT false NOT NULL, + brandability_score numeric(4,3), + classification_source character varying, + classification_model character varying, + confidence numeric(4,3), + classified_at timestamp(6) without time zone, + raw_llm_response jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: domain_classifications_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.domain_classifications_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: domain_classifications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.domain_classifications_id_seq OWNED BY public.domain_classifications.id; + + -- -- Name: domain_participate_auctions; Type: TABLE; Schema: public; Owner: - -- @@ -1992,6 +2042,13 @@ ALTER TABLE ONLY public.delayed_jobs ALTER COLUMN id SET DEFAULT nextval('public ALTER TABLE ONLY public.directo_customers ALTER COLUMN id SET DEFAULT nextval('public.directo_customers_id_seq'::regclass); +-- +-- Name: domain_classifications id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.domain_classifications ALTER COLUMN id SET DEFAULT nextval('public.domain_classifications_id_seq'::regclass); + + -- -- Name: domain_participate_auctions id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2343,6 +2400,14 @@ ALTER TABLE ONLY public.directo_customers ADD CONSTRAINT directo_customers_pkey PRIMARY KEY (id); +-- +-- Name: domain_classifications domain_classifications_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.domain_classifications + ADD CONSTRAINT domain_classifications_pkey PRIMARY KEY (id); + + -- -- Name: domain_participate_auctions domain_participate_auctions_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -2773,6 +2838,62 @@ CREATE UNIQUE INDEX index_directo_customers_on_customer_code ON public.directo_c CREATE UNIQUE INDEX index_directo_customers_on_vat_number ON public.directo_customers USING btree (vat_number); +-- +-- Name: index_domain_classifications_on_audience; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_domain_classifications_on_audience ON public.domain_classifications USING btree (audience); + + +-- +-- Name: index_domain_classifications_on_classification_source; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_domain_classifications_on_classification_source ON public.domain_classifications USING btree (classification_source); + + +-- +-- Name: index_domain_classifications_on_classified_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_domain_classifications_on_classified_at ON public.domain_classifications USING btree (classified_at); + + +-- +-- Name: index_domain_classifications_on_domain_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_domain_classifications_on_domain_name ON public.domain_classifications USING btree (domain_name); + + +-- +-- Name: index_domain_classifications_on_keywords; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_domain_classifications_on_keywords ON public.domain_classifications USING gin (keywords); + + +-- +-- Name: index_domain_classifications_on_primary_category; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_domain_classifications_on_primary_category ON public.domain_classifications USING btree (primary_category); + + +-- +-- Name: index_domain_classifications_on_tags; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_domain_classifications_on_tags ON public.domain_classifications USING gin (tags); + + +-- +-- Name: index_domain_classifications_on_uuid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_domain_classifications_on_uuid ON public.domain_classifications USING btree (uuid); + + -- -- Name: index_domain_participate_auctions_on_auction_id; Type: INDEX; Schema: public; Owner: - -- @@ -3395,6 +3516,8 @@ ALTER TABLE ONLY public.invoices SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260527090100'), +('20260527090000'), ('20260525115000'), ('20260525095700'), ('20260525095600'), diff --git a/docs/architecture/adr-001-recommendation-v2.md b/docs/architecture/adr-001-recommendation-v2.md index 6800b9449..848b2257a 100644 --- a/docs/architecture/adr-001-recommendation-v2.md +++ b/docs/architecture/adr-001-recommendation-v2.md @@ -86,8 +86,13 @@ untouched. This is deferred until there's a concrete user-visible ranking problem the current signals can't solve. **What we kept:** the `domain_classifications` table including -`description`, `keywords`, `audience` and other rich fields that -feed the scorer directly without needing vectors. +`keywords`, `audience`, `tags`, `suggested_use_cases` and other +rich fields that feed the scorer directly without needing vectors. + +Update (post-merge review): the `description` field was also dropped +to save OpenAI tokens — `keywords` alone covers the UX need (badge +display in auction card) without paying for a description we won't +read in code paths. ### D4. Cron jobs scheduled outside the app diff --git a/docs/architecture/recommendation-system.md b/docs/architecture/recommendation-system.md index 06d161a97..bf060f15e 100644 --- a/docs/architecture/recommendation-system.md +++ b/docs/architecture/recommendation-system.md @@ -47,8 +47,8 @@ domain_classifications row created with source='heuristic' [ Nightly k8s CronJob: rake recommendation:classify_unclassified ] | v -LLM batch (50 domains / call) enriches rows with description, audience, -keywords, suggested_use_cases, brandability_score +LLM batch (50 domains / call) enriches rows with keywords, audience, +suggested_use_cases, brandability_score, languages source='openai' | v @@ -76,8 +76,7 @@ Single source of truth for what a domain *means*. One row per `domain_name`. |---|---| | `domain_name` (unique) | the key | | `primary_category`, `tags[]` | hard categorical signals | -| `description`, `description_locale` | human-readable, used in UI | -| `keywords[]` | extracted semantic tokens | +| `keywords[]` | extracted semantic tokens, shown in UI as badges | | `audience` (b2b/b2c/mixed) | targeting signal | | `languages[]` | et / en / mixed | | `suggested_use_cases[]` | shop / blog / service / agency / marketplace | @@ -111,7 +110,7 @@ Tier 2 — LLM batch (cron daily, ~$0.30/month at our volume) - Recommendation::LlmDomainClassifier - OpenAI structured output (json_schema) - 50 domains per API call - - Enriches description, audience, use_cases, brandability_score, languages + - Enriches keywords, audience, suggested_use_cases, brandability_score, languages - Re-runs every 6 months for source='openai' rows Tier 1 — (future, optional) embedding-based local classifier @@ -184,7 +183,7 @@ See [domain-classification-pipeline.md](../technical/domain-classification-pipel | 5 | ~~pgvector + embeddings batch job~~ | **dropped** — see ADR-001 | | 6 | Rich-feature Scorer + embedding similarity + time decay | done | | 7 | Detail view tracking + view affinity | done | -| 8 | Show domain description in auction card | done | +| 8 | Show domain keywords as badges in auction card | done | | 9 | Polish + finalize | done | Operator runbook: see [guides/recommendation-operations.md](../guides/recommendation-operations.md). diff --git a/docs/guides/recommendation-operations.md b/docs/guides/recommendation-operations.md index 353652a0d..7d904db1b 100644 --- a/docs/guides/recommendation-operations.md +++ b/docs/guides/recommendation-operations.md @@ -41,7 +41,7 @@ is safe: nothing duplicates, fresh rows are skipped. 4. (Optional, recommended) Manually run `rake recommendation:classify_unclassified` once to perform the first LLM enrichment immediately rather than waiting for cron. -5. Verify `/auctions` renders descriptions on cards with classified +5. Verify `/auctions` renders keyword badges on cards with classified domains. ## Monitoring @@ -85,12 +85,12 @@ To roll back entirely: ## Troubleshooting -**Description shows in wrong language** — `description_locale` is -chosen by the LLM at classification time. If you want a different -locale, re-run classification with `force: true` after updating the -system prompt, or seed manual rows with `classification_source='manual'`. - **Score never updates after action** — Check whether `RefreshSingleUserAuctionScoresJob` is reaching the worker (delayed job table). The debounce window is 30 seconds; updates are not real-time. Re-enqueue manually via the admin Job UI if needed. + +**Keywords wrong / missing on a card** — Heuristic-only domains have +generic keywords. They get richer keywords from the nightly LLM run. +Force a re-classification by deleting the row and letting the next +trigger recreate it: `DomainClassification.find_by(domain_name: 'x.ee').destroy`. diff --git a/test/services/recommendation/domain_classifier_test.rb b/test/services/recommendation/domain_classifier_test.rb index 526f7b47f..0641befa5 100644 --- a/test/services/recommendation/domain_classifier_test.rb +++ b/test/services/recommendation/domain_classifier_test.rb @@ -35,8 +35,7 @@ def test_preserves_llm_enriched_fields_when_running_heuristic_again domain_name: 'rich.ee', primary_category: 'saas', tags: %w[saas b2b_service], - description: 'Description by LLM', - description_locale: 'en', + keywords: %w[cloud platform], audience: 'b2b', languages: %w[en], suggested_use_cases: %w[agency], @@ -50,7 +49,8 @@ def test_preserves_llm_enriched_fields_when_running_heuristic_again Recommendation::DomainClassifier.call('rich.ee', force: true) record.reload - assert_equal 'Description by LLM', record.description + assert_equal %w[cloud platform], record.keywords + assert_equal 'b2b', record.audience assert_equal DomainClassification::OPENAI_SOURCE, record.classification_source assert_equal 'saas', record.primary_category end diff --git a/test/services/recommendation/llm_domain_classifier_test.rb b/test/services/recommendation/llm_domain_classifier_test.rb index 877bd4f0f..f5cfe2a7a 100644 --- a/test/services/recommendation/llm_domain_classifier_test.rb +++ b/test/services/recommendation/llm_domain_classifier_test.rb @@ -19,8 +19,6 @@ def test_parses_structured_response_into_attribute_hashes cloud = result.find { |r| r[:domain_name] == 'cloudstack.ee' } assert_equal 'saas', cloud[:primary_category] assert_equal %w[saas b2b_service], cloud[:tags] - assert_equal 'Cloud platform domain.', cloud[:description] - assert_equal 'en', cloud[:description_locale] assert_equal 'b2b', cloud[:audience] assert_includes cloud[:keywords], 'cloud' assert_equal DomainClassification::OPENAI_SOURCE, cloud[:classification_source] @@ -85,8 +83,6 @@ def ai_response_with_bogus_tag(domain_name) domain_name: domain_name, primary_category: 'not_a_real_category', tags: %w[saas absolutely_made_up], - description: 'desc', - description_locale: 'en', keywords: %w[cloud], audience: 'b2b', languages: %w[en], @@ -105,8 +101,6 @@ def classification_for(name) domain_name: name, primary_category: 'saas', tags: %w[saas b2b_service], - description: 'Cloud platform domain.', - description_locale: 'en', keywords: %w[cloud platform], audience: 'b2b', languages: %w[en], From a9175d6d4dc3787dd966c20ea5a0a0b38f611563 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 28 May 2026 12:09:18 +0300 Subject: [PATCH 19/42] Add embedding similarity via Postgres double precision[] arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back vector similarity matching without pgvector, without any shared-infrastructure changes. Trade-off documented in ADR-001 D3. Schema: - Migration 20260527090300 adds three columns to domain_classifications: embedding double precision[] embedding_model string embedded_at datetime (indexed) - No extensions, no special types. Works on plain Postgres 13/14/15/16/17. Services: - Recommendation::DomainEmbedder wraps OpenAI text-embedding-3-small (1536 dim). Input is ". " — description was dropped earlier, keywords carry the semantic context. Batches up to BATCH_LIMIT=100 rows per OpenAI call. Accepts both ActiveRecord rows and plain hashes. - Recommendation::EmbedUnembeddedDomainsJob is the cron-only entry point (rake recommendation:embed_unembedded). Picks classified rows without an embedding, batches them, persists via update_columns. Guarded by Feature.open_ai_integration_enabled? and column presence. Scorer: - score_for now finishes with `score *= embedding_multiplier(auction)`. - Multiplier formula: 1 + max(0, cosine_similarity(user_centroid, auction.embedding)) Range 1.0..2.0. No-op (1.0) when: - embedding column not migrated - user has no behavioural history yet - auction not embedded yet - User centroid = time-decayed weighted average of embeddings from bids + wishlist + recent views. - cosine_similarity computed in plain Ruby — ~50ms across 200 vectors at 1536 dims. No HNSW, no vector index needed at this scale. Tests: - DomainEmbedderTest: vector shape, AR-row input, empty input, OpenAI error handling. - EmbedUnembeddedDomainsJobTest: column-missing safety, feature-flag gating, scope selection. - ScorerEmbeddingTest: aligned-vector boost, no-op without history, with explicit skips when the embedding column is not yet migrated. Wiring: - Job model registers EmbedUnembeddedDomainsJob in ALLOWED_JOB_NAMES. - rake recommendation:embed_unembedded restored. Docs: - architecture/recommendation-system.md, technical/domain-classification-pipeline.md, guides/recommendation-operations.md and ADR-001 updated to reflect the embedding path and the rationale for Postgres-native storage. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../embed_unembedded_domains_job.rb | 58 +++++++++ app/models/domain_classification.rb | 7 ++ app/models/job.rb | 1 + .../recommendation/domain_embedder.rb | 79 ++++++++++++ app/services/recommendation/scorer.rb | 98 ++++++++++++++- ...bedding_array_to_domain_classifications.rb | 19 +++ .../architecture/adr-001-recommendation-v2.md | 67 ++++++----- docs/architecture/recommendation-system.md | 23 ++-- docs/guides/recommendation-operations.md | 8 +- .../domain-classification-pipeline.md | 69 +++++++++-- lib/tasks/recommendation.rake | 5 + .../embed_unembedded_domains_job_test.rb | 64 ++++++++++ .../recommendation/domain_embedder_test.rb | 57 +++++++++ .../recommendation/scorer_embedding_test.rb | 113 ++++++++++++++++++ 14 files changed, 614 insertions(+), 54 deletions(-) create mode 100644 app/jobs/recommendation/embed_unembedded_domains_job.rb create mode 100644 app/services/recommendation/domain_embedder.rb create mode 100644 db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb create mode 100644 test/jobs/recommendation/embed_unembedded_domains_job_test.rb create mode 100644 test/services/recommendation/domain_embedder_test.rb create mode 100644 test/services/recommendation/scorer_embedding_test.rb diff --git a/app/jobs/recommendation/embed_unembedded_domains_job.rb b/app/jobs/recommendation/embed_unembedded_domains_job.rb new file mode 100644 index 000000000..770d27130 --- /dev/null +++ b/app/jobs/recommendation/embed_unembedded_domains_job.rb @@ -0,0 +1,58 @@ +module Recommendation + # Cron-only entry point (rake recommendation:embed_unembedded). + # Picks classified rows that don't yet have an embedding, batches + # them through DomainEmbedder, and persists the vectors as plain + # Postgres double precision[] arrays. + class EmbedUnembeddedDomainsJob < ApplicationJob + MAX_DOMAINS_PER_RUN = 500 + BATCH_SIZE = Recommendation::DomainEmbedder::BATCH_LIMIT + + retry_on StandardError, wait: 30.seconds, attempts: 2 + + def perform + return unless Feature.open_ai_integration_enabled? + return unless DomainClassification.column_names.include?('embedding') + + rows = self.class.scope.limit(MAX_DOMAINS_PER_RUN).to_a + return if rows.empty? + + processed = 0 + rows.each_slice(BATCH_SIZE) do |batch| + results = Recommendation::DomainEmbedder.call(rows: batch) + processed += persist(results) + end + + Rails.logger.info("EmbedUnembeddedDomainsJob embedded #{processed} domains") + processed + end + + def self.scope + DomainClassification.needs_embedding.order(:classified_at) + end + + def self.needs_to_run? + return false unless DomainClassification.column_names.include?('embedding') + + Feature.open_ai_integration_enabled? && scope.exists? + end + + private + + def persist(results) + return 0 if results.empty? + + by_name = results.index_by { |r| r[:domain_name] } + DomainClassification.where(domain_name: by_name.keys).find_each do |record| + payload = by_name[record.domain_name] + next unless payload + + record.update_columns( + embedding: payload[:embedding], + embedding_model: payload[:embedding_model], + embedded_at: payload[:embedded_at] + ) + end + results.size + end + end +end diff --git a/app/models/domain_classification.rb b/app/models/domain_classification.rb index c8dc7998e..6c3c25d64 100644 --- a/app/models/domain_classification.rb +++ b/app/models/domain_classification.rb @@ -29,6 +29,13 @@ class DomainClassification < ApplicationRecord .or(where('classification_source = ? AND classified_at < ?', OPENAI_SOURCE, LLM_REFRESH_INTERVAL.ago)) } scope :classified, -> { where.not(classified_at: nil) } + scope :needs_embedding, lambda { + if column_names.include?('embedding') + classified.where(embedding: nil) + else + none + end + } def heuristic? = classification_source == HEURISTIC_SOURCE def from_llm? = classification_source == OPENAI_SOURCE diff --git a/app/models/job.rb b/app/models/job.rb index e6d8fb6dd..a956edf3f 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -6,6 +6,7 @@ class Job SharedFooterFetcherJob DirectoInvoiceForwardJob ActiveAuctionsAiSortingJob Recommendation::ClassifyAuctionDomainsJob Recommendation::ClassifyUnclassifiedDomainsJob + Recommendation::EmbedUnembeddedDomainsJob Recommendation::RefreshUserAuctionScoresJob].freeze include ActiveModel::Model diff --git a/app/services/recommendation/domain_embedder.rb b/app/services/recommendation/domain_embedder.rb new file mode 100644 index 000000000..35cb74e69 --- /dev/null +++ b/app/services/recommendation/domain_embedder.rb @@ -0,0 +1,79 @@ +module Recommendation + # Generates OpenAI text-embedding-3-small vectors for a batch of + # DomainClassification rows. NOT called from runtime paths — only + # from EmbedUnembeddedDomainsJob (cron). + # + # Input text per domain: ". ". + # Description was intentionally removed (see ADR-001 + commit + # "Remove description from domain_classifications"), so embedding + # context is keywords + domain_name only. That's a deliberately + # sparser signal but covers our recommendation use case where we + # mostly want "this domain looks like the kind the user already bid on". + # + # Returns array of { domain_name:, embedding: [..1536..], embedding_model:, embedded_at: } + # ready for update_columns on the matching DomainClassification. + class DomainEmbedder + MODEL = 'text-embedding-3-small'.freeze + DIMENSIONS = 1536 + BATCH_LIMIT = 100 + + class << self + def call(...) + new(...).call + end + end + + def initialize(rows:) + @rows = Array(rows).first(BATCH_LIMIT) + end + + def call + return [] if @rows.empty? + + inputs = @rows.map { |row| build_input(row) } + vectors = fetch_embeddings(inputs) + + @rows.each_with_index.map do |row, index| + { + domain_name: domain_name_for(row), + embedding: vectors[index], + embedding_model: MODEL, + embedded_at: Time.current + } + end + rescue StandardError, OpenAI::Error => e + Rails.logger.warn("DomainEmbedder failed: #{e.message}") + raise + end + + private + + def fetch_embeddings(inputs) + client = OpenAI::Client.new + response = client.embeddings(parameters: { model: MODEL, input: inputs }) + + error = response.dig('error', 'message') + raise StandardError, error if error + + data = response.fetch('data') + data.sort_by { |item| item['index'] }.map { |item| item.fetch('embedding') } + end + + def build_input(row) + parts = [ + domain_name_for(row), + Array(keywords_for(row)).join(', ').presence + ].compact + parts.join('. ') + end + + def domain_name_for(row) + row.respond_to?(:domain_name) ? row.domain_name : row[:domain_name] + end + + def keywords_for(row) + raw = row.respond_to?(:keywords) ? row.keywords : row[:keywords] + Array(raw) + end + end +end diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb index 95180606e..9836babc6 100644 --- a/app/services/recommendation/scorer.rb +++ b/app/services/recommendation/scorer.rb @@ -14,9 +14,13 @@ module Recommendation # # Rich features (keywords, audience) come from domain_classifications # joined by domain_name. Legacy auctions.classification_tags remains - # a fallback during migration. Vector similarity (pgvector) was - # considered and dropped from v2 to avoid shared-infra changes — - # see docs/architecture/adr-001-recommendation-v2.md. + # a fallback during migration. + # + # Embeddings (OpenAI text-embedding-3-small, 1536 dims) are stored + # as plain Postgres double precision[] — no pgvector — and provide a + # multiplicative boost (1.0..2.0) when both user has behavioural + # history and the candidate auction is embedded. See ADR-001 for + # the rationale. class Scorer SCORING_HORIZON = 30.days SIGNAL_LOOKBACK = 1.year @@ -135,6 +139,7 @@ def score_for(auction) score += hyphen_score(domain_name) score += ai_prior_score(auction) + score *= embedding_multiplier(auction) score.round(6) end @@ -414,6 +419,93 @@ def preload_result_classifications(results) DomainClassification.where(domain_name: domain_names).index_by(&:domain_name) end + # ---------- Embedding multiplier ------------------------------------ + # + # OpenAI embeddings are stored as Postgres double precision[] arrays + # (no pgvector). Cosine similarity is computed in Ruby — at 100-200 + # auctions per scoring pass this is sub-100ms, well within budget. + # + # multiplier = 1 + max(0, cosine_similarity(user_centroid, auction)) + # Range: [1.0, 2.0]. Becomes a no-op (1.0) when: + # - embedding column not yet migrated + # - user has no behavioural history + # - auction has no embedding yet + + def embedding_multiplier(auction) + return 1.0 unless DomainClassification.column_names.include?('embedding') + + auction_embedding = embedding_for(auction) + return 1.0 if auction_embedding.nil? + return 1.0 if user_embedding_centroid.nil? + + similarity = cosine_similarity(user_embedding_centroid, auction_embedding) + return 1.0 if similarity.nil? + + 1.0 + [similarity, 0.0].max + end + + def embedding_for(auction) + dc = classification_for(auction) + Array(dc&.embedding).presence + end + + def user_embedding_centroid + return @user_embedding_centroid if defined?(@user_embedding_centroid) + + @user_embedding_centroid = compute_user_centroid + end + + def compute_user_centroid + signals = bid_domain_signals + wishlist_domain_signals + view_domain_signals + return nil if signals.empty? + + domain_names = signals.map { |s| s[:domain_name] }.uniq + embeddings = DomainClassification + .where(domain_name: domain_names) + .where.not(embedding: nil) + .index_by(&:domain_name) + return nil if embeddings.empty? + + sum = nil + total_weight = 0.0 + + signals.each do |signal| + dc = embeddings[signal[:domain_name]] + vec = Array(dc&.embedding) + next if vec.empty? + + sum ||= Array.new(vec.size, 0.0) + next if vec.size != sum.size + + weight = decay_weight(signal[:age_days]) + vec.each_with_index { |v, i| sum[i] += v.to_f * weight } + total_weight += weight + end + + return nil if sum.nil? || total_weight.zero? + + sum.map { |v| v / total_weight } + end + + def cosine_similarity(vec_a, vec_b) + return nil if vec_a.nil? || vec_b.nil? || vec_a.size != vec_b.size + + dot = 0.0 + norm_a = 0.0 + norm_b = 0.0 + vec_a.each_with_index do |a, i| + b = vec_b[i].to_f + dot += a * b + norm_a += a * a + norm_b += b * b + end + + denom = Math.sqrt(norm_a) * Math.sqrt(norm_b) + return nil if denom.zero? + + dot / denom + end + # ---------- Structural ---------------------------------------------- def similar_to_saved_domain?(domain_name) diff --git a/db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb b/db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb new file mode 100644 index 000000000..4e21e8f4b --- /dev/null +++ b/db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb @@ -0,0 +1,19 @@ +class AddEmbeddingArrayToDomainClassifications < ActiveRecord::Migration[7.0] + # Embeddings stored as a plain Postgres array of doubles — no pgvector + # extension required. Cosine similarity is computed in Ruby at scoring + # time. At our scale (100-200 active auctions per user) brute-force + # cosine over a few hundred 1536-dim vectors takes ~50ms, well within + # the RefreshSingleUserAuctionScoresJob budget. + # + # If we ever outgrow brute-force (~5k+ vectors), we can either: + # - Stand up a sidecar pgvector pod and migrate the column type, or + # - Move to an external vector API (Upstash, Qdrant, Pinecone) + # The float[] format is portable to all of the above. + def change + add_column :domain_classifications, :embedding, :"double precision[]" + add_column :domain_classifications, :embedding_model, :string + add_column :domain_classifications, :embedded_at, :datetime + + add_index :domain_classifications, :embedded_at + end +end diff --git a/docs/architecture/adr-001-recommendation-v2.md b/docs/architecture/adr-001-recommendation-v2.md index 848b2257a..82b112c4d 100644 --- a/docs/architecture/adr-001-recommendation-v2.md +++ b/docs/architecture/adr-001-recommendation-v2.md @@ -60,39 +60,40 @@ is ~$0.30/month, with ~$1 one-time backfill. because classification is computed once per domain and cached; the bandwidth cost of shipping a 10MB model to every browser exceeds the savings. -### D3. No vector embeddings in v2 (reversal of earlier draft) +### D3. Embeddings as Postgres `double precision[]`, no pgvector -Earlier drafts planned `text-embedding-3-small` embeddings stored in a -`vector(1536)` column with HNSW indexing for similarity-based ranking. -This was reversed before merge. +`text-embedding-3-small` (1536 dim) vectors stored as a plain Postgres +array of doubles. Cosine similarity is computed in Ruby at scoring time. -**Why we backed out:** +**Why not pgvector:** - The shared dev Postgres image (`postgres:13.4` in `docker-images/docker-compose.yml`) is used by every service on the team (registry, registrar_center, billing, eeid, etc.). Switching it to `pgvector/pgvector:pg13` would have been a patch-version bump on shared infrastructure for the sake of one app. - Production RDS does support pgvector natively, but rolling it out - consistently across dev/staging/test/prod adds operational risk for - marginal recommendation quality. -- Tag + keyword + behavioural affinity + time decay carries the bulk - of recommendation quality. The embedding multiplier was a 1.0..2.0 - bonus on top — useful but not load-bearing. - -**Future path** if embeddings become valuable: a sidecar pgvector pod -isolated to auction_center (separate StatefulSet in k8s, separate -ActiveRecord connection in `database.yml`). Main databases stay -untouched. This is deferred until there's a concrete user-visible -ranking problem the current signals can't solve. - -**What we kept:** the `domain_classifications` table including -`keywords`, `audience`, `tags`, `suggested_use_cases` and other -rich fields that feed the scorer directly without needing vectors. - -Update (post-merge review): the `description` field was also dropped -to save OpenAI tokens — `keywords` alone covers the UX need (badge -display in auction card) without paying for a description we won't -read in code paths. + consistently across dev/staging/test/prod adds operational risk. +- At 100-200 active auctions, brute-force cosine over a few hundred + 1536-dim vectors takes ~50ms in Ruby. HNSW index would not be + meaningfully faster at this scale. + +**Why not an external vector service (Pinecone, Qdrant, Upstash):** +- New external dependency, new auth, new failure mode for a marginal + win at our scale. +- The `double precision[]` format is portable — if we ever do need to + migrate to pgvector or an external service, the data already lives + in a structure we can dump and reload trivially. + +**What changes when we outgrow brute force (~5k+ vectors):** +- Stand up a sidecar pgvector pod isolated to auction_center (separate + StatefulSet, separate ActiveRecord connection) — main databases + stay untouched. +- Or move to an external vector API. +- The migration is a one-time script reading the column we already have. + +Description was also dropped to save OpenAI tokens — embedding input +is now `. ` only. Keywords carry the user- +visible content (badge display in auction card). ### D4. Cron jobs scheduled outside the app @@ -116,7 +117,15 @@ floating-point noise floor. clock skew across regions. Decay is mathematically equivalent and simpler to reason about. -### D6. ~~Embedding multiplier~~ — removed, see D3. +### D6. Embedding multiplier, not additive + +Final score = base_score * (1 + max(0, cosine_similarity)). User centroid is +the time-decayed weighted average of embeddings from bids, wishlist, and views. + +**Why:** Multiplicative form lets embedding act as a "boost knob" — it can't +introduce a high score in isolation (no behavioural data → no centroid → 1.0), +but it can lift a domain that other signals already mildly favour. Additive +form risks dominating the rule-based base when cosine is small. ### D7. Heuristic-only at runtime, LLM-only via cron @@ -145,9 +154,9 @@ predictable to operate. Tier 0 produces useful tags immediately while Tier 2 enriches overnight. - More tables to operate. Mitigated by the operator runbook in `docs/guides/recommendation-operations.md`. -- No vector similarity matching in v2. Mitigated by keyword overlap and - behavioural-tag affinity, which subsume the most common similarity use - cases at our domain volume. +- Cosine similarity in Ruby instead of a vector index. Acceptable for + the current scale; documented breakpoint (~5k vectors) for moving to + a real vector index. **Migration path** 1. Deploy. Migrations create `domain_classifications` and add classification diff --git a/docs/architecture/recommendation-system.md b/docs/architecture/recommendation-system.md index bf060f15e..41c07ba6f 100644 --- a/docs/architecture/recommendation-system.md +++ b/docs/architecture/recommendation-system.md @@ -22,12 +22,11 @@ Sort is **per-user**. Same `/auctions` request returns N different orderings for - No global feed cache (sort is per-user) - No client-side ranking for v2 (server is the source of truth) - No real-time LLM calls during user requests (LLM is batch-only, cron-driven) -- **No vector embeddings in v2** — pgvector was evaluated and dropped to avoid - shared-infrastructure churn (RDS extensions, Docker image bumps for the - team-shared Postgres). Tag + keyword + behavioural affinity carries most of - the recommendation value. Embeddings can be added later as a sidecar - vector database (separate Postgres pod, not the shared one) without - schema migrations to the main database. +- **No pgvector** — pgvector was evaluated and dropped to avoid shared- + infrastructure churn (RDS extensions, Docker image bumps for the team-shared + Postgres). Instead, embeddings are stored as plain Postgres `double precision[]` + arrays and cosine similarity is computed in Ruby. At 100-200 active auctions + this is sub-100ms and zero new infrastructure. See ADR-001. ## High-level data flow @@ -122,6 +121,7 @@ Tier 1 — (future, optional) embedding-based local classifier | schedule | task | purpose | |---|---|---| | `0 3 * * *` (03:00 daily) | `rake recommendation:classify_unclassified` | Tier 2 enrichment for heuristic-only/low-confidence/stale rows | +| `30 3 * * *` (03:30 daily) | `rake recommendation:embed_unembedded` | OpenAI embeddings for classified-but-unembedded rows (stored as float[]) | | one-shot | `rake recommendation:backfill` | Initial classification of all historical domains | K8s manifests live in `Ry_AWS_IaC/infrastructure/kubernetes` — outside this repo. @@ -146,8 +146,15 @@ score = 0 + ai_prior_score existing legacy + domain_offer_history_signal historical bids + result_signal won: weak negative; lost: strong positive + +multiplier = 1 + max(0, cosine_similarity(user_centroid, auction.embedding)) +score = score * multiplier ``` +User centroid = time-decayed weighted average of embeddings from the user's +bids + wishlist + recent views. Multiplier is a no-op (1.0) when the user has +no history or the auction is not yet embedded. + Time decay: `weight *= exp(-days_old / 60)` (half-life 60 days). ## Infrastructure dependencies @@ -164,8 +171,10 @@ no Docker image bumps, no schema changes outside the auction_center database. - **Backfill** (one-time): ~5000 historical unique domains - LLM classify: ~100 batches × ~3000 tokens = **~$0.50-1** + - Embeddings: 5000 × ~50 tokens × $0.02/M = **~$0.005** - **Steady state** (per day): - LLM classify: 5-20 new domains/day, batched = 0-1 OpenAI call = **~$0.01/day** + - Embeddings: 1 call/day = **~$0.0001/day** - Total: **~$0.30/month** ## Implementation phases @@ -180,7 +189,7 @@ See [domain-classification-pipeline.md](../technical/domain-classification-pipel | 3a | DomainClassifier orchestrator (heuristic only) | done | | 3b | LLM batch enrichment job (cron) | done | | 4 | Triggers + backfill rake | done | -| 5 | ~~pgvector + embeddings batch job~~ | **dropped** — see ADR-001 | +| 5 | Embedding similarity via Postgres `double precision[]` (no pgvector) | done | | 6 | Rich-feature Scorer + embedding similarity + time decay | done | | 7 | Detail view tracking + view affinity | done | | 8 | Show domain keywords as badges in auction card | done | diff --git a/docs/guides/recommendation-operations.md b/docs/guides/recommendation-operations.md index 7d904db1b..7080d52bb 100644 --- a/docs/guides/recommendation-operations.md +++ b/docs/guides/recommendation-operations.md @@ -25,6 +25,7 @@ All recurring work runs as k8s `CronJob` resources defined in | schedule | command | purpose | |---|---|---| | `0 3 * * *` | `bundle exec rake recommendation:classify_unclassified` | Tier 2 LLM enrichment of heuristic / low-conf / stale rows | +| `30 3 * * *` | `bundle exec rake recommendation:embed_unembedded` | OpenAI embeddings for classified rows without an embedding (stored as float[]) | | one-shot | `bundle exec rake recommendation:backfill` | Initial heuristic classification of all historical domains | Each task is wrapped by an idempotent ActiveJob. Re-running mid-day @@ -41,7 +42,9 @@ is safe: nothing duplicates, fresh rows are skipped. 4. (Optional, recommended) Manually run `rake recommendation:classify_unclassified` once to perform the first LLM enrichment immediately rather than waiting for cron. -5. Verify `/auctions` renders keyword badges on cards with classified +5. (Optional) Manually run `rake recommendation:embed_unembedded` for + the first embedding sweep. +6. Verify `/auctions` renders keyword badges on cards with classified domains. ## Monitoring @@ -49,7 +52,8 @@ is safe: nothing duplicates, fresh rows are skipped. | signal | check | |---|---| | LLM enrichment progress | `DomainClassification.needs_llm_enrichment.count` should trend toward zero. Tail logs for `ClassifyUnclassifiedDomainsJob processed N domains`. | -| Daily OpenAI cost | OpenAI dashboard. At steady state expect ~$0.01/day for classification. | +| Embedding backlog | `DomainClassification.needs_embedding.count` ditto. | +| Daily OpenAI cost | OpenAI dashboard. ~$0.01/day for classification + ~$0.0001/day for embeddings. | | Score freshness | `UserAuctionScore.maximum(:calculated_at)` should be within minutes for active users. | | Tracking failures | `Rails.logger.warn` lines from `EventTracker`. | diff --git a/docs/technical/domain-classification-pipeline.md b/docs/technical/domain-classification-pipeline.md index e1c9136e4..cad9fc029 100644 --- a/docs/technical/domain-classification-pipeline.md +++ b/docs/technical/domain-classification-pipeline.md @@ -11,10 +11,12 @@ app/services/recommendation/ domain_heuristic_classifier.rb # dictionary + subword tokenizer domain_dictionary.rb # ESTONIAN_ROOTS + ENGLISH_ROOTS hashes llm_domain_classifier.rb # Tier 2 — only called from cron job + domain_embedder.rb # OpenAI text-embedding-3-small wrapper app/jobs/recommendation/ classify_domain_heuristically_job.rb # instant, triggered on events classify_unclassified_domains_job.rb # cron, batched LLM + embed_unembedded_domains_job.rb # cron, batched embeddings backfill_domain_classifications_job.rb # one-shot lib/tasks/recommendation.rake # entry points for k8s CronJobs @@ -124,20 +126,58 @@ DomainClassification `raw_llm_response` (jsonb) keeps the parsed response. If schema changes later, we can re-parse from `raw_llm_response` without re-billing OpenAI. -## Embedding pipeline — dropped from v2 +## Embedding pipeline -Vector embeddings were planned as `text-embedding-3-small` + pgvector -HNSW index for cosine similarity, with a per-auction multiplier on top -of the base score. This was removed before merge because pgvector would -have required either bumping the shared dev Postgres image -(`postgres:13.4` → `pgvector/pgvector:pg13`) for the whole team or -adding extension provisioning to the shared RDS — both reach beyond -auction_center's scope. +### Trigger + +`rake recommendation:embed_unembedded` (k8s CronJob, daily 03:30). + +Selects rows where: + +```ruby +DomainClassification + .classified + .where(embedding: nil) + .limit(MAX_DOMAINS_PER_RUN) +``` + +### Input + +`. ` — kept short since we no +longer have descriptions. Tokens-per-domain ≈ 5-15. + +### Model + +`text-embedding-3-small` (1536 dim, $0.02/1M tokens). + +### Storage + +Plain Postgres `double precision[]` column — **not** pgvector. The +column is added by migration 20260527090300. No HNSW index; cosine +similarity is computed in Ruby at scoring time. At 100-200 active +auctions × 1536 dims this is ~50ms — well below the 30-second +RefreshSingleUserAuctionScoresJob debounce window. + +### Usage in Scorer + +User centroid is the time-decayed weighted average of embeddings from +the user's bids, wishlist, and recent views. The multiplier is + +``` +multiplier = 1 + max(0, cosine_similarity(user_centroid, auction.embedding)) +final_score = base_score * multiplier +``` + +Range: 1.0 (no boost) .. 2.0 (perfect alignment). No-op (1.0) when +either side is missing data. + +### Why no pgvector / external service -If we want vector similarity later, the recommended path is a sidecar -pgvector pod isolated to auction_center (separate StatefulSet, separate -ActiveRecord connection), leaving the main databases untouched. See -ADR-001 for the rationale. +See ADR-001. Bottom line: at our scale, brute force in Ruby is faster +than the operational cost of new infrastructure. If we cross 5k+ +embedded rows the plan is to move the column to a sidecar pgvector pod +isolated to auction_center, leaving the main databases untouched. The +`double precision[]` format is portable to that future state. ## Triggers (instant heuristic only) @@ -176,7 +216,9 @@ Existing `auctions.classification_tags / primary_category / classification_sourc | `DomainClassifier` (orchestrator) | upserts row with `source='heuristic'`; skips fresh rows | | `LlmDomainClassifier` | mocked OpenAI; verifies prompt and parsing | | `ClassifyUnclassifiedDomainsJob` | scope selection, batching, no LLM call when scope empty | -| `Scorer` (extended) | tag + keyword + audience + behavioural affinity paths verified | +| `DomainEmbedder` | mocked OpenAI; vector shape, AR-row handling, empty input, error response | +| `EmbedUnembeddedDomainsJob` | scope selection, feature-flag gating, missing column safety | +| `Scorer` (extended) | tag + keyword + audience + behavioural affinity + embedding multiplier paths verified | ## Operations @@ -185,3 +227,4 @@ Existing `auctions.classification_tags / primary_category / classification_sourc | Daily LLM cost | OpenAI dashboard + log lines from `LlmDomainClassifier` | | Failed classifications | `Rails.logger.warn` from `ClassifyUnclassifiedDomainsJob` | | Backlog of unclassified | `DomainClassification.where(classification_source: ['heuristic', nil]).count` | +| Embedding backlog | `DomainClassification.needs_embedding.count` | diff --git a/lib/tasks/recommendation.rake b/lib/tasks/recommendation.rake index c74874a9e..8db5b1ade 100644 --- a/lib/tasks/recommendation.rake +++ b/lib/tasks/recommendation.rake @@ -4,6 +4,11 @@ namespace :recommendation do Recommendation::ClassifyUnclassifiedDomainsJob.perform_now end + desc 'Cron entry point: embed classified-but-unembedded domains via OpenAI (batched)' + task embed_unembedded: :environment do + Recommendation::EmbedUnembeddedDomainsJob.perform_now + end + desc 'One-shot: classify all historical domains via heuristic (LLM picks up later)' task backfill: :environment do if defined?(Recommendation::BackfillDomainClassificationsJob) diff --git a/test/jobs/recommendation/embed_unembedded_domains_job_test.rb b/test/jobs/recommendation/embed_unembedded_domains_job_test.rb new file mode 100644 index 000000000..bd8fbf98c --- /dev/null +++ b/test/jobs/recommendation/embed_unembedded_domains_job_test.rb @@ -0,0 +1,64 @@ +require 'test_helper' + +module Recommendation + class EmbedUnembeddedDomainsJobTest < ActiveJob::TestCase + def setup + super + DomainClassification.delete_all + end + + def test_no_op_when_embedding_column_missing + skip 'embedding column present' if DomainClassification.column_names.include?('embedding') + + assert_nothing_raised do + Recommendation::EmbedUnembeddedDomainsJob.new.perform + end + end + + def test_no_op_when_openai_disabled + DomainClassification.create!( + domain_name: 'pending-embed.ee', + keywords: %w[k1], + classification_source: DomainClassification::OPENAI_SOURCE, + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + with_feature_flag(false) do + assert_nil Recommendation::EmbedUnembeddedDomainsJob.new.perform + end + end + + def test_scope_picks_classified_without_embedding + skip 'embedding column missing' unless DomainClassification.column_names.include?('embedding') + + target = DomainClassification.create!( + domain_name: 'embed-me.ee', + keywords: %w[cloud], + classification_source: DomainClassification::OPENAI_SOURCE, + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + unclassified = DomainClassification.create!( + domain_name: 'not-classified.ee', + classification_source: DomainClassification::HEURISTIC_SOURCE + # classified_at left nil — not yet classified, should NOT be picked + ) + + ids = Recommendation::EmbedUnembeddedDomainsJob.scope.pluck(:id) + assert_includes ids, target.id + refute_includes ids, unclassified.id + end + + private + + def with_feature_flag(enabled) + original = Feature.method(:open_ai_integration_enabled?) + Feature.define_singleton_method(:open_ai_integration_enabled?) { enabled } + yield + ensure + Feature.define_singleton_method(:open_ai_integration_enabled?, original) + end + end +end diff --git a/test/services/recommendation/domain_embedder_test.rb b/test/services/recommendation/domain_embedder_test.rb new file mode 100644 index 000000000..eef18ee06 --- /dev/null +++ b/test/services/recommendation/domain_embedder_test.rb @@ -0,0 +1,57 @@ +require 'test_helper' + +module Recommendation + class DomainEmbedderTest < ActiveSupport::TestCase + def test_returns_aligned_embeddings_for_each_row + rows = [ + { domain_name: 'a.ee', keywords: %w[one] }, + { domain_name: 'b.ee', keywords: %w[two] } + ] + + stub_embedding_request(2) + + result = Recommendation::DomainEmbedder.call(rows: rows) + assert_equal 2, result.size + assert_equal 'a.ee', result.first[:domain_name] + assert_equal Recommendation::DomainEmbedder::DIMENSIONS, result.first[:embedding].size + assert_equal Recommendation::DomainEmbedder::MODEL, result.first[:embedding_model] + assert result.first[:embedded_at].is_a?(Time) + end + + def test_handles_active_record_rows + classification = DomainClassification.create!( + domain_name: 'ar.ee', + keywords: %w[active record] + ) + stub_embedding_request(1) + + result = Recommendation::DomainEmbedder.call(rows: [classification]) + assert_equal 'ar.ee', result.first[:domain_name] + assert_equal Recommendation::DomainEmbedder::DIMENSIONS, result.first[:embedding].size + end + + def test_empty_input_returns_empty + result = Recommendation::DomainEmbedder.call(rows: []) + assert_equal [], result + end + + def test_raises_on_openai_error + stub_request(:post, 'https://api.openai.com/v1/embeddings') + .to_return_json(status: 200, body: { 'error' => { 'message' => 'oops' } }, headers: {}) + + assert_raises(StandardError) do + Recommendation::DomainEmbedder.call(rows: [{ domain_name: 'x.ee', keywords: [] }]) + end + end + + private + + def stub_embedding_request(count) + data = count.times.map do |i| + { 'index' => i, 'embedding' => Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.1) } + end + stub_request(:post, 'https://api.openai.com/v1/embeddings') + .to_return_json(status: 200, body: { 'data' => data, 'model' => 'text-embedding-3-small' }, headers: {}) + end + end +end diff --git a/test/services/recommendation/scorer_embedding_test.rb b/test/services/recommendation/scorer_embedding_test.rb new file mode 100644 index 000000000..586e11958 --- /dev/null +++ b/test/services/recommendation/scorer_embedding_test.rb @@ -0,0 +1,113 @@ +require 'test_helper' + +module Recommendation + # Verifies the embedding multiplier path. Skipped when the embedding + # column hasn't been migrated yet (so the suite is safe to run before + # rails db:migrate has applied the float[] migration). + class ScorerEmbeddingTest < ActiveSupport::TestCase + def setup + super + skip 'embedding column missing' unless DomainClassification.column_names.include?('embedding') + + @user = users(:participant) + travel_to Time.zone.parse('2026-05-27 12:00:00 UTC') + end + + def teardown + super + travel_back + end + + def test_aligned_embedding_boosts_score + # User behavioural history points strongly toward a 1.0-direction vector. + history_auction = Auction.create!( + domain_name: 'history-domain.ee', + starts_at: 1.day.ago, + ends_at: 2.days.ago, + skip_validation: true + ) + Offer.create!(user: @user, auction: history_auction, cents: 100, billing_profile_id: 0) + DomainClassification.create!( + domain_name: 'history-domain.ee', + primary_category: 'saas', + tags: %w[saas], + keywords: %w[cloud], + embedding: Array.new(8, 1.0), + classified_at: 1.day.ago, + confidence: 0.9 + ) + + # Two candidate auctions, identical except their embedding vectors: + # one aligned (same direction as the user centroid), one orthogonal. + aligned = create_active_auction(domain_name: 'aligned.ee') + DomainClassification.create!( + domain_name: 'aligned.ee', + primary_category: 'saas', + tags: %w[saas], + keywords: %w[cloud], + embedding: Array.new(8, 1.0), + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + orthogonal = create_active_auction(domain_name: 'orthogonal.ee') + DomainClassification.create!( + domain_name: 'orthogonal.ee', + primary_category: 'saas', + tags: %w[saas], + keywords: %w[cloud], + embedding: [1.0, 0, 0, 0, 0, 0, 0, 0], + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + Recommendation::Scorer.refresh_for( + user: @user, + scope: Auction.where(id: [aligned.id, orthogonal.id]) + ) + + aligned_score = UserAuctionScore.find_by!(user: @user, auction: aligned).score + orthogonal_score = UserAuctionScore.find_by!(user: @user, auction: orthogonal).score + + assert aligned_score > orthogonal_score, + "Expected aligned vector to score higher (#{aligned_score} vs #{orthogonal_score})" + end + + def test_multiplier_no_op_when_user_has_no_history + candidate = create_active_auction(domain_name: 'lonely.ee') + DomainClassification.create!( + domain_name: 'lonely.ee', + primary_category: 'saas', + tags: %w[saas], + keywords: %w[cloud], + embedding: Array.new(8, 0.5), + classified_at: 1.hour.ago, + confidence: 0.9 + ) + + Recommendation::Scorer.refresh_for( + user: @user, + scope: Auction.where(id: candidate.id) + ) + + score = UserAuctionScore.find_by!(user: @user, auction: candidate).score + # No user signals means multiplier=1.0. We just assert the job finishes + # successfully and the row exists; a precise numeric assertion would + # tie this test to the rest of the scoring formula. + assert score.is_a?(Numeric) + end + + private + + def create_active_auction(domain_name:) + Auction.create!( + domain_name: domain_name, + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + classification_tags: %w[saas], + primary_category: 'saas', + skip_validation: true + ) + end + end +end From bbfe390e0721d77bf3da573ef7742669eb56254a Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 28 May 2026 12:54:03 +0300 Subject: [PATCH 20/42] chore(demo): expand seed domains + helpers for recommendation testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DEMO_DOMAINS grouped by InterestCatalog category (~140 domains across local_service, health, shop_brand, saas, b2b_service, finance, legal, education, travel, automotive, real_estate, media_content, brandable, numeric, other). Mix of Estonian and English roots so the heuristic classifier hits cleanly on dictionary entries and falls through to LLM on novel patterns. - demo:create_blind_auctions now seeds the full set (was 57, now 140). - New demo:create_varied_auctions creates a small fan-out of auctions with different ends_at horizons (hour/day/week/month) so the SCORING_HORIZON=30.days clipping in Scorer can be exercised. - New demo:seed_user_signals attaches a recommendation profile, wishlist items and historical Offers to the first participant user, then enqueues a score refresh — gives a one-command setup for verifying the personalised /auctions sort. --- db/structure.sql | 16 ++- lib/tasks/demo_auctions.rake | 234 ++++++++++++++++++++++++++--------- 2 files changed, 186 insertions(+), 64 deletions(-) diff --git a/db/structure.sql b/db/structure.sql index 02f14b25a..27ddb656e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1192,8 +1192,6 @@ CREATE TABLE public.domain_classifications ( uuid uuid DEFAULT gen_random_uuid() NOT NULL, primary_category character varying, tags character varying[] DEFAULT '{}'::character varying[] NOT NULL, - description text, - description_locale character varying DEFAULT 'en'::character varying, keywords character varying[] DEFAULT '{}'::character varying[] NOT NULL, audience character varying, languages character varying[] DEFAULT '{}'::character varying[] NOT NULL, @@ -1209,7 +1207,10 @@ CREATE TABLE public.domain_classifications ( classified_at timestamp(6) without time zone, raw_llm_response jsonb DEFAULT '{}'::jsonb NOT NULL, created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL + updated_at timestamp(6) without time zone NOT NULL, + embedding double precision[], + embedding_model character varying, + embedded_at timestamp(6) without time zone ); @@ -2866,6 +2867,13 @@ CREATE INDEX index_domain_classifications_on_classified_at ON public.domain_clas CREATE UNIQUE INDEX index_domain_classifications_on_domain_name ON public.domain_classifications USING btree (domain_name); +-- +-- Name: index_domain_classifications_on_embedded_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_domain_classifications_on_embedded_at ON public.domain_classifications USING btree (embedded_at); + + -- -- Name: index_domain_classifications_on_keywords; Type: INDEX; Schema: public; Owner: - -- @@ -3516,6 +3524,8 @@ ALTER TABLE ONLY public.invoices SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260527090300'), +('20260527090200'), ('20260527090100'), ('20260527090000'), ('20260525115000'), diff --git a/lib/tasks/demo_auctions.rake b/lib/tasks/demo_auctions.rake index 3043fb6b8..9348abf71 100644 --- a/lib/tasks/demo_auctions.rake +++ b/lib/tasks/demo_auctions.rake @@ -1,69 +1,100 @@ namespace :demo do - desc 'Create temporary blind .ee auctions for recommendation testing' + # Domain seed list grouped by intended category. After `rake demo:create_blind_auctions` + # the Auction.after_create callback enqueues ClassifyDomainHeuristicallyJob for + # every domain. The heuristic Tier 0 classifier will tag most of these + # immediately from the dictionary. The nightly LLM cron then enriches keywords, + # audience, languages and use cases. Use the variety here to verify that + # different InterestCatalog categories surface correctly in /auctions sort. + DEMO_DOMAINS = { + # Estonian common nouns — dictionary should hit on first pass. + local_service: %w[ + kohvik.ee kohv.ee ehitus.ee remont.ee parandus.ee + ilusalong.ee kosmeetika.ee spa.ee + restoran.ee pubi.ee baar.ee + kingsepp.ee juuksur.ee maaler.ee elekter.ee + ], + health: %w[ + apteek.ee arst.ee tervis.ee hambaarst.ee + doctor.ee clinic.ee medic.ee pharma.ee + wellnesshub.ee fitness.ee yoga.ee + psyhholoog.ee labor.ee + ], + shop_brand: %w[ + pood.ee aiapood.ee kalapood.ee raamatupood.ee mobiilipood.ee + veebipood.ee tooriistad.ee lilled.ee mood.ee + shopline.ee dealzone.ee foodmarket.ee + megastore.ee bigshop.ee smartmart.ee outletshop.ee + ], + saas: %w[ + saasplatvorm.ee tarkvara.ee pilv.ee + cloudstack.ee pixelcraft.ee gamesuite.ee workzone.ee startupdesk.ee + apptools.ee devkit.ee codeflow.ee dataforge.ee + crmstack.ee apilayer.ee testlab.ee + ], + b2b_service: %w[ + turundus.ee nouv.ee konsultatsioon.ee + growthhub.ee marketflow.ee mediateam.ee accountflow.ee + adagency.ee leadgen.ee salesforce-tools.ee + brandstrategy.ee b2bpro.ee enterprisepro.ee + ], + finance: %w[ + laen.ee pank.ee raha.ee investeering.ee + fintechlab.ee fastloan.ee cashflow.ee + crypto-trade.ee invest24.ee bankhub.ee + kredit.ee finance-pro.ee + ], + legal: %w[ + jurist.ee oigusabi.ee notar.ee + legalhub.ee lawfirm.ee legaltech.ee + contractpro.ee compliance-pro.ee + ], + education: %w[ + haridus.ee kool.ee koolitus.ee opetaja.ee + academy.ee learnhub.ee coursestack.ee skillsup.ee + classmate.ee studyflow.ee + ], + travel: %w[ + reisid.ee matk.ee majutus.ee turism.ee + traveldesk.ee tripflow.ee hotelhub.ee + flightbooker.ee adventurelab.ee resort-deal.ee + ], + automotive: %w[ + autod.ee auto.ee rent.ee + carshop.ee motorflow.ee driveforge.ee + autopro.ee auto-rent.ee tireshop.ee + ], + real_estate: %w[ + kinnisvara.ee maja.ee korter.ee + propertylab.ee homehunt.ee realtypro.ee + apartment-finder.ee rentdesk.ee + ], + media_content: %w[ + meedia.ee uudised.ee ajakiri.ee + newsdesk.ee blogforge.ee podcastlab.ee + videohub.ee press-room.ee + ], + brandable: %w[ + brandforge.ee zylo.ee zenix.ee + koral.ee veylo.ee xolo.ee + qarra.ee jovi.ee plooma.ee + ], + numeric: %w[ + 24.ee 365.ee 100.ee 12345.ee + numeric24.ee 7eleven.ee top10.ee + shop42.ee deal99.ee + ], + other: %w[ + a-b-c.ee my-shop-online.ee long-domain-name-test.ee + short.ee mid-tier.ee + ] + }.freeze + + desc 'Create temporary blind .ee auctions for recommendation testing (~140 domains)' task create_blind_auctions: :environment do starts_at = Time.zone.now + 1.second ends_at = Time.zone.now + 1.month - domains = %w[ - aiapood.ee - apteek.ee - arst.ee - autod.ee - ehitus.ee - eood.ee - haridus.ee - ilusalong.ee - jurist.ee - kahvel.ee - kalapood.ee - kinnisvara.ee - kohvik.ee - koolitus.ee - kosmeetika.ee - laen.ee - lilled.ee - majutus.ee - matk.ee - meedia.ee - mobiilipood.ee - mood.ee - nouv.ee - oigusabi.ee - parandus.ee - pood.ee - raamatud.ee - raamatupood.ee - reisid.ee - remont.ee - rent.ee - saasplatvorm.ee - tarkvara.ee - tervis.ee - tooriistad.ee - turundus.ee - veebipood.ee - accountflow.ee - brandforge.ee - carshop.ee - cloudstack.ee - dealzone.ee - fintechlab.ee - foodmarket.ee - gamesuite.ee - growthhub.ee - legalhub.ee - marketflow.ee - marketplace.ee - mediateam.ee - numeric24.ee - pixelcraft.ee - propertylab.ee - shopline.ee - startupdesk.ee - traveldesk.ee - wellnesshub.ee - workzone.ee - ] + domains = DEMO_DOMAINS.values.flatten.uniq created = 0 skipped = 0 @@ -84,5 +115,86 @@ namespace :demo do end puts "Created #{created} blind auctions, skipped #{skipped} existing active auctions." + puts "Total demo domains: #{domains.size} across #{DEMO_DOMAINS.size} categories." + end + + desc 'Create varied auctions ending at different times (next hour, day, week, month)' + task create_varied_auctions: :environment do + now = Time.zone.now + horizons = { + next_hour: { ends_in: 1.hour, sample_size: 5 }, + next_day: { ends_in: 1.day, sample_size: 10 }, + next_week: { ends_in: 1.week, sample_size: 20 }, + next_month: { ends_in: 1.month, sample_size: 40 } + } + + all_domains = DEMO_DOMAINS.values.flatten.uniq.shuffle + + created = 0 + offset = 0 + + horizons.each do |_label, opts| + batch = all_domains[offset, opts[:sample_size]] || [] + offset += opts[:sample_size] + + batch.each do |domain_name| + bucket_domain = "varied-#{domain_name}" + next if Auction.where(domain_name: bucket_domain).where('ends_at > ?', now).exists? + + Auction.create!( + domain_name: bucket_domain, + starts_at: now + 1.second, + ends_at: now + opts[:ends_in] + ) + created += 1 + end + end + + puts "Created #{created} varied-horizon auctions." + end + + desc 'Seed a participant user with wishlist + bid signals so recommendations have data to chew on' + task seed_user_signals: :environment do + user = User.where('? = ANY (roles)', User::PARTICIPANT_ROLE).first + if user.nil? + puts 'No participant user found. Create one via the UI first, then re-run.' + next + end + + profile = user.recommendation_profile || user.build_recommendation_profile + profile.interest_keywords = %w[saas b2b_service custom:cloud custom:agency] + profile.preferred_length_min = 5 + profile.preferred_length_max = 18 + profile.allow_numbers = false + profile.allow_hyphens = false + profile.save! + profile.mark_completed! + + wishlist_picks = %w[cloudstack.ee fintechlab.ee marketflow.ee growthhub.ee] + wishlist_picks.each do |domain| + next if user.wishlist_items.exists?(domain_name: domain) + + WishlistItem.create!(user: user, domain_name: domain, cents: 5000) + end + + bid_picks = %w[ + apteek.ee kohvik.ee jurist.ee reisid.ee + ] + bid_picks.each do |domain| + auction = Auction.where(domain_name: domain).where('ends_at > ?', Time.zone.now).first + next if auction.nil? + next if Offer.exists?(user_id: user.id, auction_id: auction.id) + + Offer.create!(user: user, auction: auction, cents: 1500, billing_profile_id: 0) + end + + Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(user.id) + + puts "Seeded signals on user ##{user.id} (#{user.email}):" + puts " profile interests: #{profile.interest_categories.inspect} + custom #{profile.custom_interests.inspect}" + puts " wishlist: #{wishlist_picks.inspect}" + puts " bids on existing: #{bid_picks.inspect}" + puts + puts 'Score refresh enqueued. After the delayed_job runs (~30s), visit /auctions while signed in as this user.' end end From 18975f94230e60a1968ea49fa2abbe399cc570ae Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Thu, 28 May 2026 12:58:53 +0300 Subject: [PATCH 21/42] fix(demo): start auctions 5 minutes in the future, not 1 second `starts_at = Time.zone.now + 1.second` was racing the starts_at_cannot_be_in_the_past validation: by the time Rails 8.1 finished booting and the validation ran, the timestamp was already in the past. Tighter Time.current check in the validator (or just slower boot) tipped it over. Bumped the buffer to 5.minutes via a shared AUCTION_START_BUFFER constant used by both demo:create_blind_auctions and demo:create_varied_auctions. ends_at is now derived from starts_at so the relative horizons stay correct. --- lib/tasks/demo_auctions.rake | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/lib/tasks/demo_auctions.rake b/lib/tasks/demo_auctions.rake index 9348abf71..d9597f688 100644 --- a/lib/tasks/demo_auctions.rake +++ b/lib/tasks/demo_auctions.rake @@ -89,10 +89,15 @@ namespace :demo do ] }.freeze + # `+ 1.second` was tight enough that Rails 8.1 boot could land us + # in the past by the time validation ran. 5 minutes is generous + # enough to survive a slow boot or a paused debugger. + AUCTION_START_BUFFER = 5.minutes + desc 'Create temporary blind .ee auctions for recommendation testing (~140 domains)' task create_blind_auctions: :environment do - starts_at = Time.zone.now + 1.second - ends_at = Time.zone.now + 1.month + starts_at = Time.zone.now + AUCTION_START_BUFFER + ends_at = starts_at + 1.month domains = DEMO_DOMAINS.values.flatten.uniq @@ -120,12 +125,12 @@ namespace :demo do desc 'Create varied auctions ending at different times (next hour, day, week, month)' task create_varied_auctions: :environment do - now = Time.zone.now + starts_at = Time.zone.now + AUCTION_START_BUFFER horizons = { - next_hour: { ends_in: 1.hour, sample_size: 5 }, - next_day: { ends_in: 1.day, sample_size: 10 }, - next_week: { ends_in: 1.week, sample_size: 20 }, - next_month: { ends_in: 1.month, sample_size: 40 } + next_hour: { ends_at_offset: 1.hour, sample_size: 5 }, + next_day: { ends_at_offset: 1.day, sample_size: 10 }, + next_week: { ends_at_offset: 1.week, sample_size: 20 }, + next_month: { ends_at_offset: 1.month, sample_size: 40 } } all_domains = DEMO_DOMAINS.values.flatten.uniq.shuffle @@ -139,12 +144,12 @@ namespace :demo do batch.each do |domain_name| bucket_domain = "varied-#{domain_name}" - next if Auction.where(domain_name: bucket_domain).where('ends_at > ?', now).exists? + next if Auction.where(domain_name: bucket_domain).where('ends_at > ?', Time.zone.now).exists? Auction.create!( domain_name: bucket_domain, - starts_at: now + 1.second, - ends_at: now + opts[:ends_in] + starts_at: starts_at, + ends_at: starts_at + opts[:ends_at_offset] ) created += 1 end From c8821fce9e6bffd37d0f9b9f54f61302a6a7f341 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 29 May 2026 10:44:32 +0300 Subject: [PATCH 22/42] added score of domains for users for recommendation system --- .../stylesheets/components/_components.scss | 3 +- .../components/_recommendation.scss | 17 ----- app/controllers/auctions_controller.rb | 10 --- app/models/concerns/auction/searchable.rb | 6 +- app/services/recommendation/score_importer.rb | 6 +- app/services/recommendation/scorer.rb | 5 +- app/views/auctions/_auction.html.erb | 8 --- app/views/auctions/index.html.erb | 3 +- ...25095500_create_recommendation_profiles.rb | 28 -------- ...0525095500_create_recommendation_tables.rb | 67 +++++++++++++++++++ ...0525095600_create_recommendation_events.rb | 23 ------- ...260525095700_create_user_auction_scores.rb | 19 ------ ...527090000_create_domain_classifications.rb | 22 +++--- ...0100_enable_pgvector_and_add_embeddings.rb | 24 ------- ...description_from_domain_classifications.rb | 10 --- ...bedding_array_to_domain_classifications.rb | 19 ------ db/structure.sql | 3 +- docs/guides/recommendation-operations.md | 1 - .../recommendation/score_importer_test.rb | 4 +- test/services/recommendation/scorer_test.rb | 2 +- 20 files changed, 96 insertions(+), 184 deletions(-) delete mode 100644 app/assets/stylesheets/components/_recommendation.scss delete mode 100644 db/migrate/20260525095500_create_recommendation_profiles.rb create mode 100644 db/migrate/20260525095500_create_recommendation_tables.rb delete mode 100644 db/migrate/20260525095600_create_recommendation_events.rb delete mode 100644 db/migrate/20260525095700_create_user_auction_scores.rb delete mode 100644 db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb delete mode 100644 db/migrate/20260527090200_remove_description_from_domain_classifications.rb delete mode 100644 db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb diff --git a/app/assets/stylesheets/components/_components.scss b/app/assets/stylesheets/components/_components.scss index 6227ea7c5..6d91062f7 100644 --- a/app/assets/stylesheets/components/_components.scss +++ b/app/assets/stylesheets/components/_components.scss @@ -30,5 +30,4 @@ @import "accordion"; @import "pagy"; @import "notice"; -@import "slashed-zero"; -@import "recommendation"; \ No newline at end of file +@import "slashed-zero"; \ No newline at end of file diff --git a/app/assets/stylesheets/components/_recommendation.scss b/app/assets/stylesheets/components/_recommendation.scss deleted file mode 100644 index 5a9558186..000000000 --- a/app/assets/stylesheets/components/_recommendation.scss +++ /dev/null @@ -1,17 +0,0 @@ -// Recommendation system v2 — auction card enrichment. -// -// Rendered by app/views/auctions/_auction.html.erb when the matching -// domain_classifications row has keywords populated. -// Degrades silently when no classification is available. - -.c-auction__domain-keywords { - margin-top: 4px; - display: flex; - gap: 4px; - flex-wrap: wrap; -} - -.c-auction__domain-keyword { - font-size: 0.7em; - padding: 2px 6px; -} diff --git a/app/controllers/auctions_controller.rb b/app/controllers/auctions_controller.rb index a4c545848..767d7bc48 100644 --- a/app/controllers/auctions_controller.rb +++ b/app/controllers/auctions_controller.rb @@ -15,7 +15,6 @@ def index link_extra: 'data-turbo-action="advance"' ) @show_recommendation_prompt = current_user&.recommendation_profile_promptable? - @domain_classifications = preload_domain_classifications(@auctions) track_recommendation_impressions @@ -77,13 +76,4 @@ def track_recommendation_impressions ) end - def preload_domain_classifications(auctions) - return {} if auctions.blank? - - domain_names = auctions.map { |a| a.domain_name.to_s.downcase }.uniq - DomainClassification.where(domain_name: domain_names).index_by(&:domain_name) - rescue ActiveRecord::StatementInvalid - # Table not yet migrated; safely degrade. - {} - end end diff --git a/app/models/concerns/auction/searchable.rb b/app/models/concerns/auction/searchable.rb index e9214678f..8af2501c8 100644 --- a/app/models/concerns/auction/searchable.rb +++ b/app/models/concerns/auction/searchable.rb @@ -92,7 +92,9 @@ def search(params = {}, current_user = nil) sort_admin_column = params[:sort_by].presence_in(FILTERING_COLUMNS) || 'id' sort_direction = params[:sort_direction].presence_in(DIRECTION) || 'desc' is_from_admin = params[:admin] == 'true' - should_apply_user_sorting = params[:sort_by].blank? && params[:sort_direction].blank? && !is_from_admin && current_user + no_explicit_sort = params[:sort_by].blank? && params[:sort_direction].blank? && !is_from_admin + should_apply_user_sorting = no_explicit_sort && current_user + should_apply_anonymous_default = no_explicit_sort && current_user.nil? query = with_highest_offers @@ -107,6 +109,8 @@ def search(params = {}, current_user = nil) if should_apply_user_sorting query.sorted_for_user(current_user) + elsif should_apply_anonymous_default + query.order(Arel.sql('auctions.ends_at ASC NULLS LAST')) elsif params[:sort_by] == 'users_price' query.with_max_offer_cents_for_english_auction(current_user) .order("offers_subquery.max_offer_cents #{sort_direction} NULLS LAST") diff --git a/app/services/recommendation/score_importer.rb b/app/services/recommendation/score_importer.rb index a53a812d9..baf7985f7 100644 --- a/app/services/recommendation/score_importer.rb +++ b/app/services/recommendation/score_importer.rb @@ -6,9 +6,9 @@ def call(...) end end - def initialize(scores:, model_name: nil, features_version: nil, calculated_at: Time.current) + def initialize(scores:, scorer_name: nil, features_version: nil, calculated_at: Time.current) @scores = Array(scores) - @model_name = model_name + @scorer_name = scorer_name @features_version = features_version @calculated_at = calculated_at end @@ -34,7 +34,7 @@ def build_record(payload) user_id:, auction_id:, score: score.to_d, - model_name: payload[:model_name] || payload['model_name'] || @model_name, + scorer_name: payload[:scorer_name] || payload['scorer_name'] || @scorer_name, features_version: payload[:features_version] || payload['features_version'] || @features_version, calculated_at: payload[:calculated_at] || payload['calculated_at'] || @calculated_at, created_at: Time.current, diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb index 9836babc6..e989a97b4 100644 --- a/app/services/recommendation/scorer.rb +++ b/app/services/recommendation/scorer.rb @@ -22,7 +22,6 @@ module Recommendation # history and the candidate auction is embedded. See ADR-001 for # the rationale. class Scorer - SCORING_HORIZON = 30.days SIGNAL_LOOKBACK = 1.year HALF_LIFE_DAYS = 60.0 @@ -48,7 +47,7 @@ class Scorer class << self def default_scope - Auction.active.where('ends_at <= ?', SCORING_HORIZON.from_now) + Auction.active end def top_auctions_for(user:, scope: default_scope, limit: nil) @@ -93,7 +92,7 @@ def build_score_record(auction) user_id: @user.id, auction_id: auction.id, score: score_for(auction), - model_name: BASELINE_MODEL_NAME, + scorer_name: BASELINE_MODEL_NAME, features_version: FEATURES_VERSION, calculated_at: @calculated_at, created_at: Time.current, diff --git a/app/views/auctions/_auction.html.erb b/app/views/auctions/_auction.html.erb index 02b62dbd5..43e481629 100644 --- a/app/views/auctions/_auction.html.erb +++ b/app/views/auctions/_auction.html.erb @@ -1,4 +1,3 @@ -<% domain_classification = local_assigns[:domain_classification] %>

<%= auction.domain_name %>

- <% if domain_classification&.keywords.present? %> -
- <% domain_classification.keywords.first(4).each do |keyword| %> - <%= keyword %> - <% end %> -
- <% end %> <%= component 'common/auction_type_icon', auction: auction %> diff --git a/app/views/auctions/index.html.erb b/app/views/auctions/index.html.erb index bd96d25f6..3e892eecf 100644 --- a/app/views/auctions/index.html.erb +++ b/app/views/auctions/index.html.erb @@ -57,8 +57,7 @@ <%= render partial: 'auction', locals: { auction: auction, user: current_user, - updated: false, - domain_classification: (@domain_classifications || {})[auction.domain_name.to_s.downcase] + updated: false } %> <% end %> <% end %> diff --git a/db/migrate/20260525095500_create_recommendation_profiles.rb b/db/migrate/20260525095500_create_recommendation_profiles.rb deleted file mode 100644 index 6368a0d90..000000000 --- a/db/migrate/20260525095500_create_recommendation_profiles.rb +++ /dev/null @@ -1,28 +0,0 @@ -class CreateRecommendationProfiles < ActiveRecord::Migration[7.0] - def change - create_table :recommendation_profiles do |t| - t.references :user, null: false, foreign_key: true, index: { unique: true } - t.uuid :uuid, default: 'gen_random_uuid()' - t.string :preferred_tlds, array: true, default: [], null: false - t.string :interest_keywords, array: true, default: [], null: false - t.string :preferred_platforms, array: true, default: [], null: false - t.integer :preferred_length_min - t.integer :preferred_length_max - t.integer :budget_min_cents - t.integer :budget_max_cents - t.boolean :allow_numbers - t.boolean :allow_hyphens - t.datetime :completed_at - t.datetime :prompt_dismissed_at - t.datetime :last_prompted_at - t.integer :prompt_shown_count, default: 0, null: false - - t.timestamps - end - - add_index :recommendation_profiles, :uuid, unique: true - add_index :recommendation_profiles, :preferred_tlds, using: :gin - add_index :recommendation_profiles, :interest_keywords, using: :gin - add_index :recommendation_profiles, :preferred_platforms, using: :gin - end -end diff --git a/db/migrate/20260525095500_create_recommendation_tables.rb b/db/migrate/20260525095500_create_recommendation_tables.rb new file mode 100644 index 000000000..fbca74bdf --- /dev/null +++ b/db/migrate/20260525095500_create_recommendation_tables.rb @@ -0,0 +1,67 @@ +class CreateRecommendationTables < ActiveRecord::Migration[7.0] + def change + create_table :recommendation_profiles do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.uuid :uuid, default: 'gen_random_uuid()' + t.string :preferred_tlds, array: true, default: [], null: false + t.string :interest_keywords, array: true, default: [], null: false + t.string :preferred_platforms, array: true, default: [], null: false + t.integer :preferred_length_min + t.integer :preferred_length_max + t.integer :budget_min_cents + t.integer :budget_max_cents + t.boolean :allow_numbers + t.boolean :allow_hyphens + t.datetime :completed_at + t.datetime :prompt_dismissed_at + t.datetime :last_prompted_at + t.integer :prompt_shown_count, default: 0, null: false + + t.timestamps + end + + add_index :recommendation_profiles, :uuid, unique: true + add_index :recommendation_profiles, :preferred_tlds, using: :gin + add_index :recommendation_profiles, :interest_keywords, using: :gin + add_index :recommendation_profiles, :preferred_platforms, using: :gin + + create_table :recommendation_events do |t| + t.references :user, foreign_key: true + t.references :auction, foreign_key: true + t.uuid :uuid, default: 'gen_random_uuid()' + t.string :event_type, null: false + t.string :source + t.string :session_id + t.string :request_id + t.datetime :occurred_at, null: false + t.jsonb :properties, default: {}, null: false + + t.timestamps + end + + add_index :recommendation_events, :uuid, unique: true + add_index :recommendation_events, :event_type + add_index :recommendation_events, :occurred_at + add_index :recommendation_events, %i[user_id event_type occurred_at], name: 'idx_rec_events_user_type_time' + add_index :recommendation_events, :properties, using: :gin + + # `scorer_name` (not `model_name`) — Rails 8.1 reserves `model_name` + # as a class method on ActiveRecord, and using it as a column raises + # ActiveRecord::DangerousAttributeError when records are loaded. + create_table :user_auction_scores do |t| + t.references :user, null: false, foreign_key: true + t.references :auction, null: false, foreign_key: true + t.uuid :uuid, default: 'gen_random_uuid()' + t.decimal :score, precision: 10, scale: 6, null: false + t.string :scorer_name + t.string :features_version + t.datetime :calculated_at, null: false + + t.timestamps + end + + add_index :user_auction_scores, :uuid, unique: true + add_index :user_auction_scores, %i[user_id auction_id], unique: true + add_index :user_auction_scores, %i[user_id score] + end +end diff --git a/db/migrate/20260525095600_create_recommendation_events.rb b/db/migrate/20260525095600_create_recommendation_events.rb deleted file mode 100644 index 2befefc04..000000000 --- a/db/migrate/20260525095600_create_recommendation_events.rb +++ /dev/null @@ -1,23 +0,0 @@ -class CreateRecommendationEvents < ActiveRecord::Migration[7.0] - def change - create_table :recommendation_events do |t| - t.references :user, foreign_key: true - t.references :auction, foreign_key: true - t.uuid :uuid, default: 'gen_random_uuid()' - t.string :event_type, null: false - t.string :source - t.string :session_id - t.string :request_id - t.datetime :occurred_at, null: false - t.jsonb :properties, default: {}, null: false - - t.timestamps - end - - add_index :recommendation_events, :uuid, unique: true - add_index :recommendation_events, :event_type - add_index :recommendation_events, :occurred_at - add_index :recommendation_events, %i[user_id event_type occurred_at], name: 'idx_rec_events_user_type_time' - add_index :recommendation_events, :properties, using: :gin - end -end diff --git a/db/migrate/20260525095700_create_user_auction_scores.rb b/db/migrate/20260525095700_create_user_auction_scores.rb deleted file mode 100644 index 4df836a5e..000000000 --- a/db/migrate/20260525095700_create_user_auction_scores.rb +++ /dev/null @@ -1,19 +0,0 @@ -class CreateUserAuctionScores < ActiveRecord::Migration[7.0] - def change - create_table :user_auction_scores do |t| - t.references :user, null: false, foreign_key: true - t.references :auction, null: false, foreign_key: true - t.uuid :uuid, default: 'gen_random_uuid()' - t.decimal :score, precision: 10, scale: 6, null: false - t.string :model_name - t.string :features_version - t.datetime :calculated_at, null: false - - t.timestamps - end - - add_index :user_auction_scores, :uuid, unique: true - add_index :user_auction_scores, %i[user_id auction_id], unique: true - add_index :user_auction_scores, %i[user_id score] - end -end diff --git a/db/migrate/20260527090000_create_domain_classifications.rb b/db/migrate/20260527090000_create_domain_classifications.rb index 5603ed70a..43fe568d4 100644 --- a/db/migrate/20260527090000_create_domain_classifications.rb +++ b/db/migrate/20260527090000_create_domain_classifications.rb @@ -1,39 +1,40 @@ class CreateDomainClassifications < ActiveRecord::Migration[7.0] + # Per-domain semantic classification. Single source of truth for what a + # domain *means*, decoupled from any specific auction. See ADR-001. + # + # Embeddings are stored as a plain Postgres array of doubles — no + # pgvector — so this migration runs on the shared dev image and on + # production RDS without extension setup. Cosine similarity is + # computed in Ruby at scoring time. def change create_table :domain_classifications do |t| t.string :domain_name, null: false t.uuid :uuid, default: 'gen_random_uuid()', null: false - # Categorical signals t.string :primary_category t.string :tags, array: true, default: [], null: false - - # Description / readable enrichment - t.text :description - t.string :description_locale, default: 'en' - - # Semantic tokens t.string :keywords, array: true, default: [], null: false - # Audience / language / use-case hints t.string :audience t.string :languages, array: true, default: [], null: false t.string :suggested_use_cases, array: true, default: [], null: false - # Cached structural features t.boolean :has_digits, default: false, null: false t.boolean :has_hyphens, default: false, null: false t.integer :token_count t.boolean :dictionary_word, default: false, null: false t.decimal :brandability_score, precision: 4, scale: 3 - # Provenance t.string :classification_source # 'heuristic' | 'openai' | 'manual' | 'imported' t.string :classification_model t.decimal :confidence, precision: 4, scale: 3 t.datetime :classified_at t.jsonb :raw_llm_response, default: {}, null: false + t.column :embedding, :"double precision[]" + t.string :embedding_model + t.datetime :embedded_at + t.timestamps end @@ -45,5 +46,6 @@ def change add_index :domain_classifications, :audience add_index :domain_classifications, :classified_at add_index :domain_classifications, :classification_source + add_index :domain_classifications, :embedded_at end end diff --git a/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb b/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb deleted file mode 100644 index 5df6a6a5e..000000000 --- a/db/migrate/20260527090100_enable_pgvector_and_add_embeddings.rb +++ /dev/null @@ -1,24 +0,0 @@ -class EnablePgvectorAndAddEmbeddings < ActiveRecord::Migration[7.0] - # Phase 5 of recommendation v2 originally planned to add a pgvector - # `embedding` column to `domain_classifications`. That path was dropped - # to avoid touching the shared dev Postgres image and the shared RDS - # extension landscape (see docs/architecture/adr-001-recommendation-v2.md). - # - # This migration is intentionally a no-op so: - # - Fresh environments that have never applied it skip cleanly. - # - Local dev environments that earlier applied an embedding column - # get it removed on next run. - # - Production never had embedding columns, so this is harmless there. - def up - if column_exists?(:domain_classifications, :embedding) - execute 'DROP INDEX IF EXISTS idx_domain_classifications_embedding' - remove_column :domain_classifications, :embedding - remove_column :domain_classifications, :embedding_model if column_exists?(:domain_classifications, :embedding_model) - remove_column :domain_classifications, :embedded_at if column_exists?(:domain_classifications, :embedded_at) - end - end - - def down - # Nothing to roll back to — the embedding feature is not part of v2. - end -end diff --git a/db/migrate/20260527090200_remove_description_from_domain_classifications.rb b/db/migrate/20260527090200_remove_description_from_domain_classifications.rb deleted file mode 100644 index d4e2e5971..000000000 --- a/db/migrate/20260527090200_remove_description_from_domain_classifications.rb +++ /dev/null @@ -1,10 +0,0 @@ -class RemoveDescriptionFromDomainClassifications < ActiveRecord::Migration[7.0] - # Description was originally added to back UI auction-card copy and to - # feed the (later dropped) embedding text input. With keywords + tags - # carrying both the UX and recommendation needs, we drop description - # to save OpenAI tokens at classification time. - def change - remove_column :domain_classifications, :description, :text - remove_column :domain_classifications, :description_locale, :string, default: 'en' - end -end diff --git a/db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb b/db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb deleted file mode 100644 index 4e21e8f4b..000000000 --- a/db/migrate/20260527090300_add_embedding_array_to_domain_classifications.rb +++ /dev/null @@ -1,19 +0,0 @@ -class AddEmbeddingArrayToDomainClassifications < ActiveRecord::Migration[7.0] - # Embeddings stored as a plain Postgres array of doubles — no pgvector - # extension required. Cosine similarity is computed in Ruby at scoring - # time. At our scale (100-200 active auctions per user) brute-force - # cosine over a few hundred 1536-dim vectors takes ~50ms, well within - # the RefreshSingleUserAuctionScoresJob budget. - # - # If we ever outgrow brute-force (~5k+ vectors), we can either: - # - Stand up a sidecar pgvector pod and migrate the column type, or - # - Move to an external vector API (Upstash, Qdrant, Pinecone) - # The float[] format is portable to all of the above. - def change - add_column :domain_classifications, :embedding, :"double precision[]" - add_column :domain_classifications, :embedding_model, :string - add_column :domain_classifications, :embedded_at, :datetime - - add_index :domain_classifications, :embedded_at - end -end diff --git a/db/structure.sql b/db/structure.sql index 27ddb656e..ee38c40bc 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1749,7 +1749,7 @@ CREATE TABLE public.user_auction_scores ( auction_id bigint NOT NULL, uuid uuid DEFAULT gen_random_uuid(), score numeric(10,6) NOT NULL, - model_name character varying, + scorer_name character varying, features_version character varying, calculated_at timestamp(6) without time zone NOT NULL, created_at timestamp(6) without time zone NOT NULL, @@ -3524,6 +3524,7 @@ ALTER TABLE ONLY public.invoices SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260528100000'), ('20260527090300'), ('20260527090200'), ('20260527090100'), diff --git a/docs/guides/recommendation-operations.md b/docs/guides/recommendation-operations.md index 7080d52bb..0ad48629e 100644 --- a/docs/guides/recommendation-operations.md +++ b/docs/guides/recommendation-operations.md @@ -64,7 +64,6 @@ them requires a deploy — no DB migration needed. | constant | default | meaning | |---|---|---| -| `SCORING_HORIZON` | 30 days | Auctions ending later than this are not scored. | | `HALF_LIFE_DAYS` | 60 | Behavioural signal decay half-life. | | `WISHLIST_HIT` | 120 | Bonus if exact-match wishlist domain is up for auction. | | `TAG_WEIGHT` / `KEYWORD_WEIGHT` | 35 / 15 | Per-interest-match boost. | diff --git a/test/services/recommendation/score_importer_test.rb b/test/services/recommendation/score_importer_test.rb index ae9a3ab17..f79db875e 100644 --- a/test/services/recommendation/score_importer_test.rb +++ b/test/services/recommendation/score_importer_test.rb @@ -17,7 +17,7 @@ def test_imports_scores_by_uuid score: 0.75 } ], - model_name: 'lightfm_stub', + scorer_name: 'lightfm_stub', features_version: 'v1' ) @@ -25,7 +25,7 @@ def test_imports_scores_by_uuid record = UserAuctionScore.find_by!(user: @user, auction: @auction) assert_equal BigDecimal('0.75'), record.score - assert_equal 'lightfm_stub', record.model_name + assert_equal 'lightfm_stub', record.scorer_name assert_equal 'v1', record.features_version end diff --git a/test/services/recommendation/scorer_test.rb b/test/services/recommendation/scorer_test.rb index 6def6a0a4..e167421e7 100644 --- a/test/services/recommendation/scorer_test.rb +++ b/test/services/recommendation/scorer_test.rb @@ -33,7 +33,7 @@ def test_refresh_for_prioritizes_wishlist_category_and_custom_interest_matches assert scores[wishlist_auction.id].score > scores[category_auction.id].score assert scores[category_auction.id].score > scores[custom_auction.id].score assert scores[custom_auction.id].score > scores[neutral_auction.id].score - assert_equal Recommendation::Scorer::BASELINE_MODEL_NAME, scores[wishlist_auction.id].model_name + assert_equal Recommendation::Scorer::BASELINE_MODEL_NAME, scores[wishlist_auction.id].scorer_name end def test_refresh_for_uses_bid_history_tag_affinity From 4cde4689c8a2f7949c938d25dd83c321c06ac3b7 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 29 May 2026 11:50:56 +0300 Subject: [PATCH 23/42] refactor --- .gitignore | 4 + .../classify_auction_domains_job.rb | 29 --- .../refresh_user_auction_scores_job.rb | 22 -- app/models/job.rb | 4 +- .../auction_domain_classifier.rb | 155 ------------ .../recommendation/dataset_snapshot.rb | 113 --------- app/services/recommendation/score_importer.rb | 53 ---- app/services/recommendation/scorer.rb | 1 - .../architecture/adr-001-recommendation-v2.md | 176 -------------- docs/architecture/recommendation-system.md | 208 ---------------- docs/guides/recommendation-operations.md | 99 -------- .../domain-classification-pipeline.md | 230 ------------------ test/models/job_test.rb | 10 +- .../auction_domain_classifier_test.rb | 50 ---- .../recommendation/dataset_snapshot_test.rb | 48 ---- .../recommendation/score_importer_test.rb | 54 ---- 16 files changed, 10 insertions(+), 1246 deletions(-) delete mode 100644 app/jobs/recommendation/classify_auction_domains_job.rb delete mode 100644 app/jobs/recommendation/refresh_user_auction_scores_job.rb delete mode 100644 app/services/recommendation/auction_domain_classifier.rb delete mode 100644 app/services/recommendation/dataset_snapshot.rb delete mode 100644 app/services/recommendation/score_importer.rb delete mode 100644 docs/architecture/adr-001-recommendation-v2.md delete mode 100644 docs/architecture/recommendation-system.md delete mode 100644 docs/guides/recommendation-operations.md delete mode 100644 docs/technical/domain-classification-pipeline.md delete mode 100644 test/services/recommendation/auction_domain_classifier_test.rb delete mode 100644 test/services/recommendation/dataset_snapshot_test.rb delete mode 100644 test/services/recommendation/score_importer_test.rb diff --git a/.gitignore b/.gitignore index a161bb3cb..579f3e396 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,7 @@ CLAUDE.md .specstory # Local Claude Code workspace artifacts .claude/ + +# AI / working docs — kept locally, not committed to the shared repo +/docs/ +/style-guide/ diff --git a/app/jobs/recommendation/classify_auction_domains_job.rb b/app/jobs/recommendation/classify_auction_domains_job.rb deleted file mode 100644 index 75227c9cd..000000000 --- a/app/jobs/recommendation/classify_auction_domains_job.rb +++ /dev/null @@ -1,29 +0,0 @@ -module Recommendation - # DEPRECATED: superseded by Recommendation::ClassifyUnclassifiedDomainsJob (v2). - # Kept registered so existing scheduling and the admin Job UI keep - # working during the rollout. Both jobs are idempotent and consult - # different scopes (auctions.classified_at vs - # domain_classifications.classified_at), so running both is safe. - # Drop this class after auctions.classification_* columns are removed. - class ClassifyAuctionDomainsJob < ApplicationJob - retry_on StandardError, wait: 5.seconds, attempts: 3 - - def perform(auction_ids = nil) - return unless self.class.needs_to_run? - - auctions = self.class.scope_for(auction_ids) - should_refresh_scores = auctions.exists? - Recommendation::AuctionDomainClassifier.call(auctions:) - Recommendation::RefreshUserAuctionScoresJob.perform_later if should_refresh_scores - end - - def self.needs_to_run? - Feature.open_ai_integration_enabled? && scope_for.exists? - end - - def self.scope_for(auction_ids = nil) - scope = auction_ids.present? ? Auction.where(id: auction_ids) : Auction.active - scope.where(classified_at: nil).or(scope.where('classified_at < ?', 7.days.ago)) - end - end -end diff --git a/app/jobs/recommendation/refresh_user_auction_scores_job.rb b/app/jobs/recommendation/refresh_user_auction_scores_job.rb deleted file mode 100644 index 721460321..000000000 --- a/app/jobs/recommendation/refresh_user_auction_scores_job.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Recommendation - class RefreshUserAuctionScoresJob < ApplicationJob - retry_on StandardError, wait: 5.seconds, attempts: 3 - - def perform(user_ids = nil) - return unless self.class.needs_to_run? - - self.class.scope_for(user_ids).find_each do |user| - Recommendation::Scorer.refresh_for(user:) - end - end - - def self.needs_to_run? - Auction.active.exists? && scope_for.exists? - end - - def self.scope_for(user_ids = nil) - scope = user_ids.present? ? User.where(id: user_ids) : User.where('? = ANY (roles)', User::PARTICIPANT_ROLE) - scope.includes(:recommendation_profile) - end - end -end diff --git a/app/models/job.rb b/app/models/job.rb index a956edf3f..84c9519a8 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -4,10 +4,8 @@ class Job DomainRegistrationReminderJob UnpaidInvoiceReminderJob DailySummaryJob DailyBroadcastAuctionsJob DailyViewRefreshJob SharedFooterFetcherJob DirectoInvoiceForwardJob ActiveAuctionsAiSortingJob - Recommendation::ClassifyAuctionDomainsJob Recommendation::ClassifyUnclassifiedDomainsJob - Recommendation::EmbedUnembeddedDomainsJob - Recommendation::RefreshUserAuctionScoresJob].freeze + Recommendation::EmbedUnembeddedDomainsJob].freeze include ActiveModel::Model diff --git a/app/services/recommendation/auction_domain_classifier.rb b/app/services/recommendation/auction_domain_classifier.rb deleted file mode 100644 index 9b0ee814d..000000000 --- a/app/services/recommendation/auction_domain_classifier.rb +++ /dev/null @@ -1,155 +0,0 @@ -module Recommendation - # DEPRECATED: superseded by Recommendation::LlmDomainClassifier (v2). - # Writes legacy auctions.classification_* columns and is kept only so - # the existing Auction::UserSortable fallback path stays warm during - # the v2 rollout. New code paths should use LlmDomainClassifier and - # domain_classifications instead. - # - # Remove this class once auctions.classification_* columns are dropped - # (planned for the release after v2 has been stable in production). - class AuctionDomainClassifier - DEFAULT_TEMPERATURE = 0.2 - - class << self - def call(...) - new(...).call - end - end - - def initialize(auctions:, temperature: DEFAULT_TEMPERATURE) - @auctions = Array(auctions) - @temperature = temperature - end - - def call - return [] if @auctions.empty? - - response = fetch_ai_response - classifications = JSON.parse(response).fetch('classifications', []) - apply_classifications(classifications) - rescue StandardError, OpenAI::Error => e - Rails.logger.info "Auction domain classification failed: #{e.message}" - raise - end - - private - - def fetch_ai_response - client = OpenAI::Client.new - response = client.chat(parameters: chat_parameters) - - finish_reason = response.dig('choices', 0, 'finish_reason') - raise StandardError, 'Incomplete response' if finish_reason == 'length' - - refusal = response.dig('choices', 0, 'message', 'refusal') - raise StandardError, refusal if refusal - - content = response.dig('choices', 0, 'message', 'content') - raise StandardError, response.dig('error', 'message') || 'No response content' if content.nil? - - content - end - - def chat_parameters - model_name = openai_model - { - model: model_name, - response_format: { - type: 'json_schema', - json_schema: schema - }, - messages: messages - }.merge(OpenaiStructuredOutputSupport.temperature_options(model_name, @temperature)) - end - - def schema - { - name: 'auction_domain_classification', - schema: { - type: 'object', - properties: { - classifications: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'number' }, - domain_name: { type: 'string' }, - primary_category: { type: 'string' }, - tags: { - type: 'array', - items: { type: 'string' } - } - }, - required: %w[id domain_name primary_category tags], - additionalProperties: false - } - } - }, - required: ['classifications'], - additionalProperties: false - }, - strict: true - } - end - - def messages - [ - { role: 'system', content: system_message }, - { role: 'user', content: auctions_payload.to_json } - ] - end - - def system_message - setting = Setting.find_by(code: 'openai_domain_classification_prompt')&.retrieve - return setting if setting.present? - - <<~PROMPT.squish - You classify .ee auction domains for a recommendation system. - For each domain choose one primary category and 1-4 tags from this fixed vocabulary only: - #{Recommendation::InterestCatalog.categories.join(', ')}. - Return only categories that are actually inferable from the domain name. - Prefer conservative classification over guessing. - PROMPT - end - - def auctions_payload - @auctions.map do |auction| - { - id: auction.id, - domain_name: auction.domain_name, - platform: auction.platform || 'blind', - starts_at: auction.starts_at, - ends_at: auction.ends_at - } - end - end - - def openai_model - OpenaiStructuredOutputSupport.model(Setting.find_by(code: 'openai_model').retrieve) - end - - def apply_classifications(classifications) - now = Time.current - - classifications.filter_map do |payload| - auction = @auctions.find { |item| item.id == payload['id'] } - next unless auction - - tags = Array(payload['tags']).map(&:to_s).map(&:downcase) & Recommendation::InterestCatalog.categories - primary_category = payload['primary_category'].to_s.downcase - primary_category = tags.first if primary_category.blank? || !Recommendation::InterestCatalog.categories.include?(primary_category) - - auction.update_columns( - classification_tags: tags, - primary_category: primary_category, - classification_source: 'openai', - classification_model: openai_model, - classified_at: now - ) - - { auction_id: auction.id, tags:, primary_category: } - end - end - end -end diff --git a/app/services/recommendation/dataset_snapshot.rb b/app/services/recommendation/dataset_snapshot.rb deleted file mode 100644 index 2690fc042..000000000 --- a/app/services/recommendation/dataset_snapshot.rb +++ /dev/null @@ -1,113 +0,0 @@ -module Recommendation - class DatasetSnapshot - class << self - def call(...) - new(...).call - end - end - - def initialize(users: User.all, auctions: Auction.all, recommendation_events: RecommendationEvent.all) - @users = users - @auctions = auctions - @recommendation_events = recommendation_events - end - - def call - { - users: users_payload, - auctions: auctions_payload, - interactions: interactions_payload - } - end - - private - - def users_payload - @users.includes(:recommendation_profile).map do |user| - profile = user.recommendation_profile - - { - user_uuid: user.uuid, - locale: user.locale, - country_code: user.country_code, - daily_summary: user.daily_summary, - interest_categories: profile&.interest_categories || [], - custom_interests: profile&.custom_interests || [], - preferred_length_min: profile&.preferred_length_min, - preferred_length_max: profile&.preferred_length_max - } - end - end - - def auctions_payload - @auctions.map do |auction| - { - auction_uuid: auction.uuid, - domain_name: auction.domain_name, - platform: auction.platform || 'blind', - starts_at: auction.starts_at, - ends_at: auction.ends_at, - turns_count: auction.turns_count, - ai_score: auction.ai_score, - classification_tags: auction.classification_tags, - primary_category: auction.primary_category, - classification_source: auction.classification_source, - classified_at: auction.classified_at, - starting_price: auction.starting_price, - min_bids_step: auction.min_bids_step, - slipping_end: auction.slipping_end, - enable_deposit: auction.enable_deposit, - requirement_deposit_in_cents: auction.requirement_deposit_in_cents - } - end - end - - def interactions_payload - explicit_interactions + historical_offer_interactions + historical_wishlist_interactions - end - - def explicit_interactions - @recommendation_events.includes(:user, :auction).map do |event| - { - user_uuid: event.user&.uuid, - auction_uuid: event.auction&.uuid, - event_type: event.event_type, - source: event.source, - occurred_at: event.occurred_at, - properties: event.properties - } - end - end - - def historical_offer_interactions - Offer.includes(:user, :auction).map do |offer| - { - user_uuid: offer.user&.uuid, - auction_uuid: offer.auction&.uuid, - event_type: 'historical_bid', - source: 'offers', - occurred_at: offer.updated_at, - properties: { cents: offer.cents, billing_profile_id: offer.billing_profile_id, username: offer.username } - } - end - end - - def historical_wishlist_interactions - auctions_by_domain = Auction.where(domain_name: WishlistItem.select(:domain_name).distinct) - .index_by(&:domain_name) - - WishlistItem.includes(:user).filter_map do |item| - auction = auctions_by_domain[item.domain_name] - - { - user_uuid: item.user&.uuid, - auction_uuid: auction&.uuid, - event_type: 'historical_wishlist', - source: 'wishlist_items', - occurred_at: item.updated_at, - properties: { domain_name: item.domain_name, cents: item.cents } - } - end - end - end -end diff --git a/app/services/recommendation/score_importer.rb b/app/services/recommendation/score_importer.rb deleted file mode 100644 index baf7985f7..000000000 --- a/app/services/recommendation/score_importer.rb +++ /dev/null @@ -1,53 +0,0 @@ -module Recommendation - class ScoreImporter - class << self - def call(...) - new(...).call - end - end - - def initialize(scores:, scorer_name: nil, features_version: nil, calculated_at: Time.current) - @scores = Array(scores) - @scorer_name = scorer_name - @features_version = features_version - @calculated_at = calculated_at - end - - def call - records = @scores.filter_map { |payload| build_record(payload) } - return 0 if records.empty? - - UserAuctionScore.upsert_all(records, unique_by: %i[user_id auction_id]) - records.size - end - - private - - def build_record(payload) - user_id = resolve_user_id(payload) - auction_id = resolve_auction_id(payload) - score = payload[:score] || payload['score'] - - return if user_id.blank? || auction_id.blank? || score.blank? - - { - user_id:, - auction_id:, - score: score.to_d, - scorer_name: payload[:scorer_name] || payload['scorer_name'] || @scorer_name, - features_version: payload[:features_version] || payload['features_version'] || @features_version, - calculated_at: payload[:calculated_at] || payload['calculated_at'] || @calculated_at, - created_at: Time.current, - updated_at: Time.current - } - end - - def resolve_user_id(payload) - payload[:user_id] || payload['user_id'] || User.find_by(uuid: payload[:user_uuid] || payload['user_uuid'])&.id - end - - def resolve_auction_id(payload) - payload[:auction_id] || payload['auction_id'] || Auction.find_by(uuid: payload[:auction_uuid] || payload['auction_uuid'])&.id - end - end -end diff --git a/app/services/recommendation/scorer.rb b/app/services/recommendation/scorer.rb index e989a97b4..74153852b 100644 --- a/app/services/recommendation/scorer.rb +++ b/app/services/recommendation/scorer.rb @@ -22,7 +22,6 @@ module Recommendation # history and the candidate auction is embedded. See ADR-001 for # the rationale. class Scorer - SIGNAL_LOOKBACK = 1.year HALF_LIFE_DAYS = 60.0 WISHLIST_HIT = 120 diff --git a/docs/architecture/adr-001-recommendation-v2.md b/docs/architecture/adr-001-recommendation-v2.md deleted file mode 100644 index 82b112c4d..000000000 --- a/docs/architecture/adr-001-recommendation-v2.md +++ /dev/null @@ -1,176 +0,0 @@ -# ADR 001 — Recommendation system v2 design - -**Status:** Accepted -**Date:** 2026-05-27 -**Branch:** `feature/recommendation-system-improvements` - -## Context - -The v1 recommendation system shipped on the same branch coupled classification -to the `auctions` table (`auctions.classification_tags`, `primary_category`, -`classification_source`), called the OpenAI LLM eagerly per event, and computed -scores synchronously inside controllers' callback paths. Operating it revealed -three structural problems: - -1. **Per-event LLM cost was unbounded.** Each new auction or wishlist add could - eventually trigger an LLM call, with no upper bound on monthly spend. -2. **Domains outside `auctions` were invisible to the recommender.** Wishlist - items whose domain never went to auction had no tags — wishlist affinity - silently no-op'd. -3. **Self-learning loop was broken for historical auctions.** Classification only - ran on currently active auctions, so a user's old bid history could not feed - tag affinity unless the domain happened to be on an active auction *and* had - already been classified. - -Plus operational concerns: per-impression INSERTs on `/auctions` index, no -debouncing on score recompute, no embeddings for similarity search, no time -decay on behavioural signals. - -## Decisions - -### D1. Classification lives on a per-domain table, not per-auction columns - -A new `domain_classifications` table is the single source of truth for what a -domain *means*. Unique by `domain_name`. Auctions, wishlist items, offer -histories, and result records all reference it indirectly via `domain_name`. - -**Why:** The semantics of `cloud-shop.ee` don't change between auctions. Storing -tags per auction duplicates state and prevents non-auction inputs (wishlist, -historical bids) from contributing to scoring. - -**Cost:** A migration. Legacy `auctions.classification_*` columns stay populated -during transition as fallback; planned removal after v2 stabilises. - -### D2. Three-tier classifier pipeline (Ruby → LLM-batch → embeddings) - -- **Tier 0** — `DomainHeuristicClassifier`, deterministic Ruby with an et+en - dictionary, runs at runtime on every event. Covers ~60-70% of Estonian - domains, free, microsecond-scale. -- **Tier 2** — `LlmDomainClassifier`, structured-output OpenAI call, batched 50 - domains per request. Runs ONLY from cron (`rake recommendation:classify_unclassified`), - never from request paths. -- **Embeddings** — `DomainEmbedder` using `text-embedding-3-small`, batched 100 - per call. Same cron-only constraint. - -**Why:** Decouples user-facing latency from OpenAI variability and bounds -monthly cost. At our auction volume (100-200 active) steady-state OpenAI cost -is ~$0.30/month, with ~$1 one-time backfill. - -**Alternative considered:** WASM-based on-device classifier. Rejected for v2 -because classification is computed once per domain and cached; the bandwidth -cost of shipping a 10MB model to every browser exceeds the savings. - -### D3. Embeddings as Postgres `double precision[]`, no pgvector - -`text-embedding-3-small` (1536 dim) vectors stored as a plain Postgres -array of doubles. Cosine similarity is computed in Ruby at scoring time. - -**Why not pgvector:** -- The shared dev Postgres image (`postgres:13.4` in - `docker-images/docker-compose.yml`) is used by every service on the - team (registry, registrar_center, billing, eeid, etc.). Switching it - to `pgvector/pgvector:pg13` would have been a patch-version bump on - shared infrastructure for the sake of one app. -- Production RDS does support pgvector natively, but rolling it out - consistently across dev/staging/test/prod adds operational risk. -- At 100-200 active auctions, brute-force cosine over a few hundred - 1536-dim vectors takes ~50ms in Ruby. HNSW index would not be - meaningfully faster at this scale. - -**Why not an external vector service (Pinecone, Qdrant, Upstash):** -- New external dependency, new auth, new failure mode for a marginal - win at our scale. -- The `double precision[]` format is portable — if we ever do need to - migrate to pgvector or an external service, the data already lives - in a structure we can dump and reload trivially. - -**What changes when we outgrow brute force (~5k+ vectors):** -- Stand up a sidecar pgvector pod isolated to auction_center (separate - StatefulSet, separate ActiveRecord connection) — main databases - stay untouched. -- Or move to an external vector API. -- The migration is a one-time script reading the column we already have. - -Description was also dropped to save OpenAI tokens — embedding input -is now `. ` only. Keywords carry the user- -visible content (badge display in auction card). - -### D4. Cron jobs scheduled outside the app - -The application exposes rake tasks (`recommendation:classify_unclassified`, -`recommendation:embed_unembedded`, `recommendation:backfill`) and registers -the underlying jobs in `Job::ALLOWED_JOB_NAMES`. Scheduling lives in -`Ry_AWS_IaC/infrastructure/kubernetes` as k8s `CronJob` resources. - -**Why:** Matches the project's existing pattern — server-side scheduling is -infra concern, app exposes idempotent entry points. Avoids an in-process -scheduler (whenever, sidekiq-cron, good_job). - -### D5. Time-decayed behavioural signals, no SQL date cutoff - -Bid, wishlist, view, and result signals are weighted by -`exp(-days_old / HALF_LIFE_DAYS)` with `HALF_LIFE_DAYS = 60`. No `WHERE -updated_at > X` clause; decay alone reduces multi-year-old signals below the -floating-point noise floor. - -**Why:** SQL date cutoffs interact badly with `travel_to` in tests and with -clock skew across regions. Decay is mathematically equivalent and simpler to -reason about. - -### D6. Embedding multiplier, not additive - -Final score = base_score * (1 + max(0, cosine_similarity)). User centroid is -the time-decayed weighted average of embeddings from bids, wishlist, and views. - -**Why:** Multiplicative form lets embedding act as a "boost knob" — it can't -introduce a high score in isolation (no behavioural data → no centroid → 1.0), -but it can lift a domain that other signals already mildly favour. Additive -form risks dominating the rule-based base when cosine is small. - -### D7. Heuristic-only at runtime, LLM-only via cron - -`ClassifyDomainHeuristicallyJob` (Tier 0) fires from `Auction.after_create`, -`WishlistItem#create`, and offer controllers. `ClassifyUnclassifiedDomainsJob` -(Tier 2) fires only from cron. There is no code path that triggers an LLM call -in response to a user request. - -**Why:** Bounds cost, hides OpenAI latency from users, makes the system -predictable to operate. - -## Consequences - -**Positive** -- Cost bound to ~$0.30/month steady state, ~$1 one-time backfill. -- No user-visible latency tied to OpenAI. -- Wishlist and historical bids on non-auction domains now contribute to - recommendations. -- Time decay means stale interests fade automatically; recent activity weighs - more. -- Zero infrastructure changes outside auction_center. Shared dev Postgres - image, RDS extensions, Terraform — all untouched. - -**Negative** -- Two writes per domain (heuristic, then LLM enrichment). Acceptable because - Tier 0 produces useful tags immediately while Tier 2 enriches overnight. -- More tables to operate. Mitigated by the operator runbook in - `docs/guides/recommendation-operations.md`. -- Cosine similarity in Ruby instead of a vector index. Acceptable for - the current scale; documented breakpoint (~5k vectors) for moving to - a real vector index. - -**Migration path** -1. Deploy. Migrations create `domain_classifications` and add classification - fields to auctions. No extensions, no shared infra. -2. Run `rake recommendation:backfill` once. -3. Run `rake recommendation:classify_unclassified` manually for first LLM pass. -4. Schedule one cron job in k8s (`recommendation:classify_unclassified` daily). -5. Optionally drop `auctions.classification_*` columns after a release of - stable v2 operation. - -## References - -- `docs/architecture/recommendation-system.md` — high-level overview, schemas, - phase list. -- `docs/technical/domain-classification-pipeline.md` — pipeline internals, - prompts, schema. -- `docs/guides/recommendation-operations.md` — operator runbook. diff --git a/docs/architecture/recommendation-system.md b/docs/architecture/recommendation-system.md deleted file mode 100644 index 41c07ba6f..000000000 --- a/docs/architecture/recommendation-system.md +++ /dev/null @@ -1,208 +0,0 @@ -# Recommendation System v2 — Architecture - -**Status:** in progress -**Branch:** `feature/recommendation-system-improvements` -**Owner:** auction_center team -**Last updated:** 2026-05-27 - -## Goal - -Personalised auction feed on `/auctions` index that ranks domains by combining: - -1. User's explicit interests (recommendation profile) -2. Wishlist (current + historical) -3. Bid history including finished auctions (`Offer`, `EnglishOffer`, `DomainOfferHistory`) -4. Detail-page views (with dwell-time signal) -5. Auction outcomes (`Result` — won/lost) - -Sort is **per-user**. Same `/auctions` request returns N different orderings for N users. - -## Non-goals - -- No global feed cache (sort is per-user) -- No client-side ranking for v2 (server is the source of truth) -- No real-time LLM calls during user requests (LLM is batch-only, cron-driven) -- **No pgvector** — pgvector was evaluated and dropped to avoid shared- - infrastructure churn (RDS extensions, Docker image bumps for the team-shared - Postgres). Instead, embeddings are stored as plain Postgres `double precision[]` - arrays and cosine similarity is computed in Ruby. At 100-200 active auctions - this is sub-100ms and zero new infrastructure. See ADR-001. - -## High-level data flow - -``` -Domain enters system (Auction.create / WishlistItem.create / Offer.create / DomainOfferHistory) - | - v -ClassifyDomainHeuristicallyJob (instant, Ruby-only, no external calls) - | - v -domain_classifications row created with source='heuristic' - | - v - |---> Scorer uses these tags/keywords immediately - | - v -[ Nightly k8s CronJob: rake recommendation:classify_unclassified ] - | - v -LLM batch (50 domains / call) enriches rows with keywords, audience, -suggested_use_cases, brandability_score, languages -source='openai' - | - v -Scorer per user-event: - bid affinity, wishlist affinity, view affinity, time decay, - tag/keyword overlap, structural bonuses - | - v -user_auction_scores (upsert, unique on user_id + auction_id) - | - v -Auction::UserSortable.with_user_priority_sorting(user) - | - v -/auctions index — sorted per user -``` - -## Key tables - -### `domain_classifications` (new) - -Single source of truth for what a domain *means*. One row per `domain_name`. - -| column | purpose | -|---|---| -| `domain_name` (unique) | the key | -| `primary_category`, `tags[]` | hard categorical signals | -| `keywords[]` | extracted semantic tokens, shown in UI as badges | -| `audience` (b2b/b2c/mixed) | targeting signal | -| `languages[]` | et / en / mixed | -| `suggested_use_cases[]` | shop / blog / service / agency / marketplace | -| `has_digits`, `has_hyphens`, `token_count`, `dictionary_word`, `brandability_score` | structural cache | -| `classification_source` | heuristic / openai / manual / imported | -| `confidence` (0..1) | gate for "stale, needs LLM re-run" | -| `raw_llm_response` (jsonb) | audit trail, allows re-parsing without re-billing | - -### `recommendation_events` (existing) - -Append-only log of user behaviour. Used both for scoring inputs and analytics. - -### `user_auction_scores` (existing) - -Per-user × per-active-auction precomputed score. Updated by `Recommendation::Scorer` on user events. **This is the personalisation cache.** LEFT JOINed by `Auction::UserSortable`. - -### `recommendation_profiles` (existing) - -Explicit user preferences (interests, length, digit/hyphen tolerance). - -## Classification tiers - -``` -Tier 0 — Structural + Heuristic (Ruby, instant, free) - - DomainStructuralAnalyzer: has_digits, has_hyphens, token_count, dictionary_word - - DomainHeuristicClassifier: dictionary lookup (et+en roots), subword tokenizer - - Coverage: ~60-70% Estonian domains, ~30% English - - Output: tags, keywords, confidence - -Tier 2 — LLM batch (cron daily, ~$0.30/month at our volume) - - Recommendation::LlmDomainClassifier - - OpenAI structured output (json_schema) - - 50 domains per API call - - Enriches keywords, audience, suggested_use_cases, brandability_score, languages - - Re-runs every 6 months for source='openai' rows - -Tier 1 — (future, optional) embedding-based local classifier - - Not in v2 scope. Pending data accumulation from Tier 2. -``` - -## Cron jobs (k8s CronJob, NOT in-app scheduler) - -| schedule | task | purpose | -|---|---|---| -| `0 3 * * *` (03:00 daily) | `rake recommendation:classify_unclassified` | Tier 2 enrichment for heuristic-only/low-confidence/stale rows | -| `30 3 * * *` (03:30 daily) | `rake recommendation:embed_unembedded` | OpenAI embeddings for classified-but-unembedded rows (stored as float[]) | -| one-shot | `rake recommendation:backfill` | Initial classification of all historical domains | - -K8s manifests live in `Ry_AWS_IaC/infrastructure/kubernetes` — outside this repo. - -## Scorer signals (v2) - -Final score per (user, auction) is a weighted sum + multiplier: - -``` -score = 0 - + 120 if wishlist hit - + matching_tags * 35 category overlap - + matching_keywords * 15 keyword overlap - + audience_match * 10 dominant-audience inference - + bid_feature_aggregate (decay-weighted) uses domain_classifications - + wishlist_feature_aggregate (decay) uses domain_classifications - + view_feature_aggregate (decay) from auction_detail_view events - + similar_to_saved_domain * 15 - + preferred_length_match * 10 - + digit_score -20 .. +8 - + hyphen_score -12 .. +5 - + ai_prior_score existing legacy - + domain_offer_history_signal historical bids - + result_signal won: weak negative; lost: strong positive - -multiplier = 1 + max(0, cosine_similarity(user_centroid, auction.embedding)) -score = score * multiplier -``` - -User centroid = time-decayed weighted average of embeddings from the user's -bids + wishlist + recent views. Multiplier is a no-op (1.0) when the user has -no history or the auction is not yet embedded. - -Time decay: `weight *= exp(-days_old / 60)` (half-life 60 days). - -## Infrastructure dependencies - -| Dependency | Source | Status | -|---|---|---| -| OpenAI API | existing integration via `Feature.open_ai_integration_enabled?` | reused | -| K8s CronJobs | maintained in `Ry_AWS_IaC` | scheduled by infra team | - -**No infrastructure changes are required for v2.** No new extensions on RDS, -no Docker image bumps, no schema changes outside the auction_center database. - -## Cost estimate - -- **Backfill** (one-time): ~5000 historical unique domains - - LLM classify: ~100 batches × ~3000 tokens = **~$0.50-1** - - Embeddings: 5000 × ~50 tokens × $0.02/M = **~$0.005** -- **Steady state** (per day): - - LLM classify: 5-20 new domains/day, batched = 0-1 OpenAI call = **~$0.01/day** - - Embeddings: 1 call/day = **~$0.0001/day** - - Total: **~$0.30/month** - -## Implementation phases - -See [domain-classification-pipeline.md](../technical/domain-classification-pipeline.md) for pipeline details. Phase-by-phase task tracking lives in the project task list. - -| Phase | What | Status | -|---|---|---| -| 0 | Snapshot + docs scaffold | done | -| 1 | Performance foundation (batch impressions, debounce score refresh) | done | -| 2 | `domain_classifications` table + heuristic Tier 0 | done | -| 3a | DomainClassifier orchestrator (heuristic only) | done | -| 3b | LLM batch enrichment job (cron) | done | -| 4 | Triggers + backfill rake | done | -| 5 | Embedding similarity via Postgres `double precision[]` (no pgvector) | done | -| 6 | Rich-feature Scorer + embedding similarity + time decay | done | -| 7 | Detail view tracking + view affinity | done | -| 8 | Show domain keywords as badges in auction card | done | -| 9 | Polish + finalize | done | - -Operator runbook: see [guides/recommendation-operations.md](../guides/recommendation-operations.md). - -## Open questions / future work - -- **Tier 1 local ML classifier** — after enough Tier 2 training data accumulates (~1000 LLM-classified rows), consider training a small linear classifier on character-ngram BoW + Tier 2 labels. Removes most LLM cost. Out of v2 scope. -- **Client-side in-session re-ranker** — locally upweight cards user clicked in this session, without server roundtrip. Out of v2 scope. -- **Auction.classification_* columns deprecation** — keep as fallback for 1-2 releases, then drop. - -## Non-personal sort fallback - -For new users with no `user_auction_scores` and no history, `Auction::UserSortable` falls back to existing `ai_score` + `RANDOM()` tiers. Same path for unauthenticated visitors. diff --git a/docs/guides/recommendation-operations.md b/docs/guides/recommendation-operations.md deleted file mode 100644 index 0ad48629e..000000000 --- a/docs/guides/recommendation-operations.md +++ /dev/null @@ -1,99 +0,0 @@ -# Recommendation System — Operations Guide - -Operator-facing guide for running the v2 recommendation system in -production. For architecture, see -[architecture/recommendation-system.md](../architecture/recommendation-system.md); -for pipeline internals, see -[technical/domain-classification-pipeline.md](../technical/domain-classification-pipeline.md). - -## Environment prerequisites - -| Item | Where | Notes | -|---|---|---| -| `Feature.open_ai_integration_enabled?` | App settings | Must be true for LLM enrichment. The recommendation profile UI, heuristic classifier, and scorer work without it. | -| `openai_model` Setting | DB seed | Currently `gpt-5`. `OpenaiStructuredOutputSupport` will fall back to a safe default if a non-supporting model is configured. | -| OpenAI API key | Rails credentials / env | Existing integration used by `LlmDomainClassifier`. | - -**No special database setup required.** No extensions, no shared-image changes, -no schema changes outside auction_center's own database. - -## Kubernetes cron schedule - -All recurring work runs as k8s `CronJob` resources defined in -`Ry_AWS_IaC/infrastructure/kubernetes`. Suggested schedule (UTC): - -| schedule | command | purpose | -|---|---|---| -| `0 3 * * *` | `bundle exec rake recommendation:classify_unclassified` | Tier 2 LLM enrichment of heuristic / low-conf / stale rows | -| `30 3 * * *` | `bundle exec rake recommendation:embed_unembedded` | OpenAI embeddings for classified rows without an embedding (stored as float[]) | -| one-shot | `bundle exec rake recommendation:backfill` | Initial heuristic classification of all historical domains | - -Each task is wrapped by an idempotent ActiveJob. Re-running mid-day -is safe: nothing duplicates, fresh rows are skipped. - -## First-time rollout checklist - -1. Deploy the branch. -2. Run migrations (`rails db:migrate`). All migrations are plain - schema changes — no extensions, no shared-DB modifications. -3. Run `rake recommendation:backfill`. Watch the log line - `BackfillDomainClassificationsJob created N classifications` to - confirm scope. -4. (Optional, recommended) Manually run - `rake recommendation:classify_unclassified` once to perform the - first LLM enrichment immediately rather than waiting for cron. -5. (Optional) Manually run `rake recommendation:embed_unembedded` for - the first embedding sweep. -6. Verify `/auctions` renders keyword badges on cards with classified - domains. - -## Monitoring - -| signal | check | -|---|---| -| LLM enrichment progress | `DomainClassification.needs_llm_enrichment.count` should trend toward zero. Tail logs for `ClassifyUnclassifiedDomainsJob processed N domains`. | -| Embedding backlog | `DomainClassification.needs_embedding.count` ditto. | -| Daily OpenAI cost | OpenAI dashboard. ~$0.01/day for classification + ~$0.0001/day for embeddings. | -| Score freshness | `UserAuctionScore.maximum(:calculated_at)` should be within minutes for active users. | -| Tracking failures | `Rails.logger.warn` lines from `EventTracker`. | - -## Tunables - -These constants live in `app/services/recommendation/scorer.rb`. Bumping -them requires a deploy — no DB migration needed. - -| constant | default | meaning | -|---|---|---| -| `HALF_LIFE_DAYS` | 60 | Behavioural signal decay half-life. | -| `WISHLIST_HIT` | 120 | Bonus if exact-match wishlist domain is up for auction. | -| `TAG_WEIGHT` / `KEYWORD_WEIGHT` | 35 / 15 | Per-interest-match boost. | -| `BID_AFFINITY_*` / `WISHLIST_AFFINITY_*` / `VIEW_AFFINITY_*` | 8/24, 6/18, 4/12 | Weight + cap for behavioural affinity. | -| `RESULT_LOST_BONUS` / `RESULT_WON_PENALTY` | +25 / -5 | Outcome signals. | - -## Rolling back - -To pause the system without removing it: - -1. Set `Feature.open_ai_integration_enabled?` to false. LLM enrichment - becomes a no-op; heuristic-only continues. -2. Drop the k8s CronJobs. -3. The legacy sort still works because `Auction::UserSortable` falls - back through `user_auction_scores` → interest match → ai_score → random. - -To roll back entirely: - -1. `rails db:rollback STEP=2` removes the domain_classifications - table and the no-op pgvector placeholder. -2. Revert Phase 6 commit; scorer reverts to v1 baseline. - -## Troubleshooting - -**Score never updates after action** — Check whether -`RefreshSingleUserAuctionScoresJob` is reaching the worker (delayed -job table). The debounce window is 30 seconds; updates are not -real-time. Re-enqueue manually via the admin Job UI if needed. - -**Keywords wrong / missing on a card** — Heuristic-only domains have -generic keywords. They get richer keywords from the nightly LLM run. -Force a re-classification by deleting the row and letting the next -trigger recreate it: `DomainClassification.find_by(domain_name: 'x.ee').destroy`. diff --git a/docs/technical/domain-classification-pipeline.md b/docs/technical/domain-classification-pipeline.md deleted file mode 100644 index cad9fc029..000000000 --- a/docs/technical/domain-classification-pipeline.md +++ /dev/null @@ -1,230 +0,0 @@ -# Domain Classification Pipeline — Technical Details - -Companion to [recommendation-system.md](../architecture/recommendation-system.md). This document describes implementation of the classification pipeline. - -## Components - -``` -app/services/recommendation/ - domain_classifier.rb # orchestrator (Tier 0 only at runtime) - domain_structural_analyzer.rb # purely structural, no semantics - domain_heuristic_classifier.rb # dictionary + subword tokenizer - domain_dictionary.rb # ESTONIAN_ROOTS + ENGLISH_ROOTS hashes - llm_domain_classifier.rb # Tier 2 — only called from cron job - domain_embedder.rb # OpenAI text-embedding-3-small wrapper - -app/jobs/recommendation/ - classify_domain_heuristically_job.rb # instant, triggered on events - classify_unclassified_domains_job.rb # cron, batched LLM - embed_unembedded_domains_job.rb # cron, batched embeddings - backfill_domain_classifications_job.rb # one-shot - -lib/tasks/recommendation.rake # entry points for k8s CronJobs -``` - -## Tier 0 — Heuristic classifier - -### Structural analyzer - -Computes deterministic structural features for any domain name. No external dependencies. Output is stable and cheap to recompute, but stored for query speed. - -| feature | how | -|---|---| -| `has_digits` | `domain_name =~ /\d/` | -| `has_hyphens` | `domain_name.include?('-')` | -| `token_count` | greedy subword split + count | -| `dictionary_word` | exact match in et+en root dictionary | -| `length` | `domain_name.length` after stripping `.ee` | - -### Heuristic classifier - -Algorithm: - -1. Strip TLD (`.ee`) -2. Greedy subword split using dictionary: - - Try longest prefix match against `ESTONIAN_ROOTS ∪ ENGLISH_ROOTS` - - Recurse on remainder -3. For each matched root, collect its assigned category and confidence -4. Aggregate: deduplicate categories, pick highest-confidence as `primary_category` -5. `confidence = matched_chars / total_chars` (heuristic — full dictionary match = 1.0, partial = proportional) -6. If `confidence < 0.6` → flag for LLM enrichment - -### Dictionary (DomainDictionary) - -Two static hashes — Estonian and English roots → category symbol. - -```ruby -ESTONIAN_ROOTS = { - 'kohvik' => :local_service, 'apteek' => :health, - 'pood' => :shop_brand, 'kinnisvara' => :real_estate, - 'laen' => :finance, 'jurist' => :legal, - # ... ~200 entries -} - -ENGLISH_ROOTS = { - 'shop' => :shop_brand, 'tech' => :saas, - 'cloud' => :saas, 'med' => :health, - # ... ~200 entries -} -``` - -Initial dictionary seeded from the existing `lib/tasks/demo_auctions.rake` seed list and OpenAI test classifications. Grows over time as Tier 2 reveals new patterns. - -## Tier 2 — LLM enrichment (cron-only) - -### Trigger - -`rake recommendation:classify_unclassified` (k8s CronJob, daily 03:00). - -Selects rows where: - -```ruby -DomainClassification - .where(classification_source: ['heuristic', nil]) - .or(DomainClassification.where('confidence < 0.6')) - .or(DomainClassification.where('classification_source = ? AND classified_at < ?', 'openai', 6.months.ago)) - .limit(MAX_DOMAINS_PER_RUN) -``` - -### Batching - -50 domains per OpenAI call. JSON schema includes every rich field. - -### Schema (OpenAI structured output) - -```json -{ - "name": "domain_classifications", - "schema": { - "type": "object", - "properties": { - "classifications": { - "type": "array", - "items": { - "type": "object", - "properties": { - "domain_name": { "type": "string" }, - "primary_category": { "type": "string", "enum": [...InterestCatalog...] }, - "tags": { "type": "array", "items": { "type": "string" } }, - "description": { "type": "string" }, - "description_locale": { "type": "string", "enum": ["en", "et"] }, - "keywords": { "type": "array", "items": { "type": "string" } }, - "audience": { "type": "string", "enum": ["b2b", "b2c", "mixed", "unclear"] }, - "languages": { "type": "array", "items": { "type": "string" } }, - "suggested_use_cases": { "type": "array", "items": { "type": "string" } }, - "brandability_score": { "type": "number" } - }, - "required": ["domain_name", "primary_category", "tags", "description"] - } - } - } - } -} -``` - -### Idempotency - -`raw_llm_response` (jsonb) keeps the parsed response. If schema changes later, we can re-parse from `raw_llm_response` without re-billing OpenAI. - -## Embedding pipeline - -### Trigger - -`rake recommendation:embed_unembedded` (k8s CronJob, daily 03:30). - -Selects rows where: - -```ruby -DomainClassification - .classified - .where(embedding: nil) - .limit(MAX_DOMAINS_PER_RUN) -``` - -### Input - -`. ` — kept short since we no -longer have descriptions. Tokens-per-domain ≈ 5-15. - -### Model - -`text-embedding-3-small` (1536 dim, $0.02/1M tokens). - -### Storage - -Plain Postgres `double precision[]` column — **not** pgvector. The -column is added by migration 20260527090300. No HNSW index; cosine -similarity is computed in Ruby at scoring time. At 100-200 active -auctions × 1536 dims this is ~50ms — well below the 30-second -RefreshSingleUserAuctionScoresJob debounce window. - -### Usage in Scorer - -User centroid is the time-decayed weighted average of embeddings from -the user's bids, wishlist, and recent views. The multiplier is - -``` -multiplier = 1 + max(0, cosine_similarity(user_centroid, auction.embedding)) -final_score = base_score * multiplier -``` - -Range: 1.0 (no boost) .. 2.0 (perfect alignment). No-op (1.0) when -either side is missing data. - -### Why no pgvector / external service - -See ADR-001. Bottom line: at our scale, brute force in Ruby is faster -than the operational cost of new infrastructure. If we cross 5k+ -embedded rows the plan is to move the column to a sidecar pgvector pod -isolated to auction_center, leaving the main databases untouched. The -`double precision[]` format is portable to that future state. - -## Triggers (instant heuristic only) - -These fire `ClassifyDomainHeuristicallyJob` — NEVER call LLM directly. - -| trigger | location | argument | -|---|---|---| -| `Auction` created | `after_create` callback | `auction.domain_name` | -| `WishlistItem` created | controller | `wishlist_item.domain_name` | -| `Offer` created | controller | `offer.auction.domain_name` | -| `EnglishOffer` created | controller | `english_offer.auction.domain_name` | - -Job is idempotent — if a row exists with `classified_at < 1.hour.ago` from any source, skip. - -## Backfill - -`rake recommendation:backfill` — one-shot. Collects unique domains from: - -- `Auction.distinct.pluck(:domain_name)` -- `WishlistItem.distinct.pluck(:domain_name)` -- `DomainOfferHistory.distinct.pluck(:domain_name)` (if applicable) -- `Result.distinct.pluck(:domain_name)` (via auction) - -For each: run heuristic synchronously, upsert into `domain_classifications`. LLM enrichment happens on next nightly cron run. - -## Migration from current state - -Existing `auctions.classification_tags / primary_category / classification_source / classification_model / classified_at` columns remain populated during transition. `Scorer` reads from `domain_classifications` with fallback to `auctions.classification_*` while migration is in flight. Columns deprecated in Phase 9. - -## Tests - -| layer | what | -|---|---| -| `DomainStructuralAnalyzer` | unit tests on edge cases (digits, hyphens, single-char, very long) | -| `DomainHeuristicClassifier` | known et/en domains map to expected categories; ambiguous → low confidence | -| `DomainClassifier` (orchestrator) | upserts row with `source='heuristic'`; skips fresh rows | -| `LlmDomainClassifier` | mocked OpenAI; verifies prompt and parsing | -| `ClassifyUnclassifiedDomainsJob` | scope selection, batching, no LLM call when scope empty | -| `DomainEmbedder` | mocked OpenAI; vector shape, AR-row handling, empty input, error response | -| `EmbedUnembeddedDomainsJob` | scope selection, feature-flag gating, missing column safety | -| `Scorer` (extended) | tag + keyword + audience + behavioural affinity + embedding multiplier paths verified | - -## Operations - -| signal | where to look | -|---|---| -| Daily LLM cost | OpenAI dashboard + log lines from `LlmDomainClassifier` | -| Failed classifications | `Rails.logger.warn` from `ClassifyUnclassifiedDomainsJob` | -| Backlog of unclassified | `DomainClassification.where(classification_source: ['heuristic', nil]).count` | -| Embedding backlog | `DomainClassification.needs_embedding.count` | diff --git a/test/models/job_test.rb b/test/models/job_test.rb index b86d0214a..316e81f9d 100644 --- a/test/models/job_test.rb +++ b/test/models/job_test.rb @@ -14,16 +14,16 @@ def test_instance_methods_correspond_with_the_class end def test_namespaced_job_is_allowed - job = Job.new('Recommendation::ClassifyAuctionDomainsJob') + job = Job.new('Recommendation::ClassifyUnclassifiedDomainsJob') assert job.valid? - assert_equal Recommendation::ClassifyAuctionDomainsJob, job.job_class + assert_equal Recommendation::ClassifyUnclassifiedDomainsJob, job.job_class end - def test_refresh_scores_job_is_allowed - job = Job.new('Recommendation::RefreshUserAuctionScoresJob') + def test_embed_job_is_allowed + job = Job.new('Recommendation::EmbedUnembeddedDomainsJob') assert job.valid? - assert_equal Recommendation::RefreshUserAuctionScoresJob, job.job_class + assert_equal Recommendation::EmbedUnembeddedDomainsJob, job.job_class end end diff --git a/test/services/recommendation/auction_domain_classifier_test.rb b/test/services/recommendation/auction_domain_classifier_test.rb deleted file mode 100644 index cc2313614..000000000 --- a/test/services/recommendation/auction_domain_classifier_test.rb +++ /dev/null @@ -1,50 +0,0 @@ -require 'test_helper' - -module Recommendation - class AuctionDomainClassifierTest < ActiveSupport::TestCase - def setup - super - @auction = auctions(:valid_without_offers) - @openai_model = Setting.find_by(code: 'openai_model') - end - - def test_classifies_auction_domains_with_openai_response - @openai_model.update!(value: 'gpt-3.5-turbo') - - stub_request(:post, 'https://api.openai.com/v1/chat/completions') - .to_return_json(status: 200, body: ai_response, headers: {}) - - result = AuctionDomainClassifier.call(auctions: [@auction]) - - @auction.reload - - assert_equal 1, result.size - assert_equal %w[shop_brand brandable], @auction.classification_tags - assert_equal 'shop_brand', @auction.primary_category - assert_equal 'openai', @auction.classification_source - assert_equal 'gpt-5', @auction.classification_model - assert @auction.classified? - end - - private - - def ai_response - { - 'choices' => [{ - 'message' => { - 'content' => { - classifications: [ - { - id: @auction.id, - domain_name: @auction.domain_name, - primary_category: 'shop_brand', - tags: %w[shop_brand brandable] - } - ] - }.to_json - } - }] - } - end - end -end diff --git a/test/services/recommendation/dataset_snapshot_test.rb b/test/services/recommendation/dataset_snapshot_test.rb deleted file mode 100644 index 548dc2957..000000000 --- a/test/services/recommendation/dataset_snapshot_test.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'test_helper' - -module Recommendation - class DatasetSnapshotTest < ActiveSupport::TestCase - def setup - super - @user = users(:participant) - @auction = auctions(:valid_without_offers) - @auction.update!(classification_tags: %w[shop_brand local_service], primary_category: 'shop_brand') - - @user.create_recommendation_profile!( - interest_keywords: %w[legal other custom:marketplace] - ) - - RecommendationEvent.create!( - user: @user, - auction: @auction, - event_type: 'auction_click', - source: 'test', - occurred_at: Time.current, - properties: { foo: 'bar' } - ) - - WishlistItem.create!(user: @user, domain_name: @auction.domain_name, cents: 2000) - end - - def test_snapshot_contains_users_auctions_and_interactions - snapshot = DatasetSnapshot.call( - users: User.where(id: @user.id), - auctions: Auction.where(id: @auction.id), - recommendation_events: RecommendationEvent.where(user_id: @user.id) - ) - - assert_equal 1, snapshot[:users].size - assert_equal @user.uuid, snapshot[:users].first[:user_uuid] - assert_equal %w[legal other], snapshot[:users].first[:interest_categories] - assert_equal ['marketplace'], snapshot[:users].first[:custom_interests] - - assert_equal 1, snapshot[:auctions].size - assert_equal @auction.uuid, snapshot[:auctions].first[:auction_uuid] - assert_equal %w[shop_brand local_service], snapshot[:auctions].first[:classification_tags] - - event_types = snapshot[:interactions].map { |item| item[:event_type] } - assert_includes event_types, 'auction_click' - assert_includes event_types, 'historical_wishlist' - end - end -end diff --git a/test/services/recommendation/score_importer_test.rb b/test/services/recommendation/score_importer_test.rb deleted file mode 100644 index f79db875e..000000000 --- a/test/services/recommendation/score_importer_test.rb +++ /dev/null @@ -1,54 +0,0 @@ -require 'test_helper' - -module Recommendation - class ScoreImporterTest < ActiveSupport::TestCase - def setup - super - @user = users(:participant) - @auction = auctions(:english) - end - - def test_imports_scores_by_uuid - imported_count = ScoreImporter.call( - scores: [ - { - user_uuid: @user.uuid, - auction_uuid: @auction.uuid, - score: 0.75 - } - ], - scorer_name: 'lightfm_stub', - features_version: 'v1' - ) - - assert_equal 1, imported_count - - record = UserAuctionScore.find_by!(user: @user, auction: @auction) - assert_equal BigDecimal('0.75'), record.score - assert_equal 'lightfm_stub', record.scorer_name - assert_equal 'v1', record.features_version - end - - def test_upserts_existing_scores - UserAuctionScore.create!( - user: @user, - auction: @auction, - score: 0.10, - calculated_at: 1.day.ago - ) - - ScoreImporter.call( - scores: [ - { - user_id: @user.id, - auction_id: @auction.id, - score: 0.90 - } - ] - ) - - assert_equal 1, UserAuctionScore.where(user: @user, auction: @auction).count - assert_equal BigDecimal('0.90'), UserAuctionScore.find_by!(user: @user, auction: @auction).score - end - end -end From ee7113a1fdd9a092882de85f146a6f57b6fdc79d Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 29 May 2026 14:10:35 +0300 Subject: [PATCH 24/42] updated principle of scoring --- .../stylesheets/components/_account.scss | 2 + app/controllers/english_offers_controller.rb | 4 +- app/controllers/offers_controller.rb | 4 +- app/controllers/wishlist_items_controller.rb | 2 +- .../backfill_domain_classifications_job.rb | 54 ++++--- .../classify_domain_heuristically_job.rb | 14 -- .../recommendation/classify_domain_job.rb | 45 ++++++ .../classify_unclassified_domains_job.rb | 36 +++-- app/models/auction.rb | 2 +- app/models/concerns/auction/user_sortable.rb | 14 +- .../recommendation/domain_classifier.rb | 58 -------- .../recommendation/domain_dictionary.rb | 136 ------------------ .../domain_heuristic_classifier.rb | 116 --------------- .../domain_structural_analyzer.rb | 45 ------ ...527090000_create_domain_classifications.rb | 4 - lib/tasks/demo_auctions.rake | 10 +- ...ackfill_domain_classifications_job_test.rb | 68 ++++++--- .../classify_domain_heuristically_job_test.rb | 41 ------ .../classify_domain_job_test.rb | 104 ++++++++++++++ .../classify_unclassified_domains_job_test.rb | 88 +++++++++--- ...esh_single_user_auction_scores_job_test.rb | 9 +- test/models/user_test.rb | 2 +- .../recommendation/domain_classifier_test.rb | 68 --------- .../domain_heuristic_classifier_test.rb | 53 ------- .../domain_structural_analyzer_test.rb | 44 ------ .../recommendation/scorer_embedding_test.rb | 20 ++- .../scorer_rich_features_test.rb | 16 ++- 27 files changed, 394 insertions(+), 665 deletions(-) delete mode 100644 app/jobs/recommendation/classify_domain_heuristically_job.rb create mode 100644 app/jobs/recommendation/classify_domain_job.rb delete mode 100644 app/services/recommendation/domain_classifier.rb delete mode 100644 app/services/recommendation/domain_dictionary.rb delete mode 100644 app/services/recommendation/domain_heuristic_classifier.rb delete mode 100644 app/services/recommendation/domain_structural_analyzer.rb delete mode 100644 test/jobs/recommendation/classify_domain_heuristically_job_test.rb create mode 100644 test/jobs/recommendation/classify_domain_job_test.rb delete mode 100644 test/services/recommendation/domain_classifier_test.rb delete mode 100644 test/services/recommendation/domain_heuristic_classifier_test.rb delete mode 100644 test/services/recommendation/domain_structural_analyzer_test.rb diff --git a/app/assets/stylesheets/components/_account.scss b/app/assets/stylesheets/components/_account.scss index 09b15cc3b..5125e041d 100644 --- a/app/assets/stylesheets/components/_account.scss +++ b/app/assets/stylesheets/components/_account.scss @@ -157,6 +157,8 @@ .c-acount__buttons { gap: 5px; + // Separate the Billing / Delete row from the "Edit interests" button above it. + margin-top: 24px; @include mq(mobile) { gap: 10px; diff --git a/app/controllers/english_offers_controller.rb b/app/controllers/english_offers_controller.rb index 04fe20e23..c8ff7af53 100644 --- a/app/controllers/english_offers_controller.rb +++ b/app/controllers/english_offers_controller.rb @@ -38,7 +38,7 @@ def create update_auction_values(@auction, t('english_offers.create.created')) Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) - Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@auction.domain_name) + Recommendation::ClassifyDomainJob.perform_later(@auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @auction, @@ -82,7 +82,7 @@ def update update_auction_values(@auction, t('english_offers.edit.bid_updated')) Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) - Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@auction.domain_name) + Recommendation::ClassifyDomainJob.perform_later(@auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @auction, diff --git a/app/controllers/offers_controller.rb b/app/controllers/offers_controller.rb index ca7ed2450..8d66d5d6d 100644 --- a/app/controllers/offers_controller.rb +++ b/app/controllers/offers_controller.rb @@ -32,7 +32,7 @@ def create elsif create_predicate Rails.logger.info("User #{current_user.id} created offer #{@offer.id} for auction #{@auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) - Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@auction.domain_name) + Recommendation::ClassifyDomainJob.perform_later(@auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @auction, @@ -79,7 +79,7 @@ def update if update_predicate Rails.logger.info("User #{current_user.id} updated offer #{@offer.id} for auction #{@offer.auction.id}") Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) - Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@offer.auction.domain_name) + Recommendation::ClassifyDomainJob.perform_later(@offer.auction.domain_name) Recommendation::EventTracker.call( user: current_user, auction: @offer.auction, diff --git a/app/controllers/wishlist_items_controller.rb b/app/controllers/wishlist_items_controller.rb index 8dabee41b..42b6e6963 100644 --- a/app/controllers/wishlist_items_controller.rb +++ b/app/controllers/wishlist_items_controller.rb @@ -25,7 +25,7 @@ def create respond_to do |format| if create_predicate Recommendation::RefreshSingleUserAuctionScoresJob.enqueue_debounced(current_user.id) - Recommendation::ClassifyDomainHeuristicallyJob.perform_later(@wishlist_item.domain_name) + Recommendation::ClassifyDomainJob.perform_later(@wishlist_item.domain_name) Recommendation::EventTracker.call( user: current_user, auction: Auction.find_by(domain_name: @wishlist_item.domain_name), diff --git a/app/jobs/recommendation/backfill_domain_classifications_job.rb b/app/jobs/recommendation/backfill_domain_classifications_job.rb index 728c9ca12..812f12978 100644 --- a/app/jobs/recommendation/backfill_domain_classifications_job.rb +++ b/app/jobs/recommendation/backfill_domain_classifications_job.rb @@ -1,16 +1,19 @@ module Recommendation # One-shot job that collects every domain name we know about from # auctions, wishlist_items, domain_offer_histories, and result records, - # then ensures each has a heuristic classification row. + # then classifies the ones without a row via the LLM (batched). # - # Tier 2 (LLM) enrichment happens on the next nightly run of - # ClassifyUnclassifiedDomainsJob — this job DOES NOT call the LLM. + # The LLM maps each domain onto an InterestCatalog category code. There + # is no heuristic fallback — if OpenAI is disabled this job is a no-op. # - # Invoked manually or via `rake recommendation:backfill`. + # Invoked manually or via `rake recommendation:backfill`. One-time cost + # is ~$1 over a few thousand historical domains. class BackfillDomainClassificationsJob < ApplicationJob - BATCH_SIZE = 500 + BATCH_SIZE = Recommendation::LlmDomainClassifier::BATCH_LIMIT def perform + return unless Feature.open_ai_integration_enabled? + domains = collect_domains Rails.logger.info("BackfillDomainClassificationsJob found #{domains.size} unique domains") @@ -19,28 +22,34 @@ def perform Rails.logger.info("BackfillDomainClassificationsJob classifying #{pending.size} new domains") - created = 0 + classified = 0 pending.each_slice(BATCH_SIZE) do |slice| - slice.each do |domain| - Recommendation::DomainClassifier.call(domain) - created += 1 - rescue StandardError => e - Rails.logger.warn("Backfill failed for #{domain}: #{e.message}") - end + classified += upsert(Recommendation::LlmDomainClassifier.call(domain_names: slice)) + rescue StandardError => e + Rails.logger.warn("Backfill batch failed (#{slice.first}..): #{e.message}") end - Rails.logger.info("BackfillDomainClassificationsJob created #{created} classifications") - created + Rails.logger.info("BackfillDomainClassificationsJob created #{classified} classifications") + classified end private + def upsert(attributes_list) + return 0 if attributes_list.blank? + + timestamps = { created_at: Time.current, updated_at: Time.current } + rows = attributes_list.map { |attrs| attrs.merge(timestamps) } + DomainClassification.upsert_all(rows, unique_by: :domain_name) + rows.size + end + def collect_domains sources = [ Auction.distinct.pluck(:domain_name), WishlistItem.distinct.pluck(:domain_name), - safe_pluck(DomainOfferHistory, :domain_name), - safe_pluck(Result, :domain_name) + safe_pluck('DomainOfferHistory', :domain_name), + safe_pluck('Result', :domain_name) ] sources.flatten @@ -49,13 +58,18 @@ def collect_domains .uniq end - def safe_pluck(model, column) - return [] unless defined?(model) && model.respond_to?(:column_names) - return [] unless model.column_names.include?(column.to_s) + # Resolve the model by name so an absent constant (e.g. DomainOfferHistory + # is not defined in this codebase) degrades to [] instead of raising + # NameError when the argument is evaluated. + def safe_pluck(model_name, column) + return [] unless Object.const_defined?(model_name) + + model = Object.const_get(model_name) + return [] unless model.respond_to?(:column_names) && model.column_names.include?(column.to_s) model.distinct.pluck(column) rescue StandardError => e - Rails.logger.warn("Backfill source #{model} failed: #{e.message}") + Rails.logger.warn("Backfill source #{model_name} failed: #{e.message}") [] end end diff --git a/app/jobs/recommendation/classify_domain_heuristically_job.rb b/app/jobs/recommendation/classify_domain_heuristically_job.rb deleted file mode 100644 index a45963a5d..000000000 --- a/app/jobs/recommendation/classify_domain_heuristically_job.rb +++ /dev/null @@ -1,14 +0,0 @@ -module Recommendation - # Triggered on every new auction / wishlist / offer event. - # Runs heuristic-only classification — NEVER calls the LLM. - # Idempotent: DomainClassifier returns the existing fresh row without work. - class ClassifyDomainHeuristicallyJob < ApplicationJob - retry_on StandardError, wait: 5.seconds, attempts: 3 - - def perform(domain_name) - return if domain_name.to_s.strip.blank? - - Recommendation::DomainClassifier.call(domain_name) - end - end -end diff --git a/app/jobs/recommendation/classify_domain_job.rb b/app/jobs/recommendation/classify_domain_job.rb new file mode 100644 index 000000000..12096e61e --- /dev/null +++ b/app/jobs/recommendation/classify_domain_job.rb @@ -0,0 +1,45 @@ +module Recommendation + # Classifies a single domain via the LLM as soon as it enters the system + # (new auction / bid / wishlist add). Runs in the background, bounded to + # at most one LLM call per not-yet-classified domain, so cost stays + # predictable. + # + # The LLM maps any domain — Estonian, English, Finnish, transliterated + # Russian, invented brand — onto the fixed InterestCatalog category codes. + # No heuristic dictionary: the model figures out the category. + # + # The nightly ClassifyUnclassifiedDomainsJob is the safety net: it picks + # up anything this job missed (e.g. OpenAI was disabled when the domain + # first appeared, or the job failed) and re-classifies stale rows. + class ClassifyDomainJob < ApplicationJob + retry_on StandardError, wait: 10.seconds, attempts: 3 + + def perform(domain_name) + name = domain_name.to_s.strip.downcase + return if name.blank? + return unless Feature.open_ai_integration_enabled? + return if already_classified?(name) + + attributes_list = Recommendation::LlmDomainClassifier.call(domain_names: [name]) + upsert(attributes_list) + end + + private + + # Skip if we already have a non-stale LLM classification. A bare row + # with no LLM data (or a stale one) is re-classified. + def already_classified?(name) + existing = DomainClassification.find_by(domain_name: name) + existing&.from_llm? && !existing.stale? + end + + def upsert(attributes_list) + return 0 if attributes_list.blank? + + timestamps = { created_at: Time.current, updated_at: Time.current } + rows = attributes_list.map { |attrs| attrs.merge(timestamps) } + DomainClassification.upsert_all(rows, unique_by: :domain_name) + rows.size + end + end +end diff --git a/app/jobs/recommendation/classify_unclassified_domains_job.rb b/app/jobs/recommendation/classify_unclassified_domains_job.rb index afac611a0..e2f2d4015 100644 --- a/app/jobs/recommendation/classify_unclassified_domains_job.rb +++ b/app/jobs/recommendation/classify_unclassified_domains_job.rb @@ -1,9 +1,13 @@ module Recommendation # Runs once per day via a k8s CronJob (rake recommendation:classify_unclassified). - # Picks domain_classifications rows that need LLM enrichment, batches them, - # and upserts the enriched attributes. # - # NEVER called from user-facing request paths. + # Safety-net + maintenance pass for the LLM classifier: + # - discovers active-auction / wishlist domains that have NO classification + # row yet (e.g. ClassifyDomainJob never ran because OpenAI was disabled + # when the domain appeared, or the job failed), and + # - re-classifies existing rows that are low-confidence or stale (>6mo). + # + # Batches everything through the LLM. NEVER called from request paths. class ClassifyUnclassifiedDomainsJob < ApplicationJob MAX_DOMAINS_PER_RUN = 200 BATCH_SIZE = Recommendation::LlmDomainClassifier::BATCH_LIMIT @@ -13,7 +17,7 @@ class ClassifyUnclassifiedDomainsJob < ApplicationJob def perform return unless Feature.open_ai_integration_enabled? - domains = scope.limit(MAX_DOMAINS_PER_RUN).pluck(:domain_name) + domains = pending_domains return if domains.empty? processed = 0 @@ -26,20 +30,36 @@ def perform processed end + # Existing rows that need re-classification (low confidence / stale LLM). def self.scope DomainClassification.needs_llm_enrichment.order(Arel.sql('COALESCE(classified_at, to_timestamp(0)) ASC')) end - def self.needs_to_run? - Feature.open_ai_integration_enabled? && scope.exists? + # Active-auction / wishlist domains with no classification row at all. + def self.missing_domains + known = DomainClassification.pluck(:domain_name).to_set + candidates = (Auction.active.distinct.pluck(:domain_name) + + WishlistItem.distinct.pluck(:domain_name)) + .map { |d| d.to_s.strip.downcase } + .reject(&:blank?) + .uniq + candidates.reject { |d| known.include?(d) } end - def scope - self.class.scope + def self.needs_to_run? + return false unless Feature.open_ai_integration_enabled? + + scope.exists? || missing_domains.any? end private + def pending_domains + (self.class.missing_domains + self.class.scope.pluck(:domain_name)) + .uniq + .first(MAX_DOMAINS_PER_RUN) + end + def upsert(attributes_list) return 0 if attributes_list.blank? diff --git a/app/models/auction.rb b/app/models/auction.rb index 1df301414..722bdbff4 100644 --- a/app/models/auction.rb +++ b/app/models/auction.rb @@ -213,7 +213,7 @@ def find_auction_turns def enqueue_domain_classification return if domain_name.blank? - Recommendation::ClassifyDomainHeuristicallyJob.perform_later(domain_name) + Recommendation::ClassifyDomainJob.perform_later(domain_name) end def calculate_turns_count diff --git a/app/models/concerns/auction/user_sortable.rb b/app/models/concerns/auction/user_sortable.rb index 33842b8aa..49a7b3b2e 100644 --- a/app/models/concerns/auction/user_sortable.rb +++ b/app/models/concerns/auction/user_sortable.rb @@ -23,7 +23,7 @@ def with_user_priority_sorting(user) end def with_recommendation_scores(user_id) - join_sql = ActiveRecord::Base.sanitize_sql_array([ + scores_join = ActiveRecord::Base.sanitize_sql_array([ <<~SQL.squish, LEFT JOIN user_auction_scores ON user_auction_scores.auction_id = auctions.id @@ -32,7 +32,15 @@ def with_recommendation_scores(user_id) user_id ]) - joins(join_sql) + # Join domain_classifications by domain_name so the interest-match + # tier can read live category tags (auctions.classification_* is no + # longer written — classification lives on domain_classifications). + classifications_join = <<~SQL.squish + LEFT JOIN domain_classifications + ON LOWER(domain_classifications.domain_name) = LOWER(auctions.domain_name) + SQL + + joins(scores_join).joins(classifications_join) end def build_five_tier_priority_sql(wishlist_domains, interest_categories, custom_interests) @@ -81,7 +89,7 @@ def interest_match_sql(interest_categories, custom_interests) quoted_categories = whitelisted_categories .map { |item| ActiveRecord::Base.connection.quote(item) } .join(',') - match_clauses << "auctions.classification_tags && ARRAY[#{quoted_categories}]::varchar[]" + match_clauses << "domain_classifications.tags && ARRAY[#{quoted_categories}]::varchar[]" end custom_interest_clauses = Array(custom_interests) diff --git a/app/services/recommendation/domain_classifier.rb b/app/services/recommendation/domain_classifier.rb deleted file mode 100644 index 629628b74..000000000 --- a/app/services/recommendation/domain_classifier.rb +++ /dev/null @@ -1,58 +0,0 @@ -module Recommendation - # Runtime entry point for classifying a single domain. - # - # This orchestrator NEVER calls the LLM. It runs structural analysis - # + heuristic classification synchronously and upserts the result into - # domain_classifications. The LLM enrichment pass runs in - # ClassifyUnclassifiedDomainsJob (cron-driven, batched). - # - # Returns the persisted DomainClassification (or nil if domain_name is blank). - class DomainClassifier - FRESH_WINDOW = 1.hour - - class << self - def call(...) - new(...).call - end - end - - def initialize(domain_name, force: false) - @domain_name = domain_name.to_s.strip.downcase - @force = force - end - - def call - return nil if @domain_name.blank? - - existing = DomainClassification.find_by(domain_name: @domain_name) - return existing if existing && !@force && fresh?(existing) - - attributes = DomainHeuristicClassifier.call(@domain_name) - upsert_attributes = attributes.merge(updated_at: Time.current) - - record = existing || DomainClassification.new(domain_name: @domain_name) - preserve_llm_fields!(record, attributes) if existing&.from_llm? - - record.assign_attributes(upsert_attributes.except(:domain_name)) - record.save! - record - end - - private - - def fresh?(record) - record.classified_at.present? && record.classified_at > FRESH_WINDOW.ago - end - - # If a row was previously enriched by the LLM, do NOT clobber the - # rich fields with a weaker heuristic pass. Heuristic only refreshes - # structural and provenance metadata in that case. - def preserve_llm_fields!(record, attributes) - %i[ - keywords audience languages suggested_use_cases - primary_category tags brandability_score - confidence classification_source classification_model classified_at - ].each { |field| attributes.delete(field) if record.send(field).present? } - end - end -end diff --git a/app/services/recommendation/domain_dictionary.rb b/app/services/recommendation/domain_dictionary.rb deleted file mode 100644 index d64424347..000000000 --- a/app/services/recommendation/domain_dictionary.rb +++ /dev/null @@ -1,136 +0,0 @@ -module Recommendation - module DomainDictionary - # Maps a root token -> InterestCatalog category symbol. - # Sources: Estonian common-noun domains we already see in seeds, English - # SaaS/marketing vocabulary, common shop/service terms. Grow over time - # as Tier 2 (LLM) reveals new patterns. - ESTONIAN_ROOTS = { - 'kohvik' => :local_service, 'kohv' => :local_service, - 'apteek' => :health, 'arst' => :health, 'tervis' => :health, 'med' => :health, - 'pood' => :shop_brand, 'aiapood' => :shop_brand, 'kalapood' => :shop_brand, - 'raamatupood' => :shop_brand, 'veebipood' => :shop_brand, 'mobiilipood' => :shop_brand, - 'kinnisvara' => :real_estate, 'maja' => :real_estate, 'korter' => :real_estate, - 'laen' => :finance, 'pank' => :finance, 'raha' => :finance, - 'jurist' => :legal, 'oigus' => :legal, 'oigusabi' => :legal, 'notar' => :legal, - 'haridus' => :education, 'kool' => :education, 'koolitus' => :education, - 'reisid' => :travel, 'matk' => :travel, 'majutus' => :travel, 'reisi' => :travel, - 'auto' => :automotive, 'autod' => :automotive, 'rent' => :automotive, - 'ilusalong' => :health, 'kosmeetika' => :health, 'spaa' => :health, - 'meedia' => :media_content, 'uudised' => :media_content, 'ajakiri' => :media_content, - 'remont' => :local_service, 'parandus' => :local_service, 'ehitus' => :local_service, - 'tooriistad' => :shop_brand, 'mood' => :shop_brand, 'lilled' => :shop_brand, - 'turundus' => :b2b_service, 'nouv' => :b2b_service, - 'tarkvara' => :saas, 'saasplatvorm' => :saas, 'platvorm' => :saas, 'pilv' => :saas - }.freeze - - ENGLISH_ROOTS = { - # Shop / commerce - 'shop' => :shop_brand, 'store' => :shop_brand, 'market' => :shop_brand, - 'marketplace' => :shop_brand, 'mart' => :shop_brand, 'deal' => :shop_brand, - 'dealzone' => :shop_brand, 'foodmarket' => :shop_brand, 'shopline' => :shop_brand, - - # SaaS / tech - 'saas' => :saas, 'cloud' => :saas, 'cloudstack' => :saas, 'tech' => :saas, - 'soft' => :saas, 'software' => :saas, 'app' => :saas, 'apps' => :saas, - 'stack' => :saas, 'platform' => :saas, 'flow' => :saas, 'forge' => :saas, - 'craft' => :saas, 'pixelcraft' => :saas, 'lab' => :saas, 'hub' => :saas, - 'desk' => :saas, 'suite' => :saas, 'gamesuite' => :saas, 'tools' => :saas, - 'data' => :saas, 'api' => :saas, 'dev' => :saas, 'devkit' => :saas, - 'workzone' => :saas, 'startupdesk' => :saas, - - # Finance - 'fin' => :finance, 'finance' => :finance, 'fintech' => :finance, 'fintechlab' => :finance, - 'bank' => :finance, 'pay' => :finance, 'invest' => :finance, 'loan' => :finance, - 'crypto' => :finance, 'capital' => :finance, 'accountflow' => :finance, - - # B2B - 'b2b' => :b2b_service, 'agency' => :b2b_service, 'consult' => :b2b_service, - 'enterprise' => :b2b_service, 'pro' => :b2b_service, 'corp' => :b2b_service, - 'growth' => :b2b_service, 'growthhub' => :b2b_service, 'marketflow' => :b2b_service, - - # Media / content - 'media' => :media_content, 'mediateam' => :media_content, - 'news' => :media_content, 'blog' => :media_content, 'press' => :media_content, - 'magazine' => :media_content, - - # Legal - 'legal' => :legal, 'legalhub' => :legal, 'lawyer' => :legal, 'law' => :legal, - - # Health - 'health' => :health, 'wellness' => :health, 'wellnesshub' => :health, - 'medic' => :health, 'pharma' => :health, 'clinic' => :health, 'fit' => :health, - - # Education - 'edu' => :education, 'school' => :education, 'academy' => :education, - 'course' => :education, 'learn' => :education, - - # Travel - 'travel' => :travel, 'traveldesk' => :travel, 'trip' => :travel, 'tour' => :travel, - 'hotel' => :travel, 'flight' => :travel, 'booking' => :travel, - - # Automotive - 'car' => :automotive, 'carshop' => :automotive, 'auto' => :automotive, - 'motor' => :automotive, 'drive' => :automotive, - - # Real estate - 'property' => :real_estate, 'propertylab' => :real_estate, 'realty' => :real_estate, - 'estate' => :real_estate, 'rent' => :real_estate, 'lease' => :real_estate, - 'home' => :real_estate, 'house' => :real_estate, - - # Brandable / generic positive - 'brand' => :brandable, 'brandforge' => :brandable, 'premium' => :brandable - }.freeze - - ALL_ROOTS = ESTONIAN_ROOTS.merge(ENGLISH_ROOTS).freeze - - # Roots sorted by length DESC for greedy longest-prefix matching. - SORTED_ROOTS = ALL_ROOTS.keys.sort_by { |k| -k.length }.freeze - - MIN_TOKEN_LENGTH = 3 - - class << self - def lookup(token) - ALL_ROOTS[token.to_s.downcase] - end - - def known?(token) - ALL_ROOTS.key?(token.to_s.downcase) - end - - # Greedy subword tokenization: from the front, consume the longest - # known root. Falls back to consuming a single character so we always - # make progress and never loop. - def tokenize(bare_name) - name = bare_name.to_s.downcase - return [] if name.empty? - - tokens = [] - index = 0 - - while index < name.length - remaining = name[index..] - matched_root = SORTED_ROOTS.find do |root| - root.length >= MIN_TOKEN_LENGTH && remaining.start_with?(root) - end - - if matched_root - tokens << matched_root - index += matched_root.length - else - # No known root at this position; consume the next contiguous - # alphabetic run as a single unknown token, or skip a non-letter. - run_match = remaining.match(/\A[a-z]+/) - if run_match - tokens << run_match[0] - index += run_match[0].length - else - index += 1 - end - end - end - - tokens - end - end - end -end diff --git a/app/services/recommendation/domain_heuristic_classifier.rb b/app/services/recommendation/domain_heuristic_classifier.rb deleted file mode 100644 index 432a02a8f..000000000 --- a/app/services/recommendation/domain_heuristic_classifier.rb +++ /dev/null @@ -1,116 +0,0 @@ -module Recommendation - # Tier 0 classifier: deterministic, instant, free. - # - # Approach: - # 1. Run DomainStructuralAnalyzer to extract tokens and structural features. - # 2. Map known tokens -> categories via DomainDictionary. - # 3. Confidence = ratio of matched characters to total bare-name length, - # capped at 1.0. A dictionary_word match short-circuits to 1.0. - # 4. If purely numeric -> 'numeric' category, confidence 1.0. - # 5. Output mirrors the columns of DomainClassification so a caller can - # upsert directly. - class DomainHeuristicClassifier - BRANDABLE_BONUS_THRESHOLD = 0.7 # short, no digits, no hyphens, no dictionary match - - class << self - def call(domain_name) - new(domain_name).call - end - end - - def initialize(domain_name) - @domain_name = domain_name.to_s.strip.downcase - @structure = DomainStructuralAnalyzer.call(@domain_name) - end - - def call - tags, primary_category, matched_chars = derive_categories - brandability = compute_brandability(matched_chars) - - { - domain_name: @domain_name, - primary_category: primary_category&.to_s, - tags: tags.map(&:to_s).uniq, - keywords: derive_keywords, - languages: derive_languages, - audience: nil, - suggested_use_cases: [], - has_digits: @structure[:has_digits], - has_hyphens: @structure[:has_hyphens], - token_count: @structure[:token_count], - dictionary_word: @structure[:dictionary_word], - brandability_score: brandability, - confidence: derive_confidence(matched_chars), - classification_source: DomainClassification::HEURISTIC_SOURCE, - classification_model: 'heuristic_v1', - classified_at: Time.current - } - end - - private - - def derive_categories - if @structure[:numeric_only] - return [%i[numeric], :numeric, @structure[:bare_name].length] - end - - tags = [] - matched_chars = 0 - - @structure[:tokens].each do |token| - category = DomainDictionary.lookup(token) - next unless category - - tags << category - matched_chars += token.length - end - - # If we matched a category, but tokens include digits, layer in :numeric. - tags << :numeric if @structure[:has_digits] && !tags.include?(:numeric) - - primary = tags.first - [tags, primary, matched_chars] - end - - def derive_keywords - # Surface non-trivial structural tokens as keywords so the scorer - # can do keyword-overlap matching even without LLM enrichment. - @structure[:tokens] - .reject { |t| t.length < DomainDictionary::MIN_TOKEN_LENGTH } - .uniq - end - - def derive_languages - languages = [] - tokens = @structure[:tokens] - languages << 'et' if tokens.any? { |t| DomainDictionary::ESTONIAN_ROOTS.key?(t) } - languages << 'en' if tokens.any? { |t| DomainDictionary::ENGLISH_ROOTS.key?(t) } - languages - end - - def derive_confidence(matched_chars) - bare_length = [@structure[:bare_name].length, 1].max - return 1.0 if @structure[:dictionary_word] - return 1.0 if @structure[:numeric_only] - - ratio = matched_chars.to_f / bare_length - ratio.clamp(0.0, 1.0).round(3) - end - - def compute_brandability(matched_chars) - bare = @structure[:bare_name] - return 0.0 if bare.empty? - - score = 1.0 - score -= 0.2 if @structure[:has_digits] - score -= 0.15 if @structure[:has_hyphens] - score -= 0.1 if bare.length > 14 - score -= 0.2 if bare.length > 20 - # If dictionary words eat the whole name, it's literal, not brandable. - coverage = matched_chars.to_f / bare.length - score -= 0.25 if coverage > 0.85 - - score.clamp(0.0, 1.0).round(3) - end - end -end diff --git a/app/services/recommendation/domain_structural_analyzer.rb b/app/services/recommendation/domain_structural_analyzer.rb deleted file mode 100644 index 6dd702cdb..000000000 --- a/app/services/recommendation/domain_structural_analyzer.rb +++ /dev/null @@ -1,45 +0,0 @@ -module Recommendation - class DomainStructuralAnalyzer - TLD_PATTERN = /\.[a-z]+\z/i.freeze - - class << self - def call(domain_name) - new(domain_name).call - end - end - - def initialize(domain_name) - @domain_name = domain_name.to_s.strip.downcase - end - - def call - { - domain_name: @domain_name, - bare_name: bare_name, - length: bare_name.length, - has_digits: bare_name.match?(/\d/), - has_hyphens: bare_name.include?('-'), - token_count: tokens.size, - tokens: tokens, - dictionary_word: dictionary_word?, - numeric_only: bare_name.match?(/\A\d+\z/) - } - end - - def bare_name - @bare_name ||= @domain_name.sub(TLD_PATTERN, '') - end - - def tokens - @tokens ||= Recommendation::DomainDictionary.tokenize(bare_name) - end - - def dictionary_word? - return false if tokens.empty? - return false if tokens.size > 2 - - Recommendation::DomainDictionary.known?(bare_name) || - tokens.all? { |token| Recommendation::DomainDictionary.known?(token) } - end - end -end diff --git a/db/migrate/20260527090000_create_domain_classifications.rb b/db/migrate/20260527090000_create_domain_classifications.rb index 43fe568d4..a047ccdc0 100644 --- a/db/migrate/20260527090000_create_domain_classifications.rb +++ b/db/migrate/20260527090000_create_domain_classifications.rb @@ -19,10 +19,6 @@ def change t.string :languages, array: true, default: [], null: false t.string :suggested_use_cases, array: true, default: [], null: false - t.boolean :has_digits, default: false, null: false - t.boolean :has_hyphens, default: false, null: false - t.integer :token_count - t.boolean :dictionary_word, default: false, null: false t.decimal :brandability_score, precision: 4, scale: 3 t.string :classification_source # 'heuristic' | 'openai' | 'manual' | 'imported' diff --git a/lib/tasks/demo_auctions.rake b/lib/tasks/demo_auctions.rake index d9597f688..c43b52e44 100644 --- a/lib/tasks/demo_auctions.rake +++ b/lib/tasks/demo_auctions.rake @@ -1,10 +1,10 @@ namespace :demo do # Domain seed list grouped by intended category. After `rake demo:create_blind_auctions` - # the Auction.after_create callback enqueues ClassifyDomainHeuristicallyJob for - # every domain. The heuristic Tier 0 classifier will tag most of these - # immediately from the dictionary. The nightly LLM cron then enriches keywords, - # audience, languages and use cases. Use the variety here to verify that - # different InterestCatalog categories surface correctly in /auctions sort. + # the Auction.after_create callback enqueues ClassifyDomainJob for every domain, + # which calls the LLM (requires Feature.open_ai_integration_enabled?) to map the + # domain onto an InterestCatalog category code. The nightly cron re-classifies + # anything missed. Use the variety here to verify that different InterestCatalog + # categories surface correctly in /auctions sort. DEMO_DOMAINS = { # Estonian common nouns — dictionary should hit on first pass. local_service: %w[ diff --git a/test/jobs/recommendation/backfill_domain_classifications_job_test.rb b/test/jobs/recommendation/backfill_domain_classifications_job_test.rb index 1077fe33b..74a0b422a 100644 --- a/test/jobs/recommendation/backfill_domain_classifications_job_test.rb +++ b/test/jobs/recommendation/backfill_domain_classifications_job_test.rb @@ -16,8 +16,10 @@ def test_classifies_auction_and_wishlist_domains ) WishlistItem.create!(user: users(:participant), domain_name: 'backfill-wishlist.ee', cents: 1_000) - assert_difference -> { DomainClassification.count }, ->(count) { count >= 2 } do - Recommendation::BackfillDomainClassificationsJob.new.perform + with_feature_flag(true) do + stub_llm do + Recommendation::BackfillDomainClassificationsJob.new.perform + end end assert DomainClassification.exists?(domain_name: 'backfill-auction.ee') @@ -39,36 +41,64 @@ def test_skips_already_classified_domains classified_at: 1.hour.ago ) - assert_no_difference -> { DomainClassification.where(domain_name: 'skip-me.ee').count } do - Recommendation::BackfillDomainClassificationsJob.new.perform + with_feature_flag(true) do + stub_llm do + assert_no_difference -> { DomainClassification.where(domain_name: 'skip-me.ee').count } do + Recommendation::BackfillDomainClassificationsJob.new.perform + end + end end end - def test_continues_after_individual_failures + def test_no_op_when_openai_disabled Auction.create!( - domain_name: 'good.ee', + domain_name: 'no-llm.ee', starts_at: 1.hour.ago, ends_at: 1.day.from_now, skip_validation: true ) - # Simulate a failure in classifier for one specific input - original = Recommendation::DomainClassifier.method(:call) - Recommendation::DomainClassifier.define_singleton_method(:call) do |name, **opts| - raise StandardError, 'simulated' if name.to_s.include?('good') - - original.call(name, **opts) + with_feature_flag(false) do + assert_no_difference -> { DomainClassification.count } do + Recommendation::BackfillDomainClassificationsJob.new.perform + end end + end - assert_nothing_raised do - Recommendation::BackfillDomainClassificationsJob.new.perform - end - ensure - if original - Recommendation::DomainClassifier.define_singleton_method(:call) do |name, **opts| - original.call(name, **opts) + private + + def stub_llm + original = Recommendation::LlmDomainClassifier.method(:call) + Recommendation::LlmDomainClassifier.define_singleton_method(:call) do |domain_names:, **| + Array(domain_names).map do |d| + { + domain_name: d.to_s.strip.downcase, + primary_category: 'other', + tags: ['other'], + keywords: [], + audience: nil, + languages: [], + suggested_use_cases: [], + brandability_score: nil, + confidence: 0.9, + classification_source: DomainClassification::OPENAI_SOURCE, + classification_model: 'gpt-5', + classified_at: Time.current, + raw_llm_response: {} + } end end + yield + ensure + Recommendation::LlmDomainClassifier.define_singleton_method(:call, original) + end + + def with_feature_flag(enabled) + original = Feature.method(:open_ai_integration_enabled?) + Feature.define_singleton_method(:open_ai_integration_enabled?) { enabled } + yield + ensure + Feature.define_singleton_method(:open_ai_integration_enabled?, original) end end end diff --git a/test/jobs/recommendation/classify_domain_heuristically_job_test.rb b/test/jobs/recommendation/classify_domain_heuristically_job_test.rb deleted file mode 100644 index 6d3850482..000000000 --- a/test/jobs/recommendation/classify_domain_heuristically_job_test.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'test_helper' - -module Recommendation - class ClassifyDomainHeuristicallyJobTest < ActiveJob::TestCase - def test_creates_classification_for_unknown_domain - DomainClassification.where(domain_name: 'apteek.ee').delete_all - - assert_difference -> { DomainClassification.count }, 1 do - Recommendation::ClassifyDomainHeuristicallyJob.new.perform('apteek.ee') - end - end - - def test_idempotent_for_fresh_classification - DomainClassification.where(domain_name: 'apteek.ee').delete_all - Recommendation::ClassifyDomainHeuristicallyJob.new.perform('apteek.ee') - - assert_no_difference -> { DomainClassification.count } do - Recommendation::ClassifyDomainHeuristicallyJob.new.perform('apteek.ee') - end - end - - def test_no_op_for_blank_domain - assert_no_difference -> { DomainClassification.count } do - Recommendation::ClassifyDomainHeuristicallyJob.new.perform('') - Recommendation::ClassifyDomainHeuristicallyJob.new.perform(nil) - Recommendation::ClassifyDomainHeuristicallyJob.new.perform(' ') - end - end - - def test_auction_create_enqueues_classification - assert_enqueued_with(job: Recommendation::ClassifyDomainHeuristicallyJob) do - Auction.create!( - domain_name: "trigger-test-#{SecureRandom.hex(4)}.ee", - starts_at: 1.hour.ago, - ends_at: 1.day.from_now, - skip_validation: true - ) - end - end - end -end diff --git a/test/jobs/recommendation/classify_domain_job_test.rb b/test/jobs/recommendation/classify_domain_job_test.rb new file mode 100644 index 000000000..042fc6255 --- /dev/null +++ b/test/jobs/recommendation/classify_domain_job_test.rb @@ -0,0 +1,104 @@ +require 'test_helper' + +module Recommendation + class ClassifyDomainJobTest < ActiveJob::TestCase + def setup + super + DomainClassification.where(domain_name: 'apteek.ee').delete_all + Setting.find_by(code: 'openai_model')&.update!(value: 'gpt-5') + end + + def test_classifies_unknown_domain_via_llm + stub_openai('apteek.ee', primary: 'health') + + with_feature_flag(true) do + assert_difference -> { DomainClassification.count }, 1 do + Recommendation::ClassifyDomainJob.new.perform('apteek.ee') + end + end + + row = DomainClassification.find_by(domain_name: 'apteek.ee') + assert_equal 'health', row.primary_category + assert_equal DomainClassification::OPENAI_SOURCE, row.classification_source + end + + def test_no_op_when_openai_disabled + with_feature_flag(false) do + assert_no_difference -> { DomainClassification.count } do + Recommendation::ClassifyDomainJob.new.perform('apteek.ee') + end + end + assert_not_requested :post, 'https://api.openai.com/v1/chat/completions' + end + + def test_skips_fresh_llm_classification + DomainClassification.create!(domain_name: 'apteek.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.9, + classified_at: 1.hour.ago) + + with_feature_flag(true) do + assert_no_difference -> { DomainClassification.count } do + Recommendation::ClassifyDomainJob.new.perform('apteek.ee') + end + end + assert_not_requested :post, 'https://api.openai.com/v1/chat/completions' + end + + def test_no_op_for_blank_domain + with_feature_flag(true) do + assert_no_difference -> { DomainClassification.count } do + Recommendation::ClassifyDomainJob.new.perform('') + Recommendation::ClassifyDomainJob.new.perform(nil) + Recommendation::ClassifyDomainJob.new.perform(' ') + end + end + end + + def test_auction_create_enqueues_classification + assert_enqueued_with(job: Recommendation::ClassifyDomainJob) do + Auction.create!( + domain_name: "trigger-test-#{SecureRandom.hex(4)}.ee", + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + skip_validation: true + ) + end + end + + private + + def stub_openai(domain_name, primary:) + body = { + 'choices' => [{ + 'finish_reason' => 'stop', + 'message' => { + 'content' => { + classifications: [{ + domain_name: domain_name, + primary_category: primary, + tags: [primary], + keywords: %w[example], + audience: 'b2c', + languages: %w[et], + suggested_use_cases: %w[service], + brandability_score: 0.5, + confidence: 0.9 + }] + }.to_json + } + }] + } + stub_request(:post, 'https://api.openai.com/v1/chat/completions') + .to_return_json(status: 200, body: body, headers: {}) + end + + def with_feature_flag(enabled) + original = Feature.method(:open_ai_integration_enabled?) + Feature.define_singleton_method(:open_ai_integration_enabled?) { enabled } + yield + ensure + Feature.define_singleton_method(:open_ai_integration_enabled?, original) + end + end +end diff --git a/test/jobs/recommendation/classify_unclassified_domains_job_test.rb b/test/jobs/recommendation/classify_unclassified_domains_job_test.rb index e0dfbe28c..ec604c7a2 100644 --- a/test/jobs/recommendation/classify_unclassified_domains_job_test.rb +++ b/test/jobs/recommendation/classify_unclassified_domains_job_test.rb @@ -9,45 +9,64 @@ def setup def test_no_op_when_openai_integration_disabled DomainClassification.create!(domain_name: 'pending.ee', - classification_source: DomainClassification::HEURISTIC_SOURCE, + classification_source: DomainClassification::OPENAI_SOURCE, classified_at: Time.current, confidence: 0.3) with_feature_flag(false) do - result = Recommendation::ClassifyUnclassifiedDomainsJob.new.perform - assert_nil result + assert_nil Recommendation::ClassifyUnclassifiedDomainsJob.new.perform end end - def test_no_op_when_no_pending_rows + def test_no_op_when_nothing_pending with_feature_flag(true) do - result = Recommendation::ClassifyUnclassifiedDomainsJob.new.perform - assert_nil result + with_missing_domains([]) do + assert_nil Recommendation::ClassifyUnclassifiedDomainsJob.new.perform + end end end - def test_scope_picks_heuristic_low_confidence_and_stale_llm_rows - heuristic_row = DomainClassification.create!( - domain_name: 'h.ee', - classification_source: DomainClassification::HEURISTIC_SOURCE, - confidence: 0.9, - classified_at: 1.day.ago - ) + def test_reclassifies_low_confidence_existing_row + DomainClassification.create!(domain_name: 'weak.ee', + classification_source: DomainClassification::OPENAI_SOURCE, + confidence: 0.3, + classified_at: 1.day.ago) + + with_feature_flag(true) do + with_missing_domains([]) do + stub_llm do + processed = Recommendation::ClassifyUnclassifiedDomainsJob.new.perform + assert_equal 1, processed + end + end + end + end + def test_classifies_missing_domains + with_feature_flag(true) do + with_missing_domains(['fresh-domain.ee']) do + stub_llm do + Recommendation::ClassifyUnclassifiedDomainsJob.new.perform + end + end + end + + assert DomainClassification.exists?(domain_name: 'fresh-domain.ee') + end + + def test_scope_picks_low_confidence_and_stale_llm_rows low_conf_row = DomainClassification.create!( domain_name: 'l.ee', classification_source: DomainClassification::OPENAI_SOURCE, confidence: 0.3, classified_at: 1.day.ago ) - stale_row = DomainClassification.create!( domain_name: 's.ee', classification_source: DomainClassification::OPENAI_SOURCE, confidence: 0.95, classified_at: 9.months.ago ) - fresh_llm = DomainClassification.create!( domain_name: 'f.ee', classification_source: DomainClassification::OPENAI_SOURCE, @@ -56,20 +75,19 @@ def test_scope_picks_heuristic_low_confidence_and_stale_llm_rows ) scope_ids = Recommendation::ClassifyUnclassifiedDomainsJob.scope.pluck(:id) - assert_includes scope_ids, heuristic_row.id assert_includes scope_ids, low_conf_row.id assert_includes scope_ids, stale_row.id refute_includes scope_ids, fresh_llm.id end - def test_needs_to_run_reflects_feature_and_scope + def test_needs_to_run_reflects_feature_and_work with_feature_flag(false) do refute Recommendation::ClassifyUnclassifiedDomainsJob.needs_to_run? end DomainClassification.create!( domain_name: 'pending.ee', - classification_source: DomainClassification::HEURISTIC_SOURCE, + classification_source: DomainClassification::OPENAI_SOURCE, confidence: 0.3, classified_at: Time.current ) @@ -81,6 +99,40 @@ def test_needs_to_run_reflects_feature_and_scope private + def stub_llm + original = Recommendation::LlmDomainClassifier.method(:call) + Recommendation::LlmDomainClassifier.define_singleton_method(:call) do |domain_names:, **| + Array(domain_names).map do |d| + { + domain_name: d.to_s.strip.downcase, + primary_category: 'other', + tags: ['other'], + keywords: [], + audience: nil, + languages: [], + suggested_use_cases: [], + brandability_score: nil, + confidence: 0.9, + classification_source: DomainClassification::OPENAI_SOURCE, + classification_model: 'gpt-5', + classified_at: Time.current, + raw_llm_response: {} + } + end + end + yield + ensure + Recommendation::LlmDomainClassifier.define_singleton_method(:call, original) + end + + def with_missing_domains(list) + original = Recommendation::ClassifyUnclassifiedDomainsJob.method(:missing_domains) + Recommendation::ClassifyUnclassifiedDomainsJob.define_singleton_method(:missing_domains) { list } + yield + ensure + Recommendation::ClassifyUnclassifiedDomainsJob.define_singleton_method(:missing_domains, original) + end + def with_feature_flag(enabled) original = Feature.method(:open_ai_integration_enabled?) Feature.define_singleton_method(:open_ai_integration_enabled?) { enabled } diff --git a/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb b/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb index 6e6b457a3..aeee0cc70 100644 --- a/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb +++ b/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb @@ -36,7 +36,12 @@ def test_perform_skips_when_user_has_fresh_scores end def test_perform_refreshes_when_scores_are_stale - auction = auctions(:valid_without_offers) + auction = Auction.create!( + domain_name: "stale-refresh-#{SecureRandom.hex(4)}.ee", + starts_at: 1.hour.ago, + ends_at: 1.day.from_now, + skip_validation: true + ) UserAuctionScore.create!( user: @user, auction: auction, @@ -48,7 +53,7 @@ def test_perform_refreshes_when_scores_are_stale Recommendation::RefreshSingleUserAuctionScoresJob.new.perform(@user.id) end - reloaded = UserAuctionScore.where(user: @user).order(:calculated_at).last + reloaded = UserAuctionScore.find_by!(user: @user, auction: auction) assert reloaded.calculated_at > 1.minute.ago, 'stale scores must be refreshed' end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index e90d2e238..19d124259 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -446,7 +446,7 @@ def test_user_can_accept_nested_recommendation_profile_attributes } assert user.save - assert_equal(%w[saas other], user.recommendation_profile.interest_categories.sort) + assert_equal(%w[other saas], user.recommendation_profile.interest_categories.sort) assert_equal(['marketplace'], user.recommendation_profile.custom_interests) end diff --git a/test/services/recommendation/domain_classifier_test.rb b/test/services/recommendation/domain_classifier_test.rb deleted file mode 100644 index 0641befa5..000000000 --- a/test/services/recommendation/domain_classifier_test.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'test_helper' - -module Recommendation - class DomainClassifierTest < ActiveSupport::TestCase - def test_creates_classification_for_unseen_domain - assert_difference -> { DomainClassification.count }, 1 do - Recommendation::DomainClassifier.call('kohvik.ee') - end - - record = DomainClassification.find_by(domain_name: 'kohvik.ee') - assert_equal 'local_service', record.primary_category - assert_equal DomainClassification::HEURISTIC_SOURCE, record.classification_source - end - - def test_returns_existing_record_when_fresh - Recommendation::DomainClassifier.call('cloudstack.ee') - - assert_no_difference -> { DomainClassification.count } do - Recommendation::DomainClassifier.call('cloudstack.ee') - end - end - - def test_force_recomputes_even_when_fresh - Recommendation::DomainClassifier.call('apteek.ee') - record = DomainClassification.find_by(domain_name: 'apteek.ee') - record.update_columns(classified_at: 10.seconds.ago) - - Recommendation::DomainClassifier.call('apteek.ee', force: true) - record.reload - assert record.classified_at > 1.second.ago - end - - def test_preserves_llm_enriched_fields_when_running_heuristic_again - record = DomainClassification.create!( - domain_name: 'rich.ee', - primary_category: 'saas', - tags: %w[saas b2b_service], - keywords: %w[cloud platform], - audience: 'b2b', - languages: %w[en], - suggested_use_cases: %w[agency], - brandability_score: 0.9, - confidence: 0.92, - classification_source: DomainClassification::OPENAI_SOURCE, - classification_model: 'gpt-5', - classified_at: 2.hours.ago - ) - - Recommendation::DomainClassifier.call('rich.ee', force: true) - record.reload - - assert_equal %w[cloud platform], record.keywords - assert_equal 'b2b', record.audience - assert_equal DomainClassification::OPENAI_SOURCE, record.classification_source - assert_equal 'saas', record.primary_category - end - - def test_no_op_for_blank_domain - assert_nil Recommendation::DomainClassifier.call('') - assert_nil Recommendation::DomainClassifier.call(nil) - end - - def test_lowercases_input - Recommendation::DomainClassifier.call(' KOHVIK.EE ') - assert DomainClassification.exists?(domain_name: 'kohvik.ee') - end - end -end diff --git a/test/services/recommendation/domain_heuristic_classifier_test.rb b/test/services/recommendation/domain_heuristic_classifier_test.rb deleted file mode 100644 index 51bb3c1ab..000000000 --- a/test/services/recommendation/domain_heuristic_classifier_test.rb +++ /dev/null @@ -1,53 +0,0 @@ -require 'test_helper' - -module Recommendation - class DomainHeuristicClassifierTest < ActiveSupport::TestCase - def test_known_estonian_domain_maps_to_expected_category - result = Recommendation::DomainHeuristicClassifier.call('kohvik.ee') - assert_equal 'local_service', result[:primary_category] - assert_includes result[:tags], 'local_service' - assert_equal 1.0, result[:confidence] - assert_includes result[:languages], 'et' - end - - def test_compound_english_domain_maps_to_first_match - result = Recommendation::DomainHeuristicClassifier.call('marketflow.ee') - assert_includes result[:tags], 'shop_brand' - assert result[:confidence] > 0.5 - assert_includes result[:languages], 'en' - end - - def test_numeric_domain_classified_as_numeric - result = Recommendation::DomainHeuristicClassifier.call('12345.ee') - assert_equal 'numeric', result[:primary_category] - assert_includes result[:tags], 'numeric' - assert_equal 1.0, result[:confidence] - end - - def test_unknown_domain_has_low_confidence - result = Recommendation::DomainHeuristicClassifier.call('zxyqwerty.ee') - assert result[:confidence] < Recommendation::DomainHeuristicClassifier::BRANDABLE_BONUS_THRESHOLD - assert_nil result[:primary_category] - assert_empty result[:tags] - end - - def test_metadata_is_present - result = Recommendation::DomainHeuristicClassifier.call('cloudstack.ee') - assert_equal DomainClassification::HEURISTIC_SOURCE, result[:classification_source] - assert_equal 'heuristic_v1', result[:classification_model] - assert result[:classified_at].is_a?(Time) - end - - def test_hyphenated_lowers_brandability - with_hyphen = Recommendation::DomainHeuristicClassifier.call('cool-shop.ee') - without_hyphen = Recommendation::DomainHeuristicClassifier.call('coolshop.ee') - assert with_hyphen[:brandability_score] < without_hyphen[:brandability_score] - end - - def test_digits_layer_in_numeric_tag - result = Recommendation::DomainHeuristicClassifier.call('shop42.ee') - assert_includes result[:tags], 'numeric' - assert_includes result[:tags], 'shop_brand' - end - end -end diff --git a/test/services/recommendation/domain_structural_analyzer_test.rb b/test/services/recommendation/domain_structural_analyzer_test.rb deleted file mode 100644 index a4e26aaaa..000000000 --- a/test/services/recommendation/domain_structural_analyzer_test.rb +++ /dev/null @@ -1,44 +0,0 @@ -require 'test_helper' - -module Recommendation - class DomainStructuralAnalyzerTest < ActiveSupport::TestCase - def test_detects_digits - result = Recommendation::DomainStructuralAnalyzer.call('numeric24.ee') - assert result[:has_digits] - end - - def test_detects_hyphens - result = Recommendation::DomainStructuralAnalyzer.call('my-shop.ee') - assert result[:has_hyphens] - end - - def test_strips_tld - result = Recommendation::DomainStructuralAnalyzer.call('cloudstack.ee') - assert_equal 'cloudstack', result[:bare_name] - assert_equal 'cloudstack'.length, result[:length] - end - - def test_dictionary_word_for_single_known_root - result = Recommendation::DomainStructuralAnalyzer.call('kohvik.ee') - assert result[:dictionary_word] - end - - def test_numeric_only_domain - result = Recommendation::DomainStructuralAnalyzer.call('12345.ee') - assert result[:numeric_only] - assert result[:has_digits] - end - - def test_tokenization_of_compound_known_roots - result = Recommendation::DomainStructuralAnalyzer.call('marketflow.ee') - assert_includes result[:tokens], 'market' - assert_includes result[:tokens], 'flow' - end - - def test_unknown_token_falls_through_as_single_alphabetic_run - result = Recommendation::DomainStructuralAnalyzer.call('zxyqwerty.ee') - assert_equal ['zxyqwerty'], result[:tokens] - refute result[:dictionary_word] - end - end -end diff --git a/test/services/recommendation/scorer_embedding_test.rb b/test/services/recommendation/scorer_embedding_test.rb index 586e11958..f6a0a06ca 100644 --- a/test/services/recommendation/scorer_embedding_test.rb +++ b/test/services/recommendation/scorer_embedding_test.rb @@ -22,11 +22,11 @@ def test_aligned_embedding_boosts_score # User behavioural history points strongly toward a 1.0-direction vector. history_auction = Auction.create!( domain_name: 'history-domain.ee', - starts_at: 1.day.ago, - ends_at: 2.days.ago, + starts_at: 2.days.ago, + ends_at: 1.day.ago, skip_validation: true ) - Offer.create!(user: @user, auction: history_auction, cents: 100, billing_profile_id: 0) + create_history_offer(history_auction) DomainClassification.create!( domain_name: 'history-domain.ee', primary_category: 'saas', @@ -109,5 +109,19 @@ def create_active_auction(domain_name:) skip_validation: true ) end + + # Bid history on a past/ended auction. Offer validations (active auction, + # minimum price) don't apply to backfilled history, so persist without + # validation. A real billing_profile satisfies the FK. + def create_history_offer(auction, cents: 100) + offer = Offer.new( + user: @user, + auction: auction, + cents: cents, + billing_profile: billing_profiles(:private_person) + ) + offer.save(validate: false) + offer + end end end diff --git a/test/services/recommendation/scorer_rich_features_test.rb b/test/services/recommendation/scorer_rich_features_test.rb index bf98b15a5..bc6b196ff 100644 --- a/test/services/recommendation/scorer_rich_features_test.rb +++ b/test/services/recommendation/scorer_rich_features_test.rb @@ -64,7 +64,7 @@ def test_recent_bid_outweighs_old_bid_via_time_decay classification_tags: ['numeric'], skip_validation: true ) - old_offer = Offer.create!(user: bidder, auction: old_auction, cents: 100, billing_profile_id: 0) + old_offer = create_history_offer(bidder, old_auction) old_offer.update_columns(updated_at: 10.years.ago) target_with_numeric = create_active_auction( @@ -110,5 +110,19 @@ def create_active_auction(domain_name:, classification_tags:, ai_score: 1.0) skip_validation: true ) end + + # Bid history on a past/ended auction. Offer validations (active auction, + # minimum price) don't apply to backfilled history, so persist without + # validation. A real billing_profile satisfies the FK. + def create_history_offer(user, auction, cents: 100) + offer = Offer.new( + user: user, + auction: auction, + cents: cents, + billing_profile: billing_profiles(:private_person) + ) + offer.save(validate: false) + offer + end end end From 6941470ac333fa1914b602fae422dbb6aef94666 Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Mon, 1 Jun 2026 13:40:34 +0300 Subject: [PATCH 25/42] move interests functionality into admin panel --- app/components/common/header/component.rb | 1 + .../admin/interest_categories_controller.rb | 64 ++++++++++++++++ .../recommendation_profiles_controller.rb | 7 ++ app/models/ability.rb | 1 + app/models/interest_category.rb | 54 ++++++++++++++ app/models/recommendation_profile.rb | 16 +++- app/models/user.rb | 2 + .../recommendation/interest_catalog.rb | 69 ++++++++++++----- .../admin/interest_categories/_form.html.erb | 41 ++++++++++ .../admin/interest_categories/edit.html.erb | 5 ++ .../admin/interest_categories/index.html.erb | 35 +++++++++ .../admin/interest_categories/new.html.erb | 5 ++ .../recommendation_profiles/_fields.html.erb | 2 +- app/views/users/_user_info.html.erb | 30 ++++---- config/locales/en.yml | 1 + config/locales/et.yml | 1 + config/locales/interest_categories.en.yml | 18 +++++ config/locales/interest_categories.et.yml | 18 +++++ config/routes.rb | 1 + ...260601100000_create_interest_categories.rb | 18 +++++ ...00_seed_interest_categories_and_setting.rb | 23 ++++++ db/seeds.rb | 15 ++++ db/structure.sql | 74 +++++++++++++++++++ ...recommendation_profiles_controller_test.rb | 20 +++++ test/fixtures/settings.yml | 8 ++ .../interest_categories_controller_test.rb | 74 +++++++++++++++++++ test/models/interest_category_test.rb | 56 ++++++++++++++ .../recommendation/interest_catalog_test.rb | 33 +++++++++ 28 files changed, 658 insertions(+), 34 deletions(-) create mode 100644 app/controllers/admin/interest_categories_controller.rb create mode 100644 app/models/interest_category.rb create mode 100644 app/views/admin/interest_categories/_form.html.erb create mode 100644 app/views/admin/interest_categories/edit.html.erb create mode 100644 app/views/admin/interest_categories/index.html.erb create mode 100644 app/views/admin/interest_categories/new.html.erb create mode 100644 config/locales/interest_categories.en.yml create mode 100644 config/locales/interest_categories.et.yml create mode 100644 db/migrate/20260601100000_create_interest_categories.rb create mode 100644 db/migrate/20260601100100_seed_interest_categories_and_setting.rb create mode 100644 test/integration/admin/interest_categories_controller_test.rb create mode 100644 test/models/interest_category_test.rb create mode 100644 test/services/recommendation/interest_catalog_test.rb diff --git a/app/components/common/header/component.rb b/app/components/common/header/component.rb index 3fd0ad5e8..61dc3b0a2 100644 --- a/app/components/common/header/component.rb +++ b/app/components/common/header/component.rb @@ -57,6 +57,7 @@ def admin_menu_list_items { name: t(:invoices_name), path: admin_invoices_path }, { name: t(:jobs_name), path: admin_jobs_path }, { name: t(:settings_name), path: admin_settings_path }, + { name: t(:interest_categories_name), path: admin_interest_categories_path }, { name: t(:paid_deposits_name), path: admin_paid_deposits_path }, { name: t(:statistics_name), path: admin_statistics_path }] end diff --git a/app/controllers/admin/interest_categories_controller.rb b/app/controllers/admin/interest_categories_controller.rb new file mode 100644 index 000000000..5b7d87d2d --- /dev/null +++ b/app/controllers/admin/interest_categories_controller.rb @@ -0,0 +1,64 @@ +module Admin + class InterestCategoriesController < BaseController + before_action :authorize_user + before_action :set_interest_category, only: %i[edit update destroy] + + # GET /admin/interest_categories + def index + @interest_categories = InterestCategory.ordered + end + + # GET /admin/interest_categories/new + def new + @interest_category = InterestCategory.new(active: true, position: next_position) + end + + # POST /admin/interest_categories + def create + @interest_category = InterestCategory.new(interest_category_params) + + if @interest_category.save + redirect_to admin_interest_categories_path, notice: t(:created) + else + render :new, status: :unprocessable_entity + end + end + + # GET /admin/interest_categories/1/edit + def edit; end + + # PUT /admin/interest_categories/1 + def update + if @interest_category.update(interest_category_params) + redirect_to admin_interest_categories_path, notice: t(:updated) + else + render :edit, status: :unprocessable_entity + end + end + + # DELETE /admin/interest_categories/1 + def destroy + @interest_category.destroy + redirect_to admin_interest_categories_path, notice: t(:deleted) + end + + private + + def set_interest_category + @interest_category = InterestCategory.find(params[:id]) + end + + def interest_category_params + params.require(:interest_category) + .permit(:code, :name_en, :name_et, :position, :active) + end + + def next_position + (InterestCategory.maximum(:position) || 0) + 1 + end + + def authorize_user + authorize! :manage, InterestCategory + end + end +end diff --git a/app/controllers/recommendation_profiles_controller.rb b/app/controllers/recommendation_profiles_controller.rb index 446de8d21..cf54a6dda 100644 --- a/app/controllers/recommendation_profiles_controller.rb +++ b/app/controllers/recommendation_profiles_controller.rb @@ -1,5 +1,6 @@ class RecommendationProfilesController < ApplicationController before_action :authenticate_user! + before_action :ensure_selection_enabled, only: %i[edit update] before_action :set_recommendation_profile def edit; end @@ -51,6 +52,12 @@ def dismiss private + def ensure_selection_enabled + return if RecommendationProfile.selection_enabled? + + redirect_to user_path(current_user.uuid) + end + def set_recommendation_profile @recommendation_profile = current_user.recommendation_profile || current_user.build_recommendation_profile end diff --git a/app/models/ability.rb b/app/models/ability.rb index f1a44f630..8f45a8275 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -83,6 +83,7 @@ def administrator can %i[read create], Job can :manage, User can %i[read update], Setting + can :manage, InterestCategory can :read, Offer can :read, Result can :read, PaymentOrder diff --git a/app/models/interest_category.rb b/app/models/interest_category.rb new file mode 100644 index 000000000..86381c214 --- /dev/null +++ b/app/models/interest_category.rb @@ -0,0 +1,54 @@ +class InterestCategory < ApplicationRecord + # Initial vocabulary, also mirrored in Recommendation::InterestCatalog::FALLBACK_CATEGORIES. + # Seeded via .seed_defaults! from both db/seeds.rb (fresh setup) and a data + # migration (existing environments). Idempotent — never clobbers admin edits. + DEFAULTS = [ + { code: 'brandable', name_en: 'Brandable names', name_et: 'Bränditavad nimed' }, + { code: 'shop_brand', name_en: 'Shop or store names', name_et: 'Poe- või kaubamärgi nimed' }, + { code: 'saas', name_en: 'SaaS and software', name_et: 'SaaS ja tarkvara' }, + { code: 'b2b_service', name_en: 'B2B services', name_et: 'B2B teenused' }, + { code: 'local_service', name_en: 'Local or service businesses', name_et: 'Kohalikud ja teenusettevõtted' }, + { code: 'media_content', name_en: 'Media and content', name_et: 'Meedia ja sisu' }, + { code: 'finance', name_en: 'Finance and fintech', name_et: 'Finants ja fintech' }, + { code: 'legal', name_en: 'Legal and professional services', name_et: 'Õigus- ja professionaalsed teenused' }, + { code: 'health', name_en: 'Health and wellness', name_et: 'Tervis ja heaolu' }, + { code: 'education', name_en: 'Education and courses', name_et: 'Haridus ja kursused' }, + { code: 'travel', name_en: 'Travel and tourism', name_et: 'Reisimine ja turism' }, + { code: 'automotive', name_en: 'Automotive', name_et: 'Autondus' }, + { code: 'real_estate', name_en: 'Real estate', name_et: 'Kinnisvara' }, + { code: 'numeric', name_en: 'Numeric domains', name_et: 'Numbrilised domeenid' }, + { code: 'other', name_en: 'Other', name_et: 'Muu' } + ].freeze + + def self.seed_defaults! + DEFAULTS.each_with_index do |attrs, index| + find_or_create_by(code: attrs[:code]) do |category| + category.name_en = attrs[:name_en] + category.name_et = attrs[:name_et] + category.position = index + 1 + category.active = true + end + end + end + + validates :code, presence: true, uniqueness: { case_sensitive: false } + validates :name_en, presence: true + validates :name_et, presence: true + validates :position, numericality: { only_integer: true } + + before_validation :normalize_code + + scope :active, -> { where(active: true) } + scope :ordered, -> { order(:position, :code) } + + # Locale-aware display name. Falls back to the English name. + def name + I18n.locale.to_s == 'et' ? name_et.presence || name_en : name_en + end + + private + + def normalize_code + self.code = code.to_s.strip.downcase.presence + end +end diff --git a/app/models/recommendation_profile.rb b/app/models/recommendation_profile.rb index 6379dcb8f..299464f67 100644 --- a/app/models/recommendation_profile.rb +++ b/app/models/recommendation_profile.rb @@ -16,6 +16,18 @@ class RecommendationProfile < ApplicationRecord validate :length_range_is_valid validate :interest_categories_are_supported + SELECTION_ENABLED_SETTING = 'recommendation_interests_enabled'.freeze + + # Admin toggle (Setting). When false, the interest-selection UI is hidden + # from users (no prompt, no form). Already-saved interests still affect + # ranking — this only gates the UI. Defaults to false when the setting is + # missing, so a fresh / un-seeded deploy never shows an empty picker. + def self.selection_enabled? + Setting.find_by(code: SELECTION_ENABLED_SETTING)&.retrieve == true + rescue StandardError + false + end + def completed? = completed_at.present? def promptable? @@ -55,7 +67,7 @@ def interest_categories=(values) end def interest_categories_labels - interest_categories.map { |category| I18n.t("recommendation_profiles.categories.#{category}") } + interest_categories.map { |category| Recommendation::InterestCatalog.label_for(category) } end def rankable_interest_categories @@ -77,7 +89,7 @@ def custom_interests=(values) def summary_lines [].tap do |lines| if rankable_interest_categories.any? - labels = rankable_interest_categories.map { |category| I18n.t("recommendation_profiles.categories.#{category}") } + labels = rankable_interest_categories.map { |category| Recommendation::InterestCatalog.label_for(category) } lines << "#{I18n.t('recommendation_profiles.summary.categories')}: #{labels.join(', ')}" end diff --git a/app/models/user.rb b/app/models/user.rb index b374a9771..d50d17ddf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -246,6 +246,8 @@ def active_for_authentication? end def recommendation_profile_promptable? + return false unless RecommendationProfile.selection_enabled? + recommendation_profile&.promptable? != false end diff --git a/app/services/recommendation/interest_catalog.rb b/app/services/recommendation/interest_catalog.rb index 97ce835df..814c4879f 100644 --- a/app/services/recommendation/interest_catalog.rb +++ b/app/services/recommendation/interest_catalog.rb @@ -1,25 +1,60 @@ module Recommendation + # Single source of truth for the interest-category vocabulary. + # + # Categories are admin-managed (InterestCategory table). This module is the + # read API used by the user form, the LLM classifier (category enum), the + # scorer, and the sort. Because every consumer reads `categories` from here, + # adding a category in admin automatically widens the LLM vocabulary and the + # scoring match — no code change needed. + # + # FALLBACK_CATEGORIES is used only when the table is empty or unavailable + # (fresh DB before seeding, or mid-migration) so classification and scoring + # never break. The table holds ~15 rows queried over an (active, position) + # index, so reads are cheap and uncached — avoids cross-process staleness. module InterestCatalog - CATEGORIES = %w[ - brandable - shop_brand - saas - b2b_service - local_service - media_content - finance - legal - health - education - travel - automotive - real_estate - numeric - other + FALLBACK_CATEGORIES = %w[ + brandable shop_brand saas b2b_service local_service media_content + finance legal health education travel automotive real_estate numeric other ].freeze class << self - def categories = CATEGORIES + def categories + db_codes.presence || FALLBACK_CATEGORIES + end + + # Locale-aware label for a category code. Falls back to the I18n + # translation, then to the raw code, so it never returns blank. + def label_for(code) + key = code.to_s + labels_by_code[key] || + I18n.t("recommendation_profiles.categories.#{key}", default: key) + end + + private + + def db_codes + return [] unless table_available? + + InterestCategory.active.ordered.pluck(:code) + rescue StandardError + [] + end + + def labels_by_code + return {} unless table_available? + + InterestCategory.active.ordered.each_with_object({}) do |category, acc| + acc[category.code] = category.name + end + rescue StandardError + {} + end + + def table_available? + InterestCategory.table_exists? + rescue StandardError + false + end end end end diff --git a/app/views/admin/interest_categories/_form.html.erb b/app/views/admin/interest_categories/_form.html.erb new file mode 100644 index 000000000..4518c2c3b --- /dev/null +++ b/app/views/admin/interest_categories/_form.html.erb @@ -0,0 +1,41 @@ +<%= form_with model: interest_category, url: url do |f| %> + <% if interest_category.errors.any? %> +
+
<%= t(:errors_name) %>
+
    + <% interest_category.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= f.label :code, t('interest_categories.code'), style: 'width: 150px;' %> + <%= f.text_field :code, class: "form-control" %> +
+
+ <%= f.label :name_en, t('interest_categories.name_en'), style: 'width: 150px;' %> + <%= f.text_field :name_en, class: "form-control" %> +
+
+ <%= f.label :name_et, t('interest_categories.name_et'), style: 'width: 150px;' %> + <%= f.text_field :name_et, class: "form-control" %> +
+
+ <%= f.label :position, t('interest_categories.position'), style: 'width: 150px;' %> + <%= f.number_field :position, class: "form-control" %> +
+
+ <%= f.label :active, t('interest_categories.active'), style: 'width: 150px;' %> + <%= f.check_box :active %> +
+ +
+ <%= f.button t(:submit), class: "c-btn c-btn--blue", data: { turbo: false } %> +
+ +
+ <%= link_to t(:back), admin_interest_categories_path, class: "c-btn c-btn--green" %> +
+<% end %> diff --git a/app/views/admin/interest_categories/edit.html.erb b/app/views/admin/interest_categories/edit.html.erb new file mode 100644 index 000000000..b03aa06aa --- /dev/null +++ b/app/views/admin/interest_categories/edit.html.erb @@ -0,0 +1,5 @@ +<% content_for :title, t('.title') %> + +
+ <%= render 'form', interest_category: @interest_category, url: admin_interest_category_path(@interest_category) %> +
diff --git a/app/views/admin/interest_categories/index.html.erb b/app/views/admin/interest_categories/index.html.erb new file mode 100644 index 000000000..eead93d2d --- /dev/null +++ b/app/views/admin/interest_categories/index.html.erb @@ -0,0 +1,35 @@ +<% content_for :title, t('.title') %> + +
+
+ <%= link_to t('.new'), new_admin_interest_category_path, class: "c-btn c-btn--blue" %> +
+ +
+ <% header_collection = [{ column: nil, caption: t('interest_categories.position'), options: {} }, + { column: nil, caption: t('interest_categories.code'), options: {} }, + { column: nil, caption: t('interest_categories.name_en'), options: {} }, + { column: nil, caption: t('interest_categories.name_et'), options: {} }, + { column: nil, caption: t('interest_categories.active'), options: {} }, + { column: nil, caption: '', options: {} }] %> + <%= component 'common/table', header_collection:, options: { class: 'js-table-dt dataTable no-footer' } do %> + <%= tag.tbody class: 'contents' do %> + <% @interest_categories.each do |category| %> + + <%= category.position %> + <%= category.code %> + <%= category.name_en %> + <%= category.name_et %> + <%= category.active? ? t('interest_categories.yes') : t('interest_categories.no') %> + + <%= link_to t(:edit), edit_admin_interest_category_path(category), class: "c-btn c-btn--green" %> + <%= link_to t(:delete), admin_interest_category_path(category), + data: { turbo_method: :delete, turbo_confirm: t(:are_you_sure) }, + class: "c-btn c-btn--red" %> + + + <% end %> + <% end %> + <% end %> +
+
diff --git a/app/views/admin/interest_categories/new.html.erb b/app/views/admin/interest_categories/new.html.erb new file mode 100644 index 000000000..3918f402f --- /dev/null +++ b/app/views/admin/interest_categories/new.html.erb @@ -0,0 +1,5 @@ +<% content_for :title, t('.title') %> + +
+ <%= render 'form', interest_category: @interest_category, url: admin_interest_categories_path %> +
diff --git a/app/views/recommendation_profiles/_fields.html.erb b/app/views/recommendation_profiles/_fields.html.erb index cf7bc168b..b8ad8a3c7 100644 --- a/app/views/recommendation_profiles/_fields.html.erb +++ b/app/views/recommendation_profiles/_fields.html.erb @@ -10,7 +10,7 @@
<% row.each do |category| %>