Class: Feed::OpenaiAds::ProductPresenter

Inherits:
Google::GoogleProductPresenter
  • Object
show all
Defined in:
app/services/feed/openai_ads/product_presenter.rb

Overview

Presents a catalog item as a row in the OpenAI Ads product feed.

OpenAI's Ads product feed is Google-Merchant-Center-shaped, so this reuses the
maintained Google::GoogleProductPresenter field logic wholesale and
adds only what the advertising feed needs on top:

  • #ads_eligible? — backs OpenAI's canonical is_ads_eligible flag; true
    for products we intend to advertise (the Shopping-feed gate: new,
    in-stock-able, has shipping dimensions + imagery, not dealer-channel-only).
  • #feed_includable? — looser gate (drops the image requirement) used to
    decide which products appear in the file at all, so a sellable product
    missing imagery is still listed but flagged is_ads_eligible=false.
  • #additional_image_link — collapses the image list to a single
    comma-joined cell for CSV output.

Adapted from the dropped Feed::Openai::OpenaiProductPresenter (the Agentic
Commerce / Instant-Checkout feed OpenAI retired in March 2026). That presenter
targeted the checkout spec (enable_checkout / seller_tos / q_and_a / geo_price);
this one targets the advertising spec and inherits its catalog mapping from the
Google presenter rather than re-deriving it.

Direct Known Subclasses

Feed::OpenaiCommerce::ProductPresenter

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#review_stats=(value) ⇒ Object (writeonly)

Review aggregates backing the review_count / star_rating and
store_review_count / store_star_rating columns of OpenAI's refreshed
product card (https://developers.openai.com/commerce/specs/feed — "Reviews
and Q&A"). Injected by CatalogFeedGenerator after a batch load (one
grouped query per feed run, not per row); nil means "no data" and the
readers below emit blank cells.

Per-item cells use the product line aggregate — the same rollup the
public site's star badges show (ReviewsIo.stats_for_product_lines) — so
every variant row carries the line's rating instead of a fragment of it.



34
35
36
# File 'app/services/feed/openai_ads/product_presenter.rb', line 34

def review_stats=(value)
  @review_stats = value
end

#store_review_stats=(value) ⇒ Object (writeonly)

Review aggregates backing the review_count / star_rating and
store_review_count / store_star_rating columns of OpenAI's refreshed
product card (https://developers.openai.com/commerce/specs/feed — "Reviews
and Q&A"). Injected by CatalogFeedGenerator after a batch load (one
grouped query per feed run, not per row); nil means "no data" and the
readers below emit blank cells.

Per-item cells use the product line aggregate — the same rollup the
public site's star badges show (ReviewsIo.stats_for_product_lines) — so
every variant row carries the line's rating instead of a fragment of it.



34
35
36
# File 'app/services/feed/openai_ads/product_presenter.rb', line 34

def store_review_stats=(value)
  @store_review_stats = value
end

Instance Method Details

GMC permits repeated additional_image_link values; a CSV feed carries them
as a single comma-joined cell.



83
84
85
# File 'app/services/feed/openai_ads/product_presenter.rb', line 83

def additional_image_link
  additional_image_links.join(',').presence
end

#ads_eligible?Boolean

Whether the product is eligible to advertise — backs the is_ads_eligible
column. Same gate as the Google Shopping feed (requires product imagery).

Returns:

  • (Boolean)


70
71
72
# File 'app/services/feed/openai_ads/product_presenter.rb', line 70

def ads_eligible?
  valid_for_google?(include_image_check: true)
end

#descriptionObject



99
100
101
# File 'app/services/feed/openai_ads/product_presenter.rb', line 99

def description
  Heatwave::Normalizers.feed_safe(super)
end

#feed_includable?Boolean

Whether the product should appear in the feed file at all. Looser than
#ads_eligible? — a sellable, correctly-dimensioned product with no imagery
is still listed (flagged is_ads_eligible=false) rather than dropped.

Returns:

  • (Boolean)


77
78
79
# File 'app/services/feed/openai_ads/product_presenter.rb', line 77

def feed_includable?
  valid_for_google?(include_image_check: false)
end

#review_countInteger?

Returns product-line review count; nil when no reviews.

Returns:

  • (Integer, nil)

    product-line review count; nil when no reviews



37
38
39
40
# File 'app/services/feed/openai_ads/product_presenter.rb', line 37

def review_count
  count = @review_stats&.dig(:num)
  count if count&.positive?
end

#star_ratingString?

Returns product-line average rating, OpenAI's "4.50" format.
Blank unless there is at least one review AND a positive average — 0 is
truthy in Ruby, so { num: 0, star_avg: 4.5 } must not emit a rating
without a count.

Returns:

  • (String, nil)

    product-line average rating, OpenAI's "4.50" format.
    Blank unless there is at least one review AND a positive average — 0 is
    truthy in Ruby, so { num: 0, star_avg: 4.5 } must not emit a rating
    without a count.



46
47
48
49
50
51
# File 'app/services/feed/openai_ads/product_presenter.rb', line 46

def star_rating
  return unless review_count

  avg = @review_stats&.dig(:star_avg)
  format('%.2f', avg) if avg&.positive?
end

#store_review_countInteger?

Returns store-level review count; nil when no reviews.

Returns:

  • (Integer, nil)

    store-level review count; nil when no reviews



54
55
56
57
# File 'app/services/feed/openai_ads/product_presenter.rb', line 54

def store_review_count
  count = @store_review_stats&.dig(:num)
  count if count&.positive?
end

#store_star_ratingString?

Returns store-level average rating, OpenAI's "4.50" format.
Gated on a positive count + average like #star_rating.

Returns:

  • (String, nil)

    store-level average rating, OpenAI's "4.50" format.
    Gated on a positive count + average like #star_rating.



61
62
63
64
65
66
# File 'app/services/feed/openai_ads/product_presenter.rb', line 61

def store_star_rating
  return unless store_review_count

  avg = @store_review_stats&.dig(:star_avg)
  format('%.2f', avg) if avg&.positive?
end

#titleObject

Normalize the human-readable fields with the shared retailer-feed rule
(Heatwave::Normalizers.feed_safe): abbreviate feet/inch marks (49′ → 49 ft.,
30″ → 30 in.), normalize smart quotes / dashes, and strip legal/IP glyphs
(™ ® © …). They add nothing for ad matching/display and render as mojibake in
some spreadsheet viewers; true measurement glyphs (120°F, ½, ×, µ) are kept.
#title already gets this upstream via reported_name (which now cleans the
Google feed too); re-applying here is idempotent and keeps the presenter
self-contained.



95
96
97
# File 'app/services/feed/openai_ads/product_presenter.rb', line 95

def title
  Heatwave::Normalizers.feed_safe(super)
end