Skip to content

Feature/recommendation system improvements - #1585

Open
OlegPhenomenon wants to merge 42 commits into
masterfrom
feature/recommendation-system-improvements
Open

OlegPhenomenon wants to merge 42 commits into
masterfrom
feature/recommendation-system-improvements

Conversation

@OlegPhenomenon

@OlegPhenomenon OlegPhenomenon commented May 29, 2026

Copy link
Copy Markdown
Contributor

Overview

Personalises the public auction list. Every auction is ranked per user by how
well the domain matches their behaviour and stated interests, built on a single
embedding space ("magnets"): everything about a user — bids, wishlist, views,
selected interest categories, free-text interests — becomes a vector, and
domains are ranked by their pull towards those vectors. Multilingual for free
(Estonian ≈ English in the embedding space).

Admins can also see the AI-generated data: LLM classification on the auction
detail page, and enrichment (description / keywords / embedding) on interest
categories.

How to use the recommendation system

Prerequisite: feature flag

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

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

When disabled, nothing 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.

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

In normal operation this is all that happens — the system keeps itself current.

Runs on a schedule (cron)

Batched catch-up for bulk-created or stale (6-month refresh) domains. Schedule
these like the other cron jobs (outside the app):

bundle exec rake recommendation:classify_unclassified   # ClassifyUnclassifiedDomainsJob (batched)
bundle exec rake recommendation:embed_unembedded        # EmbedUnembeddedDomainsJob (batched)

Run manually

After every deploy (and after first enabling the feature) run the full
pipeline once. It is incremental (force: false) — enriches interest
categories, backfills embeddings for existing users' custom interests,
classifies + embeds any domains still missing it, refreshes the global
ai_score, and recomputes every participant's personal scores. Work already
done is not redone.

bundle exec rake recommendation:init

Other manual entry points:

Command / action When to use
rake recommendation:init After a deploy, or after first turning the feature on
rake recommendation:init_demo Staging/test only — seeds mock active auctions + signals, then runs the pipeline
rake recommendation:backfill One-shot heuristic classification of historical domains (LLM refines them later)
rake recommendation:compare_versions Dev tool — prints the ranking for inspection (LIMIT=, USER_ID=)
Recommendation::RebuildRecommendationsJob on /admin/jobs Full re-tag of every domain (force: true). Run after the interest-category catalog changes (add/rename/delete)

Where to see results (admin)

  • /admin/interest_categories — AI status (enriched / pending) per category;
    the edit page shows the generated description, keywords and embedding details.
  • /admin/auctions/:id — 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.

Deployment note

The pipeline includes a backfill step for existing users' custom interests, so
running rake recommendation:init once after deploy is enough — existing
interests and already-processed domains are handled without re-processing.

OlegPhenomenon and others added 25 commits May 27, 2026 11:21
Initial implementation of personalized auction sorting:

- recommendation_profiles, recommendation_events, user_auction_scores tables
- baseline rule-based Scorer (wishlist hit, tag/custom-interest match,
  bid/wishlist affinity, structural bonuses, AI prior)
- AuctionDomainClassifier via OpenAI structured outputs
- ClassifyAuctionDomainsJob + RefreshUserAuctionScoresJob
- Auction::UserSortable extended to 4/5-tier priority with LEFT JOIN
  on user_auction_scores
- DatasetSnapshot service for offline model training pipeline
- ScoreImporter for external score uploads
- RecommendationProfile fields embedded in sign-up form
- Prompt modal on /auctions for users without filled profile
- Event tracking from controllers (impressions, clicks, bids, wishlist)
- recommendation_tracker Stimulus controller for client-side clicks
- custom_interest_tags Stimulus controller (tag input UX)
- OpenaiStructuredOutputSupport helper (model fallback, temperature)
- Tests for scorer, classifier, dataset snapshot, score importer,
  recommendation profile model and controller
- en/et locales for recommendation profile UI

This commit captures the pre-v2 state on branch
feature/recommendation-system-improvements. Following commits introduce
the v2 plan documented in docs/architecture/recommendation-system.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two living documents describing the planned v2 of the recommendation
system on this branch:

- docs/architecture/recommendation-system.md
  Goal, data flow, table responsibilities, scorer signals,
  classification tiers, cron schedule, cost estimate, phase list.

- docs/technical/domain-classification-pipeline.md
  Component layout, Tier 0 heuristic algorithm, Tier 2 LLM batch flow,
  embedding pipeline, triggers, backfill, migration from v1, tests.

Key v2 decisions captured:
- domain_classifications becomes single source of truth, decoupled
  from auctions (covers wishlist domains and historical bids too)
- LLM is cron-only (daily k8s CronJob), never per-request
- pgvector embeddings for similarity-based affinity
- Time decay on behavioural signals (half-life 60d)
- Heuristic Ruby classifier handles 60-70% offline, LLM enriches the rest
- AWS RDS Postgres 17 supports pgvector natively; no Dockerfile/IaC
  changes required, only one-time CREATE EXTENSION

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of recommendation system v2 (see
docs/architecture/recommendation-system.md).

- EventTracker.track_impressions now uses RecommendationEvent.insert_all
  for a single SQL INSERT regardless of page size. /auctions index used
  to fire N inserts per render (one per visible card).
- RefreshSingleUserAuctionScoresJob gains enqueue_debounced (30s wait)
  and skip-if-fresh guard, so a burst of user actions collapses into a
  single recompute pass rather than N identical jobs.
- All controllers switched to enqueue_debounced.
- Recommendation::Scorer default scope narrows to auctions ending within
  SCORING_HORIZON (30 days). top_auctions_for, refresh_for and the
  constructor share the same default.
- Auction::UserSortable#interest_match_sql now whitelists categories
  against InterestCatalog before SQL quoting and caps custom-interest
  LIKE clauses at MAX_CUSTOM_INTERESTS_IN_SQL (10) with a 2-char min
  to avoid pathological patterns.
- Log level for tracking failures bumped from info to warn.
- New tests for batched impressions (single INSERT assertion) and
  debounce skip/refresh behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .claude/ directory contains per-developer local state from the
Claude Code agentic harness (ralph-loop checkpoint, local settings).
It accidentally landed in the previous commit; this commit untracks
it and excludes the whole directory going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of recommendation system v2 (see
docs/architecture/recommendation-system.md).

Schema:
- New table domain_classifications, one row per domain_name.
  Stores rich features: primary_category, tags[], description,
  description_locale, keywords[], audience, languages[],
  suggested_use_cases[], structural cache (has_digits, has_hyphens,
  token_count, dictionary_word, brandability_score), provenance
  (classification_source, classification_model, confidence,
  classified_at, raw_llm_response).
- Indexes: unique on domain_name and uuid; GIN on tags and keywords;
  btree on primary_category, audience, classified_at,
  classification_source.

Model:
- DomainClassification with validations, normalized domain_name,
  source/refresh scopes (needs_llm_enrichment, low_confidence,
  unclassified, classified) and predicates.

Tier 0 services:
- DomainStructuralAnalyzer extracts deterministic structural features
  and tokens.
- DomainDictionary holds et+en root -> InterestCatalog category
  mappings and a greedy longest-prefix tokenizer.
- DomainHeuristicClassifier composes the two and emits a hash ready
  to upsert into domain_classifications, including confidence and
  brandability_score heuristics.

Tests:
- Analyzer covers digits, hyphens, tokenization, numeric-only.
- Heuristic classifier covers Estonian dictionary words, compound
  English domains, numeric domains, unknown low-confidence cases,
  metadata, and brandability deltas.
- DomainClassification model: validation, normalization, uniqueness,
  needs_llm_enrichment scope across heuristic / low-confidence / stale
  branches.

Note: embedding column and its scope land in Phase 5 (pgvector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3a of recommendation system v2.

- Recommendation::DomainClassifier is the synchronous entry point for
  classifying a single domain. It runs structural + heuristic Tier 0
  and upserts into domain_classifications. The LLM path is never
  triggered from runtime — it lives behind the cron-only batch job
  (Phase 3b).
- Idempotent: skips work when an existing row is fresh (within
  FRESH_WINDOW = 1.hour) unless force: true.
- Preserves LLM-enriched fields when re-running on a row whose source
  is openai, so heuristic passes never clobber better data.
- Recommendation::ClassifyDomainHeuristicallyJob wraps the orchestrator
  for use by event triggers (Phase 4).
- Tests cover unseen domain, freshness guard, force recompute,
  LLM-field preservation, blank input, and case-insensitive input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3b of recommendation system v2.

- Recommendation::LlmDomainClassifier: replaces the existing
  AuctionDomainClassifier with a richer JSON schema covering
  description, description_locale, keywords, audience, languages,
  suggested_use_cases, brandability_score, confidence. Tags and
  primary_category are enum-constrained to InterestCatalog. Raw
  response is captured so future schema migrations can re-parse
  without re-billing OpenAI. Accepts plain domain names rather than
  Auction records, decoupling classification from the auctions table.
- Recommendation::ClassifyUnclassifiedDomainsJob: cron-only entry
  point. Pulls DomainClassification rows that need LLM enrichment
  (heuristic source, low confidence, or stale openai rows), batches
  them at LlmDomainClassifier::BATCH_LIMIT, and upserts. Guarded by
  Feature.open_ai_integration_enabled?. Logs processed count.
  Provides needs_to_run? for the admin Job UI.
- lib/tasks/recommendation.rake: defines classify_unclassified,
  embed_unembedded, and backfill task targets for k8s CronJobs.
  Placeholder branches gracefully skip until later phases land.
- app/models/job.rb: registers the new job in ALLOWED_JOB_NAMES so
  admins can also trigger it manually.
- Tests cover feature-flag gating, scope selection (heuristic,
  low-conf, stale, fresh-llm), and needs_to_run? predicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of recommendation system v2.

Triggers fire ClassifyDomainHeuristicallyJob (Tier 0 only — heuristic,
free, no external calls). LLM enrichment still happens exclusively
through the nightly cron job.

- Auction after_create: enqueue_domain_classification by domain_name.
- WishlistItem create: trigger on @wishlist_item.domain_name so
  domains that aren't auctions still get tags.
- OffersController create/update: trigger on @auction.domain_name so
  legacy auctions created before this feature still get classified
  when someone bids.
- EnglishOffersController create/update: same pattern.

BackfillDomainClassificationsJob:
- Collects unique domain names from Auction, WishlistItem,
  DomainOfferHistory, Result via safe_pluck (skips missing tables/
  columns).
- Skips already-classified domains.
- Runs heuristic for each via DomainClassifier (idempotent).
- Logs progress.
- Invoked by `rake recommendation:backfill` (k8s one-shot).

Tests cover heuristic-job idempotency, blank input, and the
auction-create -> classification enqueue path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of recommendation system v2.

Dependencies / schema:
- Gemfile: add neighbor (~> 0.5) for pgvector ActiveRecord integration.
- Migration enable_pgvector_and_add_embeddings: enables 'vector'
  extension (idempotent), adds embedding vector(1536), embedding_model,
  embedded_at columns, and an HNSW index for cosine distance.
- AWS RDS Postgres 17.4 supports pgvector natively; no Dockerfile or
  Terraform changes required. CREATE EXTENSION may need a one-time
  manual run as rds_superuser if the app DB role lacks the grant.

Model:
- DomainClassification has_neighbors :embedding (guarded so model loads
  before the migration has run).
- New needs_embedding scope, also guarded.

Services / jobs:
- Recommendation::DomainEmbedder wraps OpenAI text-embedding-3-small,
  batches up to BATCH_LIMIT=100 rows per call. Input is
  "domain_name. description. keywords...". Accepts both records and
  hashes.
- Recommendation::EmbedUnembeddedDomainsJob is the cron-only entry
  point. Picks rows with description but no embedding, batches them,
  upserts vectors via update_columns. Guarded by feature flag and
  presence of the embedding column.

Wiring:
- Job model registers EmbedUnembeddedDomainsJob in ALLOWED_JOB_NAMES.
- rake recommendation:embed_unembedded already wired in Phase 3b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 6 of recommendation system v2.

Recommendation::Scorer rewritten to consume the v2 signal stack:

- Tags + keywords + audience pulled from domain_classifications,
  joined by domain_name. Falls back to auctions.classification_tags
  for legacy or yet-to-be-classified domains.
- Behavioural affinity (bids, wishlist, views, domain_offer_history)
  is time-decayed with HALF_LIFE_DAYS=60 via exp(-days/60). Old
  signals naturally vanish without explicit cutoff dates.
- New audience match bonus (+10) when domain_classifications.audience
  matches a profile preference.
- View affinity (weight 4, cap 12) sourced from
  RecommendationEvent#auction_detail_view (Phase 7 hooks it up).
- DomainOfferHistory affinity (weight 3, cap 12) — Estonian-auction
  historical bids beyond the offers table.
- Result signal: lost auctions on a tag boost the tag (+25 decayed);
  won auctions slightly damp it (-5 decayed). Defensive guards
  around variant Result schemas.
- Embedding multiplier: 1 + max(0, cosine(auction.embedding, user_centroid))
  where user_centroid is the time-decayed weighted average of
  embeddings from bids + wishlist + views. Guarded so it returns 1.0
  when pgvector column missing or no user signals.
- Classifications preloaded for the entire @scope up front to avoid
  N+1.
- FEATURES_VERSION='rich_v1' and BASELINE_MODEL_NAME='baseline_rules_v2'
  mark scored rows so a future model upgrade can detect stale rows.

Existing scorer_test.rb relies on auctions.classification_tags
fallback path and continues to work. New scorer_rich_features_test.rb
verifies keyword overlap boost, time-decay attenuation of ancient
bids, and features_version metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 7 of recommendation system v2.

Server-side capture:
- OffersController#new and EnglishOffersController#new now call
  EventTracker with event_type='auction_detail_view'. Source field
  records which controller fired so debugging is easy.
- Both ignore unauthenticated requests via the existing
  authenticate_user! pipeline.

Client-side capture:
- New Stimulus controller recommendation_dwell_controller attaches
  to auction cards and fires an auction_detail_view event ONLY
  after the card stays at >= 50% visibility for dwellMsValue
  (default 1500ms). Uses navigator.sendBeacon when available so
  events survive page navigation.
- Registered in app/javascript/controllers/index.js.
- Cards opt in by attaching the controller and providing
  recommendation-dwell-auction-uuid-value. No-op when the value is
  absent or IntersectionObserver is unsupported.

Scorer-side: view_feature_aggregate (weight 4, cap 12) already wired
in Phase 6 reads from RecommendationEvent#auction_detail_view, so
these new signals flow directly into the score.

Integration test verifies offers#new persists the detail-view event
with the expected user, auction, and source attribution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 8 of recommendation system v2.

- AuctionsController#index preloads DomainClassification rows for the
  paginated auctions into @domain_classifications (single batched
  SELECT keyed by lowercased domain_name). Defensively returns {}
  if the table is not yet migrated.
- index.html.erb passes the matching DomainClassification to each
  row partial as a local.
- _auction.html.erb renders, when present:
  - description as a muted paragraph beneath the domain name
  - first four keywords as small badges
  Layout degrades silently when nothing is classified.
- Each authenticated row gains recommendation-dwell Stimulus values
  so a 1.5s in-viewport hover fires auction_detail_view via
  navigator.sendBeacon. Anonymous visitors don't trigger tracking.

Layout uses inline styles to avoid touching the global CSS pipeline
in this phase; a follow-up can move them to dartsass partials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 9 of recommendation system v2.

UX polish:
- UsersController#create: if a brand-new user signed up without
  filling the recommendation profile, call dismiss_prompt! on the
  empty profile. This stops the modal from popping up on the next
  /auctions render right after they explicitly skipped the form.
  They will see it again after PROMPT_REMINDER_INTERVAL (14 days).

Documentation:
- docs/architecture/recommendation-system.md: mark every phase as
  done, link to the operator runbook.
- docs/guides/recommendation-operations.md: new operator-facing
  runbook with environment prerequisites, k8s cron schedule,
  first-time rollout checklist, monitoring queries, tunable
  constants, rollback steps, and a troubleshooting section
  covering pgvector permission errors and language mismatches.
- CHANGELOG: high-level entry under today's date.

No code changes outside the sign-up dismiss and docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-review pass after iteration 1.

Scorer improvements:
- compute_result_signal previously did one SELECT per Result row
  to fetch tags. preload_result_classifications now batches a single
  SELECT keyed by lowercased domain_name.
- dominant_user_audience replaces the unreachable
  profile.audience_preference branch. Audience preference is inferred
  from the user's own bid+wishlist history (majority audience across
  classified domains). Cached per scorer instance.
- bid_wishlist_classification_cache memoises the join so audience
  inference doesn't trigger extra round-trips when several auctions
  are scored in the same pass.

Deprecation markers:
- Recommendation::AuctionDomainClassifier and
  Recommendation::ClassifyAuctionDomainsJob get clear DEPRECATED
  comments. Both stay live so the legacy auctions.classification_*
  fallback path keeps working until those columns are dropped in a
  later release.

New tests:
- LlmDomainClassifierTest uses WebMock stubs to verify structured-
  output parsing, enum filtering of bogus tags, batch-limit
  truncation, empty input handling, and incomplete-response error.
- DomainEmbedderTest covers vector length, AR-row input, empty
  input, and OpenAI error response.

Test compatibility verified by walking through the legacy scorer_test
expectations:
- wishlist > category > custom > neutral ordering preserved via the
  auctions.classification_tags fallback path in aggregate_features.
- bid-tag affinity still flows through the same fallback for
  fixtures that have no domain_classifications row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final polish pass.

Style extraction:
- _auction.html.erb dropped all inline style attributes.
- New _recommendation.scss component module defines
  .c-auction__domain-description, .c-auction__domain-keywords, and
  .c-auction__domain-keyword. Imported from _components.scss.

Tests:
- BackfillDomainClassificationsJobTest covers full happy-path
  classification of auction + wishlist domains, skip-existing
  behaviour, and resilience to per-domain failures.
- EmbedUnembeddedDomainsJobTest covers no-op without the pgvector
  column, no-op when OpenAI is disabled, and scope filtering of rows
  without descriptions.

Architectural decision record:
- docs/architecture/adr-001-recommendation-v2.md captures the seven
  design decisions of v2: per-domain classification table, three-tier
  pipeline, RDS-native pgvector with no Dockerfile changes, infra-
  side cron, time decay over SQL date cutoffs, multiplicative
  embedding multiplier, and heuristic-only-at-runtime policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 migration crashed on local dev because docker-images/
docker-compose.dev.v2.yml uses plain postgres:13.4 which has no
pgvector. Production (RDS 17) was always fine. Two fixes:

Migration:
- ensure_pgvector_available! probes pg_available_extensions before
  trying CREATE EXTENSION and raises a clear PgvectorUnavailable
  error with remediation steps (point at the pgvector image and the
  SKIP env var) instead of the cryptic "vector.control not found".
- Down migration uses column_exists? guards so a partial-rollback
  doesn't blow up.
- SKIP_PGVECTOR_MIGRATION=true env var lets operators skip the
  whole migration when they cannot upgrade the image right away;
  the recommendation system degrades to tag/keyword scoring with
  no similarity multiplier and can be re-migrated later.

Operations:
- Updated docs/guides/recommendation-operations.md prerequisites
  table and troubleshooting section with the dev-image guidance
  and the SKIP env-var instructions.

Note: the shared compose change to pgvector/pgvector:pg13 lives
in registry/docker-images/docker-compose.dev.v2.yml (separate
repo) and is staged as part of this hotfix flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of the recommendation v2 plan (pgvector + embedding similarity)
is reversed before merge. Rationale: provisioning pgvector touches
shared infrastructure across dev, staging, test and prod for marginal
ranking value. See docs/architecture/adr-001-recommendation-v2.md
decision D3 for the full reasoning.

Removed:
- Gemfile / Gemfile.lock: neighbor gem dropped
- app/services/recommendation/domain_embedder.rb
- app/jobs/recommendation/embed_unembedded_domains_job.rb
- test/services/recommendation/domain_embedder_test.rb
- test/jobs/recommendation/embed_unembedded_domains_job_test.rb
- Embedding multiplier, user centroid, cosine similarity helpers,
  embedding_for, EMBEDDING_DIMENSIONS, needs_embedding scope, and
  has_neighbors association from Scorer / DomainClassification.
- ALLOWED_JOB_NAMES entry for EmbedUnembeddedDomainsJob.
- `recommendation:embed_unembedded` rake task.

Migration 20260527090100_enable_pgvector_and_add_embeddings now does
the inverse: if a local environment previously applied the original
version and has the embedding column, the up branch drops it cleanly.
Production and fresh local environments hit a no-op.

What stays:
- domain_classifications table including description, keywords,
  audience, suggested_use_cases — all the rich fields the scorer
  actually uses.
- LLM enrichment via nightly cron, heuristic Tier 0 at runtime.
- Tag overlap, keyword overlap, behavioural affinity with time decay,
  audience inference, result signals, structural bonuses, view tracking.

Docs updated: architecture/recommendation-system.md,
technical/domain-classification-pipeline.md,
guides/recommendation-operations.md,
architecture/adr-001-recommendation-v2.md, CHANGELOG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Description was originally added to back UI card copy and to feed
the embedding text input. With embeddings dropped (and now coming
back as keywords-only input — see follow-up commit) the description
no longer earns its OpenAI tokens.

Schema:
- New migration 20260527090200 removes description and
  description_locale columns from domain_classifications.

Services:
- LlmDomainClassifier JSON schema and system prompt drop the
  description / description_locale fields. Required-fields list
  trimmed accordingly.
- DomainHeuristicClassifier no longer emits description / locale
  in its attribute hash.
- DomainClassifier orchestrator's preserve_llm_fields! no longer
  references description fields.

UI:
- _auction.html.erb stops rendering the description paragraph.
  Keywords-as-badges remain.
- _recommendation.scss simplified to keyword-badge styling only.

Tests:
- LlmDomainClassifierTest assertions and fixtures dropped the two
  description fields.
- DomainClassifierTest preserve-LLM-fields case switched to assert
  keywords are preserved instead of description.

Docs:
- recommendation-system.md, adr-001, and operations runbook updated
  to reflect the schema change and the keyword-only UX.

structure.sql includes the original create-table migration run from
local dev — the new remove-column migration will rewrite it cleanly
on next db:migrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings back vector similarity matching without pgvector, without any
shared-infrastructure changes. Trade-off documented in ADR-001 D3.

Schema:
- Migration 20260527090300 adds three columns to domain_classifications:
    embedding         double precision[]
    embedding_model   string
    embedded_at       datetime (indexed)
- No extensions, no special types. Works on plain Postgres 13/14/15/16/17.

Services:
- Recommendation::DomainEmbedder wraps OpenAI text-embedding-3-small
  (1536 dim). Input is "<domain_name>. <keywords>" — description was
  dropped earlier, keywords carry the semantic context. Batches up to
  BATCH_LIMIT=100 rows per OpenAI call. Accepts both ActiveRecord rows
  and plain hashes.
- Recommendation::EmbedUnembeddedDomainsJob is the cron-only entry
  point (rake recommendation:embed_unembedded). Picks classified rows
  without an embedding, batches them, persists via update_columns.
  Guarded by Feature.open_ai_integration_enabled? and column presence.

Scorer:
- score_for now finishes with `score *= embedding_multiplier(auction)`.
- Multiplier formula:
    1 + max(0, cosine_similarity(user_centroid, auction.embedding))
  Range 1.0..2.0. No-op (1.0) when:
    - embedding column not migrated
    - user has no behavioural history yet
    - auction not embedded yet
- User centroid = time-decayed weighted average of embeddings from
  bids + wishlist + recent views.
- cosine_similarity computed in plain Ruby — ~50ms across 200 vectors
  at 1536 dims. No HNSW, no vector index needed at this scale.

Tests:
- DomainEmbedderTest: vector shape, AR-row input, empty input,
  OpenAI error handling.
- EmbedUnembeddedDomainsJobTest: column-missing safety, feature-flag
  gating, scope selection.
- ScorerEmbeddingTest: aligned-vector boost, no-op without history,
  with explicit skips when the embedding column is not yet migrated.

Wiring:
- Job model registers EmbedUnembeddedDomainsJob in ALLOWED_JOB_NAMES.
- rake recommendation:embed_unembedded restored.

Docs:
- architecture/recommendation-system.md, technical/domain-classification-pipeline.md,
  guides/recommendation-operations.md and ADR-001 updated to reflect
  the embedding path and the rationale for Postgres-native storage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DEMO_DOMAINS grouped by InterestCatalog category (~140 domains
  across local_service, health, shop_brand, saas, b2b_service,
  finance, legal, education, travel, automotive, real_estate,
  media_content, brandable, numeric, other). Mix of Estonian and
  English roots so the heuristic classifier hits cleanly on
  dictionary entries and falls through to LLM on novel patterns.
- demo:create_blind_auctions now seeds the full set (was 57, now 140).
- New demo:create_varied_auctions creates a small fan-out of auctions
  with different ends_at horizons (hour/day/week/month) so the
  SCORING_HORIZON=30.days clipping in Scorer can be exercised.
- New demo:seed_user_signals attaches a recommendation profile,
  wishlist items and historical Offers to the first participant
  user, then enqueues a score refresh — gives a one-command setup
  for verifying the personalised /auctions sort.
`starts_at = Time.zone.now + 1.second` was racing the starts_at_cannot_be_in_the_past validation: by the time Rails 8.1 finished booting and the validation ran, the timestamp was already in the past. Tighter Time.current check in the validator (or just slower boot) tipped it over.

Bumped the buffer to 5.minutes via a shared AUCTION_START_BUFFER constant used by both demo:create_blind_auctions and demo:create_varied_auctions. ends_at is now derived from starts_at so the relative horizons stay correct.
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🚀 Deploy Complete!

Property Value
App auction
Slot 1
URL https://auction1-dev.cloud.tld.ee
Namespace auction1-dev

(Environment ready for testing)

@sonarqubecloud

sonarqubecloud Bot commented Jun 1, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🚀 Deploy Complete!

Property Value
App auction
Slot 1
URL https://auction1-dev.cloud.tld.ee
Namespace auction1-dev

(Environment ready for testing)

1 similar comment
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🚀 Deploy Complete!

Property Value
App auction
Slot 1
URL https://auction1-dev.cloud.tld.ee
Namespace auction1-dev

(Environment ready for testing)

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🚀 Deploy Complete!

Property Value
App auction
Slot 1
URL https://auction1-dev.cloud.tld.ee
Namespace auction1-dev

(Environment ready for testing)

1 similar comment
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🚀 Deploy Complete!

Property Value
App auction
Slot 1
URL https://auction1-dev.cloud.tld.ee
Namespace auction1-dev

(Environment ready for testing)

OlegPhenomenon and others added 16 commits July 8, 2026 11:54
Add a per-domain Postgres advisory lock to ClassifyDomainJob so concurrent
enqueues (auction after_create + first bid/offer/wishlist) can no longer
each fire a paid OpenAI call for the same domain; a worker that cannot grab
the lock skips (the nightly ClassifyUnclassifiedDomainsJob is the backstop).

Reset embedding/embedding_model/embedded_at on every (re)classification so
EmbedUnembeddedDomainsJob recomputes the vector after keywords change,
closing the gap where a re-classified domain kept its stale embedding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gnals

Respect the wishlist_size Setting instead of blindly create!-ing, so a
participant whose wishlist is already full no longer aborts the task. Switch
wishlist and offer creation to non-bang save with error logging, and report
what was actually added rather than what was requested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Orchestration:
- PipelineRunner: classify -> embed -> ai_score -> per-user scores, run
  synchronously with batch draining and a placeholder-prompt guard
- rake recommendation:init (prod, incremental) and :init_demo
  (staging: active mock auctions + signals, then pipeline)
- RebuildRecommendationsJob in /admin/jobs (force re-tag after the
  interest catalog changes) + localized EN/ET notice on the categories page

Fixes:
- refresh_single_user job re-enqueues instead of silently dropping a
  debounced recompute
- backfill upsert resets stale embeddings (mirrors classify job)
- InterestCategory#destroy purges orphaned codes from profiles and
  domain classifications
- correct stale comments in classify_domain_job / llm_domain_classifier

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Delete unused DomainClassification scopes (unclassified, by_source,
  low_confidence) and #heuristic?, RecommendationProfile#interest_categories_labels,
  Auction#classified? — no call sites anywhere
- Scorer: memoize the four signal collectors (bid/wishlist/view/offer-history);
  they were re-queried 2-4x per refresh, now once
- Add missing recommendation_profiles.summary.{custom_interests,length} keys
  (EN/ET) that summary_lines rendered as "translation missing"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Performance (behavior-preserving):
- Add functional index on LOWER(domain_classifications.domain_name) so the
  /auctions user-sort join uses an index instead of seq-scanning
- Add wishlist_items(user_id) index for the recommendation hot paths

Refactors:
- Extract embedding vector math (cosine similarity, weighted centroid) from
  the 579-line Scorer into a pure Recommendation::Embedding module + unit tests
- Move prompt-lifecycle side effects (rescore enqueue + event tracking) from
  RecommendationProfilesController/UsersController into RecommendationProfile
  #complete!/#skip!/#dismiss!, deduping the two controllers
- Drop the redundant user_auction_scores uniqueness validation (DB unique index
  + upsert_all already enforce it; no form surfaces it)
- Remove dead error-swallowing rescues in Scorer result signals (guards already
  cover the model-absent case)
- Extract the removable-interest badge markup into .c-badge--interest /
  .c-badge__remove so the ERB and Stimulus renderings can't drift; share the
  CSRF-token lookup between the two recommendation-event controllers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the v2 hybrid (string-matched interests + ~12 hand-tuned weights +
embedding multiplier) with a single vector space where everything about the
user — bids, wishlist, views, selected categories, custom interests — is an
embedding "magnet". A candidate is scored by the mean of its two strongest
weighted-cosine pulls; no magnet/embedding means no row, so it falls to the
ai_score/RANDOM tail.

Phase A (data + enrichment):
- interest_categories: description, keywords[], embedding[], embedded_at
- recommendation_profiles: custom_interest_vectors (jsonb), embedded_at
- InterestCategoryEnricher (LLM keywords EN+ET → embedding), TextEmbedder
- EnrichInterestCategoriesJob, EmbedCustomInterestsJob + model auto-triggers
- PipelineRunner gains enrich_categories + force-mode invalidation

Phase B (shadow): MagnetScorer computes v3 in memory (no persistence);
rake recommendation:compare_versions prints v2 vs v3 side by side.

Phase C (switchover): Scorer writes v3 as primary (magnet base x100 +
length/digits/hyphen + result signal); drops WISHLIST_HIT, TAG/KEYWORD/
AUDIENCE weights, all affinities, embedding multiplier, centroid, ai_prior,
SIMILAR_DOMAIN_BONUS. UserSortable collapses 5 tiers to 3 (offer / wishlist /
score-or-tail); interest_match_sql and its domain_classifications join removed.

Tests: 159 runs, 0 failures in Docker. Removed obsolete v2 scorer tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The after_save_commit trigger only embeds a profile's custom interests on save,
so users who set free-text interests before v3 (or before a force reset) would
never get custom magnets on `recommendation:init`. Add an idempotent
embed_custom_interests stage (only profiles with unembedded custom interests)
plus a force-mode invalidation, so init fully processes existing users.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two operator-reported fixes:

1. Custom (free-text "other") interests could not be removed. The profile
   form had no empty sentinel for custom_interests[], so removing every tag
   dropped the param entirely, strong-params discarded it, and the setter
   that clears old values never ran. Add the sentinel; make `other` a purely
   derived marker of "has custom interests" so a dangling checkbox can't
   linger as an empty category.

2. Admin auction list & detail now surface the LLM-added classification —
   category, tags, keywords, audience, languages, use cases, brandability,
   confidence, source/model, and embedding status. Preloaded by domain name
   (no N+1); joined the same way the scorer does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behind the auction_tags_display_enabled flag in customization.yml (default
off). When enabled, the public auction list swaps the "auction type" column
for the domain's LLM-derived tags (rendered as badges); when off, the
auction-type icon is shown exactly as before.

Tags come from DomainClassification, joined by domain name. The list preloads
them into a hash (no N+1) only when the flag is on; the single-row turbo-stream
path falls back to one lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Missing key already resolves to nil (feature off) via OrderedOptions, so the
absence is safe; add it to the sample for discoverability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extra column overflowed the already-wide admin auctions table. Keep the
full LLM breakdown on the auction detail page (vertical layout, no width
pressure); only the list column is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The category-side LLM enrichment (description, keywords, embedding) produced by
InterestCategoryEnricher was stored but never surfaced. Add an "AI status"
badge to the categories list (enriched/pending + keyword count) and a read-only
enrichment panel on the edit page showing the description, keyword badges,
embedding status, model and timestamp — with a clear "not enriched yet" note
and how to generate it when the vector is missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add an operator guide: the OpenAI feature flag prerequisite, what runs
automatically (event-driven jobs), what runs on cron, what to run manually
(recommendation:init after deploy, RebuildRecommendationsJob after catalog
changes), where to see results in admin, and the experimental tags flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address the ranking gaps surfaced in review: the per-domain embedding was
built from a deliberately sparse "<domain>. <keywords>" text, so custom
interests and wishlist look-alikes barely moved the ranking (cosine over
coarse vectors, no string matching).

Embedding enrichment (rolls out through the existing pipeline, no new tasks):
- DomainEmbedder#build_input now builds a rich structured text
  (description + category + tags + use cases + audience + keywords), skipping
  blank parts. Bump DomainEmbedder::INPUT_VERSION so needs_embedding re-embeds
  legacy rows on the next `recommendation:init` (explicit NULL branch — SQL
  where.not excludes NULLs). RebuildRecommendationsJob backfills descriptions.
- LlmDomainClassifier emits a new `description` (schema + prompt + persisted
  column via migration); shown on the admin auction classification panel.
- Custom-interest backfill in PipelineRunner now re-embeds a profile when its
  stored vectors no longer match its interests, so a silently-failed embed
  (OpenAI off / worker down at save time) self-heals on the next init.

Event retention:
- PruneRecommendationEventsJob deletes recommendation_events >6mo and
  auction_impression >1mo in batches; on /admin/jobs and `prune_events` rake.

Slim the operational surface (7 rake tasks -> 4):
- Drop the classify_unclassified/embed_unembedded rake wrappers (init already
  drains those batched jobs; the jobs stay, reachable via init and /admin/jobs)
  and the dev-only compare_versions inspector.
- Cron is now a single command: `recommendation:init` (+ `prune_events`).
- Rewrite the README recommendation section into one flow-map and fix the job
  comments that pointed at the removed tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "Other" checkbox was a client-side-only toggle that revealed the
free-text keyword input. Being the last item in the category grid it was
easy to miss, and unchecking it did not remove already-added keywords —
users could overlook the feature entirely (we have had support emails
asking for functionality that already exists).

Server-side, `other` is a derived marker: RecommendationProfile derives it
from having any free-text keyword and excludes it from scoring, so the
checkbox never affected stored data. Removing it aligns the UI with the
model:

- _fields.html.erb: exclude `other` from the checkbox grid; the keyword
  field is now always visible.
- custom_interest_tags_controller.js: drop the otherToggle/field targets and
  the toggle/syncVisibility logic; keep add/remove.
- Relabel to "Add your own keywords" / "Lisa oma märksõnad".

No model/controller/migration change: the sentinel empty param (needed to
clear the last keyword) stays, and the server still strips a stray `other`
posted without custom interests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant