diff --git a/db/migrate/20260601202711_add_summary_to_coplan_plans.co_plan.rb b/db/migrate/20260601202711_add_summary_to_coplan_plans.co_plan.rb new file mode 100644 index 00000000..67d28e1f --- /dev/null +++ b/db/migrate/20260601202711_add_summary_to_coplan_plans.co_plan.rb @@ -0,0 +1,15 @@ +# This migration comes from co_plan (originally 20260601152600) +class AddSummaryToCoplanPlans < ActiveRecord::Migration[8.1] + def change + change_table :coplan_plans do |t| + t.text :summary + t.datetime :summary_generated_at + # SHA256 of the PlanVersion content the summary was generated from. + # Used by SummarizePlanJob to debounce regeneration: if the plan's + # current content sha hasn't changed since the last summary, the job + # no-ops. This lets us fire the job from every PlanVersion#after_create_commit + # without re-calling the AI on rapid back-to-back edits. + t.string :summary_content_sha256, limit: 64 + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 33574be1..1a0c1ccf 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.1].define(version: 2026_06_01_202009) do +ActiveRecord::Schema[8.1].define(version: 2026_06_01_202711) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -226,6 +226,9 @@ t.string "plan_type_id", limit: 36 t.text "search_text", size: :medium t.string "status", default: "brainstorm", null: false + t.text "summary" + t.string "summary_content_sha256", limit: 64 + t.datetime "summary_generated_at" t.string "title", null: false t.datetime "updated_at", null: false t.index ["created_by_user_id"], name: "index_coplan_plans_on_created_by_user_id" diff --git a/engine/app/jobs/coplan/summarize_plan_job.rb b/engine/app/jobs/coplan/summarize_plan_job.rb new file mode 100644 index 00000000..f2d1c68f --- /dev/null +++ b/engine/app/jobs/coplan/summarize_plan_job.rb @@ -0,0 +1,70 @@ +module CoPlan + # Regenerates a plan's AI-generated summary. + # + # Enqueued from PlanVersion#after_create_commit. Debounced via + # `plan.summary_content_sha256`: each job atomically "claims" the + # current content sha before calling the AI. Two workers that wake + # up against the same plan-and-sha will race on the claim — only one + # wins, the other no-ops. Without the atomic claim, both would pass + # a naive pre-check, both call the AI, and waste a full AI request. + # + # AI errors are discarded rather than retried — a stale summary is + # fine, and the next PlanVersion will trigger another attempt. The + # claim survives the failure, which is intentional: we'd rather skip + # this revision than retry a broken prompt in a loop. + class SummarizePlanJob < ApplicationJob + PROMPT_PATH = CoPlan::Engine.root.join("prompts", "summarize.md").freeze + + queue_as :default + + discard_on CoPlan::Ai::Error + + def perform(plan_id:) + plan = Plan.find_by(id: plan_id) + return unless plan + + current_sha = plan.current_plan_version&.content_sha256 + return if current_sha.blank? + + return unless claim_sha(plan, current_sha) + + summary = generate_summary(plan) + return if summary.blank? + + persist_summary(plan, summary, current_sha) + end + + private + + # Atomic claim: set summary_content_sha256 = current_sha only if it + # isn't already current_sha. Returns true if THIS job won the claim. + # + # Using a single conditional UPDATE (one round-trip, atomic at the + # DB) means concurrent workers can't both pass the check and both + # call the AI — exactly one row update succeeds per sha. + def claim_sha(plan, current_sha) + claimed = Plan.where(id: plan.id) + .where("summary_content_sha256 IS NULL OR summary_content_sha256 != ?", current_sha) + .update_all(summary_content_sha256: current_sha) + claimed.positive? + end + + def generate_summary(plan) + content = plan.current_content + return nil if content.blank? + + CoPlan::Ai.call( + system_prompt: File.read(PROMPT_PATH), + user_content: content + ).to_s.strip.presence + end + + # Write the summary only if our claim is still current — if a newer + # version landed mid-flight, a fresher job has already re-claimed + # the sha and we'd be stomping its work. + def persist_summary(plan, summary, expected_sha) + Plan.where(id: plan.id, summary_content_sha256: expected_sha) + .update_all(summary: summary, summary_generated_at: Time.current) + end + end +end diff --git a/engine/app/models/coplan/plan_version.rb b/engine/app/models/coplan/plan_version.rb index d84c9116..11215d1f 100644 --- a/engine/app/models/coplan/plan_version.rb +++ b/engine/app/models/coplan/plan_version.rb @@ -23,6 +23,7 @@ def history_kind after_create_commit :extract_references after_create_commit :broadcast_history_update + after_create_commit :enqueue_summary_regeneration private @@ -64,5 +65,13 @@ def broadcast_references_update def compute_sha256 self.content_sha256 = Digest::SHA256.hexdigest(content_markdown) end + + # Fire SummarizePlanJob after every new version. The job is debounced + # against `plan.summary_content_sha256`, so rapid back-to-back versions + # collapse to a single AI call. The `wait` further reduces wasted calls + # during a burst of edits (e.g. a session commit followed by another). + def enqueue_summary_regeneration + SummarizePlanJob.set(wait: 10.seconds).perform_later(plan_id: plan_id) + end end end diff --git a/engine/app/services/coplan/ai.rb b/engine/app/services/coplan/ai.rb new file mode 100644 index 00000000..4ce9d6a8 --- /dev/null +++ b/engine/app/services/coplan/ai.rb @@ -0,0 +1,23 @@ +module CoPlan + # Provider-agnostic facade for AI calls where the caller doesn't care + # which underlying provider runs the prompt. Use this from any place + # that just wants "an AI" (e.g. SummarizePlanJob). + # + # Provider-specific jobs that need to pin a model or provider per call + # (e.g. AutomatedReviewJob, where each reviewer is configured with its + # own provider+model) should keep calling AiProviders::OpenAi / + # AiProviders::Anthropic directly. + # + # The provider chosen here is an implementation detail; swap it without + # touching callers. Raises CoPlan::Ai::Error on provider failure so + # callers can `discard_on` without knowing which provider is in use. + module Ai + class Error < StandardError; end + + def self.call(system_prompt:, user_content:) + AiProviders::OpenAi.call(system_prompt: system_prompt, user_content: user_content) + rescue AiProviders::OpenAi::Error => e + raise Error, e.message + end + end +end diff --git a/engine/db/migrate/20260601152600_add_summary_to_coplan_plans.rb b/engine/db/migrate/20260601152600_add_summary_to_coplan_plans.rb new file mode 100644 index 00000000..52477f5d --- /dev/null +++ b/engine/db/migrate/20260601152600_add_summary_to_coplan_plans.rb @@ -0,0 +1,14 @@ +class AddSummaryToCoplanPlans < ActiveRecord::Migration[8.1] + def change + change_table :coplan_plans do |t| + t.text :summary + t.datetime :summary_generated_at + # SHA256 of the PlanVersion content the summary was generated from. + # Used by SummarizePlanJob to debounce regeneration: if the plan's + # current content sha hasn't changed since the last summary, the job + # no-ops. This lets us fire the job from every PlanVersion#after_create_commit + # without re-calling the AI on rapid back-to-back edits. + t.string :summary_content_sha256, limit: 64 + end + end +end diff --git a/engine/prompts/summarize.md b/engine/prompts/summarize.md new file mode 100644 index 00000000..069ef3b8 --- /dev/null +++ b/engine/prompts/summarize.md @@ -0,0 +1,7 @@ +You are summarizing engineering planning documents. + +Write a 1-2 sentence summary of the plan below. Be concrete — what is the +plan proposing, and (if applicable) why. Skip filler like "This plan +discusses..." or "The author proposes...". Start with the substance. + +Plain prose only. No Markdown, no lists, no headings. Maximum ~280 characters. diff --git a/spec/jobs/summarize_plan_job_spec.rb b/spec/jobs/summarize_plan_job_spec.rb new file mode 100644 index 00000000..ae296e5d --- /dev/null +++ b/spec/jobs/summarize_plan_job_spec.rb @@ -0,0 +1,160 @@ +require "rails_helper" + +RSpec.describe CoPlan::SummarizePlanJob, type: :job do + include ActiveJob::TestHelper + + let(:plan) { create(:plan) } + let(:current_sha) { plan.current_plan_version.content_sha256 } + + before do + allow(CoPlan::Ai).to receive(:call).and_return("Fresh summary.") + end + + describe "#perform" do + it "passes the summarize prompt and plan content to CoPlan::Ai" do + described_class.perform_now(plan_id: plan.id) + + expect(CoPlan::Ai).to have_received(:call).with( + system_prompt: File.read(CoPlan::SummarizePlanJob::PROMPT_PATH), + user_content: plan.current_content + ) + end + + it "updates summary, generated_at, and sha when content has changed" do + freeze_time do + expect { + described_class.perform_now(plan_id: plan.id) + }.to change { plan.reload.summary }.from(nil).to("Fresh summary.") + .and change { plan.reload.summary_content_sha256 }.from(nil).to(current_sha) + + expect(plan.reload.summary_generated_at).to eq(Time.current) + end + end + + it "strips whitespace from the AI response before persisting" do + allow(CoPlan::Ai).to receive(:call).and_return(" trimmed\n\n") + + described_class.perform_now(plan_id: plan.id) + + expect(plan.reload.summary).to eq("trimmed") + end + + it "no-ops when summary_content_sha256 already matches current content" do + plan.update!( + summary: "Existing.", + summary_generated_at: 1.hour.ago, + summary_content_sha256: current_sha + ) + + described_class.perform_now(plan_id: plan.id) + + expect(CoPlan::Ai).not_to have_received(:call) + expect(plan.reload.summary).to eq("Existing.") + end + + it "regenerates when a new PlanVersion changes the content sha" do + plan.update!(summary: "Old.", summary_content_sha256: current_sha, summary_generated_at: 1.hour.ago) + new_version = create(:plan_version, plan: plan, revision: plan.current_revision + 1, + content_markdown: "# New content\n\nDifferent text.") + plan.update!(current_plan_version: new_version, current_revision: new_version.revision) + + described_class.perform_now(plan_id: plan.id) + + expect(plan.reload.summary).to eq("Fresh summary.") + expect(plan.summary_content_sha256).to eq(new_version.content_sha256) + end + + it "does not update when the AI returns blank" do + allow(CoPlan::Ai).to receive(:call).and_return(" \n") + + expect { + described_class.perform_now(plan_id: plan.id) + }.not_to change { plan.reload.summary } + end + + it "no-ops when the plan has no current version" do + plan.update_columns(current_plan_version_id: nil, current_revision: 0) + + described_class.perform_now(plan_id: plan.id) + + expect(CoPlan::Ai).not_to have_received(:call) + end + + it "no-ops when the plan has been deleted" do + missing_id = SecureRandom.uuid + + expect { + described_class.perform_now(plan_id: missing_id) + }.not_to raise_error + expect(CoPlan::Ai).not_to have_received(:call) + end + + # Concurrency guard: two workers waking up against the same plan-and-sha + # must collapse to a single AI call. Without the atomic claim, both jobs + # would pass a naive pre-check and both burn an AI request. + it "claims the sha before calling AI so a concurrent worker skips" do + call_count = 0 + allow(CoPlan::Ai).to receive(:call) do + call_count += 1 + # Simulate a second worker firing while we're mid-AI-call. With the + # claim already taken, this inner perform should no-op. + described_class.perform_now(plan_id: plan.id) + "Fresh summary." + end + + described_class.perform_now(plan_id: plan.id) + + expect(call_count).to eq(1) + expect(plan.reload.summary).to eq("Fresh summary.") + end + + # Race-condition guard: a slow job started against revision N must + # NOT overwrite a fresher summary already persisted for revision N+1. + it "does not overwrite a fresher summary when a newer version landed mid-flight" do + stale_sha = current_sha + + # Simulate "a newer version landed while the AI was thinking" by + # mutating the plan's current_plan_version between the AI call and + # the persist step. + allow(CoPlan::Ai).to receive(:call) do + newer = create(:plan_version, plan: plan, revision: plan.current_revision + 1, + content_markdown: "# Fresher\n\nNewer body.") + plan.update!(current_plan_version: newer, current_revision: newer.revision, + summary: "Fresher summary persisted by the newer job.", + summary_content_sha256: newer.content_sha256, + summary_generated_at: Time.current) + "Stale summary from the slow job." + end + + described_class.perform_now(plan_id: plan.id) + + expect(plan.reload.summary).to eq("Fresher summary persisted by the newer job.") + expect(plan.summary_content_sha256).not_to eq(stale_sha) + end + + it "discards on AI errors instead of retrying" do + allow(CoPlan::Ai).to receive(:call).and_raise(CoPlan::Ai::Error, "boom") + + expect { + perform_enqueued_jobs { described_class.perform_later(plan_id: plan.id) } + }.not_to raise_error + end + + it "enqueues on the default queue" do + existing_plan_id = plan.id + expect { + described_class.perform_later(plan_id: existing_plan_id) + }.to have_enqueued_job(described_class).on_queue("default").with(plan_id: existing_plan_id) + end + end + + describe "PlanVersion after_create_commit hook" do + it "enqueues SummarizePlanJob when a new version is created" do + existing_plan = create(:plan) + + expect { + create(:plan_version, plan: existing_plan, revision: existing_plan.current_revision + 1) + }.to have_enqueued_job(described_class).with(plan_id: existing_plan.id) + end + end +end diff --git a/spec/services/ai_spec.rb b/spec/services/ai_spec.rb new file mode 100644 index 00000000..2d6b257d --- /dev/null +++ b/spec/services/ai_spec.rb @@ -0,0 +1,26 @@ +require "rails_helper" + +RSpec.describe CoPlan::Ai do + describe ".call" do + it "delegates to AiProviders::OpenAi and returns its response" do + allow(CoPlan::AiProviders::OpenAi).to receive(:call).and_return("ai output") + + result = described_class.call(system_prompt: "sys", user_content: "body") + + expect(result).to eq("ai output") + expect(CoPlan::AiProviders::OpenAi).to have_received(:call).with( + system_prompt: "sys", + user_content: "body" + ) + end + + it "wraps provider errors in CoPlan::Ai::Error so callers don't know the provider" do + allow(CoPlan::AiProviders::OpenAi).to receive(:call) + .and_raise(CoPlan::AiProviders::OpenAi::Error, "rate limited") + + expect { + described_class.call(system_prompt: "sys", user_content: "body") + }.to raise_error(CoPlan::Ai::Error, "rate limited") + end + end +end