diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 89f8abb..2017840 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -13,6 +13,13 @@ def index .published .includes(:user) .order(created_at: :desc) - @testimonials = Testimonial.published.includes(:user).order(Arel.sql("RANDOM()")).limit(20) + 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 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/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 5d715da..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_025116) 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 @@ -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 @@ -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" 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