From a06f2efaf02d6be4d9c6b6a16db8e41832ddbae3 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:23:27 +0100 Subject: [PATCH 1/3] Fix testimonial heading collisions crashing generation job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove hard uniqueness constraint on heading (DB index + model validation) to prevent crashes when AI generates duplicate headings. Instead, deduplicate at display time — homepage selects one testimonial per unique heading via GROUP BY subquery. Improve generation prompt: allow 1-3 word headings with richer examples, raise temperature to 0.8, increase retries from 3 to 5, and gracefully save on exhausted retries instead of crashing. Co-Authored-By: Claude Opus 4.6 --- app/controllers/home_controller.rb | 4 +++- app/jobs/generate_testimonial_fields_job.rb | 17 ++++++++++++----- app/jobs/validate_testimonial_job.rb | 5 ++--- app/models/testimonial.rb | 1 - app/views/layouts/application.html.erb | 8 ++++---- ...ve_unique_index_from_testimonials_heading.rb | 6 ++++++ db/schema.rb | 4 ++-- test/models/testimonial_test.rb | 7 +++---- 8 files changed, 32 insertions(+), 20 deletions(-) create mode 100644 db/migrate/20260212134515_remove_unique_index_from_testimonials_heading.rb diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 89f8abb..d3cd515 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -13,6 +13,8 @@ def index .published .includes(:user) .order(created_at: :desc) - @testimonials = Testimonial.published.includes(:user).order(Arel.sql("RANDOM()")).limit(20) + @testimonials = Testimonial.published.includes(:user) + .where(id: Testimonial.published.group(:heading).select("MIN(id)")) + .order(Arel.sql("RANDOM()")).limit(10) end end diff --git a/app/jobs/generate_testimonial_fields_job.rb b/app/jobs/generate_testimonial_fields_job.rb index 918b82d..cf1db19 100644 --- a/app/jobs/generate_testimonial_fields_job.rb +++ b/app/jobs/generate_testimonial_fields_job.rb @@ -1,7 +1,7 @@ class GenerateTestimonialFieldsJob < ApplicationJob queue_as :default - MAX_HEADING_RETRIES = 3 + MAX_HEADING_RETRIES = 5 def perform(testimonial) existing_headings = Testimonial.where.not(id: testimonial.id).where.not(heading: nil).pluck(:heading) @@ -41,6 +41,10 @@ def perform(testimonial) return end + if heading_taken?(parsed["heading"], testimonial.id) + Rails.logger.warn "Testimonial #{testimonial.id}: heading '#{parsed["heading"]}' still collides after #{MAX_HEADING_RETRIES} retries, saving anyway" + end + testimonial.update!( heading: parsed["heading"], subheading: parsed["subheading"], @@ -66,10 +70,13 @@ def build_system_prompt(existing_headings) You generate structured testimonial content for a Ruby programming language advocacy site. Given a user's quote about why they love Ruby, generate: - 1. heading: A single unique 1-2 word heading that captures the THEME of the quote (e.g., "Elegance", "Joy", "Craft"). + 1. heading: A unique 1-3 word heading that captures the THEME or FEELING of the quote. + Be creative and specific. Go beyond generic words. Think of evocative nouns, metaphors, compound phrases, or poetic concepts. + The heading must make sense as an answer to "Why Ruby?" — e.g. "Why Ruby?" → "Flow State", "Clarity", "Pure Joy". + Good examples: "Spark", "Flow State", "Quiet Power", "Warm Glow", "First Love", "Playground", "Second Nature", "Deep Roots", "Readable Code", "Clean Slate", "Smooth Sailing", "Expressiveness", "Old Friend", "Sharp Tools", "Creative Freedom", "Solid Ground", "Calm Waters", "Poetic Logic", "Builder's Joy", "Sweet Spot", "Hidden Gem", "Fresh Start", "True North", "Clarity", "Belonging", "Empowerment", "Momentum", "Simplicity", "Trust", "Confidence" #{taken} 2. subheading: A short tagline under 10 words. - 3. body_text: 2-3 sentences that EXTEND and DEEPEN the user's idea — add new angles, examples, or implications. + 3. body_text: 2-3 sentences that EXTEND and DEEPEN the user's idea. Add new angles, examples, or implications. Do NOT repeat or paraphrase what the user already said. Build on top of it. WRITING STYLE — sound like a real person, not an AI: @@ -136,7 +143,7 @@ def generate_with_anthropic(system_prompt, user_prompt) parameters: { model: "claude-3-haiku-20240307", max_tokens: 300, - temperature: 0.7, + temperature: 0.8, system: system_prompt, messages: [ { role: "user", content: user_prompt } ] } @@ -161,7 +168,7 @@ def generate_with_openai(system_prompt, user_prompt) { role: "system", content: system_prompt }, { role: "user", content: user_prompt } ], - temperature: 0.7, + temperature: 0.8, max_tokens: 300 } ) diff --git a/app/jobs/validate_testimonial_job.rb b/app/jobs/validate_testimonial_job.rb index 40f364b..4ccda7c 100644 --- a/app/jobs/validate_testimonial_job.rb +++ b/app/jobs/validate_testimonial_job.rb @@ -21,11 +21,10 @@ def perform(testimonial) VALIDATION RULES: 1. First check the user's QUOTE against the content policy. If it violates (including being negative about Ruby), reject immediately with reject_reason "quote". 2. If the quote is fine, check the AI-generated fields (heading/subheading/body). ONLY reject generation if there is a CLEAR problem: - - The heading duplicates an existing one listed below - The body contradicts or misrepresents the quote - The subheading is nonsensical or unrelated - The content is factually wrong about Ruby - Do NOT reject just because the fields could be "better" or "more creative". Good enough is good enough — publish it. + Do NOT reject for duplicate headings (handled elsewhere). Do NOT reject just because the fields could be "better" or "more creative". Good enough is good enough — publish it. 3. If everything looks acceptable, publish it. AI-SOUNDING LANGUAGE CHECK: @@ -37,7 +36,7 @@ def perform(testimonial) - Superficial -ing tack-ons ("ensuring...", "highlighting...", "fostering...") If the quote itself is fine but the generated text sounds like AI wrote it, set reject_reason to "generation" and explain which phrases sound artificial. - Existing published testimonials (avoid duplicate headings/themes): + Existing published testimonials (for context): #{existing.presence || "None yet."} Respond with valid JSON only: {"publish": true/false, "reject_reason": "quote" or "generation" or null, "feedback": "..."} diff --git a/app/models/testimonial.rb b/app/models/testimonial.rb index 353e957..e947118 100644 --- a/app/models/testimonial.rb +++ b/app/models/testimonial.rb @@ -3,7 +3,6 @@ class Testimonial < ApplicationRecord validates :quote, length: { minimum: 140, maximum: 320 }, allow_blank: true validates :user_id, uniqueness: true - validates :heading, uniqueness: true, allow_nil: true scope :published, -> { where(published: true) } scope :ordered, -> { order(Arel.sql("position ASC NULLS LAST, created_at DESC")) } diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9a16d56..1ea3bab 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -124,10 +124,10 @@ <% else %> <% Category.with_posts.ordered.each do |category| %> <% if category.is_success_story? && has_success_stories? %> - <%= link_to category.name, category_path(category), + <%= link_to category.name, main_site_url(category_path(category)), class: "#{category_menu_active?(category) ? 'bg-red-100 text-red-700' : 'text-gray-600 hover:bg-gray-100'} px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap" %> <% elsif !category.is_success_story? %> - <%= link_to category.name, category_path(category), + <%= link_to category.name, main_site_url(category_path(category)), class: "#{category_menu_active?(category) ? 'bg-red-100 text-red-700' : 'text-gray-600 hover:bg-gray-100'} px-3 py-2 rounded-md text-sm font-medium whitespace-nowrap" %> <% end %> <% end %> @@ -202,10 +202,10 @@ <% else %> <% Category.with_posts.ordered.limit(7).each do |category| %> <% if category.is_success_story? && has_success_stories? %> - <%= link_to category.name, category_path(category), + <%= link_to category.name, main_site_url(category_path(category)), class: "#{category_menu_active?(category) ? 'bg-red-100 text-red-700' : 'text-gray-600 hover:bg-gray-100'} block px-3 py-2 rounded-md text-base font-medium" %> <% elsif !category.is_success_story? %> - <%= link_to category.name, category_path(category), + <%= link_to category.name, main_site_url(category_path(category)), class: "#{category_menu_active?(category) ? 'bg-red-100 text-red-700' : 'text-gray-600 hover:bg-gray-100'} block px-3 py-2 rounded-md text-base font-medium" %> <% end %> <% end %> diff --git a/db/migrate/20260212134515_remove_unique_index_from_testimonials_heading.rb b/db/migrate/20260212134515_remove_unique_index_from_testimonials_heading.rb new file mode 100644 index 0000000..f4db6a9 --- /dev/null +++ b/db/migrate/20260212134515_remove_unique_index_from_testimonials_heading.rb @@ -0,0 +1,6 @@ +class RemoveUniqueIndexFromTestimonialsHeading < ActiveRecord::Migration[8.2] + def change + remove_index :testimonials, :heading, unique: true + add_index :testimonials, :heading + end +end diff --git a/db/schema.rb b/db/schema.rb index 5d715da..8677655 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_12_025116) do +ActiveRecord::Schema[8.2].define(version: 2026_02_12_134515) do create_table "_litestream_lock", id: false, force: :cascade do |t| t.integer "id" end @@ -198,7 +198,7 @@ t.string "subheading" t.datetime "updated_at", null: false t.string "user_id", null: false - t.index ["heading"], name: "index_testimonials_on_heading", unique: true + t.index ["heading"], name: "index_testimonials_on_heading" t.index ["position"], name: "index_testimonials_on_position" t.index ["published"], name: "index_testimonials_on_published" t.index ["user_id"], name: "index_testimonials_on_user_id", unique: true diff --git a/test/models/testimonial_test.rb b/test/models/testimonial_test.rb index dab461a..0d19c72 100644 --- a/test/models/testimonial_test.rb +++ b/test/models/testimonial_test.rb @@ -22,16 +22,15 @@ class TestimonialTest < ActiveSupport::TestCase assert_includes duplicate.errors[:user_id], "has already been taken" end - test "validates uniqueness of heading allowing nil" do + test "allows duplicate headings" do existing = testimonials(:published) other_user = users(:user_no_testimonial) new_testimonial = Testimonial.new( user: other_user, - quote: "My quote", + quote: "I love Ruby because it makes programming feel like poetry. The syntax reads so naturally that you can focus on solving problems instead of fighting the language. It truly is a joy.", heading: existing.heading ) - assert_not new_testimonial.valid? - assert_includes new_testimonial.errors[:heading], "has already been taken" + assert new_testimonial.valid? end test "allows nil heading" do From 595c925af4bc06e9ea8e5d388b557e0e7c085cc0 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:25:20 +0100 Subject: [PATCH 2/3] Fix homepage testimonials to show random selection per unique heading The previous MIN(id) approach always picked the same testimonial per heading. Use a window function with RANDOM() ordering so each page load gets a truly random testimonial per unique heading, all in a single query. Co-Authored-By: Claude Opus 4.6 --- app/controllers/home_controller.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index d3cd515..2017840 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -13,8 +13,13 @@ def index .published .includes(:user) .order(created_at: :desc) - @testimonials = Testimonial.published.includes(:user) - .where(id: Testimonial.published.group(:heading).select("MIN(id)")) - .order(Arel.sql("RANDOM()")).limit(10) + subquery = Testimonial.published + .select("testimonials.*", "ROW_NUMBER() OVER (PARTITION BY LOWER(heading) ORDER BY RANDOM()) AS rn") + @testimonials = Testimonial + .from(subquery, :testimonials) + .where("rn = 1") + .order(Arel.sql("RANDOM()")) + .limit(10) + .includes(:user) end end From 21897c48d89cc7baef2ca84ce00c4f639d4b87f1 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:20:02 +0100 Subject: [PATCH 3/3] Remove NOT NULL constraint from users.email column GitHub users may not have a public email, causing failures when syncing GitHub data. Allow email to be nullable. Co-Authored-By: Claude Opus 4.6 --- ...0212163246_remove_email_not_null_constraint_from_users.rb | 5 +++++ db/schema.rb | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260212163246_remove_email_not_null_constraint_from_users.rb diff --git a/db/migrate/20260212163246_remove_email_not_null_constraint_from_users.rb b/db/migrate/20260212163246_remove_email_not_null_constraint_from_users.rb new file mode 100644 index 0000000..dde4802 --- /dev/null +++ b/db/migrate/20260212163246_remove_email_not_null_constraint_from_users.rb @@ -0,0 +1,5 @@ +class RemoveEmailNotNullConstraintFromUsers < ActiveRecord::Migration[8.2] + def change + change_column_null :users, :email, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 8677655..2e2ef68 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2026_02_12_134515) do +ActiveRecord::Schema[8.2].define(version: 2026_02_12_163246) do create_table "_litestream_lock", id: false, force: :cascade do |t| t.integer "id" end @@ -212,7 +212,7 @@ t.datetime "created_at", null: false t.string "cross_domain_token" t.datetime "cross_domain_token_expires_at" - t.string "email", null: false + t.string "email" t.datetime "github_data_updated_at" t.integer "github_id", null: false t.integer "github_repos_count"