Class: SiteMapRecommendation

Inherits:
ApplicationRecord show all
Defined in:
app/models/site_map_recommendation.rb

Overview

Tracks individual SEO recommendations extracted from AI-generated seo_report.
Each recommendation has a lifecycle: pending -> accepted -> in_progress -> completed/ignored.

Fingerprinting enables smart merge on re-analysis: completed/ignored items are preserved
while pending items get refreshed details.
== Schema Information

Table name: site_map_recommendations
Database name: primary

id :bigint not null, primary key
analysis_generation :integer default(0), not null
category :string not null
completed_at :datetime
details :jsonb not null
effort :string
fingerprint :string not null
impact :string
notes :text
shared :boolean default(FALSE), not null
status :string default("pending"), not null
title :string not null
created_at :datetime not null
updated_at :datetime not null
site_map_id :bigint not null

Indexes

index_site_map_recommendations_on_category (category)
index_site_map_recommendations_on_site_map_id_and_fingerprint (site_map_id,fingerprint) UNIQUE
index_site_map_recommendations_on_site_map_id_and_status (site_map_id,status)
index_site_map_recommendations_on_status (status)

Foreign Keys

fk_rails_... (site_map_id => site_maps.id)

Constant Summary collapse

CATEGORIES =

Categories.

%w[
  priority_action
  internal_linking
  faq_recommendation
  content_recommendation
  technical_recommendation
  structured_data
  aio_recommendation
  people_also_ask
  cannibalization
  paid_organic_synergy
].freeze
SHARED_CATEGORIES =

Content-level categories where the recommendation applies to all locales
of the same path (the underlying content is shared across locales).

%w[
  priority_action internal_linking faq_recommendation
  content_recommendation structured_data aio_recommendation
].freeze
STATUSES =

Statuses.

%w[pending accepted in_progress completed ignored stale].freeze
IMPACTS =

Impacts.

%w[high medium low].freeze
EFFORTS =

Efforts.

%w[high medium low].freeze

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#categoryObject (readonly)



71
# File 'app/models/site_map_recommendation.rb', line 71

validates :category, presence: true, inclusion: { in: CATEGORIES }

#effortObject (readonly)



74
# File 'app/models/site_map_recommendation.rb', line 74

validates :effort,   inclusion: { in: EFFORTS }, allow_nil: true

#fingerprintObject (readonly)



76
# File 'app/models/site_map_recommendation.rb', line 76

validates :fingerprint, presence: true, uniqueness: { scope: :site_map_id }

#impactObject (readonly)



73
# File 'app/models/site_map_recommendation.rb', line 73

validates :impact,   inclusion: { in: IMPACTS }, allow_nil: true

#statusObject (readonly)



72
# File 'app/models/site_map_recommendation.rb', line 72

validates :status,   presence: true, inclusion: { in: STATUSES }

#titleObject (readonly)



75
# File 'app/models/site_map_recommendation.rb', line 75

validates :title,    presence: true

Class Method Details

.acceptedActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are accepted. Active Record Scope

Returns:

See Also:



79
# File 'app/models/site_map_recommendation.rb', line 79

scope :accepted,     -> { where(status: 'accepted') }

.actionableActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are actionable. Active Record Scope

Returns:

See Also:



85
# File 'app/models/site_map_recommendation.rb', line 85

scope :actionable,      -> { where(status: %w[pending accepted in_progress]) }

.by_effortActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are by effort. Active Record Scope

Returns:

See Also:



90
# File 'app/models/site_map_recommendation.rb', line 90

scope :by_effort,    -> { order(Arel.sql("CASE effort WHEN 'low' THEN 0 WHEN 'medium' THEN 1 WHEN 'high' THEN 2 ELSE 3 END")) }

.by_impactActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are by impact. Active Record Scope

Returns:

See Also:



89
# File 'app/models/site_map_recommendation.rb', line 89

scope :by_impact,    -> { order(Arel.sql("CASE impact WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END")) }

.by_priorityActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are by priority. Active Record Scope

Returns:

See Also:



91
# File 'app/models/site_map_recommendation.rb', line 91

scope :by_priority,  -> { by_impact.by_effort }

.completedActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are completed. Active Record Scope

Returns:

See Also:



81
# File 'app/models/site_map_recommendation.rb', line 81

scope :completed,    -> { where(status: 'completed') }

.generate_fingerprint(category, title) ⇒ Object



101
102
103
# File 'app/models/site_map_recommendation.rb', line 101

def self.generate_fingerprint(category, title)
  Digest::SHA256.hexdigest("#{category}|#{title.to_s.strip.downcase}")[0..63]
end

.human_category_label(cat) ⇒ Object



121
122
123
# File 'app/models/site_map_recommendation.rb', line 121

def self.human_category_label(cat)
  cat.to_s.titleize.gsub('Aio', 'AIO').gsub('Faq', 'FAQ').gsub('Paid Organic', 'Paid/Organic')
end

.ignoredActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are ignored. Active Record Scope

Returns:

See Also:



82
# File 'app/models/site_map_recommendation.rb', line 82

scope :ignored,      -> { where(status: 'ignored') }

.in_progressActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are in progress. Active Record Scope

Returns:

See Also:



80
# File 'app/models/site_map_recommendation.rb', line 80

scope :in_progress,  -> { where(status: 'in_progress') }

.locale_specificActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are locale specific. Active Record Scope

Returns:

See Also:



88
# File 'app/models/site_map_recommendation.rb', line 88

scope :locale_specific, -> { where(shared: false) }

.pendingActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are pending. Active Record Scope

Returns:

See Also:



78
# File 'app/models/site_map_recommendation.rb', line 78

scope :pending,      -> { where(status: 'pending') }

.resolvedActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are resolved. Active Record Scope

Returns:

See Also:



86
# File 'app/models/site_map_recommendation.rb', line 86

scope :resolved,        -> { where(status: %w[completed ignored]) }

.shared_category?(cat) ⇒ Boolean

Returns:

  • (Boolean)


97
98
99
# File 'app/models/site_map_recommendation.rb', line 97

def self.shared_category?(cat)
  SHARED_CATEGORIES.include?(cat.to_s)
end

.shared_recsActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are shared recs. Active Record Scope

Returns:

See Also:



87
# File 'app/models/site_map_recommendation.rb', line 87

scope :shared_recs,     -> { where(shared: true) }

.sibling_locales_batch_context(recs) ⇒ Object

Batch version: list unique path+locales so Sunny knows which pages share content



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'app/models/site_map_recommendation.rb', line 294

def self.sibling_locales_batch_context(recs)
  paths = recs.map { |r| r.site_map.path }.uniq
  lines = []
  paths.each do |path|
    sm = recs.find { |r| r.site_map.path == path }&.site_map
    next unless sm

    siblings = sm.sibling_site_maps.to_a
    next if siblings.empty?

    lines << "#{path}: also exists in #{siblings.map(&:locale).join(', ')}#{siblings.map(&:production_url).join(' ; ')}"
  end
  return '' if lines.empty?

  "**Same pages in other locales** (content shared \u2014 ensure fixes apply consistently and don't affect the other locale):\n#{lines.join("\n")}\n\n"
end

.staleActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are stale. Active Record Scope

Returns:

See Also:



83
# File 'app/models/site_map_recommendation.rb', line 83

scope :stale,        -> { where(status: 'stale') }

.sunny_batch_prompt(ids) ⇒ Object



278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'app/models/site_map_recommendation.rb', line 278

def self.sunny_batch_prompt(ids)
  recs = where(id: ids).includes(:site_map).by_priority
  return '' if recs.empty?

  paths = recs.map { |r| r.site_map.path }.uniq
  lines = ["I'd like to work on these #{recs.size} SEO action items for #{paths.join(', ')}:", ""]
  # Include sibling-locale context so Sunny has all countries in context when fixing shared content
  sibling_context = sibling_locales_batch_context(recs)
  lines << sibling_context if sibling_context.present?
  recs.each { |r| lines << "- Action Item ##{r.id}: #{r.title}" }
  lines << ""
  lines << "Use seo_get_action_items with ids [#{recs.map(&:id).join(', ')}] to fetch full details, then help me address them. Mark items as in_progress when you start working and completed when done."
  lines.join("\n")
end

Instance Method Details

#actionable?Boolean

Returns:

  • (Boolean)


109
110
111
# File 'app/models/site_map_recommendation.rb', line 109

def actionable?
  status.in?(%w[pending accepted in_progress])
end

#category_labelObject



125
126
127
# File 'app/models/site_map_recommendation.rb', line 125

def category_label
  self.class.human_category_label(category)
end

#claude_code_hintString

Category-specific pointer to the right part of the codebase / convention,
used by #claude_prompt to orient a coding agent.

Returns:

  • (String)


250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'app/models/site_map_recommendation.rb', line 250

def claude_code_hint
  case category
  when 'structured_data'
    <<~HINT.squish
      Add or fix JSON-LD structured data. This app emits page schema via the
      `add_page_schema` helper (app/helpers/www/seo_helper.rb). From a ViewComponent,
      call `helpers.add_page_schema` so the schema lands in the view's set — a bare
      `add_page_schema` writes the component instance and is silently discarded.
      Validate the rendered JSON-LD afterward.
    HINT
  when 'internal_linking'
    "Add the recommended internal link(s) in the view/partial/component that renders this page's body. Use locale-aware path helpers, not hardcoded URLs."
  when 'cannibalization'
    <<~HINT.squish
      Resolve cannibalization in code: a rel=canonical to the preferred page, a 301
      via the cloudflare-redirects skill (edge redirects, not Rails routes), and/or
      de-optimizing the on-page title/meta/copy. Pick the lightest fix that matches
      the evidence.
    HINT
  when 'technical_recommendation', 'priority_action'
    'Technical/code change. Find the controller, view, component, or helper responsible for the affected element (title, meta description, headings, canonical, hreflang, image alt, etc.) and update it there.'
  when 'faq_recommendation', 'people_also_ask'
    "FAQ content. Prefer the app's FAQ model/CMS over hardcoding; if it's template-driven, edit the FAQ partial/component for this page."
  else
    'Find the template or CMS record that renders this page\'s content and apply the recommendation. Purely editorial copy may be better handled in-app via Sunny; a code/template change is appropriate when the content is template-driven.'
  end
end

#claude_promptString

Developer-facing prompt for pasting into Claude Code (or any coding agent)
working in this repository. Where #sunny_prompt drives the in-app Sunny
assistant over its content tools, this frames the recommendation as a code
change
in the heatwave codebase — for the items Sunny can't action on its
own (structured data, internal-linking templates, canonical/hreflang, etc.).

Returns:

  • (String)

    a self-contained prompt: the page, the SEO issue, the
    repo convention for this category, and locale considerations.



197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'app/models/site_map_recommendation.rb', line 197

def claude_prompt
  sm = site_map
  lines = []
  lines << 'Address this SEO action item from the WarmlyYours CRM by making the necessary code change in this repository (warmlyyours/heatwave).'
  lines << ''
  lines << '## Page'
  lines << "- URL: #{sm.production_url}"
  lines << "- Path: #{sm.path}"
  lines << "- Locale: #{sm.locale}"
  lines << "- Page type: #{sm.category}" if sm.category.present?
  lines << ''
  lines << "## Action item ##{id}#{category_label}"
  lines << "- #{title}"
  meta = []
  meta << "Impact: #{impact}" if impact.present?
  meta << "Effort: #{effort}" if effort.present?
  lines << "- #{meta.join(' | ')}" if meta.any?
  evidence = details&.dig('evidence')
  lines << "- Evidence: #{evidence}" if evidence.present?

  if category == 'cannibalization'
    lines << '- This keyword is marked **undesired** for this page — de-optimize or consolidate to the preferred page.' if details&.dig('undesired')
    if details&.dig('competitors').present?
      lines << '- Competing pages:'
      details['competitors'].each { |c| lines << "  - #{c['url']} (position ##{c['position']})" }
    end
  end

  lines << ''
  lines << '## Where to make the change'
  lines << claude_code_hint

  siblings = sm.sibling_site_maps.to_a
  if siblings.any?
    lines << ''
    lines << "## Other locales (content is shared — apply the change consistently and don't break the other locale)"
    siblings.each { |s| lines << "- #{s.locale}: #{s.production_url}" }
  end

  lines << ''
  lines << <<~CLOSING.squish
    Locate the controller/view/component/helper that renders this page, make the
    smallest change that resolves the issue, and verify it (mise exec -- bin/rails zeitwerk:check,
    the rendered output, and any touched tests) before finishing. Follow AGENTS.md
    and the relevant skill under .agents/skills/.
  CLOSING
  lines.join("\n")
end

#impact_badge_classObject



129
130
131
132
133
134
135
136
# File 'app/models/site_map_recommendation.rb', line 129

def impact_badge_class
  case impact
  when 'high'   then 'text-bg-danger'
  when 'medium' then 'text-bg-warning'
  when 'low'    then 'text-bg-secondary'
  else 'text-bg-light'
  end
end

#locale_specific?Boolean

Returns:

  • (Boolean)


93
94
95
# File 'app/models/site_map_recommendation.rb', line 93

def locale_specific?
  !shared?
end

#mark_completed!(notes_text = nil) ⇒ Object



113
114
115
# File 'app/models/site_map_recommendation.rb', line 113

def mark_completed!(notes_text = nil)
  update!(status: 'completed', completed_at: Time.current, notes: notes_text || notes)
end

#mark_ignored!(notes_text = nil) ⇒ Object



117
118
119
# File 'app/models/site_map_recommendation.rb', line 117

def mark_ignored!(notes_text = nil)
  update!(status: 'ignored', notes: notes_text || notes)
end

#resolved?Boolean

Returns:

  • (Boolean)


105
106
107
# File 'app/models/site_map_recommendation.rb', line 105

def resolved?
  status.in?(%w[completed ignored])
end

#sibling_locales_contextObject

Context for Sunny: same path exists in other locales (shared content). Fixes should
apply consistently and not inadvertently affect the other locale.



178
179
180
181
182
183
184
185
186
187
# File 'app/models/site_map_recommendation.rb', line 178

def sibling_locales_context
  siblings = site_map.sibling_site_maps.to_a
  return '' if siblings.empty?

  lines = []
  lines << "**Same page in other locales** (content is shared — ensure changes apply to both and don't conflict):"
  siblings.each { |sm| lines << "- #{sm.locale}: #{sm.production_url}" }
  lines << ""
  lines.join("\n")
end

#site_mapSiteMap

Returns:

See Also:



69
# File 'app/models/site_map_recommendation.rb', line 69

belongs_to :site_map

#status_badge_classObject



138
139
140
141
142
143
144
145
146
147
148
# File 'app/models/site_map_recommendation.rb', line 138

def status_badge_class
  case status
  when 'pending'     then 'text-bg-info'
  when 'accepted'    then 'text-bg-primary'
  when 'in_progress' then 'text-bg-warning'
  when 'completed'   then 'text-bg-success'
  when 'ignored'     then 'text-bg-secondary'
  when 'stale'       then 'text-bg-light'
  else 'text-bg-light'
  end
end

#sunny_promptObject



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'app/models/site_map_recommendation.rb', line 150

def sunny_prompt
  lines = []
  lines << "I'd like to work on this SEO recommendation for #{site_map.path}:"
  lines << ""
  lines << sibling_locales_context if sibling_locales_context.present?
  lines << "**#{category_label}**: #{title}"
  meta = []
  meta << "Impact: #{impact}" if impact.present?
  meta << "Effort: #{effort}" if effort.present?
  lines << meta.join(' | ') if meta.any?
  evidence = details&.dig('evidence')
  lines << "\nEvidence: #{evidence}" if evidence.present?
  if category == 'cannibalization'
    lines << "\nThis keyword is marked **undesired** for this page — the goal is to de-optimize or consolidate to another page." if details&.dig('undesired')
    if details&.dig('competitors').present?
      lines << "\nCompeting pages:"
      details['competitors'].each do |c|
        lines << "- #{c['url']} (position ##{c['position']})"
      end
    end
  end
  lines << ""
  lines << "Please review the current page content and suggest specific changes to address this."
  lines.join("\n")
end