mirror of
https://github.com/discourse/discourse.git
synced 2026-09-05 04:40:41 -05:00
FIX: Keep AI bot PM titles working once the bot's edit budget is spent (#42523)
Previously, automatic AI bot conversation titles were saved through `PostRevisor` without bypassing the edit rate limiter, so each one counted against the bot account's daily allowance. Agent bot users are TL4 but not staff, so unlike the system user they are not exempt — once a busy bot spent its allowance the limiter raised inside the enclosing transaction, the title write was rolled back, and conversations were left as "[Untitled AI bot PM]" while replies carried on working. Reported by a customer who noticed exactly `max_edits_per_day` × the TL4 multiplier successful titles in a day, and none after. This change passes `bypass_rate_limiter` on the title and regenerate-reply paths, moves `bypass_bump`/`skip_validations` in the LLM tagger and triage automations out of the fields hash and into the options hash where `PostRevisor` actually reads them, and makes title generation resilient to an empty or over-long model response by falling back to an excerpt of the member's own first post, truncating to a valid length, and only announcing the title over MessageBus once the save succeeded.
This commit is contained in:
@@ -8,6 +8,7 @@ module DiscourseAi
|
||||
# 10 minutes is enough for vast majority of cases
|
||||
# there is a small chance that some reasoning models may take longer
|
||||
MAX_STREAM_DELAY_SECONDS = 600
|
||||
FALLBACK_TITLE_LENGTH = 80
|
||||
|
||||
attr_reader :bot
|
||||
|
||||
@@ -295,12 +296,33 @@ module DiscourseAi
|
||||
DiscourseAi::Completions::Llm.text_from_response(
|
||||
bot.llm.generate(title_prompt, user: user, feature_name: "bot_title"),
|
||||
)
|
||||
new_title = new_title.strip.split("\n").last
|
||||
new_title = new_title.to_s.strip.split("\n").last.to_s
|
||||
new_title = new_title.delete_prefix('"').delete_suffix('"')
|
||||
|
||||
PostRevisor.new(post.topic.first_post, post.topic).revise!(
|
||||
bot.bot_user,
|
||||
title: new_title.sub(/\A"/, "").sub(/"\Z/, ""),
|
||||
)
|
||||
first_post = post.topic.first_post
|
||||
|
||||
if new_title.blank?
|
||||
new_title =
|
||||
PrettyText.excerpt(
|
||||
first_post.cooked,
|
||||
FALLBACK_TITLE_LENGTH,
|
||||
strip_links: true,
|
||||
text_entities: true,
|
||||
)
|
||||
end
|
||||
|
||||
return if new_title.blank?
|
||||
|
||||
new_title = new_title.truncate(SiteSetting.max_topic_title_length, separator: /\s/)
|
||||
|
||||
revised =
|
||||
PostRevisor.new(first_post, post.topic).revise!(
|
||||
bot.bot_user,
|
||||
{ title: new_title },
|
||||
bypass_rate_limiter: true,
|
||||
)
|
||||
|
||||
return if !revised
|
||||
|
||||
allowed_users = post.topic.topic_allowed_users.pluck(:user_id)
|
||||
MessageBus.publish(
|
||||
@@ -313,6 +335,8 @@ module DiscourseAi
|
||||
{ title: post.topic.title, topic_id: post.topic.id },
|
||||
user_ids: allowed_users,
|
||||
)
|
||||
rescue StandardError => e
|
||||
Discourse.warn_exception(e, message: "Discourse AI: Unable to generate title")
|
||||
end
|
||||
|
||||
def reply_to_chat_message(message, channel, context_post_ids)
|
||||
@@ -689,6 +713,7 @@ module DiscourseAi
|
||||
{ raw: reply },
|
||||
skip_validations: true,
|
||||
force_new_version: true,
|
||||
bypass_rate_limiter: true,
|
||||
)
|
||||
save_ai_custom_fields(reply_post, authorization_user_id: authorization_user_id)
|
||||
else
|
||||
|
||||
@@ -543,11 +543,7 @@ module DiscourseAi
|
||||
agent_class = DiscourseAi::Agents::Agent.find_by(id: @agent.id, user: @current_user)
|
||||
if agent_class
|
||||
bot = DiscourseAi::Agents::Bot.as(@reply_user, agent: agent_class.new, model: llm_model)
|
||||
begin
|
||||
DiscourseAi::AiBot::Playground.new(bot).title_playground(reply_post, @user)
|
||||
rescue StandardError => e
|
||||
Discourse.warn_exception(e, message: "Discourse AI: Unable to generate stream title")
|
||||
end
|
||||
DiscourseAi::AiBot::Playground.new(bot).title_playground(reply_post, @user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -189,9 +189,12 @@ module DiscourseAi
|
||||
first_post = topic.posts.where(post_number: 1).first
|
||||
return unless first_post
|
||||
|
||||
changes = { tags: all_tags, bypass_bump: true, skip_validations: true }
|
||||
|
||||
first_post.revise(Discourse.system_user, changes)
|
||||
first_post.revise(
|
||||
Discourse.system_user,
|
||||
{ tags: all_tags },
|
||||
bypass_bump: true,
|
||||
skip_validations: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -216,9 +216,12 @@ module DiscourseAi
|
||||
|
||||
if changes.present?
|
||||
first_post = post.topic.posts.where(post_number: 1).first
|
||||
changes[:bypass_bump] = true
|
||||
changes[:skip_validations] = true
|
||||
first_post.revise(Discourse.system_user, changes)
|
||||
first_post.revise(
|
||||
Discourse.system_user,
|
||||
changes,
|
||||
bypass_bump: true,
|
||||
skip_validations: true,
|
||||
)
|
||||
end
|
||||
|
||||
post.topic.update!(visible: false) if hide_topic
|
||||
|
||||
@@ -66,8 +66,9 @@ RSpec.describe DiscourseAi::Automation::LlmTagger do
|
||||
Tag.find_by(name: "question")&.update!(public_topic_count: 1)
|
||||
end
|
||||
|
||||
it "processes a post and applies appropriate tags" do
|
||||
it "processes a post and applies appropriate tags without bumping the topic" do
|
||||
mock_response = { "tags" => ["bug"], "confidence" => 90 }.to_json
|
||||
bumped_at = topic.bumped_at
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([mock_response]) do
|
||||
described_class.handle(
|
||||
@@ -83,6 +84,7 @@ RSpec.describe DiscourseAi::Automation::LlmTagger do
|
||||
end
|
||||
|
||||
expect(topic.reload.tags.map(&:name)).to include("bug")
|
||||
expect(topic.bumped_at).to eq_time(bumped_at)
|
||||
end
|
||||
|
||||
it "includes document uploads independently from image uploads" do
|
||||
|
||||
@@ -1131,6 +1131,80 @@ RSpec.describe DiscourseAi::AiBot::Playground do
|
||||
expect(pm.reload.title).to eq(expected_response)
|
||||
end
|
||||
end
|
||||
|
||||
it "falls back to an excerpt of the first post when the model returns nothing" do
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([""]) do
|
||||
playground.title_playground(third_post, user)
|
||||
end
|
||||
|
||||
expect(pm.reload.title).to eq(first_post.raw)
|
||||
end
|
||||
|
||||
it "truncates a title the model returns too long to be valid" do
|
||||
long_title = "word " * 100
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([long_title]) do
|
||||
playground.title_playground(third_post, user)
|
||||
end
|
||||
|
||||
expect(pm.reload.title.length).to be <= SiteSetting.max_topic_title_length
|
||||
expect(pm.reload.title.downcase).to start_with("word word")
|
||||
end
|
||||
|
||||
it "does not announce a title that was not saved" do
|
||||
PostRevisor.any_instance.stubs(:revise!).returns(false)
|
||||
|
||||
messages =
|
||||
MessageBus.track_publish("/discourse-ai/ai-bot/topic-titles") do
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
|
||||
playground.title_playground(third_post, user)
|
||||
end
|
||||
end
|
||||
|
||||
expect(messages).to be_empty
|
||||
expect(pm.reload.title).to eq("This is my special PM")
|
||||
end
|
||||
|
||||
it "logs and swallows errors instead of propagating them to the caller" do
|
||||
PostRevisor.any_instance.stubs(:revise!).raises(StandardError.new("boom"))
|
||||
Discourse.expects(:warn_exception).once
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
|
||||
expect { playground.title_playground(third_post, user) }.to_not raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context "when the bot user's daily edit allowance is exhausted" do
|
||||
fab!(:agent) { Fabricate(:ai_agent, enabled: false) }
|
||||
fab!(:agent_user) { agent.create_user! }
|
||||
|
||||
let(:agent_playground) do
|
||||
described_class.new(
|
||||
DiscourseAi::Agents::Bot.as(agent_user, agent: agent.class_instance.new, model: claude_2),
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
RateLimiter.enable
|
||||
SiteSetting.editing_grace_period = 0
|
||||
SiteSetting.max_edits_per_day = 1
|
||||
SiteSetting.tl4_additional_edits_per_day_multiplier = 1
|
||||
end
|
||||
|
||||
it "still updates the title" do
|
||||
expect(agent_user.trust_level).to eq(TrustLevel[4])
|
||||
expect(agent_user.staff?).to eq(false)
|
||||
|
||||
scratch_post = Fabricate(:post, user: agent_user)
|
||||
PostRevisor.new(scratch_post).revise!(agent_user, raw: "using up the daily allowance")
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses([expected_response]) do
|
||||
agent_playground.title_playground(third_post, user)
|
||||
end
|
||||
|
||||
expect(pm.reload.title).to eq(expected_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#reply_to" do
|
||||
|
||||
@@ -43,8 +43,9 @@ describe DiscourseAi::Automation::LlmTriage do
|
||||
expect(post.topic.reload.visible).to eq(false)
|
||||
end
|
||||
|
||||
it "can categorize topics on triage" do
|
||||
it "can categorize topics on triage without bumping the topic" do
|
||||
category = Fabricate(:category)
|
||||
bumped_at = post.topic.bumped_at
|
||||
|
||||
DiscourseAi::Completions::Llm.with_prepared_responses(["bad"]) do
|
||||
triage(
|
||||
@@ -57,6 +58,7 @@ describe DiscourseAi::Automation::LlmTriage do
|
||||
end
|
||||
|
||||
expect(post.topic.reload.category_id).to eq(category.id)
|
||||
expect(post.topic.bumped_at).to eq_time(bumped_at)
|
||||
end
|
||||
|
||||
it "can reply to topics on triage" do
|
||||
|
||||
Reference in New Issue
Block a user