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 =
%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 =
%w[pending accepted in_progress completed ignored stale].freeze
IMPACTS =
%w[high medium low].freeze
EFFORTS =
%w[high medium low].freeze

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 Models::EventPublishable

#publish_event

Instance Attribute Details

#categoryObject (readonly)



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

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

#effortObject (readonly)



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

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

#fingerprintObject (readonly)



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

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

#impactObject (readonly)



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

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

#statusObject (readonly)



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

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

#titleObject (readonly)



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

validates :title,    presence: true

Class Method Details

.acceptedActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are accepted. Active Record Scope

Returns:

See Also:



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

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

.actionableActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are actionable. Active Record Scope

Returns:

See Also:



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

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:



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

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:



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

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:



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

scope :by_priority,  -> { by_impact.by_effort }

.completedActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are completed. Active Record Scope

Returns:

See Also:



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

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

.generate_fingerprint(category, title) ⇒ Object



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

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

.human_category_label(cat) ⇒ Object



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

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:



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

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

.in_progressActiveRecord::Relation<SiteMapRecommendation>

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

Returns:

See Also:



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

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:



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

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

.pendingActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are pending. Active Record Scope

Returns:

See Also:



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

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

.resolvedActiveRecord::Relation<SiteMapRecommendation>

A relation of SiteMapRecommendations that are resolved. Active Record Scope

Returns:

See Also:



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

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

.shared_category?(cat) ⇒ Boolean

Returns:

  • (Boolean)


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

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:



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

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



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'app/models/site_map_recommendation.rb', line 201

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 — 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:



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

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

.sunny_batch_prompt(ids) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'app/models/site_map_recommendation.rb', line 185

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)


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

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

#category_labelObject



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

def category_label
  self.class.human_category_label(category)
end

#impact_badge_classObject



125
126
127
128
129
130
131
132
# File 'app/models/site_map_recommendation.rb', line 125

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)


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

def locale_specific?
  !shared?
end

#mark_completed!(notes_text = nil) ⇒ Object



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

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

#mark_ignored!(notes_text = nil) ⇒ Object



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

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

#resolved?Boolean

Returns:

  • (Boolean)


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

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.



174
175
176
177
178
179
180
181
182
183
# File 'app/models/site_map_recommendation.rb', line 174

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:



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

belongs_to :site_map

#status_badge_classObject



134
135
136
137
138
139
140
141
142
143
144
# File 'app/models/site_map_recommendation.rb', line 134

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



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

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