Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b31ac25
Snapshot of recommendation system v1 (baseline scoring + tracking)
OlegPhenomenon May 27, 2026
b84f97c
Document recommendation system v2 architecture and pipeline
OlegPhenomenon May 27, 2026
ad34516
perf: batch impressions, debounce score refresh, narrow scope
OlegPhenomenon May 27, 2026
bd88e97
chore: ignore local .claude workspace artifacts
OlegPhenomenon May 27, 2026
209c233
feat: domain_classifications table + heuristic Tier 0 classifier
OlegPhenomenon May 27, 2026
42e8f97
feat: DomainClassifier orchestrator (heuristic-only at runtime)
OlegPhenomenon May 27, 2026
26a1801
feat: LLM batch enrichment job (cron-driven, never runtime)
OlegPhenomenon May 27, 2026
7d42049
feat: classification triggers + backfill job
OlegPhenomenon May 27, 2026
e0a94c4
feat: pgvector embeddings + nightly embed job
OlegPhenomenon May 27, 2026
1f73d27
feat: rich-feature scorer with embedding similarity + time decay
OlegPhenomenon May 27, 2026
87723c8
feat: detail view tracking + view affinity in scorer
OlegPhenomenon May 27, 2026
c7eecb1
feat: show domain description and keywords in auction card
OlegPhenomenon May 27, 2026
0d081e4
chore: polish + operator runbook + finalize v2 rollout
OlegPhenomenon May 27, 2026
5b6174f
refactor: scorer N+1 fixes, deprecation notes, LLM/embedder tests
OlegPhenomenon May 27, 2026
c9d1a28
polish: extract auction-card styles to SCSS, add missing job tests, ADR
OlegPhenomenon May 27, 2026
2b94fb8
fix: pgvector migration gives clear error + escape hatch
OlegPhenomenon May 27, 2026
9252607
Drop pgvector path — rely on tag/keyword affinity + time decay
OlegPhenomenon May 28, 2026
9dbff94
Remove description from domain_classifications
OlegPhenomenon May 28, 2026
a9175d6
Add embedding similarity via Postgres double precision[] arrays
OlegPhenomenon May 28, 2026
bbfe390
chore(demo): expand seed domains + helpers for recommendation testing
OlegPhenomenon May 28, 2026
18975f9
fix(demo): start auctions 5 minutes in the future, not 1 second
OlegPhenomenon May 28, 2026
c8821fc
added score of domains for users for recommendation system
OlegPhenomenon May 29, 2026
4cde468
refactor
OlegPhenomenon May 29, 2026
ee7113a
updated principle of scoring
OlegPhenomenon May 29, 2026
6941470
move interests functionality into admin panel
OlegPhenomenon Jun 1, 2026
e028d8f
added featuer flag for interests into sign up form
OlegPhenomenon Jun 1, 2026
58813c1
Prevent duplicate LLM classification and stale embeddings
OlegPhenomenon Jul 8, 2026
c32681b
Make demo:seed_user_signals resilient to full wishlist and invalid si…
OlegPhenomenon Jul 8, 2026
0e5eae4
fixed names
OlegPhenomenon Jul 8, 2026
3c29100
Add one-command recommendation pipeline + fix pipeline defects
OlegPhenomenon Jul 8, 2026
34ee408
Remove dead code, memoize scorer signals, fix missing summary i18n
OlegPhenomenon Jul 8, 2026
5c42750
Optimize hot paths and apply style-guide refactors
OlegPhenomenon Jul 8, 2026
e5fc935
Recommendation v3: unified embedding "magnets" scoring
OlegPhenomenon Jul 9, 2026
bd97cf2
Backfill custom-interest vectors in the pipeline
OlegPhenomenon Jul 9, 2026
628d101
Fix custom-interest clearing + show LLM classification in admin
OlegPhenomenon Jul 9, 2026
8e6f20a
Add experimental tags column to public auction list
OlegPhenomenon Jul 9, 2026
0c169f8
Document auction_tags_display_enabled in customization sample
OlegPhenomenon Jul 9, 2026
a1749df
Drop AI classification column from admin auction list
OlegPhenomenon Jul 9, 2026
aed6097
Show LLM enrichment results on admin interest categories
OlegPhenomenon Jul 9, 2026
d80fbc6
Document the recommendation system in the README
OlegPhenomenon Jul 9, 2026
420ab20
Enrich domain embeddings and slim the recommendation surface
OlegPhenomenon Jul 10, 2026
0d29832
Always show the custom-keywords field, drop the "Other" checkbox
OlegPhenomenon Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,10 @@ CLAUDE.md
/app/assets/builds/*
!/app/assets/builds/.keep
.cursorindexingignore
.specstory
.specstory
# Local Claude Code workspace artifacts
.claude/

# AI / working docs — kept locally, not committed to the shared repo
/docs/
/style-guide/
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
27.05.2026
* Recommendation system v2: per-user auction sorting backed by
domain_classifications, time-decayed bid/wishlist/view affinity,
and nightly LLM enrichment as a k8s CronJob
(rake recommendation:classify_unclassified). No shared-infrastructure
changes — no RDS extensions, no Docker image bumps for the shared
dev Postgres. See docs/architecture/recommendation-system.md and
docs/architecture/adr-001-recommendation-v2.md.
* Batch impressions on /auctions index via insert_all (one query
regardless of page size), debounced score recompute on user actions.

30.12.2025
* Allow comma in billing profile street field https://github.com/internetee/auction_center/issues/1502
* Fixed false error message bug on billing profile update https://github.com/internetee/auction_center/issues/1503
Expand Down
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,84 @@ To send out emails and perform other asynchronous tasks, we use a background pro

Part of running the application according to EIS business rules includes creating new auctions at the beginning of the day. Jobs are scheduled outside of the application, as the exact times are no concern of the application.

## Recommendation system

The public auction list is personalised: each user sees auctions ranked by how
well every domain matches their behaviour and stated interests. Ranking is
built on a single embedding space ("magnets") — everything about a user (bids,
wishlist, views, selected categories, free-text interests) becomes a vector,
and domains are ranked by their pull towards those vectors.

### Prerequisite: feature flag

The whole system is gated by the OpenAI integration. In
`config/customization.yml`:

```yaml
openai:
enabled: true
access_token: 'sk-...'
```

When disabled, no classification, embedding or scoring runs and the list falls
back to the global `ai_score` / default order. A running job worker
(`bundle exec rails jobs:work`) is required for every `*_later` job below.

### What runs automatically (event-driven, no manual action)

| Trigger | Job | Effect |
|---|---|---|
| A new auction is created | `ClassifyDomainJob` (`Auction after_create`) | Classifies + embeds that domain |
| A user bids / wishlists / views a domain | `RefreshSingleUserAuctionScoresJob` (30 s debounce, via `EventTracker`) | Re-scores that one user |
| A user saves their interest profile | `RefreshSingleUserAuctionScoresJob` (debounced) | Re-scores that user |
| A user adds/edits free-text ("other") interests | `EmbedCustomInterestsJob` | Embeds the new interests, then re-scores |
| An admin adds/renames an interest category | `EnrichInterestCategoriesJob` | Generates description + keywords + embedding for that category |

In normal operation this is all that happens — the system keeps itself current
as auctions, bids and profiles change.

### What runs on a schedule (cron)

Only two scheduled commands (outside the app, like the other cron jobs):

```bash
bundle exec rake recommendation:init # the whole pipeline, incremental
bundle exec rake recommendation:prune_events # retention: events >6mo (impressions >1mo)
```

`init` is the single pipeline entry point. It is incremental (`force: false`):
it enriches interest categories, backfills embeddings for existing users' custom
interests, then **drains the batched classify + embed passes itself** — picking
up any domain that is unclassified, stale (>6mo), or whose vector was built under
an older embedding-input format (`DomainEmbedder::INPUT_VERSION`, so a format
bump rolls out on the next run with no extra command) — refreshes the global
`ai_score`, and recomputes every participant's personal scores. Already-done work
is **not** redone. Run it on every deploy; the same run also serves as the cron
catch-up, so there is no separate classify/embed cron task.

### What you run manually (rarely)

| Command / action | When to use |
|---|---|
| `rake recommendation:backfill` | **Once**, when first enabling the feature — classifies the full historical domain set (ended auctions, wishlist, offer histories, results), a wider universe than `init` (active only), so past-bid domains get embeddings and can form magnets |
| `rake recommendation:init_demo` | Staging/test only — seeds mock active auctions + signals, then runs the pipeline |
| `Recommendation::RebuildRecommendationsJob` on **/admin/jobs** | Full re-tag of **every** domain (`force: true`): re-classifies via the LLM (fills the `description` field) and re-embeds. Run after the interest-category catalog changes, or once to backfill descriptions after the embedding-enrichment change |

### Where to see the results (admin)

- **/admin/interest_categories** — each category shows its AI status
(enriched / pending); the edit page shows the generated description,
keywords and embedding details.
- **/admin/auctions/:id** — the auction detail page shows the domain's full LLM
classification (category, tags, keywords, audience, languages, brandability,
confidence, embedding status).

### Experimental: tags on the public list

`auction_tags_display_enabled` in `config/customization.yml` (default `false`)
swaps the "auction type" column on the public auction list for the domain's
LLM-derived tags. A missing key is treated as `false`.

## Audits

Due to various regulatory requirements, all database tables are audited according to the following procedure:
Expand Down
2 changes: 2 additions & 0 deletions app/assets/stylesheets/components/_account.scss
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@

.c-acount__buttons {
gap: 5px;
// Separate the Billing / Delete row from the "Edit interests" button above it.
margin-top: 24px;

@include mq(mobile) {
gap: 10px;
Expand Down
15 changes: 15 additions & 0 deletions app/assets/stylesheets/components/_badge.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@
}


// Removable interest tag: spacing + a bare "x" button. Shared by the
// server-rendered tags (recommendation_profiles/_fields) and the ones the
// custom-interest-tags Stimulus controller injects, so the two can't drift.
.c-badge--interest {
margin: rem(4px) rem(8px) rem(4px) 0;
}

.c-badge__remove {
border: none;
background: transparent;
cursor: pointer;
padding: 0;
line-height: 1;
}

.c-badge--gray {
background-color: $grey-100-color;
color: $grey-400-color;
Expand Down
1 change: 1 addition & 0 deletions app/components/common/header/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def admin_menu_list_items
{ name: t(:invoices_name), path: admin_invoices_path },
{ name: t(:jobs_name), path: admin_jobs_path },
{ name: t(:settings_name), path: admin_settings_path },
{ name: t(:interest_categories_name), path: admin_interest_categories_path },
{ name: t(:paid_deposits_name), path: admin_paid_deposits_path },
{ name: t(:statistics_name), path: admin_statistics_path }]
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@
<% else %>
<%# if need to create new offer for english auction %>
<%= component 'common/links/link_button', link_title: deposit_value[:link_title], href: new_auction_english_offer_path(auction_uuid: auction.uuid),
color: deposit_value[:color], options: { data: { turbo_frame: 'modal' } } %>
color: deposit_value[:color],
options: {
data: {
turbo_frame: 'modal',
controller: 'recommendation-tracker',
action: 'click->recommendation-tracker#track',
recommendation_tracker_auction_uuid_value: auction.uuid,
recommendation_tracker_source_value: 'auction_action_new_english_offer',
recommendation_tracker_event_type_value: 'auction_click'
}
} %>
<% end %>

<% else %>
Expand All @@ -17,7 +27,17 @@
<% else %>
<%# if need to create new offer for blind auction %>
<%= component 'common/links/link_button', link_title: t('auctions.bid'), href: new_auction_offer_path(auction_uuid: auction.uuid),
color: 'green', options: { data: { turbo_frame: 'modal' } } %>
color: 'green',
options: {
data: {
turbo_frame: 'modal',
controller: 'recommendation-tracker',
action: 'click->recommendation-tracker#track',
recommendation_tracker_auction_uuid_value: auction.uuid,
recommendation_tracker_source_value: 'auction_action_new_blind_offer',
recommendation_tracker_event_type_value: 'auction_click'
}
} %>
<% end %>

<% end %>
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<div class="c-btn-group c-btn-group--ghost">
<%= component 'common/links/link_button', link_title: nil, href: edit_offer_path(@auction.users_offer_uuid),
color: 'ghost', options: { data: { turbo_frame: 'modal' } } do %>
color: 'ghost',
options: {
data: {
turbo_frame: 'modal',
controller: 'recommendation-tracker',
action: 'click->recommendation-tracker#track',
recommendation_tracker_auction_uuid_value: @auction.uuid,
recommendation_tracker_source_value: 'auction_action_edit_blind_offer',
recommendation_tracker_event_type_value: 'auction_click'
}
} do %>
<span class="o-edit-icon"></span>
<% end %>
<%= component 'common/buttons/button_to', title_caption: nil, href: offer_path(@auction.offer_from_user(@user).uuid), color: 'ghost',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<div class="c-btn-group c-btn-group--ghost">
<%= component 'common/links/link_button', link_title: nil, href: edit_english_offer_path(@auction.users_offer_uuid),
color: 'ghost', options: { data: { turbo_frame: 'modal' } } do %>
color: 'ghost',
options: {
data: {
turbo_frame: 'modal',
controller: 'recommendation-tracker',
action: 'click->recommendation-tracker#track',
recommendation_tracker_auction_uuid_value: @auction.uuid,
recommendation_tracker_source_value: 'auction_action_edit_english_offer',
recommendation_tracker_event_type_value: 'auction_click'
}
} do %>
<span class="o-edit-icon"></span>
<% end %>
</div>
1 change: 1 addition & 0 deletions app/controllers/admin/auctions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def index

# GET /admin/auctions/1
def show
@classification = DomainClassification.find_by(domain_name: @auction.domain_name.to_s.downcase)
@offers = @auction.offers.order(cents: :desc)
users = User.search_deposit_participants(params)
@pagy, @users = pagy(users, items: params[:per_page] ||= 20, link_extra: 'data-turbo-action="advance"')
Expand Down
64 changes: 64 additions & 0 deletions app/controllers/admin/interest_categories_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
module Admin
class InterestCategoriesController < BaseController
before_action :authorize_user
before_action :set_interest_category, only: %i[edit update destroy]

# GET /admin/interest_categories
def index
@interest_categories = InterestCategory.ordered
end

# GET /admin/interest_categories/new
def new
@interest_category = InterestCategory.new(active: true, position: next_position)
end

# POST /admin/interest_categories
def create
@interest_category = InterestCategory.new(interest_category_params)

if @interest_category.save
redirect_to admin_interest_categories_path, notice: t(:created)
else
render :new, status: :unprocessable_entity
end
end

# GET /admin/interest_categories/1/edit
def edit; end

Check failure on line 28 in app/controllers/admin/interest_categories_controller.rb

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add a nested comment explaining why this function is empty or complete the implementation.

See more on https://sonarcloud.io/project/issues?id=internetee_auction_center&issues=AZ9A8GWK9FzKsEyqIW2I&open=AZ9A8GWK9FzKsEyqIW2I&pullRequest=1585

# PUT /admin/interest_categories/1
def update
if @interest_category.update(interest_category_params)
redirect_to admin_interest_categories_path, notice: t(:updated)
else
render :edit, status: :unprocessable_entity
end
end

# DELETE /admin/interest_categories/1
def destroy
@interest_category.destroy
redirect_to admin_interest_categories_path, notice: t(:deleted)
end

private

def set_interest_category
@interest_category = InterestCategory.find(params[:id])
end

def interest_category_params
params.require(:interest_category)
.permit(:code, :name_en, :name_et, :position, :active)
end

def next_position
(InterestCategory.maximum(:position) || 0) + 1
end

def authorize_user
authorize! :manage, InterestCategory
end
end
end
28 changes: 28 additions & 0 deletions app/controllers/auctions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def index
limit: per_page_count,
link_extra: 'data-turbo-action="advance"'
)
@show_recommendation_prompt = current_user&.recommendation_profile_promptable?
@auction_classifications = preloaded_classifications(@auctions)

track_recommendation_impressions

respond_to do |format|
format.html
Expand All @@ -36,6 +40,17 @@ def cors_preflight_check

def fetch_auctions_list = Auction.active.search(params, current_user)

# Preload LLM tags for the list only when the experimental tags column is on,
# keyed by downcased domain name so the partial avoids an N+1.
def preloaded_classifications(auctions)
return nil unless AuctionCenter::Application.config.customization[:auction_tags_display_enabled]

domain_names = auctions.map { |auction| auction.domain_name.to_s.downcase }.uniq
DomainClassification
.where(domain_name: domain_names)
.index_by { |classification| classification.domain_name.to_s.downcase }
end

def per_page_count
count = params[:show_all] == 'true' ? @auctions_list.count : per_page
count = nil if count.zero?
Expand All @@ -60,4 +75,17 @@ def set_access_control_headers
def authorize_user
authorize! :read, Auction
end

def track_recommendation_impressions
return unless current_user
return unless request.format.html?

Recommendation::EventTracker.track_impressions(
user: current_user,
auctions: @auctions,
source: 'auctions#index',
request:
)
end

end
Loading