Class: SiteMapRecommendation
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- SiteMapRecommendation
- 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
- #category ⇒ Object readonly
- #effort ⇒ Object readonly
- #fingerprint ⇒ Object readonly
- #impact ⇒ Object readonly
- #status ⇒ Object readonly
- #title ⇒ Object readonly
Belongs to collapse
Class Method Summary collapse
-
.accepted ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are accepted.
-
.actionable ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are actionable.
-
.by_effort ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by effort.
-
.by_impact ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by impact.
-
.by_priority ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by priority.
-
.completed ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are completed.
- .generate_fingerprint(category, title) ⇒ Object
- .human_category_label(cat) ⇒ Object
-
.ignored ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are ignored.
-
.in_progress ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are in progress.
-
.locale_specific ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are locale specific.
-
.pending ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are pending.
-
.resolved ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are resolved.
- .shared_category?(cat) ⇒ Boolean
-
.shared_recs ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are shared recs.
-
.sibling_locales_batch_context(recs) ⇒ Object
Batch version: list unique path+locales so Sunny knows which pages share content.
-
.stale ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are stale.
- .sunny_batch_prompt(ids) ⇒ Object
Instance Method Summary collapse
- #actionable? ⇒ Boolean
- #category_label ⇒ Object
- #impact_badge_class ⇒ Object
- #locale_specific? ⇒ Boolean
- #mark_completed!(notes_text = nil) ⇒ Object
- #mark_ignored!(notes_text = nil) ⇒ Object
- #resolved? ⇒ Boolean
-
#sibling_locales_context ⇒ Object
Context for Sunny: same path exists in other locales (shared content).
- #status_badge_class ⇒ Object
- #sunny_prompt ⇒ Object
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation
Methods included from Models::EventPublishable
Instance Attribute Details
#category ⇒ Object (readonly)
67 |
# File 'app/models/site_map_recommendation.rb', line 67 validates :category, presence: true, inclusion: { in: CATEGORIES } |
#effort ⇒ Object (readonly)
70 |
# File 'app/models/site_map_recommendation.rb', line 70 validates :effort, inclusion: { in: EFFORTS }, allow_nil: true |
#fingerprint ⇒ Object (readonly)
72 |
# File 'app/models/site_map_recommendation.rb', line 72 validates :fingerprint, presence: true, uniqueness: { scope: :site_map_id } |
#impact ⇒ Object (readonly)
69 |
# File 'app/models/site_map_recommendation.rb', line 69 validates :impact, inclusion: { in: IMPACTS }, allow_nil: true |
#status ⇒ Object (readonly)
68 |
# File 'app/models/site_map_recommendation.rb', line 68 validates :status, presence: true, inclusion: { in: STATUSES } |
#title ⇒ Object (readonly)
71 |
# File 'app/models/site_map_recommendation.rb', line 71 validates :title, presence: true |
Class Method Details
.accepted ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are accepted. Active Record Scope
75 |
# File 'app/models/site_map_recommendation.rb', line 75 scope :accepted, -> { where(status: 'accepted') } |
.actionable ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are actionable. Active Record Scope
81 |
# File 'app/models/site_map_recommendation.rb', line 81 scope :actionable, -> { where(status: %w[pending accepted in_progress]) } |
.by_effort ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by effort. Active Record Scope
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_impact ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by impact. Active Record Scope
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_priority ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by priority. Active Record Scope
87 |
# File 'app/models/site_map_recommendation.rb', line 87 scope :by_priority, -> { by_impact.by_effort } |
.completed ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are completed. Active Record Scope
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 |
.ignored ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are ignored. Active Record Scope
78 |
# File 'app/models/site_map_recommendation.rb', line 78 scope :ignored, -> { where(status: 'ignored') } |
.in_progress ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are in progress. Active Record Scope
76 |
# File 'app/models/site_map_recommendation.rb', line 76 scope :in_progress, -> { where(status: 'in_progress') } |
.locale_specific ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are locale specific. Active Record Scope
84 |
# File 'app/models/site_map_recommendation.rb', line 84 scope :locale_specific, -> { where(shared: false) } |
.pending ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are pending. Active Record Scope
74 |
# File 'app/models/site_map_recommendation.rb', line 74 scope :pending, -> { where(status: 'pending') } |
.resolved ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are resolved. Active Record Scope
82 |
# File 'app/models/site_map_recommendation.rb', line 82 scope :resolved, -> { where(status: %w[completed ignored]) } |
.shared_category?(cat) ⇒ 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_recs ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are shared recs. Active Record Scope
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 |
.stale ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are stale. Active Record Scope
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
105 106 107 |
# File 'app/models/site_map_recommendation.rb', line 105 def actionable? status.in?(%w[pending accepted in_progress]) end |
#category_label ⇒ Object
121 122 123 |
# File 'app/models/site_map_recommendation.rb', line 121 def category_label self.class.human_category_label(category) end |
#impact_badge_class ⇒ Object
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
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
101 102 103 |
# File 'app/models/site_map_recommendation.rb', line 101 def resolved? status.in?(%w[completed ignored]) end |
#sibling_locales_context ⇒ Object
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 |
#status_badge_class ⇒ Object
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_prompt ⇒ Object
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}" = [] << "Impact: #{impact}" if impact.present? << "Effort: #{effort}" if effort.present? lines << .join(' | ') if .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 |