Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 70 additions & 0 deletions engine/app/jobs/coplan/summarize_plan_job.rb
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions engine/app/models/coplan/plan_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
23 changes: 23 additions & 0 deletions engine/app/services/coplan/ai.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions engine/db/migrate/20260601152600_add_summary_to_coplan_plans.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions engine/prompts/summarize.md
Original file line number Diff line number Diff line change
@@ -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.
160 changes: 160 additions & 0 deletions spec/jobs/summarize_plan_job_spec.rb
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions spec/services/ai_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading