+ <%= 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..03ec14e97
--- /dev/null
+++ b/app/views/admin/interest_categories/edit.html.erb
@@ -0,0 +1,6 @@
+<% content_for :title, t('.title') %>
+
+
+ <%= render 'form', interest_category: @interest_category, url: admin_interest_category_path(@interest_category) %>
+ <%= render 'enrichment', interest_category: @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..7416abdb6
--- /dev/null
+++ b/app/views/admin/interest_categories/index.html.erb
@@ -0,0 +1,50 @@
+<% content_for :title, t('.title') %>
+
+
+ <%= render 'form', interest_category: @interest_category, url: admin_interest_categories_path %>
+
diff --git a/app/views/auctions/_auction.html.erb b/app/views/auctions/_auction.html.erb
index 573459eab..cc18e8969 100644
--- a/app/views/auctions/_auction.html.erb
+++ b/app/views/auctions/_auction.html.erb
@@ -1,11 +1,35 @@
-
+ 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 %>
-
- <%= component 'common/auction_type_icon', auction: auction %>
+
+ <%= auction.domain_name %>
+
+
+ <% if auction_tags_display_enabled? %>
+
+ <% tags = auction_display_tags(auction, local_assigns.fetch(:classifications, nil)) %>
+ <% if tags.any? %>
+
+ <% tags.each do |tag| %>
+ <%= tag %>
+ <% end %>
+
+ <% else %>
+ —
+ <% end %>
+
+ <% else %>
+ <%= component 'common/auction_type_icon', auction: auction %>
+ <% end %>
<%= auction.ends_at&.strftime('%d/%m/%Y %H:%M') %>
@@ -16,7 +40,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 f9d60598d..1fef2d39a 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 %>
+ <% type_or_tags_header = auction_tags_display_enabled? ?
+ { column: nil, caption: t('auctions.tags'), options: {} } :
+ { column: 'platform', caption: t('auctions.auction_type'), options: { class: "sorting" } } %>
<% header_collection = [{ column: 'domain_name', caption: t('auctions.domain_name'), options: { class: "sorting", style: "width: 25% !important;" } },
- { column: 'platform', caption: t('auctions.auction_type'), options: { class: "sorting" } },
+ type_or_tags_header,
{ column: 'ends_at', caption: t('auctions.ends_at'), options: { class: "sorting" } },
{ column: 'users_price', caption: t('auctions.current_price'), options: { class: "sorting" } },
# { column: 'username', caption: t('auctions.offer_owner'), options: { class: "sorting" } }, # temporary turn of because at the moment we not provde english auction
@@ -53,7 +57,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,
+ classifications: @auction_classifications
+ } %>
<% end %>
<% end %>
<% end %>
diff --git a/app/views/recommendation_profiles/_fields.html.erb b/app/views/recommendation_profiles/_fields.html.erb
new file mode 100644
index 000000000..d28a3735a
--- /dev/null
+++ b/app/views/recommendation_profiles/_fields.html.erb
@@ -0,0 +1,54 @@
+
+
<%= t('recommendation_profiles.form.title') %>
+
<%= t('recommendation_profiles.form.subtitle') %>
+
+
+
+
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 @@
+
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') %>
+
+
+
+ <%= form_with model: @recommendation_profile, url: recommendation_profile_path, method: :put, class: "o-grid o-grid--two-col o-grid--sep" do |f| %>
+ <%= hidden_field_tag :return_to, params[:return_to] %>
+ <%= render 'fields', form: f %>
+
+
+
+
<%= t('.why_it_helps_title') %>
+
<%= t('.why_it_helps_body') %>
+
+ <% if @recommendation_profile.summary_lines.any? %>
+
+ <% @recommendation_profile.summary_lines.each do |line| %>
+
<%= line %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= component 'common/links/link_button', link_title: t(:cancel), href: params[:return_to].presence || user_path(current_user.uuid), color: 'ghost' %>
+ <%= component 'common/form/form_button', form: f, btn_title: t('.submit') %>
+
+ <% end %>
+
+
diff --git a/app/views/users/_sign_up_form.html.erb b/app/views/users/_sign_up_form.html.erb
index 30cfc2bc9..2f97e7730 100644
--- a/app/views/users/_sign_up_form.html.erb
+++ b/app/views/users/_sign_up_form.html.erb
@@ -76,5 +76,12 @@
<%= component 'common/form/checkboxes/checkbox_with_label', label_title: t('.daily_summary'), form: f, attribute: :daily_summary %>
+
+ <% if RecommendationProfile.selection_enabled? %>
+ <%= f.fields_for :recommendation_profile do |recommendation_form| %>
+ <%= render 'recommendation_profiles/fields', form: recommendation_form %>
+ <% end %>
+ <% end %>
+
<%= t('users.form.sign_up') %>
diff --git a/app/views/users/_user_info.html.erb b/app/views/users/_user_info.html.erb
index 4cab097a3..7665f4c77 100644
--- a/app/views/users/_user_info.html.erb
+++ b/app/views/users/_user_info.html.erb
@@ -76,6 +76,27 @@
<%= t('users.terms_and_conditions_link') %>
+
+ <% if RecommendationProfile.selection_enabled? %>
+
+
<%= t('recommendation_profiles.profile.summary_title') %>
+
+ <% if @user.recommendation_profile&.summary_lines&.any? %>
+ <% @user.recommendation_profile.summary_lines.each do |line| %>
+
<%= line %>
+ <% end %>
+ <% else %>
+
<%= t('recommendation_profiles.profile.empty_state') %>
+ <% 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' } %>
+
+ <% end %>
+
<%= 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/customization.yml.sample b/config/customization.yml.sample
index 89192daff..d2b6ad428 100644
--- a/config/customization.yml.sample
+++ b/config/customization.yml.sample
@@ -21,6 +21,9 @@ default: &default
mobile_sms_sent_time_limit_in_minutes: 1
auction_filter_available: false
ended_auctions_link_available: false
+ # Experimental: on the public auction list, replace the "auction type" column
+ # with the domain's LLM-derived tags. Off (or absent) => auction-type icon.
+ auction_tags_display_enabled: false
mailer:
# Host to which links from emails should redirect to
diff --git a/config/locales/admin/auctions_classification.en.yml b/config/locales/admin/auctions_classification.en.yml
new file mode 100644
index 000000000..324cfcc1b
--- /dev/null
+++ b/config/locales/admin/auctions_classification.en.yml
@@ -0,0 +1,21 @@
+en:
+ admin:
+ auctions:
+ classification:
+ heading: "AI classification (LLM)"
+ none: "Not classified"
+ category: "Category"
+ description: "Description"
+ tags: "Tags"
+ keywords: "Keywords"
+ audience: "Audience"
+ languages: "Languages"
+ use_cases: "Use cases"
+ brandability: "Brandability"
+ confidence: "Confidence"
+ source: "Source"
+ model: "Model"
+ classified_at: "Classified at"
+ embedding: "Embedding"
+ embedded_yes: "Vector ready"
+ embedded_no: "No vector"
diff --git a/config/locales/admin/auctions_classification.et.yml b/config/locales/admin/auctions_classification.et.yml
new file mode 100644
index 000000000..83c965881
--- /dev/null
+++ b/config/locales/admin/auctions_classification.et.yml
@@ -0,0 +1,21 @@
+et:
+ admin:
+ auctions:
+ classification:
+ heading: "AI klassifikatsioon (LLM)"
+ none: "Klassifitseerimata"
+ category: "Kategooria"
+ description: "Kirjeldus"
+ tags: "Sildid"
+ keywords: "Märksõnad"
+ audience: "Sihtrühm"
+ languages: "Keeled"
+ use_cases: "Kasutusjuhud"
+ brandability: "Brändituvus"
+ confidence: "Kindlus"
+ source: "Allikas"
+ model: "Mudel"
+ classified_at: "Klassifitseeritud"
+ embedding: "Vektor"
+ embedded_yes: "Vektor olemas"
+ embedded_no: "Vektorit pole"
diff --git a/config/locales/auctions.en.yml b/config/locales/auctions.en.yml
index cf74009c7..d07c6fd1e 100644
--- a/config/locales/auctions.en.yml
+++ b/config/locales/auctions.en.yml
@@ -8,6 +8,7 @@ en:
winning_bid: 'Winning bid'
highest_price: "Highest price"
auction_type: "Auction type"
+ tags: "Tags"
english: "English"
blind: "Blind"
offers: "Offers"
diff --git a/config/locales/auctions.et.yml b/config/locales/auctions.et.yml
index 30f5e3863..4990ee344 100644
--- a/config/locales/auctions.et.yml
+++ b/config/locales/auctions.et.yml
@@ -15,6 +15,7 @@ et:
winning_bid: "Võidupakkumus"
highest_price: "Kõrgeim hind"
auction_type: "Oksjoni tüüp"
+ tags: "Sildid"
english: "Inglise"
blind: "Pime"
offers: "Pakkumused"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 5adebda42..8344ea560 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -48,6 +48,7 @@ en:
auctions_name: "Auctions"
billing_profiles_name: "Billing Profiles"
settings_name: "Settings"
+ interest_categories_name: "Interest categories"
users_name: "Users"
results_name: "Results"
finished_auctions: "Ended auctions"
diff --git a/config/locales/et.yml b/config/locales/et.yml
index 222211a92..2e1741559 100644
--- a/config/locales/et.yml
+++ b/config/locales/et.yml
@@ -47,6 +47,7 @@ et:
auctions_name: "Oksjonid"
billing_profiles_name: "Arve aadressid"
settings_name: "Seadistus"
+ interest_categories_name: "Huvi kategooriad"
users_name: "Kasutajad"
results_name: "Tulemused"
finished_auctions: "Lõppenud oksjonite arved"
diff --git a/config/locales/interest_categories.en.yml b/config/locales/interest_categories.en.yml
new file mode 100644
index 000000000..afc862e7e
--- /dev/null
+++ b/config/locales/interest_categories.en.yml
@@ -0,0 +1,29 @@
+en:
+ interest_categories:
+ code: "Code"
+ name_en: "Name (English)"
+ name_et: "Name (Estonian)"
+ position: "Position"
+ active: "Active"
+ yes: "Yes"
+ no: "No"
+ enrichment_status: "AI status"
+ enriched: "Enriched"
+ pending: "Pending"
+ enrichment_heading: "AI enrichment (LLM)"
+ description: "Description"
+ keywords: "Keywords"
+ embedding: "Embedding"
+ embedded_at: "Enriched at"
+ model: "Model"
+ not_enriched: "Not enriched yet. Run the recommendation pipeline (rake recommendation:init) or the EnrichInterestCategoriesJob to generate the description, keywords and embedding."
+ admin:
+ interest_categories:
+ index:
+ title: "Interest categories"
+ new: "New category"
+ rebuild_notice: "After adding, renaming, or deleting categories, run the Recommendation::RebuildRecommendationsJob job on the Jobs page to re-classify domains and refresh recommendations under the new categories."
+ new:
+ title: "New interest category"
+ edit:
+ title: "Edit interest category"
diff --git a/config/locales/interest_categories.et.yml b/config/locales/interest_categories.et.yml
new file mode 100644
index 000000000..170c7529f
--- /dev/null
+++ b/config/locales/interest_categories.et.yml
@@ -0,0 +1,29 @@
+et:
+ interest_categories:
+ code: "Kood"
+ name_en: "Nimi (inglise)"
+ name_et: "Nimi (eesti)"
+ position: "Järjekord"
+ active: "Aktiivne"
+ yes: "Jah"
+ no: "Ei"
+ enrichment_status: "AI staatus"
+ enriched: "Rikastatud"
+ pending: "Ootel"
+ enrichment_heading: "AI rikastamine (LLM)"
+ description: "Kirjeldus"
+ keywords: "Märksõnad"
+ embedding: "Vektor"
+ embedded_at: "Rikastatud"
+ model: "Mudel"
+ not_enriched: "Veel rikastamata. Käivita soovituste konveier (rake recommendation:init) või EnrichInterestCategoriesJob, et luua kirjeldus, märksõnad ja vektor."
+ admin:
+ interest_categories:
+ index:
+ title: "Huvi kategooriad"
+ new: "Uus kategooria"
+ rebuild_notice: "Pärast kategooriate lisamist, ümbernimetamist või kustutamist käivita Tööde lehel töö Recommendation::RebuildRecommendationsJob, et domeenid uute kategooriate järgi ümber klassifitseerida ja soovitused värskendada."
+ new:
+ title: "Uus huvi kategooria"
+ edit:
+ title: "Muuda huvi kategooriat"
diff --git a/config/locales/recommendation_profiles.en.yml b/config/locales/recommendation_profiles.en.yml
new file mode 100644
index 000000000..738a0c18f
--- /dev/null
+++ b/config/locales/recommendation_profiles.en.yml
@@ -0,0 +1,49 @@
+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: "Add your own keywords"
+ other_interests_placeholder: "Type an interest and press Enter"
+ other_interests_hint: "Add any interests that are not in the list above."
+ 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"
+ custom_interests: "Custom interests"
+ length: "Preferred length"
+ 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..23bb764ac
--- /dev/null
+++ b/config/locales/recommendation_profiles.et.yml
@@ -0,0 +1,49 @@
+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: "Lisa oma märksõnad"
+ other_interests_placeholder: "Sisesta huvi ja vajuta Enter"
+ other_interests_hint: "Lisa huvid, mida ülal olevas 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"
+ custom_interests: "Kohandatud huvid"
+ length: "Eelistatud pikkus"
+ 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..46fb72e2e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -69,6 +69,7 @@
resources :results, only: %i[index create show], concerns: %i[auditable]
resources :settings, except: %i[create destroy], concerns: [:auditable]
+ resources :interest_categories
resources :users, concerns: %i[auditable]
end
@@ -145,6 +146,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_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/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/migrate/20260527090000_create_domain_classifications.rb b/db/migrate/20260527090000_create_domain_classifications.rb
new file mode 100644
index 000000000..a047ccdc0
--- /dev/null
+++ b/db/migrate/20260527090000_create_domain_classifications.rb
@@ -0,0 +1,47 @@
+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
+
+ t.string :primary_category
+ t.string :tags, array: true, default: [], null: false
+ t.string :keywords, array: true, default: [], null: false
+
+ t.string :audience
+ t.string :languages, array: true, default: [], null: false
+ t.string :suggested_use_cases, array: true, default: [], null: false
+
+ t.decimal :brandability_score, precision: 4, scale: 3
+
+ 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
+
+ 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
+ add_index :domain_classifications, :embedded_at
+ end
+end
diff --git a/db/migrate/20260601100000_create_interest_categories.rb b/db/migrate/20260601100000_create_interest_categories.rb
new file mode 100644
index 000000000..9c25e36ab
--- /dev/null
+++ b/db/migrate/20260601100000_create_interest_categories.rb
@@ -0,0 +1,18 @@
+class CreateInterestCategories < ActiveRecord::Migration[7.0]
+ def change
+ create_table :interest_categories do |t|
+ t.uuid :uuid, default: 'gen_random_uuid()', null: false
+ t.string :code, null: false
+ t.string :name_en, null: false
+ t.string :name_et, null: false
+ t.integer :position, default: 0, null: false
+ t.boolean :active, default: true, null: false
+
+ t.timestamps
+ end
+
+ add_index :interest_categories, :code, unique: true
+ add_index :interest_categories, :uuid, unique: true
+ add_index :interest_categories, %i[active position]
+ end
+end
diff --git a/db/migrate/20260601100100_seed_interest_categories_and_setting.rb b/db/migrate/20260601100100_seed_interest_categories_and_setting.rb
new file mode 100644
index 000000000..95c26f7ca
--- /dev/null
+++ b/db/migrate/20260601100100_seed_interest_categories_and_setting.rb
@@ -0,0 +1,23 @@
+class SeedInterestCategoriesAndSetting < ActiveRecord::Migration[7.0]
+ # Data migration so existing environments get the categories + toggle on
+ # deploy (db:migrate). Fresh setups get the same via db/seeds.rb. Both are
+ # idempotent. Schema-load (structure.sql) skips this — that path runs seeds.
+ def up
+ unless Setting.exists?(code: 'recommendation_interests_enabled')
+ Setting.create(
+ code: 'recommendation_interests_enabled',
+ value: 'false',
+ value_format: 'boolean',
+ description: "When 'true', users see the auction-interests selection " \
+ "(prompt + form). Keep 'false' until interest categories are populated in admin."
+ )
+ end
+
+ InterestCategory.seed_defaults!
+ end
+
+ def down
+ Setting.where(code: 'recommendation_interests_enabled').delete_all
+ InterestCategory.where(code: InterestCategory::DEFAULTS.map { |c| c[:code] }).delete_all
+ end
+end
diff --git a/db/migrate/20260708120000_add_lower_domain_name_index_to_domain_classifications.rb b/db/migrate/20260708120000_add_lower_domain_name_index_to_domain_classifications.rb
new file mode 100644
index 000000000..fc82b0416
--- /dev/null
+++ b/db/migrate/20260708120000_add_lower_domain_name_index_to_domain_classifications.rb
@@ -0,0 +1,10 @@
+class AddLowerDomainNameIndexToDomainClassifications < ActiveRecord::Migration[8.1]
+ # The /auctions user-sorting join matches on LOWER(domain_classifications.domain_name)
+ # (see Auction::UserSortable). The plain btree on domain_name can't serve a
+ # LOWER() predicate, so every logged-in /auctions load seq-scanned this table.
+ # A functional index on LOWER(domain_name) restores index usage on that join.
+ def change
+ add_index :domain_classifications, 'LOWER(domain_name)',
+ name: 'index_domain_classifications_on_lower_domain_name'
+ end
+end
diff --git a/db/migrate/20260708120100_add_user_id_index_to_wishlist_items.rb b/db/migrate/20260708120100_add_user_id_index_to_wishlist_items.rb
new file mode 100644
index 000000000..254c1e9ef
--- /dev/null
+++ b/db/migrate/20260708120100_add_user_id_index_to_wishlist_items.rb
@@ -0,0 +1,8 @@
+class AddUserIdIndexToWishlistItems < ActiveRecord::Migration[8.1]
+ # wishlist_items(user_id) was unindexed. The recommendation hot paths query a
+ # user's wishlist on every /auctions load (Auction::UserSortable) and during
+ # per-user scoring (Recommendation::Scorer), so index the lookup column.
+ def change
+ add_index :wishlist_items, :user_id
+ end
+end
diff --git a/db/migrate/20260709100000_add_embedding_fields_to_interest_categories.rb b/db/migrate/20260709100000_add_embedding_fields_to_interest_categories.rb
new file mode 100644
index 000000000..ba89ad51c
--- /dev/null
+++ b/db/migrate/20260709100000_add_embedding_fields_to_interest_categories.rb
@@ -0,0 +1,16 @@
+class AddEmbeddingFieldsToInterestCategories < ActiveRecord::Migration[7.0]
+ # v3 unified embedding space: an interest category becomes "like a domain".
+ # We enrich it with an LLM description + keywords, then embed that text into
+ # the same 1536-dim space as domains so a selected category can act as a
+ # magnet the same way a bid/wishlist domain does.
+ # See docs/planning/recommendation-v3-unified-embedding-plan.md.
+ def change
+ add_column :interest_categories, :description, :text
+ add_column :interest_categories, :keywords, :string, array: true, default: [], null: false
+ add_column :interest_categories, :embedding, :float, array: true
+ add_column :interest_categories, :embedding_model, :string
+ add_column :interest_categories, :embedded_at, :datetime
+
+ add_index :interest_categories, :embedded_at
+ end
+end
diff --git a/db/migrate/20260709100100_add_custom_interest_vectors_to_recommendation_profiles.rb b/db/migrate/20260709100100_add_custom_interest_vectors_to_recommendation_profiles.rb
new file mode 100644
index 000000000..75a50b72a
--- /dev/null
+++ b/db/migrate/20260709100100_add_custom_interest_vectors_to_recommendation_profiles.rb
@@ -0,0 +1,12 @@
+class AddCustomInterestVectorsToRecommendationProfiles < ActiveRecord::Migration[7.0]
+ # v3 unified embedding space: each free-text custom interest is embedded into
+ # its own vector (a separate "magnet"), not string-matched via LIKE. Stored
+ # as jsonb [{ "text" => ..., "embedding" => [..1536..] }, ...] because a
+ # profile can hold several heterogeneous custom interests and averaging them
+ # into one vector would blur them.
+ # See docs/planning/recommendation-v3-unified-embedding-plan.md.
+ def change
+ add_column :recommendation_profiles, :custom_interest_vectors, :jsonb, default: [], null: false
+ add_column :recommendation_profiles, :custom_interests_embedded_at, :datetime
+ end
+end
diff --git a/db/migrate/20260710100000_add_embedding_enrichment_to_domain_classifications.rb b/db/migrate/20260710100000_add_embedding_enrichment_to_domain_classifications.rb
new file mode 100644
index 000000000..972e517bc
--- /dev/null
+++ b/db/migrate/20260710100000_add_embedding_enrichment_to_domain_classifications.rb
@@ -0,0 +1,15 @@
+class AddEmbeddingEnrichmentToDomainClassifications < ActiveRecord::Migration[7.0]
+ # v3 embedding enrichment (see docs/technical/recommendation-embedding-enrichment.md):
+ #
+ # - description: a 1-2 sentence LLM summary of what the domain is for. Feeds the
+ # embedding input so semantically-similar domains cluster (previously the
+ # input was only ". ", a deliberately sparse signal —
+ # ADR-001 — that under-served interest/wishlist matching).
+ # - embedding_input_version: which DomainEmbedder input format produced the
+ # stored vector. Lets DomainClassification.needs_embedding re-embed rows built
+ # under an older format without a full LLM re-classification.
+ def change
+ add_column :domain_classifications, :description, :text
+ add_column :domain_classifications, :embedding_input_version, :integer
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index 775286d8d..522ae53b6 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,
@@ -540,3 +540,18 @@
description: contact_organization_email_description,
value_format: 'string')
contact_organization_email_setting.save
+
+# Recommendation interests: admin toggle that controls whether the
+# interest-selection UI is shown to users. Default OFF so a fresh deploy
+# never shows an empty picker; turn it on once categories are filled.
+recommendation_interests_enabled_description = <<~TEXT.squish
+ When 'true', users see the auction-interests selection (prompt + form).
+ Keep 'false' until interest categories are populated in admin.
+TEXT
+Setting.new(code: :recommendation_interests_enabled,
+ value: 'false',
+ description: recommendation_interests_enabled_description,
+ value_format: 'boolean').save
+
+# Recommendation interest categories (idempotent — see InterestCategory.seed_defaults!).
+InterestCategory.seed_defaults!
diff --git a/db/structure.sql b/db/structure.sql
index 50070abff..aca63b83d 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))
);
@@ -1177,6 +1182,59 @@ 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,
+ 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,
+ embedding double precision[],
+ embedding_model character varying,
+ embedded_at timestamp(6) without time zone,
+ description text,
+ embedding_input_version integer
+);
+
+
+--
+-- 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: -
--
@@ -1212,6 +1270,47 @@ CREATE SEQUENCE public.domain_participate_auctions_id_seq
ALTER SEQUENCE public.domain_participate_auctions_id_seq OWNED BY public.domain_participate_auctions.id;
+--
+-- Name: interest_categories; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.interest_categories (
+ id bigint NOT NULL,
+ uuid uuid DEFAULT gen_random_uuid() NOT NULL,
+ code character varying NOT NULL,
+ name_en character varying NOT NULL,
+ name_et character varying NOT NULL,
+ "position" integer DEFAULT 0 NOT NULL,
+ active boolean DEFAULT true NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL,
+ description text,
+ keywords character varying[] DEFAULT '{}'::character varying[] NOT NULL,
+ embedding double precision[],
+ embedding_model character varying,
+ embedded_at timestamp(6) without time zone
+);
+
+
+--
+-- Name: interest_categories_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.interest_categories_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: interest_categories_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.interest_categories_id_seq OWNED BY public.interest_categories.id;
+
+
--
-- Name: invoice_items; Type: TABLE; Schema: public; Owner: -
--
@@ -1470,6 +1569,92 @@ 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,
+ custom_interest_vectors jsonb DEFAULT '[]'::jsonb NOT NULL,
+ custom_interests_embedded_at timestamp(6) without time zone
+);
+
+
+--
+-- 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 +1784,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,
+ 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,
+ 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: -
--
@@ -1866,6 +2088,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: -
--
@@ -1873,6 +2102,13 @@ ALTER TABLE ONLY public.directo_customers ALTER COLUMN id SET DEFAULT nextval('p
ALTER TABLE ONLY public.domain_participate_auctions ALTER COLUMN id SET DEFAULT nextval('public.domain_participate_auctions_id_seq'::regclass);
+--
+-- Name: interest_categories id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.interest_categories ALTER COLUMN id SET DEFAULT nextval('public.interest_categories_id_seq'::regclass);
+
+
--
-- Name: invoice_items id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -1922,6 +2158,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 +2193,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: -
--
@@ -2196,6 +2453,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: -
--
@@ -2204,6 +2469,14 @@ ALTER TABLE ONLY public.domain_participate_auctions
ADD CONSTRAINT domain_participate_auctions_pkey PRIMARY KEY (id);
+--
+-- Name: interest_categories interest_categories_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.interest_categories
+ ADD CONSTRAINT interest_categories_pkey PRIMARY KEY (id);
+
+
--
-- Name: invoice_items invoice_items_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -2252,6 +2525,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 +2573,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 +2780,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 +2801,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: -
--
@@ -2581,6 +2899,76 @@ 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_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: -
+--
+
+CREATE INDEX index_domain_classifications_on_keywords ON public.domain_classifications USING gin (keywords);
+
+
+--
+-- Name: index_domain_classifications_on_lower_domain_name; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_domain_classifications_on_lower_domain_name ON public.domain_classifications USING btree (lower((domain_name)::text));
+
+
+--
+-- 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: -
--
@@ -2595,6 +2983,34 @@ CREATE INDEX index_domain_participate_auctions_on_auction_id ON public.domain_pa
CREATE INDEX index_domain_participate_auctions_on_user_id ON public.domain_participate_auctions USING btree (user_id);
+--
+-- Name: index_interest_categories_on_active_and_position; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_interest_categories_on_active_and_position ON public.interest_categories USING btree (active, "position");
+
+
+--
+-- Name: index_interest_categories_on_code; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_interest_categories_on_code ON public.interest_categories USING btree (code);
+
+
+--
+-- Name: index_interest_categories_on_embedded_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_interest_categories_on_embedded_at ON public.interest_categories USING btree (embedded_at);
+
+
+--
+-- Name: index_interest_categories_on_uuid; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_interest_categories_on_uuid ON public.interest_categories USING btree (uuid);
+
+
--
-- Name: index_invoice_items_on_invoice_id; Type: INDEX; Schema: public; Owner: -
--
@@ -2714,6 +3130,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 +3270,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: -
--
@@ -2833,6 +3361,13 @@ CREATE INDEX index_webpush_subscriptions_on_user_id ON public.webpush_subscripti
CREATE INDEX index_wishlist_items_on_domain_name ON public.wishlist_items USING btree (domain_name);
+--
+-- Name: index_wishlist_items_on_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_wishlist_items_on_user_id ON public.wishlist_items USING btree (user_id);
+
+
--
-- Name: users_by_identity_code_and_country; Type: INDEX; Schema: public; Owner: -
--
@@ -2924,6 +3459,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 +3475,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 +3555,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 +3587,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 +3626,22 @@ ALTER TABLE ONLY public.invoices
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
+('20260710100000'),
+('20260709100100'),
+('20260709100000'),
+('20260708120100'),
+('20260708120000'),
+('20260601100100'),
+('20260601100000'),
+('20260528100000'),
+('20260527090300'),
+('20260527090200'),
+('20260527090100'),
+('20260527090000'),
+('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..a398c12c5
--- /dev/null
+++ b/lib/tasks/demo_auctions.rake
@@ -0,0 +1,256 @@
+namespace :demo do
+ # Domain seed list grouped by intended category. After `rake demo:create_blind_auctions`
+ # 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[
+ 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
+
+ # `+ 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 + AUCTION_START_BUFFER
+ ends_at = starts_at + 1.month
+
+ domains = DEMO_DOMAINS.values.flatten.uniq
+
+ 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."
+ 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
+ starts_at = Time.zone.now + AUCTION_START_BUFFER
+ horizons = {
+ 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
+
+ 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|
+ next if Auction.where(domain_name: domain_name).where('ends_at > ?', Time.zone.now).exists?
+
+ Auction.create!(
+ domain_name: domain_name,
+ starts_at: starts_at,
+ ends_at: starts_at + opts[:ends_at_offset]
+ )
+ created += 1
+ end
+ end
+
+ puts "Created #{created} varied-horizon auctions."
+ end
+
+ desc 'Create demo auctions that are ACTIVE immediately (for staging pipeline bootstrap)'
+ task create_active_auctions: :environment do
+ # Unlike create_blind_auctions (which starts 5 min in the future), these
+ # start in the past so Auction.active includes them right away — otherwise
+ # the recommendation pipeline (classify / ai_score / scorer all scope to
+ # Auction.active) would run against an empty set. skip_validation bypasses
+ # starts_at_cannot_be_in_the_past; safe because this is demo data only.
+ starts_at = Time.zone.now - 1.minute
+ ends_at = starts_at + 1.month
+
+ domains = DEMO_DOMAINS.values.flatten.uniq
+
+ 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,
+ skip_validation: true
+ )
+
+ created += 1
+ end
+
+ puts "Created #{created} active demo auctions, skipped #{skipped} existing active 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_limit = Setting.find_by(code: 'wishlist_size').retrieve
+ wishlist_picks = %w[cloudstack.ee fintechlab.ee marketflow.ee growthhub.ee]
+ wishlist_added = []
+ wishlist_picks.each do |domain|
+ next if user.wishlist_items.exists?(domain_name: domain)
+
+ if user.wishlist_items.count >= wishlist_limit
+ puts " wishlist full (limit #{wishlist_limit}), skipping: #{(wishlist_picks - wishlist_added).inspect}"
+ break
+ end
+
+ item = WishlistItem.new(user: user, domain_name: domain, cents: 5000)
+ if item.save
+ wishlist_added << domain
+ else
+ puts " skipped wishlist #{domain}: #{item.errors.full_messages.join(', ')}"
+ end
+ end
+
+ bid_picks = %w[
+ apteek.ee kohvik.ee jurist.ee reisid.ee
+ ]
+ bids_added = []
+ 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 = Offer.new(user: user, auction: auction, cents: 1500, billing_profile_id: 0)
+ if offer.save
+ bids_added << domain
+ else
+ puts " skipped bid #{domain}: #{offer.errors.full_messages.join(', ')}"
+ end
+ 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 added: #{wishlist_added.inspect}"
+ puts " bids added: #{bids_added.inspect}"
+ puts
+ puts 'Score refresh enqueued. After the delayed_job runs (~30s), visit /auctions while signed in as this user.'
+ end
+end
diff --git a/lib/tasks/recommendation.rake b/lib/tasks/recommendation.rake
new file mode 100644
index 000000000..f135c020e
--- /dev/null
+++ b/lib/tasks/recommendation.rake
@@ -0,0 +1,46 @@
+namespace :recommendation do
+ # The single prod entry point. Incremental (force: false): enriches interest
+ # categories, backfills users' custom-interest vectors, then drains the batched
+ # classify + embed jobs (picking up any domain that is unclassified, stale, or
+ # built under an older embedding-input format), refreshes ai_score, and
+ # recomputes every participant's personal scores. Already-done work is skipped.
+ #
+ # Run on every deploy. This is also the cron catch-up: it drains the same
+ # classify/embed batches a standalone cron would, so no separate cron task is
+ # needed for them.
+ desc 'PROD/CRON: run the whole recommendation pipeline (classify -> embed -> ai_score -> per-user scores)'
+ task init: :environment do
+ summary = Recommendation::PipelineRunner.run(force: false)
+ puts "Recommendation pipeline done: #{summary.inspect}"
+ end
+
+ # Retention for the append-only recommendation_events table (unrelated to the
+ # pipeline above; schedule daily). Deletes events >6 months old and
+ # auction_impression events >1 month old, in batches.
+ desc 'CRON: prune old recommendation_events (6mo; impressions 1mo)'
+ task prune_events: :environment do
+ deleted = Recommendation::PruneRecommendationEventsJob.perform_now
+ puts "Pruned #{deleted} recommendation event(s)."
+ end
+
+ # One-shot historical seed: classify every domain we have ever seen (active +
+ # ended auctions, wishlist, offer histories, results) so past-bid domains get
+ # embeddings and can form magnets. init only classifies active auctions +
+ # wishlist, so run this once when first enabling the feature.
+ desc 'One-shot: classify all historical domains via LLM (wider universe than init)'
+ task backfill: :environment do
+ if defined?(Recommendation::BackfillDomainClassificationsJob)
+ Recommendation::BackfillDomainClassificationsJob.perform_now
+ else
+ puts 'BackfillDomainClassificationsJob is not yet available. Skipping.'
+ end
+ end
+
+ desc 'TEST/STAGING: create active mock auctions + signals, then run the pipeline'
+ task init_demo: :environment do
+ Rake::Task['demo:create_active_auctions'].invoke
+ Rake::Task['demo:seed_user_signals'].invoke
+ summary = Recommendation::PipelineRunner.run(force: false)
+ puts "Recommendation pipeline (demo) done: #{summary.inspect}"
+ end
+end
diff --git a/test/controllers/auctions_controller_test.rb b/test/controllers/auctions_controller_test.rb
index c7bf0485b..63d0e525c 100644
--- a/test/controllers/auctions_controller_test.rb
+++ b/test/controllers/auctions_controller_test.rb
@@ -93,6 +93,43 @@ 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
+
+ # v3: interest prioritisation no longer has its own string-matched sort tier.
+ # A selected category / custom interest becomes a magnet whose pull lands in
+ # user_auction_scores.score (which requires embeddings, exercised in
+ # Recommendation::MagnetScorer / Scorer unit tests and the demo comparison).
+ # At the controller level, ordering is driven by that score — see
+ # test_recommendation_scores_are_used_before_global_ai_score_for_logged_in_users.
+
def test_sorting_with_pagination_keeps_user_auctions_prioritized
sign_in @participant
@@ -126,9 +163,47 @@ def test_json_api_returns_auctions_with_user_priority
def test_admin_user_does_not_get_priority_sorting
@admin = users(:administrator)
sign_in @admin
-
+
get auctions_path, params: { admin: 'true' }
-
+
+ assert_response :success
+ end
+
+ def test_auction_type_column_shown_by_default
+ get auctions_path
+
assert_response :success
+ assert_includes response.body, I18n.t('auctions.auction_type')
+ assert_select 'td.auction-tags', false
+ end
+
+ def test_tags_column_replaces_auction_type_when_flag_enabled
+ DomainClassification.create!(
+ domain_name: @auction_without_offers.domain_name,
+ tags: %w[crypto defi],
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ confidence: 0.9,
+ classified_at: Time.current
+ )
+
+ with_tags_display(true) do
+ get auctions_path
+ end
+
+ assert_response :success
+ assert_includes response.body, I18n.t('auctions.tags')
+ assert_select 'td.auction-tags'
+ assert_select 'td.auction-tags span.c-badge', text: 'crypto'
+ end
+
+ private
+
+ def with_tags_display(enabled)
+ config = AuctionCenter::Application.config.customization
+ original = config[:auction_tags_display_enabled]
+ config[:auction_tags_display_enabled] = enabled
+ yield
+ ensure
+ config[:auction_tags_display_enabled] = original
end
end
\ No newline at end of file
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
diff --git a/test/controllers/recommendation_profiles_controller_test.rb b/test/controllers/recommendation_profiles_controller_test.rb
new file mode 100644
index 000000000..834a28921
--- /dev/null
+++ b/test/controllers/recommendation_profiles_controller_test.rb
@@ -0,0 +1,94 @@
+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_edit_form_renders_custom_interest_sentinel
+ @user.create_recommendation_profile!(interest_keywords: %w[other custom:crypto])
+
+ get edit_recommendation_profile_path
+
+ assert_response :success
+ # Without this sentinel, removing every custom-interest tag drops the param
+ # entirely and the old free-text interests can never be cleared.
+ assert_select "input[type=hidden][name='recommendation_profile[custom_interests][]'][value='']"
+ end
+
+ def test_clearing_all_custom_interests_removes_them
+ @user.create_recommendation_profile!(interest_keywords: %w[legal other custom:crypto])
+
+ # `other` is derived server-side, not a user checkbox, so the form no longer
+ # submits it — but a stray `other` (old cached page, API) with no custom
+ # interests must still be stripped, not linger as an empty category.
+ put recommendation_profile_path, params: {
+ recommendation_profile: {
+ interest_categories: %w[legal other],
+ custom_interests: ['']
+ }
+ }
+
+ @user.reload
+ assert_empty @user.recommendation_profile.custom_interests
+ # `other` must not linger as an empty category once its custom interests are gone.
+ assert_equal %w[legal], @user.recommendation_profile.interest_categories.sort
+ 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
+
+ def test_edit_redirects_when_selection_disabled
+ settings(:recommendation_interests_enabled).update!(value: 'false')
+
+ get edit_recommendation_profile_path
+
+ assert_redirected_to user_path(@user.uuid)
+ end
+
+ def test_update_blocked_when_selection_disabled
+ settings(:recommendation_interests_enabled).update!(value: 'false')
+
+ assert_no_difference -> { RecommendationProfile.count } do
+ put recommendation_profile_path, params: {
+ recommendation_profile: { interest_categories: %w[legal] }
+ }
+ end
+
+ assert_redirected_to user_path(@user.uuid)
+ end
+end
diff --git a/test/fixtures/settings.yml b/test/fixtures/settings.yml
index 2de99e062..c4dfd476d 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:
@@ -325,3 +325,11 @@ contact_organization_email:
value: 'info@internet.ee'
value_format: string
+recommendation_interests_enabled:
+ code: 'recommendation_interests_enabled'
+ description: |
+ When 'true', users see the auction-interests selection (prompt + form).
+ Keep 'false' until interest categories are populated in admin.
+ value: 'true'
+ value_format: boolean
+
diff --git a/test/integration/admin/auctions_controller_test.rb b/test/integration/admin/auctions_controller_test.rb
index 79c42ffa0..bec16ad4c 100644
--- a/test/integration/admin/auctions_controller_test.rb
+++ b/test/integration/admin/auctions_controller_test.rb
@@ -30,6 +30,29 @@ def test_show_renders_ok
assert_response :ok
end
+ def test_show_renders_llm_classification
+ auction = auctions(:valid_without_offers)
+ DomainClassification.create!(
+ domain_name: auction.domain_name,
+ primary_category: 'saas',
+ tags: %w[cloud analytics],
+ keywords: %w[dashboard metrics],
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ confidence: 0.91,
+ classified_at: Time.current
+ )
+
+ sign_in @administrator
+ User.stub(:search_deposit_participants, User.none) do
+ get admin_auction_path(auction.id)
+ end
+
+ assert_response :ok
+ assert_includes response.body, 'saas'
+ assert_includes response.body, 'cloud'
+ assert_includes response.body, I18n.t('admin.auctions.classification.heading')
+ end
+
def test_destroy_deletes_auction_when_not_started
auction = auctions(:english_nil_starts)
diff --git a/test/integration/admin/interest_categories_controller_test.rb b/test/integration/admin/interest_categories_controller_test.rb
new file mode 100644
index 000000000..adc5eee6e
--- /dev/null
+++ b/test/integration/admin/interest_categories_controller_test.rb
@@ -0,0 +1,117 @@
+require 'test_helper'
+
+class AdminInterestCategoriesControllerTest < ActionDispatch::IntegrationTest
+ include Devise::Test::IntegrationHelpers
+
+ def setup
+ @admin = users(:administrator)
+ @participant = users(:participant)
+ @category = InterestCategory.create!(code: 'saas', name_en: 'SaaS', name_et: 'SaaS', position: 1)
+ end
+
+ def test_index_renders_for_admin
+ sign_in @admin
+ get admin_interest_categories_path
+ assert_response :ok
+ assert_includes response.body, 'saas'
+ end
+
+ def test_new_renders_for_admin
+ sign_in @admin
+ get new_admin_interest_category_path
+ assert_response :ok
+ end
+
+ def test_create_adds_category
+ sign_in @admin
+
+ assert_difference -> { InterestCategory.count }, 1 do
+ post admin_interest_categories_path, params: {
+ interest_category: { code: 'Finance', name_en: 'Finance', name_et: 'Finants', position: 2, active: true }
+ }
+ end
+
+ assert_redirected_to admin_interest_categories_path
+ assert InterestCategory.exists?(code: 'finance'), 'code should be normalized to lowercase'
+ end
+
+ def test_update_edits_category
+ sign_in @admin
+
+ patch admin_interest_category_path(@category), params: {
+ interest_category: { name_en: 'Software', name_et: 'Tarkvara' }
+ }
+
+ assert_redirected_to admin_interest_categories_path
+ assert_equal 'Software', @category.reload.name_en
+ end
+
+ def test_destroy_removes_category
+ sign_in @admin
+
+ assert_difference -> { InterestCategory.count }, -1 do
+ delete admin_interest_category_path(@category)
+ end
+ end
+
+ def test_create_rejects_blank_names
+ sign_in @admin
+
+ assert_no_difference -> { InterestCategory.count } do
+ post admin_interest_categories_path, params: {
+ interest_category: { code: 'broken', name_en: '', name_et: '' }
+ }
+ end
+
+ assert_response :unprocessable_entity
+ end
+
+ def test_index_shows_enrichment_status
+ sign_in @admin
+ @category.update_columns(
+ description: 'SaaS and software tools',
+ keywords: %w[crm billing analytics],
+ embedding: Array.new(3, 0.1),
+ embedding_model: 'text-embedding-3-small',
+ embedded_at: Time.current
+ )
+
+ get admin_interest_categories_path
+
+ assert_response :ok
+ assert_includes response.body, I18n.t('interest_categories.enriched')
+ end
+
+ def test_edit_shows_llm_enrichment_results
+ sign_in @admin
+ @category.update_columns(
+ description: 'SaaS and software tools',
+ keywords: %w[crm billing analytics],
+ embedding: Array.new(3, 0.1),
+ embedding_model: 'text-embedding-3-small',
+ embedded_at: Time.current
+ )
+
+ get edit_admin_interest_category_path(@category)
+
+ assert_response :ok
+ assert_includes response.body, I18n.t('interest_categories.enrichment_heading')
+ assert_includes response.body, 'SaaS and software tools'
+ assert_includes response.body, 'crm'
+ end
+
+ def test_edit_shows_not_enriched_note_when_missing
+ sign_in @admin
+
+ get edit_admin_interest_category_path(@category)
+
+ assert_response :ok
+ assert_includes response.body, I18n.t('interest_categories.not_enriched')
+ end
+
+ def test_not_accessible_for_non_admin
+ sign_in @participant
+ get admin_interest_categories_path
+ assert_response :not_found
+ end
+end
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/jobs/recommendation/backfill_domain_classifications_job_test.rb b/test/jobs/recommendation/backfill_domain_classifications_job_test.rb
new file mode 100644
index 000000000..96c9f8d41
--- /dev/null
+++ b/test/jobs/recommendation/backfill_domain_classifications_job_test.rb
@@ -0,0 +1,135 @@
+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)
+
+ with_feature_flag(true) do
+ stub_llm do
+ Recommendation::BackfillDomainClassificationsJob.new.perform
+ end
+ 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
+ )
+
+ 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_upsert_resets_stale_embedding_on_conflict
+ skip 'embedding column not present' unless DomainClassification.column_names.include?('embedding')
+
+ DomainClassification.create!(
+ domain_name: 'reset-me.ee',
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ confidence: 0.9,
+ classified_at: 1.hour.ago,
+ embedding: [0.1, 0.2, 0.3],
+ embedding_model: 'text-embedding-3-small',
+ embedded_at: 1.hour.ago
+ )
+
+ job = Recommendation::BackfillDomainClassificationsJob.new
+ job.send(:upsert, [{
+ domain_name: 'reset-me.ee',
+ primary_category: 'other',
+ tags: ['other'],
+ keywords: [],
+ confidence: 0.9,
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ classification_model: 'gpt-5',
+ classified_at: Time.current
+ }])
+
+ row = DomainClassification.find_by(domain_name: 'reset-me.ee')
+ assert_nil row.embedding
+ assert_nil row.embedding_model
+ assert_nil row.embedded_at
+ end
+
+ def test_no_op_when_openai_disabled
+ Auction.create!(
+ domain_name: 'no-llm.ee',
+ starts_at: 1.hour.ago,
+ ends_at: 1.day.from_now,
+ skip_validation: true
+ )
+
+ with_feature_flag(false) do
+ assert_no_difference -> { DomainClassification.count } do
+ Recommendation::BackfillDomainClassificationsJob.new.perform
+ end
+ end
+ end
+
+ 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_job_test.rb b/test/jobs/recommendation/classify_domain_job_test.rb
new file mode 100644
index 000000000..21b78d628
--- /dev/null
+++ b/test/jobs/recommendation/classify_domain_job_test.rb
@@ -0,0 +1,117 @@
+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_skips_llm_when_domain_lock_is_held_by_another_worker
+ job = Recommendation::ClassifyDomainJob.new
+ # Simulate pg_try_advisory_lock returning false (another worker owns it).
+ job.define_singleton_method(:with_domain_lock) { |_name, &blk| blk.call(false) }
+
+ with_feature_flag(true) do
+ assert_no_difference -> { DomainClassification.count } do
+ job.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
new file mode 100644
index 000000000..41bc26936
--- /dev/null
+++ b/test/jobs/recommendation/classify_unclassified_domains_job_test.rb
@@ -0,0 +1,169 @@
+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::OPENAI_SOURCE,
+ classified_at: Time.current,
+ confidence: 0.3)
+
+ with_feature_flag(false) do
+ assert_nil Recommendation::ClassifyUnclassifiedDomainsJob.new.perform
+ end
+ end
+
+ def test_no_op_when_nothing_pending
+ with_feature_flag(true) do
+ with_missing_domains([]) do
+ assert_nil Recommendation::ClassifyUnclassifiedDomainsJob.new.perform
+ end
+ end
+ end
+
+ 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_reclassification_resets_stale_embedding
+ skip 'embedding column not present' unless DomainClassification.column_names.include?('embedding')
+
+ row = DomainClassification.create!(domain_name: 'weak.ee',
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ confidence: 0.3,
+ classified_at: 1.day.ago,
+ embedding: [0.1, 0.2, 0.3],
+ embedding_model: 'text-embedding-3-small',
+ embedded_at: 1.day.ago)
+
+ with_feature_flag(true) do
+ with_missing_domains([]) do
+ stub_llm do
+ Recommendation::ClassifyUnclassifiedDomainsJob.new.perform
+ end
+ end
+ end
+
+ row.reload
+ assert_nil row.embedding
+ assert_nil row.embedding_model
+ assert_nil row.embedded_at
+ 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,
+ confidence: 0.95,
+ classified_at: 1.day.ago
+ )
+
+ scope_ids = Recommendation::ClassifyUnclassifiedDomainsJob.scope.pluck(: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_work
+ with_feature_flag(false) do
+ refute Recommendation::ClassifyUnclassifiedDomainsJob.needs_to_run?
+ end
+
+ DomainClassification.create!(
+ domain_name: 'pending.ee',
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ confidence: 0.3,
+ classified_at: Time.current
+ )
+
+ with_feature_flag(true) do
+ assert Recommendation::ClassifyUnclassifiedDomainsJob.needs_to_run?
+ end
+ end
+
+ 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 }
+ yield
+ ensure
+ Feature.define_singleton_method(:open_ai_integration_enabled?, original)
+ end
+ end
+end
diff --git a/test/jobs/recommendation/embed_custom_interests_job_test.rb b/test/jobs/recommendation/embed_custom_interests_job_test.rb
new file mode 100644
index 000000000..194c2fc10
--- /dev/null
+++ b/test/jobs/recommendation/embed_custom_interests_job_test.rb
@@ -0,0 +1,85 @@
+require 'test_helper'
+
+module Recommendation
+ class EmbedCustomInterestsJobTest < ActiveJob::TestCase
+ def setup
+ super
+ @user = users(:participant)
+ end
+
+ def test_noop_when_openai_disabled
+ profile = build_profile(%w[crypto])
+ with_feature_flag(false) do
+ assert_nil Recommendation::EmbedCustomInterestsJob.new.perform(profile.id)
+ end
+ end
+
+ def test_embeds_each_custom_interest_as_its_own_vector
+ profile = build_profile(['crypto', 'learning platform'])
+
+ with_feature_flag(true) do
+ stub_embeddings(2)
+ assert_enqueued_with(job: Recommendation::RefreshSingleUserAuctionScoresJob, args: [@user.id]) do
+ Recommendation::EmbedCustomInterestsJob.new.perform(profile.id)
+ end
+ end
+
+ vectors = profile.reload.custom_interest_vectors
+ assert_equal %w[crypto learning\ platform].sort, vectors.map { |v| v['text'] }.sort
+ assert_equal Recommendation::DomainEmbedder::DIMENSIONS, vectors.first['embedding'].size
+ assert profile.custom_interests_embedded_at.present?
+ end
+
+ def test_reuses_cached_vectors_for_unchanged_texts
+ profile = build_profile(['crypto', 'realty'])
+ cached_vector = Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.9)
+ profile.update_columns(custom_interest_vectors: [{ 'text' => 'crypto', 'embedding' => cached_vector }])
+
+ with_feature_flag(true) do
+ stub_embeddings(1) # only 'realty' is missing
+ Recommendation::EmbedCustomInterestsJob.new.perform(profile.id)
+ end
+
+ vectors = profile.reload.custom_interest_vectors.index_by { |v| v['text'] }
+ assert_equal cached_vector, vectors['crypto']['embedding'], 'unchanged text keeps its cached vector'
+ assert_equal Recommendation::DomainEmbedder::DIMENSIONS, vectors['realty']['embedding'].size
+ end
+
+ def test_clears_vectors_when_no_custom_interests
+ profile = build_profile([])
+ profile.update_columns(custom_interest_vectors: [{ 'text' => 'stale', 'embedding' => [0.1] }])
+
+ with_feature_flag(true) do
+ Recommendation::EmbedCustomInterestsJob.new.perform(profile.id)
+ end
+
+ assert_equal [], profile.reload.custom_interest_vectors
+ assert profile.custom_interests_embedded_at.present?
+ end
+
+ private
+
+ def build_profile(custom_interests)
+ with_feature_flag(false) do
+ profile = RecommendationProfile.find_or_initialize_by(user: @user)
+ profile.custom_interests = custom_interests
+ profile.save!
+ profile
+ end
+ 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
+
+ def stub_embeddings(count)
+ data = count.times.map { |i| { 'index' => i, 'embedding' => Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.1) } }
+ stub_request(:post, 'https://api.openai.com/v1/embeddings')
+ .to_return_json(status: 200, body: { 'data' => data }, headers: {})
+ 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..2f1c2994a
--- /dev/null
+++ b/test/jobs/recommendation/embed_unembedded_domains_job_test.rb
@@ -0,0 +1,91 @@
+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
+
+ def test_persist_stamps_the_current_input_version
+ skip 'version column missing' unless DomainClassification.column_names.include?('embedding_input_version')
+
+ DomainClassification.create!(
+ domain_name: 'version-me.ee',
+ keywords: %w[cloud],
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ classified_at: 1.hour.ago,
+ confidence: 0.9
+ )
+
+ with_feature_flag(true) do
+ stub_embeddings(1)
+ Recommendation::EmbedUnembeddedDomainsJob.new.perform
+ end
+
+ record = DomainClassification.find_by(domain_name: 'version-me.ee')
+ assert_equal Recommendation::DomainEmbedder::INPUT_VERSION, record.embedding_input_version
+ assert_equal Recommendation::DomainEmbedder::DIMENSIONS, record.embedding.size
+ end
+
+ private
+
+ def stub_embeddings(count)
+ data = count.times.map { |i| { 'index' => i, 'embedding' => Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.1) } }
+ stub_request(:post, 'https://api.openai.com/v1/embeddings')
+ .to_return_json(status: 200, body: { 'data' => data }, 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/enrich_interest_categories_job_test.rb b/test/jobs/recommendation/enrich_interest_categories_job_test.rb
new file mode 100644
index 000000000..3711738d8
--- /dev/null
+++ b/test/jobs/recommendation/enrich_interest_categories_job_test.rb
@@ -0,0 +1,93 @@
+require 'test_helper'
+
+module Recommendation
+ class EnrichInterestCategoriesJobTest < ActiveJob::TestCase
+ def setup
+ super
+ Setting.find_by(code: 'openai_model')&.update!(value: 'gpt-5')
+ InterestCategory.delete_all
+ end
+
+ def test_noop_when_openai_disabled
+ with_feature_flag(false) do
+ assert_nil Recommendation::EnrichInterestCategoriesJob.new.perform
+ end
+ end
+
+ def test_enriches_and_persists_active_categories
+ category = create_category('finance', 'Finance and fintech', 'Finants ja fintech')
+
+ with_feature_flag(true) do
+ stub_enrichment(%w[finance])
+ stub_embeddings(1)
+ Recommendation::EnrichInterestCategoriesJob.new.perform
+ end
+
+ category.reload
+ assert_equal 'Money things.', category.description
+ assert_includes category.keywords, 'finants'
+ assert_equal Recommendation::DomainEmbedder::DIMENSIONS, category.embedding.size
+ assert category.embedded_at.present?
+ end
+
+ def test_single_id_only_enriches_that_category
+ target = create_category('finance', 'Finance', 'Finants')
+ create_category('travel', 'Travel', 'Reisimine')
+
+ with_feature_flag(true) do
+ stub_enrichment(%w[finance])
+ stub_embeddings(1)
+ Recommendation::EnrichInterestCategoriesJob.new.perform(target.id)
+ end
+
+ assert target.reload.embedded_at.present?
+ assert_nil InterestCategory.find_by(code: 'travel').embedded_at
+ end
+
+ def test_needs_to_run_reflects_unembedded_active_categories
+ create_category('finance', 'Finance', 'Finants')
+
+ with_feature_flag(true) do
+ assert Recommendation::EnrichInterestCategoriesJob.needs_to_run?
+ end
+ with_feature_flag(false) do
+ refute Recommendation::EnrichInterestCategoriesJob.needs_to_run?
+ end
+ end
+
+ private
+
+ def create_category(code, name_en, name_et)
+ InterestCategory.create!(code: code, name_en: name_en, name_et: name_et, active: true)
+ 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
+
+ def stub_enrichment(codes)
+ body = {
+ 'choices' => [{
+ 'finish_reason' => 'stop',
+ 'message' => {
+ 'content' => {
+ categories: codes.map { |c| { code: c, description: 'Money things.', keywords: %w[finance finants] } }
+ }.to_json
+ }
+ }]
+ }
+ stub_request(:post, 'https://api.openai.com/v1/chat/completions')
+ .to_return_json(status: 200, body: body, headers: {})
+ end
+
+ def stub_embeddings(count)
+ data = count.times.map { |i| { 'index' => i, 'embedding' => Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.1) } }
+ stub_request(:post, 'https://api.openai.com/v1/embeddings')
+ .to_return_json(status: 200, body: { 'data' => data }, headers: {})
+ end
+ end
+end
diff --git a/test/jobs/recommendation/prune_recommendation_events_job_test.rb b/test/jobs/recommendation/prune_recommendation_events_job_test.rb
new file mode 100644
index 000000000..a60b055f1
--- /dev/null
+++ b/test/jobs/recommendation/prune_recommendation_events_job_test.rb
@@ -0,0 +1,52 @@
+require 'test_helper'
+
+module Recommendation
+ class PruneRecommendationEventsJobTest < ActiveJob::TestCase
+ def setup
+ super
+ RecommendationEvent.delete_all
+ end
+
+ def test_deletes_events_older_than_default_retention
+ old = event('auction_detail_view', 7.months.ago)
+ recent = event('auction_detail_view', 3.months.ago)
+
+ Recommendation::PruneRecommendationEventsJob.new.perform
+
+ refute RecommendationEvent.exists?(old.id), 'event past retention should be pruned'
+ assert RecommendationEvent.exists?(recent.id), 'recent scoring event should be kept'
+ end
+
+ def test_prunes_impressions_on_the_shorter_window
+ stale_impression = event('auction_impression', 2.months.ago)
+ fresh_impression = event('auction_impression', 2.weeks.ago)
+
+ Recommendation::PruneRecommendationEventsJob.new.perform
+
+ refute RecommendationEvent.exists?(stale_impression.id), 'impression past 1 month should be pruned'
+ assert RecommendationEvent.exists?(fresh_impression.id), 'recent impression should be kept'
+ end
+
+ def test_needs_to_run_reflects_pending_rows
+ refute Recommendation::PruneRecommendationEventsJob.needs_to_run?, 'empty table needs no run'
+
+ event('auction_detail_view', 7.months.ago)
+ assert Recommendation::PruneRecommendationEventsJob.needs_to_run?
+ end
+
+ def test_batching_deletes_everything_past_retention
+ 3.times { event('auction_detail_view', 8.months.ago) }
+
+ deleted = Recommendation::PruneRecommendationEventsJob.new.perform(batch_size: 2)
+
+ assert_equal 3, deleted
+ assert_equal 0, RecommendationEvent.where(event_type: 'auction_detail_view').count
+ end
+
+ private
+
+ def event(type, occurred_at)
+ RecommendationEvent.create!(event_type: type, occurred_at: occurred_at)
+ end
+ end
+end
diff --git a/test/jobs/recommendation/rebuild_recommendations_job_test.rb b/test/jobs/recommendation/rebuild_recommendations_job_test.rb
new file mode 100644
index 000000000..9e12bb3f4
--- /dev/null
+++ b/test/jobs/recommendation/rebuild_recommendations_job_test.rb
@@ -0,0 +1,39 @@
+require 'test_helper'
+
+module Recommendation
+ class RebuildRecommendationsJobTest < ActiveJob::TestCase
+ def test_needs_to_run_follows_feature_flag
+ with_feature_flag(false) do
+ refute Recommendation::RebuildRecommendationsJob.needs_to_run?
+ end
+ with_feature_flag(true) do
+ assert Recommendation::RebuildRecommendationsJob.needs_to_run?
+ end
+ end
+
+ def test_perform_runs_pipeline_with_force
+ captured = nil
+ original = Recommendation::PipelineRunner.method(:run)
+ Recommendation::PipelineRunner.define_singleton_method(:run) do |force: false|
+ captured = force
+ {}
+ end
+
+ Recommendation::RebuildRecommendationsJob.new.perform
+
+ assert_equal true, captured
+ ensure
+ Recommendation::PipelineRunner.define_singleton_method(:run, original)
+ 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/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..546a3481d
--- /dev/null
+++ b/test/jobs/recommendation/refresh_single_user_auction_scores_job_test.rb
@@ -0,0 +1,109 @@
+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
+ skip 'embedding column missing' unless DomainClassification.column_names.include?('embedding')
+
+ # v3: a score row only survives a refresh when the candidate earns a magnet
+ # pull, so give the user one signal and the candidate a matching embedding.
+ give_user_a_magnet
+ domain = "stale-refresh-#{SecureRandom.hex(4)}.ee"
+ auction = Auction.create!(
+ domain_name: domain,
+ starts_at: 1.hour.ago,
+ ends_at: 1.day.from_now,
+ skip_validation: true
+ )
+ classify(domain, Array.new(8, 1.0))
+ 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.find_by!(user: @user, auction: auction)
+ assert reloaded.calculated_at > 1.minute.ago, 'stale scores must be refreshed'
+ end
+
+ def test_perform_reenqueues_when_recently_refreshed
+ auction = auctions(:valid_without_offers)
+ UserAuctionScore.create!(
+ user: @user,
+ auction: auction,
+ score: 10,
+ calculated_at: 5.seconds.ago
+ )
+
+ assert_enqueued_with(
+ job: Recommendation::RefreshSingleUserAuctionScoresJob,
+ args: [@user.id]
+ ) do
+ Recommendation::RefreshSingleUserAuctionScoresJob.new.perform(@user.id)
+ end
+ end
+
+ def test_perform_no_op_for_unknown_user
+ assert_nothing_raised do
+ Recommendation::RefreshSingleUserAuctionScoresJob.new.perform(-1)
+ end
+ end
+
+ private
+
+ def give_user_a_magnet
+ history = Auction.create!(
+ domain_name: "history-#{SecureRandom.hex(4)}.ee",
+ starts_at: 2.days.ago, ends_at: 1.day.ago, skip_validation: true
+ )
+ Offer.new(user: @user, auction: history, cents: 100,
+ billing_profile: billing_profiles(:private_person)).save(validate: false)
+ classify(history.domain_name, Array.new(8, 1.0))
+ end
+
+ def classify(domain_name, embedding)
+ DomainClassification.create!(
+ domain_name: domain_name, primary_category: 'saas', tags: %w[saas], keywords: %w[cloud],
+ embedding: embedding, classified_at: 1.hour.ago, confidence: 0.9
+ )
+ end
+ end
+end
diff --git a/test/models/domain_classification_test.rb b/test/models/domain_classification_test.rb
new file mode 100644
index 000000000..a1585d18e
--- /dev/null
+++ b/test/models/domain_classification_test.rb
@@ -0,0 +1,106 @@
+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
+
+ def test_needs_embedding_picks_rows_without_a_vector
+ skip 'embedding column missing' unless DomainClassification.column_names.include?('embedding')
+
+ bare = DomainClassification.create!(domain_name: 'bare-embed.ee', classified_at: 1.hour.ago)
+ assert_includes DomainClassification.needs_embedding.to_a, bare
+ end
+
+ def test_needs_embedding_picks_rows_built_under_an_older_input_version
+ skip 'version column missing' unless DomainClassification.column_names.include?('embedding_input_version')
+
+ # Has a vector but NULL version (every pre-versioning row) — must be re-embedded.
+ legacy = DomainClassification.create!(
+ domain_name: 'legacy-vec.ee',
+ classified_at: 1.hour.ago,
+ embedding: Array.new(3, 0.1),
+ embedding_input_version: nil
+ )
+ # Has a vector built under an explicitly older version.
+ older = DomainClassification.create!(
+ domain_name: 'older-vec.ee',
+ classified_at: 1.hour.ago,
+ embedding: Array.new(3, 0.1),
+ embedding_input_version: Recommendation::DomainEmbedder::INPUT_VERSION - 1
+ )
+
+ scope = DomainClassification.needs_embedding.to_a
+ assert_includes scope, legacy, 'NULL version must be caught (NULL != current is unknown in SQL)'
+ assert_includes scope, older
+ end
+
+ def test_needs_embedding_excludes_rows_at_the_current_input_version
+ skip 'version column missing' unless DomainClassification.column_names.include?('embedding_input_version')
+
+ current = DomainClassification.create!(
+ domain_name: 'current-vec.ee',
+ classified_at: 1.hour.ago,
+ embedding: Array.new(3, 0.1),
+ embedding_input_version: Recommendation::DomainEmbedder::INPUT_VERSION
+ )
+ refute_includes DomainClassification.needs_embedding.to_a, current
+ end
+
+ def test_embedding_reset_attributes_nulls_the_input_version
+ skip 'version column missing' unless DomainClassification.column_names.include?('embedding_input_version')
+
+ assert_includes DomainClassification.embedding_reset_attributes.keys, :embedding_input_version
+ assert_nil DomainClassification.embedding_reset_attributes[:embedding_input_version]
+ end
+end
diff --git a/test/models/interest_category_test.rb b/test/models/interest_category_test.rb
new file mode 100644
index 000000000..4fb13e36d
--- /dev/null
+++ b/test/models/interest_category_test.rb
@@ -0,0 +1,119 @@
+require 'test_helper'
+
+class InterestCategoryTest < ActiveSupport::TestCase
+ include ActiveJob::TestHelper
+
+ def test_enqueues_enrichment_on_create_when_openai_enabled
+ with_feature_flag(true) do
+ assert_enqueued_with(job: Recommendation::EnrichInterestCategoriesJob) do
+ InterestCategory.create!(code: 'newcat', name_en: 'New', name_et: 'Uus')
+ end
+ end
+ end
+
+ def test_does_not_enqueue_enrichment_when_openai_disabled
+ with_feature_flag(false) do
+ assert_no_enqueued_jobs only: Recommendation::EnrichInterestCategoriesJob do
+ InterestCategory.create!(code: 'newcat', name_en: 'New', name_et: 'Uus')
+ end
+ end
+ end
+
+ def test_does_not_re_enqueue_on_unrelated_save
+ category = InterestCategory.create!(code: 'newcat', name_en: 'New', name_et: 'Uus')
+ category.update_columns(embedding: Array.new(3, 0.1), embedded_at: Time.current)
+
+ with_feature_flag(true) do
+ assert_no_enqueued_jobs only: Recommendation::EnrichInterestCategoriesJob do
+ category.update!(position: 99)
+ end
+ end
+ end
+
+ def test_requires_code_and_names
+ category = InterestCategory.new
+ refute category.valid?
+ assert category.errors.key?(:code)
+ assert category.errors.key?(:name_en)
+ assert category.errors.key?(:name_et)
+ end
+
+ def test_normalizes_code_to_lowercase
+ category = InterestCategory.create!(code: ' SaaS ', name_en: 'SaaS', name_et: 'SaaS')
+ assert_equal 'saas', category.code
+ end
+
+ def test_code_uniqueness_is_case_insensitive
+ InterestCategory.create!(code: 'saas', name_en: 'SaaS', name_et: 'SaaS')
+ dup = InterestCategory.new(code: 'SAAS', name_en: 'Other', name_et: 'Muu')
+ refute dup.valid?
+ end
+
+ def test_name_is_locale_aware
+ category = InterestCategory.new(code: 'health', name_en: 'Health', name_et: 'Tervis')
+
+ I18n.with_locale(:en) { assert_equal 'Health', category.name }
+ I18n.with_locale(:et) { assert_equal 'Tervis', category.name }
+ end
+
+ def test_name_falls_back_to_english_when_estonian_blank
+ category = InterestCategory.new(code: 'health', name_en: 'Health', name_et: '')
+ I18n.with_locale(:et) { assert_equal 'Health', category.name }
+ end
+
+ def test_seed_defaults_is_idempotent
+ InterestCategory.delete_all
+
+ assert_difference -> { InterestCategory.count }, InterestCategory::DEFAULTS.size do
+ InterestCategory.seed_defaults!
+ end
+
+ assert_no_difference -> { InterestCategory.count } do
+ InterestCategory.seed_defaults!
+ end
+ end
+
+ def test_destroy_purges_orphaned_code_from_profiles_and_classifications
+ category = InterestCategory.create!(code: 'temp_cat', name_en: 'Temp', name_et: 'Temp')
+
+ profile = users(:participant).recommendation_profile ||
+ RecommendationProfile.create!(user: users(:participant))
+ profile.update_columns(interest_keywords: %w[saas temp_cat]) # skip normalize
+
+ classification = DomainClassification.create!(
+ domain_name: 'temp-cat-domain.ee',
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ confidence: 0.9,
+ classified_at: 1.hour.ago,
+ primary_category: 'temp_cat',
+ tags: %w[temp_cat other]
+ )
+
+ category.destroy
+
+ assert_equal %w[saas], profile.reload.interest_keywords
+ classification.reload
+ assert_equal %w[other], classification.tags
+ assert_nil classification.primary_category
+ end
+
+ def test_seed_defaults_does_not_clobber_admin_edits
+ InterestCategory.delete_all
+ InterestCategory.seed_defaults!
+ InterestCategory.find_by(code: 'saas').update!(name_en: 'Custom SaaS')
+
+ InterestCategory.seed_defaults!
+
+ assert_equal 'Custom SaaS', InterestCategory.find_by(code: 'saas').name_en
+ 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
diff --git a/test/models/job_test.rb b/test/models/job_test.rb
index 3e58710af..316e81f9d 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::ClassifyUnclassifiedDomainsJob')
+
+ assert job.valid?
+ assert_equal Recommendation::ClassifyUnclassifiedDomainsJob, job.job_class
+ end
+
+ def test_embed_job_is_allowed
+ job = Job.new('Recommendation::EmbedUnembeddedDomainsJob')
+
+ assert job.valid?
+ assert_equal Recommendation::EmbedUnembeddedDomainsJob, 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..e78ece2b1
--- /dev/null
+++ b/test/models/recommendation_profile_test.rb
@@ -0,0 +1,93 @@
+require 'test_helper'
+
+class RecommendationProfileTest < ActiveSupport::TestCase
+ include ActiveJob::TestHelper
+
+ def setup
+ super
+ @profile = RecommendationProfile.new(user: users(:participant))
+ end
+
+ def test_custom_interest_change_enqueues_embedding_when_openai_enabled
+ with_feature_flag(true) do
+ assert_enqueued_with(job: Recommendation::EmbedCustomInterestsJob) do
+ @profile.custom_interests = ['crypto']
+ @profile.save!
+ end
+ end
+ end
+
+ def test_category_only_change_does_not_enqueue_embedding
+ with_feature_flag(false) do
+ @profile.interest_categories = %w[saas]
+ @profile.save!
+ end
+
+ with_feature_flag(true) do
+ assert_no_enqueued_jobs only: Recommendation::EmbedCustomInterestsJob do
+ @profile.interest_categories = %w[saas finance]
+ @profile.save!
+ end
+ end
+ end
+
+ def test_no_embedding_enqueued_when_openai_disabled
+ with_feature_flag(false) do
+ assert_no_enqueued_jobs only: Recommendation::EmbedCustomInterestsJob do
+ @profile.custom_interests = ['crypto']
+ @profile.save!
+ end
+ end
+ 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
+
+ def test_other_is_dropped_when_custom_interests_are_cleared
+ @profile.update!(interest_keywords: %w[legal other custom:crypto])
+
+ @profile.custom_interests = []
+ @profile.valid?
+
+ assert_empty @profile.custom_interests
+ # `other` is a derived marker of "has custom interests" — it must not linger.
+ assert_equal %w[legal], @profile.interest_categories
+ 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
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index ccd0aa21c..19d124259 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[other saas], 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/domain_embedder_test.rb b/test/services/recommendation/domain_embedder_test.rb
new file mode 100644
index 000000000..b19c3a495
--- /dev/null
+++ b/test/services/recommendation/domain_embedder_test.rb
@@ -0,0 +1,91 @@
+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)
+ assert_equal Recommendation::DomainEmbedder::INPUT_VERSION, result.first[:embedding_input_version]
+ end
+
+ def test_build_input_includes_classification_fields
+ row = {
+ domain_name: 'petshop.ee',
+ description: 'An online store selling pet food.',
+ primary_category: 'ecommerce',
+ tags: %w[shop_brand pet_care],
+ suggested_use_cases: %w[shop marketplace],
+ audience: 'b2c',
+ keywords: %w[pets food]
+ }
+
+ input = Recommendation::DomainEmbedder.new(rows: []).send(:build_input, row)
+
+ assert_includes input, 'petshop.ee'
+ assert_includes input, 'An online store selling pet food.'
+ assert_includes input, 'Category: ecommerce'
+ assert_includes input, 'Tags: shop brand, pet care' # underscores humanized
+ assert_includes input, 'Use cases: shop, marketplace'
+ assert_includes input, 'Audience: b2c'
+ assert_includes input, 'Keywords: pets, food'
+ end
+
+ def test_build_input_skips_blank_fields_without_dangling_labels
+ row = { domain_name: 'bare.ee', keywords: %w[minimal] }
+
+ input = Recommendation::DomainEmbedder.new(rows: []).send(:build_input, row)
+
+ assert_equal 'bare.ee. Keywords: minimal', input
+ refute_includes input, 'Category:'
+ refute_includes input, 'Tags:'
+ refute_includes input, ': .'
+ 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/embedding_test.rb b/test/services/recommendation/embedding_test.rb
new file mode 100644
index 000000000..cb269a0dc
--- /dev/null
+++ b/test/services/recommendation/embedding_test.rb
@@ -0,0 +1,39 @@
+require 'test_helper'
+
+module Recommendation
+ class EmbeddingTest < ActiveSupport::TestCase
+ def test_cosine_similarity_of_identical_vectors_is_one
+ assert_in_delta 1.0, Recommendation::Embedding.cosine_similarity([1.0, 2.0, 3.0], [1.0, 2.0, 3.0]), 1e-9
+ end
+
+ def test_cosine_similarity_of_orthogonal_vectors_is_zero
+ assert_in_delta 0.0, Recommendation::Embedding.cosine_similarity([1.0, 0.0], [0.0, 1.0]), 1e-9
+ end
+
+ def test_cosine_similarity_is_nil_for_mismatched_sizes
+ assert_nil Recommendation::Embedding.cosine_similarity([1.0, 2.0], [1.0])
+ end
+
+ def test_cosine_similarity_is_nil_for_zero_magnitude_vector
+ assert_nil Recommendation::Embedding.cosine_similarity([0.0, 0.0], [1.0, 1.0])
+ end
+
+ def test_weighted_centroid_averages_by_weight
+ centroid = Recommendation::Embedding.weighted_centroid([[[0.0, 0.0], 1.0], [[4.0, 8.0], 3.0]])
+
+ # (0*1 + 4*3) / 4 = 3.0 ; (0*1 + 8*3) / 4 = 6.0
+ assert_in_delta 3.0, centroid[0], 1e-9
+ assert_in_delta 6.0, centroid[1], 1e-9
+ end
+
+ def test_weighted_centroid_is_nil_when_empty
+ assert_nil Recommendation::Embedding.weighted_centroid([])
+ end
+
+ def test_weighted_centroid_skips_vectors_of_a_different_size
+ centroid = Recommendation::Embedding.weighted_centroid([[[1.0, 1.0], 1.0], [[9.9, 9.9, 9.9], 1.0]])
+
+ assert_equal [1.0, 1.0], centroid
+ 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
diff --git a/test/services/recommendation/interest_catalog_test.rb b/test/services/recommendation/interest_catalog_test.rb
new file mode 100644
index 000000000..a2faf351e
--- /dev/null
+++ b/test/services/recommendation/interest_catalog_test.rb
@@ -0,0 +1,33 @@
+require 'test_helper'
+
+module Recommendation
+ class InterestCatalogTest < ActiveSupport::TestCase
+ def test_falls_back_to_constant_when_table_empty
+ InterestCategory.delete_all
+ assert_equal Recommendation::InterestCatalog::FALLBACK_CATEGORIES,
+ Recommendation::InterestCatalog.categories
+ end
+
+ def test_reads_active_codes_from_db_in_position_order
+ InterestCategory.delete_all
+ InterestCategory.create!(code: 'beta', name_en: 'Beta', name_et: 'Beeta', position: 2)
+ InterestCategory.create!(code: 'alpha', name_en: 'Alpha', name_et: 'Alfa', position: 1)
+ InterestCategory.create!(code: 'hidden', name_en: 'Hidden', name_et: 'Peidetud', position: 3, active: false)
+
+ assert_equal %w[alpha beta], Recommendation::InterestCatalog.categories
+ end
+
+ def test_label_for_returns_locale_name_from_db
+ InterestCategory.delete_all
+ InterestCategory.create!(code: 'health', name_en: 'Health', name_et: 'Tervis', position: 1)
+
+ I18n.with_locale(:en) { assert_equal 'Health', Recommendation::InterestCatalog.label_for('health') }
+ I18n.with_locale(:et) { assert_equal 'Tervis', Recommendation::InterestCatalog.label_for('health') }
+ end
+
+ def test_label_for_unknown_code_falls_back_to_code
+ InterestCategory.delete_all
+ assert_equal 'mystery', Recommendation::InterestCatalog.label_for('mystery')
+ end
+ end
+end
diff --git a/test/services/recommendation/interest_category_enricher_test.rb b/test/services/recommendation/interest_category_enricher_test.rb
new file mode 100644
index 000000000..64ef2cb67
--- /dev/null
+++ b/test/services/recommendation/interest_category_enricher_test.rb
@@ -0,0 +1,66 @@
+require 'test_helper'
+
+module Recommendation
+ class InterestCategoryEnricherTest < ActiveSupport::TestCase
+ def setup
+ super
+ Setting.find_by(code: 'openai_model')&.update!(value: 'gpt-5')
+ end
+
+ def test_returns_description_keywords_and_embedding_per_category
+ categories = [
+ InterestCategory.new(code: 'finance', name_en: 'Finance and fintech', name_et: 'Finants ja fintech'),
+ InterestCategory.new(code: 'real_estate', name_en: 'Real estate', name_et: 'Kinnisvara')
+ ]
+
+ stub_enrichment_response(%w[finance real_estate])
+ stub_embedding_request(2)
+
+ result = Recommendation::InterestCategoryEnricher.call(categories: categories)
+
+ assert_equal 2, result.size
+ finance = result.find { |r| r[:code] == 'finance' }
+ assert_equal 'Money things.', finance[:description]
+ assert_includes finance[:keywords], 'finants'
+ assert_equal Recommendation::DomainEmbedder::DIMENSIONS, finance[:embedding].size
+ assert_equal Recommendation::DomainEmbedder::MODEL, finance[:embedding_model]
+ assert finance[:embedded_at].is_a?(Time)
+ end
+
+ def test_empty_input_returns_empty
+ assert_equal [], Recommendation::InterestCategoryEnricher.call(categories: [])
+ end
+
+ def test_skips_blank_codes
+ categories = [InterestCategory.new(code: ' ', name_en: 'x', name_et: 'x')]
+ assert_equal [], Recommendation::InterestCategoryEnricher.call(categories: categories)
+ end
+
+ private
+
+ def stub_enrichment_response(codes)
+ body = {
+ 'choices' => [{
+ 'finish_reason' => 'stop',
+ 'message' => {
+ 'content' => {
+ categories: codes.map do |code|
+ { code: code, description: 'Money things.', keywords: %w[finance finants loan laen] }
+ end
+ }.to_json
+ }
+ }]
+ }
+ stub_request(:post, 'https://api.openai.com/v1/chat/completions')
+ .to_return_json(status: 200, body: body, headers: {})
+ end
+
+ 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..ce0235ecd
--- /dev/null
+++ b/test/services/recommendation/llm_domain_classifier_test.rb
@@ -0,0 +1,115 @@
+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 'A cloudstack.ee SaaS platform for teams.', cloud[:description]
+ assert_equal %w[saas b2b_service], cloud[:tags]
+ 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],
+ 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,
+ description: "A #{name} SaaS platform for teams.",
+ primary_category: 'saas',
+ tags: %w[saas b2b_service],
+ 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
diff --git a/test/services/recommendation/magnet_scorer_test.rb b/test/services/recommendation/magnet_scorer_test.rb
new file mode 100644
index 000000000..f5bd66947
--- /dev/null
+++ b/test/services/recommendation/magnet_scorer_test.rb
@@ -0,0 +1,173 @@
+require 'test_helper'
+
+module Recommendation
+ # v3 shadow-mode scorer. Skipped before the embedding column is migrated so
+ # the suite stays green on older schemas (mirrors ScorerEmbeddingTest).
+ class MagnetScorerTest < ActiveSupport::TestCase
+ ALIGNED = Array.new(8, 1.0).freeze
+ ORTHOGONAL = [1.0, 0, 0, 0, 0, 0, 0, 0].freeze
+
+ 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_category_magnet_ranks_aligned_domain_first
+ seed_category('saas', ALIGNED)
+ @user.create_recommendation_profile!(interest_keywords: %w[saas])
+
+ aligned = classified_auction('aligned.ee', ALIGNED)
+ orthogonal = classified_auction('orthogonal.ee', ORTHOGONAL)
+
+ ranking = rank([aligned, orthogonal])
+
+ assert_equal aligned.id, ranking.first.first.id
+ assert ranking.first.last > ranking.last.last
+ end
+
+ def test_custom_interest_vector_acts_as_a_magnet
+ profile = @user.create_recommendation_profile!(interest_keywords: %w[other custom:crypto])
+ profile.update_columns(custom_interest_vectors: [{ 'text' => 'crypto', 'embedding' => ALIGNED }])
+
+ aligned = classified_auction('coin.ee', ALIGNED)
+ orthogonal = classified_auction('paper.ee', ORTHOGONAL)
+
+ ranking = rank([aligned, orthogonal])
+
+ assert_equal aligned.id, ranking.first.first.id
+ end
+
+ def test_wishlist_domain_acts_as_a_magnet
+ add_wishlist('wish.ee')
+ classify('wish.ee', ALIGNED)
+
+ aligned = classified_auction('similar-to-wish.ee', ALIGNED)
+ orthogonal = classified_auction('unrelated.ee', ORTHOGONAL)
+
+ ranking = rank([aligned, orthogonal])
+
+ assert_equal aligned.id, ranking.first.first.id,
+ 'a domain semantically close to a wishlisted domain must rank above an orthogonal one'
+ assert ranking.first.last > ranking.last.last
+ end
+
+ def test_bid_history_acts_as_a_magnet
+ history = ended_auction('history.ee')
+ place_offer(history)
+ classify('history.ee', ALIGNED)
+
+ aligned = classified_auction('similar.ee', ALIGNED)
+ orthogonal = classified_auction('different.ee', ORTHOGONAL)
+
+ ranking = rank([aligned, orthogonal])
+
+ assert_equal aligned.id, ranking.first.first.id
+ end
+
+ def test_score_is_nil_without_auction_embedding
+ seed_category('saas', ALIGNED)
+ @user.create_recommendation_profile!(interest_keywords: %w[saas])
+
+ bare = classified_auction('bare.ee', nil)
+
+ scores = Recommendation::MagnetScorer.new(user: @user, scope: Auction.where(id: bare.id)).scores
+ assert_nil scores[bare.id]
+ end
+
+ def test_score_is_nil_without_any_magnet
+ @user.recommendation_profile&.destroy
+ candidate = classified_auction('lonely.ee', ALIGNED)
+
+ scores = Recommendation::MagnetScorer.new(user: @user, scope: Auction.where(id: candidate.id)).scores
+ assert_nil scores[candidate.id]
+ end
+
+ def test_defaults_scope_to_active_auctions
+ seed_category('saas', ALIGNED)
+ @user.create_recommendation_profile!(interest_keywords: %w[saas])
+ classified_auction('default-scope.ee', ALIGNED)
+
+ # No scope: argument — must fall back to Auction.active without raising.
+ assert_nothing_raised do
+ Recommendation::MagnetScorer.new(user: @user).ranked(limit: 1)
+ end
+ end
+
+ private
+
+ def rank(auctions)
+ Recommendation::MagnetScorer
+ .new(user: @user, scope: Auction.where(id: auctions.map(&:id)))
+ .ranked
+ end
+
+ def seed_category(code, embedding)
+ category = InterestCategory.find_or_create_by!(code: code) do |c|
+ c.name_en = code.upcase
+ c.name_et = code.upcase
+ c.active = true
+ end
+ category.update_columns(embedding: embedding, embedded_at: Time.current)
+ category
+ end
+
+ def classified_auction(domain_name, embedding)
+ auction = 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
+ )
+ classify(domain_name, embedding) unless embedding.nil?
+ auction
+ end
+
+ def ended_auction(domain_name)
+ Auction.create!(
+ domain_name: domain_name,
+ starts_at: 2.days.ago,
+ ends_at: 1.day.ago,
+ skip_validation: true
+ )
+ end
+
+ def classify(domain_name, embedding)
+ DomainClassification.create!(
+ domain_name: domain_name,
+ primary_category: 'saas',
+ tags: %w[saas],
+ keywords: %w[cloud],
+ embedding: embedding,
+ classified_at: 1.hour.ago,
+ confidence: 0.9
+ )
+ end
+
+ def add_wishlist(domain_name)
+ item = WishlistItem.new(user: @user, domain_name: domain_name)
+ item.save(validate: false)
+ item
+ end
+
+ def place_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/pipeline_runner_test.rb b/test/services/recommendation/pipeline_runner_test.rb
new file mode 100644
index 000000000..7b29e7fe4
--- /dev/null
+++ b/test/services/recommendation/pipeline_runner_test.rb
@@ -0,0 +1,154 @@
+require 'test_helper'
+
+module Recommendation
+ class PipelineRunnerTest < ActiveSupport::TestCase
+ def test_no_op_when_openai_disabled
+ with_feature_flag(false) do
+ assert_equal({}, Recommendation::PipelineRunner.run)
+ end
+ end
+
+ def test_force_invalidates_openai_classifications
+ classification = DomainClassification.create!(
+ domain_name: 'force-me.ee',
+ classification_source: DomainClassification::OPENAI_SOURCE,
+ confidence: 0.9,
+ classified_at: 1.hour.ago
+ )
+
+ with_feature_flag(true) do
+ with_no_pending do
+ with_placeholder_prompt do
+ stub_scorer do
+ Recommendation::PipelineRunner.run(force: true)
+ end
+ end
+ end
+ end
+
+ assert classification.reload.classified_at < 6.months.ago,
+ 'force must push openai classifications past the staleness threshold'
+ end
+
+ def test_skips_ai_score_when_prompt_is_placeholder
+ with_feature_flag(true) do
+ with_no_pending do
+ with_placeholder_prompt do
+ stub_scorer do
+ refute_ai_sorting_called do
+ summary = Recommendation::PipelineRunner.run
+ assert_equal :skipped, summary[:ai_score]
+ end
+ end
+ end
+ end
+ end
+ end
+
+ def test_backfills_custom_interest_vectors_for_existing_profiles
+ skip 'column missing' unless RecommendationProfile.column_names.include?('custom_interest_vectors')
+
+ profile = with_feature_flag(false) do
+ p = RecommendationProfile.create!(user: users(:participant), interest_keywords: %w[other custom:crypto])
+ p.update_columns(custom_interest_vectors: [], custom_interests_embedded_at: nil)
+ p
+ end
+
+ with_feature_flag(true) do
+ with_no_pending do
+ with_placeholder_prompt do
+ stub_scorer do
+ stub_embeddings(1)
+ Recommendation::PipelineRunner.run
+ end
+ end
+ end
+ end
+
+ profile.reload
+ assert profile.custom_interests_embedded_at.present?, 'existing custom interests must get embedded on init'
+ assert_equal 1, profile.custom_interest_vectors.size
+ end
+
+ def test_scores_every_participant
+ with_feature_flag(true) do
+ with_no_pending do
+ with_placeholder_prompt do
+ stub_scorer do
+ Recommendation::PipelineRunner.run
+ end
+ end
+ end
+ end
+
+ expected = User.where('? = ANY (roles)', User::PARTICIPANT_ROLE).pluck(:id).sort
+ assert_equal expected, @scored.sort
+ 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
+
+ # Make both batch stages see an empty backlog so they don't hit the LLM.
+ def with_no_pending
+ classify = Recommendation::ClassifyUnclassifiedDomainsJob
+ embed = Recommendation::EmbedUnembeddedDomainsJob
+ originals = {
+ scope: classify.method(:scope),
+ missing: classify.method(:missing_domains),
+ embed_scope: embed.method(:scope)
+ }
+ classify.define_singleton_method(:scope) { DomainClassification.none }
+ classify.define_singleton_method(:missing_domains) { [] }
+ embed.define_singleton_method(:scope) { DomainClassification.none }
+ yield
+ ensure
+ classify.define_singleton_method(:scope, originals[:scope])
+ classify.define_singleton_method(:missing_domains, originals[:missing])
+ embed.define_singleton_method(:scope, originals[:embed_scope])
+ end
+
+ def with_placeholder_prompt
+ setting = Setting.find_by(code: 'openai_domains_evaluation_prompt')
+ original = setting.value
+ setting.update!(value: Recommendation::PipelineRunner::AI_SCORE_PLACEHOLDER_PROMPT)
+ yield
+ ensure
+ setting.update!(value: original)
+ end
+
+ def stub_scorer
+ original = Recommendation::Scorer.method(:refresh_for)
+ @scored = []
+ collected = @scored
+ Recommendation::Scorer.define_singleton_method(:refresh_for) do |user:, **|
+ collected << user.id
+ end
+ yield
+ ensure
+ Recommendation::Scorer.define_singleton_method(:refresh_for, original)
+ end
+
+ def stub_embeddings(count)
+ data = count.times.map { |i| { 'index' => i, 'embedding' => Array.new(Recommendation::DomainEmbedder::DIMENSIONS, 0.1) } }
+ stub_request(:post, 'https://api.openai.com/v1/embeddings')
+ .to_return_json(status: 200, body: { 'data' => data }, headers: {})
+ end
+
+ def refute_ai_sorting_called
+ called = false
+ original = ActiveAuctionsAiSortingJob.method(:perform_now)
+ ActiveAuctionsAiSortingJob.define_singleton_method(:perform_now) { |*| called = true }
+ yield
+ refute called, 'ai_score job must not run when the prompt is the placeholder'
+ ensure
+ ActiveAuctionsAiSortingJob.define_singleton_method(:perform_now, original)
+ 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..934495aff
--- /dev/null
+++ b/test/services/recommendation/scorer_test.rb
@@ -0,0 +1,117 @@
+require 'test_helper'
+
+module Recommendation
+ # v3 Scorer: score = magnet_base × MAGNET_SCALE + structural nudges. Skipped
+ # before the embedding column is migrated (magnet base needs it).
+ class ScorerTest < ActiveSupport::TestCase
+ ALIGNED = Array.new(8, 1.0).freeze
+ ORTHOGONAL = [1.0, 0, 0, 0, 0, 0, 0, 0].freeze
+
+ 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')
+ give_user_a_magnet
+ end
+
+ def teardown
+ super
+ travel_back
+ end
+
+ def test_writes_scaled_magnet_score_ranking_aligned_above_orthogonal
+ aligned = classified_auction('aligned.ee', ALIGNED)
+ orthogonal = classified_auction('orthogonal.ee', ORTHOGONAL)
+
+ 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
+ # magnet base (~3.0) scaled by 100 dominates the score.
+ assert aligned_score > 100, "Expected scaled magnet score, got #{aligned_score}"
+ end
+
+ def test_no_row_written_when_candidate_has_no_embedding
+ bare = classified_auction('bare.ee', nil)
+
+ count = Recommendation::Scorer.refresh_for(user: @user, scope: Auction.where(id: bare.id))
+
+ assert_equal 0, count
+ assert_nil UserAuctionScore.find_by(user: @user, auction: bare)
+ end
+
+ def test_stale_row_deleted_when_magnet_disappears
+ candidate = classified_auction('lonely.ee', ALIGNED)
+ remove_user_magnets # user no longer has any signal → no pull
+
+ UserAuctionScore.create!(user: @user, auction: candidate, score: 42, calculated_at: 1.day.ago)
+
+ Recommendation::Scorer.refresh_for(user: @user, scope: Auction.where(id: candidate.id))
+
+ assert_nil UserAuctionScore.find_by(user: @user, auction: candidate),
+ 'stale score row should be deleted once the auction no longer earns a magnet score'
+ end
+
+ def test_structural_length_bonus_applied_on_top_of_magnet
+ profile = @user.recommendation_profile || @user.create_recommendation_profile!
+ profile.update!(preferred_length_min: 1, preferred_length_max: 3)
+ short = classified_auction('abc.ee', ALIGNED) # normalized 'abc' → length 3, in range
+ long = classified_auction('abcdefgh.ee', ALIGNED)
+
+ Recommendation::Scorer.refresh_for(user: @user, scope: Auction.where(id: [short.id, long.id]))
+
+ short_score = UserAuctionScore.find_by!(user: @user, auction: short).score
+ long_score = UserAuctionScore.find_by!(user: @user, auction: long).score
+
+ assert_in_delta Recommendation::Scorer::LENGTH_MATCH_BONUS, (short_score - long_score).to_f, 0.01
+ end
+
+ def test_features_version_marker_present
+ auction = classified_auction('fv.ee', ALIGNED)
+ 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
+ assert_equal Recommendation::Scorer::SCORER_NAME, score.scorer_name
+ end
+
+ private
+
+ # A single behavioural magnet in the ALIGNED direction (a past bid on an
+ # embedded domain), so ALIGNED candidates get a strong pull.
+ def give_user_a_magnet
+ history = Auction.create!(
+ domain_name: 'history.ee', starts_at: 2.days.ago, ends_at: 1.day.ago, skip_validation: true
+ )
+ Offer.new(user: @user, auction: history, cents: 100,
+ billing_profile: billing_profiles(:private_person)).save(validate: false)
+ classify('history.ee', ALIGNED)
+ end
+
+ def remove_user_magnets
+ Offer.where(user: @user).delete_all
+ @user.wishlist_items.delete_all
+ @user.recommendation_profile&.update_columns(interest_keywords: [], custom_interest_vectors: [])
+ end
+
+ def classified_auction(domain_name, embedding)
+ auction = 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
+ )
+ classify(domain_name, embedding) unless embedding.nil?
+ auction
+ end
+
+ def classify(domain_name, embedding)
+ DomainClassification.create!(
+ domain_name: domain_name, primary_category: 'saas', tags: %w[saas], keywords: %w[cloud],
+ embedding: embedding, classified_at: 1.hour.ago, confidence: 0.9
+ )
+ end
+ end
+end
diff --git a/test/services/recommendation/text_embedder_test.rb b/test/services/recommendation/text_embedder_test.rb
new file mode 100644
index 000000000..c4df90562
--- /dev/null
+++ b/test/services/recommendation/text_embedder_test.rb
@@ -0,0 +1,30 @@
+require 'test_helper'
+
+module Recommendation
+ class TextEmbedderTest < ActiveSupport::TestCase
+ def test_returns_vectors_aligned_to_input_order
+ stub_request(:post, 'https://api.openai.com/v1/embeddings')
+ .to_return_json(status: 200, body: {
+ 'data' => [
+ { 'index' => 1, 'embedding' => [0.2, 0.2] },
+ { 'index' => 0, 'embedding' => [0.1, 0.1] }
+ ]
+ }, headers: {})
+
+ result = Recommendation::TextEmbedder.embed(%w[first second])
+
+ assert_equal [[0.1, 0.1], [0.2, 0.2]], result
+ end
+
+ def test_empty_input_returns_empty_without_calling_openai
+ assert_equal [], Recommendation::TextEmbedder.embed([])
+ end
+
+ def test_raises_on_openai_error
+ stub_request(:post, 'https://api.openai.com/v1/embeddings')
+ .to_return_json(status: 200, body: { 'error' => { 'message' => 'boom' } }, headers: {})
+
+ assert_raises(StandardError) { Recommendation::TextEmbedder.embed(%w[x]) }
+ end
+ end
+end