Class: Feed::OpenaiAds::CatalogFeedGenerator

Inherits:
BaseService
  • Object
show all
Defined in:
app/services/feed/openai_ads/catalog_feed_generator.rb

Overview

Builds the OpenAI Ads product feed — a Google-Merchant-Center-compatible CSV
of our sellable catalog — and optionally writes it to a file for delivery to
OpenAI (uploaded in the Ads Manager Feeds area, or pushed to the SFTP location
OpenAI provisions there).

Mirrors Google::ListGenerator (same catalog scope + presenter
pipeline), but emits a single CSV document rather than caching per-item XML:
OpenAI ingests an uploaded/pushed catalog file, not a hosted feed URL, so
there's no per-request assembly to optimize and no cache table to maintain.

Usage:
Feed::OpenaiAds::CatalogFeedGenerator.new.call(output_file_path: path)

Defined Under Namespace

Classes: Result

Constant Summary collapse

BATCH_SIZE =
Feed::Google::ProductBatchLoader::BATCH_SIZE
COLUMNS =

Ordered CSV columns. GMC-standard attribute names (OpenAI's ads feed is
GMC-shaped) plus OpenAI's canonical is_ads_eligible flag. Each value is a
lambda over the ProductPresenter.

{
  'id'                      => ->(p) { p.id },
  'title'                   => ->(p) { p.title },
  'description'             => ->(p) { p.description },
  'link'                    => ->(p) { p.url },
  'image_link'              => ->(p) { p.image_link },
  'additional_image_link'   => ->(p) { p.additional_image_link },
  'availability'            => ->(p) { p.availability },
  'price'                   => ->(p) { p.price_with_currency },
  'sale_price'              => ->(p) { p.sale_price_with_currency if p.sale_price_in_effect? },
  'brand'                   => ->(p) { p.brand },
  'gtin'                    => ->(p) { p.upc },
  'mpn'                     => ->(p) { p.mpn },
  'condition'               => ->(p) { p.condition },
  'product_type'            => ->(p) { p.product_type },
  'google_product_category' => ->(p) { p.google_product_category },
  'item_group_id'           => ->(p) { p.item_group_name },
  'color'                   => ->(p) { p.color_info },
  'size'                    => ->(p) { p.size_info },
  'shipping_weight'         => ->(p) { p.shipping_weight },
  'is_ads_eligible'         => ->(p) { p.ads_eligible? },
  'seller_name'             => ->(_p) { 'WarmlyYours' }, # required merchant identity field (OpenAI case #10328641)
  # "Reviews and Q&A" fields powering the refreshed product card's star
  # rating. Product-line aggregates per row; store aggregates uniform.
  'review_count'            => ->(p) { p.review_count },
  'star_rating'             => ->(p) { p.star_rating },
  'store_review_count'      => ->(p) { p.store_review_count },
  'store_star_rating'       => ->(p) { p.store_star_rating }
}.freeze

Instance Method Summary collapse

Instance Method Details

#assign_review_stats(presenters, store_stats: ReviewsIo.store_review_stats) ⇒ void

This method returns an undefined value.

Batch-loads Reviews.io aggregates and injects them into each presenter:
product-line stats (keyed by line id, from
ReviewsIo.stats_for_product_lines) for the per-item review_count /
star_rating cells, and one store-wide stats hash for the uniform
store_review_count / store_star_rating cells. One grouped query per
bounded batch instead of per-row lookups; store stats are loaded once.

Parameters:



142
143
144
145
146
147
148
149
150
# File 'app/services/feed/openai_ads/catalog_feed_generator.rb', line 142

def assign_review_stats(presenters, store_stats: ReviewsIo.store_review_stats)
  product_lines = presenters.filter_map { |p| p.item.primary_product_line }.uniq
  line_stats = ReviewsIo.stats_for_product_lines(product_lines)

  presenters.each do |presenter|
    presenter.review_stats = line_stats[presenter.item.primary_product_line&.id]
    presenter.store_review_stats = store_stats
  end
end

#build_csv(presenters) ⇒ String

self.class::COLUMNS (not the lexical constant) so subclasses like
Feed::OpenaiCommerce::CatalogFeedGenerator get their own column set.

Parameters:

Returns:

  • (String)

    CSV with a header row + one row per presenter.



156
157
158
159
160
161
# File 'app/services/feed/openai_ads/catalog_feed_generator.rb', line 156

def build_csv(presenters)
  CSV.generate do |csv|
    csv << self.class::COLUMNS.keys
    presenters.each { |presenter| csv << row_for(presenter) }
  end
end

#call(catalogs: nil, output_file_path: nil, limit: nil) ⇒ Result

Parameters:

  • catalogs (ActiveRecord::Relation<Catalog>, nil) (defaults to: nil)

    defaults to the main
    (US + CA) catalogs, matching the Google Shopping feed scope.

  • output_file_path (String, Pathname, nil) (defaults to: nil)

    when present, the CSV is
    written here (UTF-8).

  • limit (Integer, nil) (defaults to: nil)

    cap products per catalog (dev/debug).

Returns:



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'app/services/feed/openai_ads/catalog_feed_generator.rb', line 69

def call(catalogs: nil, output_file_path: nil, limit: nil)
  catalogs ||= Catalog.for_google_feed
  product_count = 0
  store_stats = ReviewsIo.store_review_stats
  csv = CSV.generate do |output|
    output << self.class::COLUMNS.keys
    catalogs.each do |catalog|
      each_product(catalog, limit:, store_stats:) do |presenter|
        output << row_for(presenter)
        product_count += 1
      end
    end
  end

  if output_file_path
    logger.info "Writing OpenAI Ads product feed (#{product_count} products) to #{output_file_path}"
    File.write(output_file_path, csv)
  end

  Result.new(csv:, output_file_path: output_file_path&.to_s, product_count:)
end

#each_product(catalog, limit: nil, batch_size: BATCH_SIZE, store_stats: ReviewsIo.store_review_stats) {|presenter| ... } ⇒ Enumerator, void

Yields one bounded batch of presenters at a time. Query caching is disabled
for the traversal so item/image association results do not remain reachable
until the whole Sidekiq job ends.

Parameters:

  • catalog (Catalog)
  • limit (Integer, nil) (defaults to: nil)
  • batch_size (Integer) (defaults to: BATCH_SIZE)
  • store_stats (Hash) (defaults to: ReviewsIo.store_review_stats)

    store-wide Reviews.io aggregates

Yield Parameters:

Returns:

  • (Enumerator, void)


114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'app/services/feed/openai_ads/catalog_feed_generator.rb', line 114

def each_product(catalog, limit: nil, batch_size: BATCH_SIZE, store_stats: ReviewsIo.store_review_stats, &block)
  return enum_for(__method__, catalog, limit:, batch_size:, store_stats:) unless block

  I18n.with_locale(catalog.locale_for_catalog) do
    Feed::Google::ProductBatchLoader.new.each_batch(catalog, limit:, batch_size:) do |batch|
      presenters = batch.records.map do |record|
        presenter_class.new(
          record,
          nil,
          country_iso: batch.country_iso,
          variant_metadata: batch.[record.item_id]
        )
      end.select(&:feed_includable?)
      assign_review_stats(presenters, store_stats:)
      presenters.each(&block)
    end
  end
end

#load_products(catalog, limit: nil) ⇒ Array<Feed::OpenaiAds::ProductPresenter>

Catalog-item presenters for one catalog, in the catalog's locale, restricted
to products that belong in the feed file.

Parameters:

  • catalog (Catalog)
  • limit (Integer, nil) (defaults to: nil)

    cap products per catalog (dev/debug)

Returns:



101
102
103
# File 'app/services/feed/openai_ads/catalog_feed_generator.rb', line 101

def load_products(catalog, limit: nil)
  each_product(catalog, limit:).to_a
end

#presenter_classClass

Presenter class built per catalog item. Feed::OpenaiCommerce::CatalogFeedGenerator
overrides this to reuse the whole loading/scoping pipeline.

Returns:

  • (Class)


94
# File 'app/services/feed/openai_ads/catalog_feed_generator.rb', line 94

def presenter_class = Feed::OpenaiAds::ProductPresenter

#row_for(presenter) ⇒ Array

Returns one CSV row aligned to COLUMNS.

Parameters:

Returns:

  • (Array)

    one CSV row aligned to COLUMNS.



165
166
167
# File 'app/services/feed/openai_ads/catalog_feed_generator.rb', line 165

def row_for(presenter)
  self.class::COLUMNS.values.map { |extractor| format_value(extractor.call(presenter)) }
end