diff --git a/engine/lib/tasks/coplan_summaries.rake b/engine/lib/tasks/coplan_summaries.rake new file mode 100644 index 00000000..c22ac864 --- /dev/null +++ b/engine/lib/tasks/coplan_summaries.rake @@ -0,0 +1,51 @@ +namespace :coplan do + namespace :summaries do + # One-time backfill of AI summaries for plans created before the + # summary infra (COPLAN-24, #118), which only fires on new + # PlanVersion creates. Enqueues SummarizePlanJob for every plan + # missing a summary; the job's sha-claim debounce makes this safe + # to re-run. + # + # Throttled with a sleep between batches to avoid spiking OpenAI + # cost/rate limits. Tune via env: + # + # BATCH_SIZE=25 INTERVAL=5 bin/rails coplan:summaries:backfill + # + # Plans that already have a summary are excluded by the query; + # plans with no current_plan_version (no content) are skipped. + desc "Backfill AI summaries for plans missing one (COPLAN-31)" + task backfill: :environment do + batch_size = Integer(ENV["BATCH_SIZE"].presence || 25) + interval = Float(ENV["INTERVAL"].presence || 5) + + # A prior SummarizePlanJob may have claimed the current content + # sha (summary_content_sha256) but then hit CoPlan::Ai::Error or + # got a blank response — leaving the plan summary-less with the + # sha already claimed. Re-enqueuing alone would no-op forever at + # the job's claim_sha guard, so those plans could never be + # backfilled. Clear the stale claims (only for summary-less + # plans; already-summarized plans keep theirs) so the enqueued + # jobs can re-claim and retry. + reset = CoPlan::Plan.where(summary: nil) + .where.not(summary_content_sha256: nil) + .update_all(summary_content_sha256: nil) + + enqueued = 0 + skipped = 0 + + CoPlan::Plan.where(summary: nil).find_each(batch_size: batch_size) do |plan| + if plan.current_plan_version_id.nil? + skipped += 1 + next + end + + CoPlan::SummarizePlanJob.perform_later(plan_id: plan.id) + enqueued += 1 + + sleep(interval) if interval.positive? && (enqueued % batch_size).zero? + end + + puts "coplan:summaries:backfill — enqueued=#{enqueued} skipped=#{skipped} reset_claims=#{reset} (skipped = no current_plan_version; already-summarized excluded by query)" + end + end +end diff --git a/spec/lib/tasks/coplan_summaries_spec.rb b/spec/lib/tasks/coplan_summaries_spec.rb new file mode 100644 index 00000000..1e6d5ade --- /dev/null +++ b/spec/lib/tasks/coplan_summaries_spec.rb @@ -0,0 +1,82 @@ +require "rails_helper" +require "rake" + +RSpec.describe "coplan:summaries:backfill", type: :task do + include ActiveJob::TestHelper + + subject(:run_task) { Rake::Task["coplan:summaries:backfill"].tap(&:reenable).invoke } + + around do |example| + previous_application = Rake.application + Rake.application = Rake::Application.new + Rake::Task.define_task(:environment) + load CoPlan::Engine.root.join("lib/tasks/coplan_summaries.rake").to_s + example.run + ensure + Rake.application = previous_application + end + + before { clear_enqueued_jobs } + + it "enqueues SummarizePlanJob for summary-less plans with a current version" do + plan = create(:plan) + + expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id) + end + + it "skips plans that already have a summary" do + create(:plan).update_columns(summary: "Already summarized.") + + expect { run_task }.not_to have_enqueued_job(CoPlan::SummarizePlanJob) + end + + it "skips plans with no current_plan_version" do + create(:plan).update_columns(current_plan_version_id: nil) + + expect { run_task }.not_to have_enqueued_job(CoPlan::SummarizePlanJob) + end + + it "enqueues only the eligible plans when mixed" do + eligible = create(:plan) + create(:plan).update_columns(summary: "Has one.") + create(:plan).update_columns(current_plan_version_id: nil) + + # Plan creation enqueues its own SummarizePlanJob (PlanVersion + # after_create_commit); clear those so we observe only the task's. + clear_enqueued_jobs + run_task + + expect(enqueued_jobs.map { |job| job[:args].first["plan_id"] }).to eq([eligible.id]) + end + + it "clears a stale sha claim on a summary-less plan so the retried job isn't debounced away" do + plan = create(:plan) + # Simulate a prior job that claimed the sha but failed/blanked: + # summary stays nil while the sha is already claimed. + plan.update_columns(summary_content_sha256: plan.current_plan_version.content_sha256) + + expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id) + expect(plan.reload.summary_content_sha256).to be_nil + end + + it "leaves an already-summarized plan's sha claim intact" do + plan = create(:plan) + sha = plan.current_plan_version.content_sha256 + plan.update_columns(summary: "Done.", summary_content_sha256: sha) + + run_task + + expect(plan.reload.summary_content_sha256).to eq(sha) + end + + it "falls back to defaults when BATCH_SIZE/INTERVAL are blank" do + plan = create(:plan) + ENV["BATCH_SIZE"] = "" + ENV["INTERVAL"] = "" + + expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id) + ensure + ENV.delete("BATCH_SIZE") + ENV.delete("INTERVAL") + end +end