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 =
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
- #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
-
#claude_code_hint ⇒ String
Category-specific pointer to the right part of the codebase / convention, used by #claude_prompt to orient a coding agent.
-
#claude_prompt ⇒ String
Developer-facing prompt for pasting into Claude Code (or any coding agent) working in this repository.
- #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 Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#category ⇒ Object (readonly)
71 |
# File 'app/models/site_map_recommendation.rb', line 71 validates :category, presence: true, inclusion: { in: CATEGORIES } |
#effort ⇒ Object (readonly)
74 |
# File 'app/models/site_map_recommendation.rb', line 74 validates :effort, inclusion: { in: EFFORTS }, allow_nil: true |
#fingerprint ⇒ Object (readonly)
76 |
# File 'app/models/site_map_recommendation.rb', line 76 validates :fingerprint, presence: true, uniqueness: { scope: :site_map_id } |
#impact ⇒ Object (readonly)
73 |
# File 'app/models/site_map_recommendation.rb', line 73 validates :impact, inclusion: { in: IMPACTS }, allow_nil: true |
#status ⇒ Object (readonly)
72 |
# File 'app/models/site_map_recommendation.rb', line 72 validates :status, presence: true, inclusion: { in: STATUSES } |
#title ⇒ Object (readonly)
75 |
# File 'app/models/site_map_recommendation.rb', line 75 validates :title, presence: true |
Class Method Details
.accepted ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are accepted. Active Record Scope
79 |
# File 'app/models/site_map_recommendation.rb', line 79 scope :accepted, -> { where(status: 'accepted') } |
.actionable ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are actionable. Active Record Scope
85 |
# File 'app/models/site_map_recommendation.rb', line 85 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
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_impact ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by impact. Active Record Scope
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_priority ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are by priority. Active Record Scope
91 |
# File 'app/models/site_map_recommendation.rb', line 91 scope :by_priority, -> { by_impact.by_effort } |
.completed ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are completed. Active Record Scope
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 |
.ignored ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are ignored. Active Record Scope
82 |
# File 'app/models/site_map_recommendation.rb', line 82 scope :ignored, -> { where(status: 'ignored') } |
.in_progress ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are in progress. Active Record Scope
80 |
# File 'app/models/site_map_recommendation.rb', line 80 scope :in_progress, -> { where(status: 'in_progress') } |
.locale_specific ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are locale specific. Active Record Scope
88 |
# File 'app/models/site_map_recommendation.rb', line 88 scope :locale_specific, -> { where(shared: false) } |
.pending ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are pending. Active Record Scope
78 |
# File 'app/models/site_map_recommendation.rb', line 78 scope :pending, -> { where(status: 'pending') } |
.resolved ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are resolved. Active Record Scope
86 |
# File 'app/models/site_map_recommendation.rb', line 86 scope :resolved, -> { where(status: %w[completed ignored]) } |
.shared_category?(cat) ⇒ 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_recs ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are shared recs. Active Record Scope
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 |
.stale ⇒ ActiveRecord::Relation<SiteMapRecommendation>
A relation of SiteMapRecommendations that are stale. Active Record Scope
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
109 110 111 |
# File 'app/models/site_map_recommendation.rb', line 109 def actionable? status.in?(%w[pending accepted in_progress]) end |
#category_label ⇒ Object
125 126 127 |
# File 'app/models/site_map_recommendation.rb', line 125 def category_label self.class.human_category_label(category) end |
#claude_code_hint ⇒ String
Category-specific pointer to the right part of the codebase / convention,
used by #claude_prompt to orient a coding agent.
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_prompt ⇒ String
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.).
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}" = [] << "Impact: #{impact}" if impact.present? << "Effort: #{effort}" if effort.present? lines << "- #{.join(' | ')}" if .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_class ⇒ Object
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
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
105 106 107 |
# File 'app/models/site_map_recommendation.rb', line 105 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.
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 |
#status_badge_class ⇒ Object
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_prompt ⇒ Object
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}" = [] << "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 |