discourse-ai/spec/jobs/scheduled/summaries_backfill_spec.rb
Roman Rizzi 46fcdb6ba5
FIX: Make summaries backfill job more resilient. (#1071)
To quickly select backfill candidates without comparing SHAs, we compare the last summarized post to the topic's highest_post_number. However, hiding or deleting a post and adding a small action will update this column, causing the job to stall and re-generate the same summary repeatedly until someone posts a regular reply. On top of this, this is not always true for topics with `best_replies`, as this last reply isn't necessarily included.

Since this is not evident at first glance and each summarization strategy picks its targets differently, I'm opting to simplify the backfill logic and how we track potential candidates.

The first step is dropping `content_range`, which serves no purpose and it's there because summary caching was supposed to work differently at the beginning. So instead, I'm replacing it with a column called `highest_target_number`, which tracks `highest_post_number` for topics and could track other things like channel's `message_count` in the future.

Now that we have this column when selecting every potential backfill candidate, we'll check if the summary is truly outdated by comparing the SHAs, and if it's not, we just update the column and move on
2025-01-16 09:42:53 -03:00

132 lines
5.0 KiB
Ruby

# frozen_string_literal: true
RSpec.describe Jobs::SummariesBackfill do
fab!(:topic) { Fabricate(:topic, word_count: 200, highest_post_number: 2) }
let(:limit) { 24 } # guarantee two summaries per batch
let(:intervals) { 12 } # budget is split into intervals. Job runs every five minutes.
before do
assign_fake_provider_to(:ai_summarization_model)
SiteSetting.ai_summarization_enabled = true
SiteSetting.ai_summary_backfill_maximum_topics_per_hour = limit
SiteSetting.ai_summary_gists_enabled = true
end
describe "#current_budget" do
let(:type) { AiSummary.summary_types[:complete] }
context "when no summary has been backfilled yet" do
it "returns the full budget" do
expect(subject.current_budget(type)).to eq(limit / intervals)
end
it "ignores summaries generated by users" do
Fabricate(:ai_summary, target: topic, origin: AiSummary.origins[:human])
expect(subject.current_budget(type)).to eq(limit / intervals)
end
it "only accounts for summaries of the given type" do
Fabricate(:topic_ai_gist, target: topic, origin: AiSummary.origins[:human])
expect(subject.current_budget(type)).to eq(limit / intervals)
end
end
end
describe "#backfill_candidates" do
let(:type) { AiSummary.summary_types[:complete] }
it "only selects posts with enough words" do
topic.update!(word_count: 100)
expect(subject.backfill_candidates(type)).to be_empty
end
it "ignores up to date summaries" do
Fabricate(:ai_summary, target: topic, highest_target_number: 2, updated_at: 10.minutes.ago)
expect(subject.backfill_candidates(type)).to be_empty
end
it "ignores outdated summaries updated less than five minutes ago" do
Fabricate(:ai_summary, target: topic, highest_target_number: 1, updated_at: 4.minutes.ago)
expect(subject.backfill_candidates(type)).to be_empty
end
it "orders candidates by topic#last_posted_at" do
topic.update!(last_posted_at: 1.minute.ago)
topic_2 = Fabricate(:topic, word_count: 200, last_posted_at: 2.minutes.ago)
expect(subject.backfill_candidates(type).map(&:id)).to contain_exactly(topic.id, topic_2.id)
end
it "prioritizes topics without summaries" do
topic_2 =
Fabricate(:topic, word_count: 200, last_posted_at: 2.minutes.ago, highest_post_number: 1)
topic.update!(last_posted_at: 1.minute.ago)
Fabricate(:ai_summary, target: topic, updated_at: 1.hour.ago, highest_target_number: 1)
expect(subject.backfill_candidates(type).map(&:id)).to contain_exactly(topic_2.id, topic.id)
end
it "respects max age setting" do
SiteSetting.ai_summary_backfill_topic_max_age_days = 1
topic.update!(created_at: 2.days.ago)
expect(subject.backfill_candidates(type)).to be_empty
end
end
describe "#execute" do
it "backfills a batch" do
topic_2 =
Fabricate(:topic, word_count: 200, last_posted_at: 2.minutes.ago, highest_post_number: 1)
topic.update!(last_posted_at: 1.minute.ago)
Fabricate(:ai_summary, target: topic, updated_at: 3.hours.ago, highest_target_number: 1)
Fabricate(:topic_ai_gist, target: topic, updated_at: 3.hours.ago, highest_target_number: 1)
summary_1 = "Summary of topic_2"
gist_1 = "Gist of topic_2"
summary_2 = "Updated summary of topic"
gist_2 = "Updated gist of topic"
DiscourseAi::Completions::Llm.with_prepared_responses(
[gist_1, gist_2, summary_1, summary_2],
) { subject.execute({}) }
expect(AiSummary.complete.find_by(target: topic_2).summarized_text).to eq(summary_1)
expect(AiSummary.gist.find_by(target: topic_2).summarized_text).to eq(gist_1)
expect(AiSummary.complete.find_by(target: topic).summarized_text).to eq(summary_2)
expect(AiSummary.gist.find_by(target: topic).summarized_text).to eq(gist_2)
# Queue has to be empty if we just generated all summaries
expect(subject.backfill_candidates(AiSummary.summary_types[:complete])).to be_empty
expect(subject.backfill_candidates(AiSummary.summary_types[:gist])).to be_empty
# Queue still empty when they are up to date and time passes.
AiSummary.update_all(updated_at: 20.minutes.ago)
expect(subject.backfill_candidates(AiSummary.summary_types[:complete])).to be_empty
expect(subject.backfill_candidates(AiSummary.summary_types[:gist])).to be_empty
end
it "updates the highest_target_number if the summary turned to be up to date" do
existing_summary =
Fabricate(
:ai_summary,
target: topic,
updated_at: 3.hours.ago,
highest_target_number: topic.highest_post_number,
)
og_highest_post_number = topic.highest_post_number
topic.update!(highest_post_number: og_highest_post_number + 1)
# No prepared responses here. We don't perform a completion call.
subject.execute({})
expect(existing_summary.reload.highest_target_number).to eq(og_highest_post_number + 1)
end
end
end