Class: Marketing::Ads::ActionItemSync

Inherits:
Object
  • Object
show all
Defined in:
app/services/marketing/ads/action_item_sync.rb

Overview

Reconciles AdActionItem rows against what the numbers currently say.

Rule-based, not LLM: each rule is a threshold over one campaign's trailing
window of SourceDataPoint metrics and its attributed invoice revenue.
LLM-generated recommendations (budget reallocation, negative keywords,
creative) are a later layer on the same table.

Reconciliation follows ListingIssues::Sync: upsert what fires now on the
(source, category, fingerprint) key, and retire what no longer fires. It
differs in one deliberate way — retirement marks items stale, not
completed, because a rule going quiet means the numbers moved, not that
anyone fixed anything. A human's completed / ignored is never
overwritten: the window is trailing, so an item resolved today keeps
matching its own rule for another 28 days, and reopening it would make the
list impossible to clear.

Idempotent — re-running the same day refreshes rows rather than stacking
duplicates.

Defined Under Namespace

Classes: Finding, Result

Constant Summary collapse

WINDOW_DAYS =

Trailing window the rules measure over. Reads the dashboard's own
default rather than restating it: an item's evidence has to line up with
what someone sees on /ads-overview when they go to check it, and a
second hardcoded 28 silently stopped doing that the moment the dashboard
moved to a month.

::Report::AdsOverviewReport::DEFAULT_WINDOW_DAYS
MIN_SPEND =

Below this, a campaign isn't spending enough for any finding to be worth
someone's afternoon.

100.0
MIN_ROAS =

Return-on-ad-spend under this, with material spend, is a finding.

2.0
SPEND_ANOMALY_PCT =

Spend moving by at least this fraction versus the prior window.

0.5
SPEND_ANOMALY_MIN =

Both windows need this much spend before a swing between them means
anything — 10 → 30 dollars is a 200% "anomaly" and pure noise.

250.0
LOW_DELIVERY_MIN_IMPRESSIONS =

Impressions served with zero clicks: delivering, nobody biting.

1_000
TRACKING_MISMATCH_MIN_CONVERSIONS =

Provider-reported PURCHASE conversions we'd expect to see sales behind.

5
PURCHASE_CONVERSION_PROVIDERS =

Providers whose normalized ad_conversions count is specifically a
purchase count. Google and Microsoft report mixed goals (lead, quote,
purchase, etc.); OpenAI does not expose the metric. Amazon's report is
the only current adapter that maps its purchases column here.

%w[amazon_ads].freeze
HIGH_IMPACT_SPEND =

Spend at or above this makes a finding high impact rather than medium.

1_000.0
STALE_NOTE =

Note stamped on items retired because their rule stopped firing.

'auto: rule no longer fires for the current window'
ACTIONABLE_STATES =

Campaign states worth asking someone to act on. A paused campaign has
already had the action taken — surfacing it asks for work that is
done, and the item goes stale on the next pass, which is the right
answer for "the state moved."

%w[ENABLED].freeze
IMPRESSION_PRICED_COST_TYPES =

Buying models priced on impressions rather than clicks. low_delivery
reads zero clicks as a delivery failure, which for vCPM is the model
working as bought. Only Sponsored Display carries a cost type; the rest
are click-priced and report nil.

%w[vcpm].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(logger: Rails.logger, today: Date.current) ⇒ ActionItemSync

Returns a new instance of ActionItemSync.

Parameters:

  • logger (Logger) (defaults to: Rails.logger)
  • today (Date) (defaults to: Date.current)


94
95
96
97
98
99
100
# File 'app/services/marketing/ads/action_item_sync.rb', line 94

def initialize(logger: Rails.logger, today: Date.current)
  @logger = logger
  # The window ends yesterday: providers finalize a day overnight, so
  # including today would make every campaign look like it collapsed.
  @current_range = (today - WINDOW_DAYS)..(today - 1)
  @prior_range = (today - (WINDOW_DAYS * 2))..(today - WINDOW_DAYS - 1)
end

Class Method Details

.run(logger: Rails.logger, today: Date.current) ⇒ Marketing::Ads::ActionItemSync::Result

Parameters:

  • logger (Logger) (defaults to: Rails.logger)
  • today (Date) (defaults to: Date.current)

    injection point for tests

Returns:



88
89
90
# File 'app/services/marketing/ads/action_item_sync.rb', line 88

def self.run(logger: Rails.logger, today: Date.current)
  new(logger:, today:).call
end

Instance Method Details

#callMarketing::Ads::ActionItemSync::Result



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'app/services/marketing/ads/action_item_sync.rb', line 103

def call
  created = updated = staled = 0
  stats = campaign_stats
  source_ids = candidate_source_ids(stats)
  existing = AdActionItem.where(source_id: source_ids).group_by(&:source_id)

  Source.where(id: source_ids).find_each do |source|
    c, u, s = reconcile(source, findings_for(stats[source.id]), existing[source.id] || [])
    created += c
    updated += u
    staled += s
  end

  Result.new(created:, updated:, staled:, campaigns: stats.size).tap do |result|
    @logger.info "[Marketing::Ads::ActionItemSync] #{result.to_h}"
  end
end