0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-ai/spec/lib/post_image_captions_spec.rb
Natalie Tay 86680f6b0d
FEATURE: Dedicated image caption AI feature and agent validations (#41729)
Follow up to https://github.com/discourse/discourse/pull/41586


1. **Bulk of the PR diff** -- there's a mix of usages for "description"
and "caption", we'll stick to V1 terminology and keep "AI caption"
instead of bringing in "description".

2. Move image caption out of AI helper, into its own dedicated "AI
Feature" for consolidated settings

3. Update default values for the existing image caption agent 
- vision_enabled was default off, now on
- update description

4. Validations and problem checks
- via Site Setting validation - agent / llm
  - when enabling the feature
  - or when selecting invalid agent

- inform admin when they've enabled the setting but the agent is
misconfigured via ProblemCheck
2026-07-16 11:29:39 +08:00

339 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
describe DiscourseAi::PostImageCaptions do
fab!(:upload) { create_image_upload("100x100.jpg", "caption-image.jpg") }
fab!(:post) { Fabricate(:post, raw: "![user supplied|200x200](#{upload.short_url})") }
before do
enable_current_plugin
llm_model = assign_fake_provider_to(:ai_default_llm_model)
llm_model.update!(vision_enabled: true)
caption_agent =
AiAgent.find_by(id: SiteSetting.ai_image_caption_agent.to_i) ||
Fabricate(:ai_agent, id: SiteSetting.ai_image_caption_agent.to_i)
caption_agent.update!(enabled: true, vision_enabled: true, default_llm_id: llm_model.id)
SiteSetting.ai_post_image_captions_enabled = true
SiteSetting.ai_helper_enabled = true
post.update_column(:cooked, post.cook(post.raw, topic_id: post.topic_id))
post.link_post_uploads
end
def create_image_upload(filename, original_filename)
UploadCreator.new(discourse_ai_image_fixture(filename), original_filename).create_for(
Discourse.system_user.id,
)
end
def discourse_ai_image_fixture(filename)
file_from_fixtures(
filename,
"images",
Rails.root.join("plugins/discourse-ai/spec/fixtures").to_s,
)
end
def store_description(description, locale: SiteSetting.default_locale, target_upload: upload)
AiPostImageCaption.upsert_all(
[
{
post_id: post.id,
upload_id: target_upload.id,
base62_sha1: target_upload.base62_sha1,
locale: locale,
description: description,
attempts: 0,
},
],
unique_by: described_class::LOOKUP_INDEX,
)
end
it "supports ActiveRecord updates with a primary key" do
image_caption =
AiPostImageCaption.create!(
post_id: post.id,
upload_id: upload.id,
base62_sha1: upload.base62_sha1,
locale: SiteSetting.default_locale,
description: "An initial description",
)
image_caption.update!(description: "An updated description")
expect(image_caption.reload.description).to eq("An updated description")
end
it "adds image caption metadata without visible text", :aggregate_failures do
description = "A lighthouse beside 字 on a sign"
store_description(description)
processor = CookedPostProcessor.new(post)
processor.post_process
doc = Nokogiri::HTML5.fragment(processor.html)
image = doc.at_css("img[data-base62-sha1='#{upload.base62_sha1}']")
lightbox = doc.at_css("a.lightbox")
expect(post.raw).not_to include(description)
expect(image["alt"]).to eq("user supplied")
expect(lightbox["title"]).to eq("user supplied")
expect(doc.at_css(".meta .filename").text).to eq("user supplied")
expect(image["aria-description"]).to eq("#{description} (Captioned by AI)")
expect(lightbox["aria-description"]).to be_blank
expect(doc.at_css(".ai-image-description")).to be_blank
expect(
ExcerptParser.to_plain_text(ExcerptParser.get_excerpt(processor.html, 200)),
).not_to include(description)
end
it "strips image caption metadata from email HTML", :aggregate_failures do
description = "A generated email-only 字 description"
store_description(description)
processor = CookedPostProcessor.new(post)
processor.post_process
email_html = PrettyText.format_for_email(processor.html, post)
email_doc = Nokogiri::HTML5.fragment(email_html)
expect(email_html).not_to include(description)
expect(email_doc.at_css("[aria-description]")).to be_blank
end
it "enqueues generation for post images only" do
attachment = create_image_upload("An image of discobot in action.png", "attachment.png")
post.update!(
raw:
"![visible image|200x200](#{upload.short_url})\n\n" \
"[attached image|attachment](#{attachment.short_url})",
)
post.update_column(:cooked, post.cook(post.raw, topic_id: post.topic_id))
post.link_post_uploads
expect_enqueued_with(
job: :generate_post_image_captions,
args: {
post_id: post.id,
locale: SiteSetting.default_locale,
base62_sha1s: [upload.base62_sha1],
},
) do
processor = CookedPostProcessor.new(post)
processor.post_process
end
end
it "does not run when post image captions are disabled" do
SiteSetting.ai_post_image_captions_enabled = false
expect_not_enqueued_with(job: :generate_post_image_captions) do
described_class.process_cooked(Nokogiri::HTML5.fragment(post.cooked), post, locale: "en")
end
end
it "enqueues generation when the caption agent is disabled for AI bot use" do
AiAgent.find_by(id: SiteSetting.ai_image_caption_agent).update!(enabled: false)
expect(described_class.generation_enabled?).to eq(true)
expect_enqueued_with(
job: :generate_post_image_captions,
args: {
post_id: post.id,
locale: SiteSetting.default_locale,
base62_sha1s: [upload.base62_sha1],
},
) do
described_class.process_cooked(
Nokogiri::HTML5.fragment(post.cooked),
post,
locale: SiteSetting.default_locale,
)
end
end
it "does not enqueue generation while cooking old posts" do
post.update_columns(created_at: 2.days.ago, updated_at: 2.days.ago)
expect_not_enqueued_with(job: :generate_post_image_captions) do
processor = CookedPostProcessor.new(post)
processor.post_process
end
end
it "enqueues generation for recently edited old posts" do
post.update_columns(created_at: 2.days.ago, updated_at: Time.zone.now)
expect_enqueued_with(
job: :generate_post_image_captions,
args: {
post_id: post.id,
locale: SiteSetting.default_locale,
base62_sha1s: [upload.base62_sha1],
},
) do
processor = CookedPostProcessor.new(post)
processor.post_process
end
end
it "preserves non-AI image aria descriptions when disabled", :aggregate_failures do
SiteSetting.ai_post_image_captions_enabled = false
doc =
Nokogiri::HTML5.fragment(
"<a class='lightbox' aria-description='custom lightbox'>" \
"<img src='https://example.com/image.png' aria-description='custom image'>" \
"</a>",
)
described_class.process_cooked(doc, post, locale: SiteSetting.default_locale)
expect(doc.at_css("img")["aria-description"]).to eq("custom image")
expect(doc.at_css("a.lightbox")["aria-description"]).to eq("custom lightbox")
end
it "skips personal messages and whispers" do
private_topic = Fabricate(:private_message_topic, user: post.user)
pm_post = Fabricate(:post, topic: private_topic, raw: post.raw)
pm_post.update_column(:cooked, post.cooked)
pm_post.link_post_uploads
whisper = Fabricate(:post, raw: post.raw, post_type: Post.types[:whisper])
whisper.update_column(:cooked, post.cooked)
whisper.link_post_uploads
expect_not_enqueued_with(job: :generate_post_image_captions) do
described_class.process_cooked(
Nokogiri::HTML5.fragment(pm_post.cooked),
pm_post,
locale: "en",
)
described_class.process_cooked(
Nokogiri::HTML5.fragment(whisper.cooked),
whisper,
locale: "en",
)
end
end
it "ignores image nodes without post upload references" do
unrelated_upload = create_image_upload("An image of discobot in action.png", "unrelated.png")
expect_not_enqueued_with(job: :generate_post_image_captions) do
described_class.process_cooked(
Nokogiri::HTML5.fragment(
"<img src='https://example.com/image.png' data-base62-sha1='#{unrelated_upload.base62_sha1}'>" \
"<img src='https://example.com/other.png' data-base62-sha1='@bad'>",
),
post,
locale: SiteSetting.default_locale,
)
end
end
it "adds current image captions to search text" do
default_description = "A searchable 字 description"
japanese_description = "A Japanese 字 description"
store_description(default_description)
store_description(japanese_description, locale: "ja")
indexed_text = described_class.append_to_search_text("body", post.id, post.cooked)
text_without_image = described_class.append_to_search_text("body", post.id, "<p>No image</p>")
expect(indexed_text).to include(default_description)
expect(indexed_text).not_to include(japanese_description)
expect(text_without_image).to eq("body")
end
it "does not delete other locale descriptions during localized cooking" do
store_description("A default locale description")
store_description("A Japanese description", locale: "ja")
other_upload = create_image_upload("An image of discobot in action.png", "other-image.png")
post.update!(raw: "![other image|200x200](#{other_upload.short_url})")
post.update_column(:cooked, post.cook(post.raw, topic_id: post.topic_id))
post.link_post_uploads
described_class.process_cooked(Nokogiri::HTML5.fragment(post.cooked), post, locale: "ja")
expect(
AiPostImageCaption.exists?(
post_id: post.id,
locale: SiteSetting.default_locale,
base62_sha1: upload.base62_sha1,
),
).to eq(true)
expect(
AiPostImageCaption.exists?(post_id: post.id, locale: "ja", base62_sha1: upload.base62_sha1),
).to eq(false)
end
it "adds descriptions while storing localized cooked", :aggregate_failures do
description = "日本語の画像説明"
store_description(description, locale: "ja")
localization =
Fabricate(:post_localization, post: post, locale: "ja", raw: post.raw, cooked: post.cooked)
Jobs::ProcessLocalizedCooked.new.execute(post_localization_id: localization.id)
doc = Nokogiri::HTML5.fragment(localization.reload.cooked)
image = doc.at_css("img[data-base62-sha1='#{upload.base62_sha1}']")
expect(image["aria-description"]).to include(description)
expect(doc.at_css(".ai-image-description")).to be_blank
end
it "falls back to original descriptions while localized generation is pending",
:aggregate_failures do
description = "A default locale fallback description"
store_description(description)
localization =
Fabricate(:post_localization, post: post, locale: "ja", raw: post.raw, cooked: post.cooked)
expect_enqueued_with(
job: :generate_post_image_captions,
args: {
post_id: post.id,
locale: "ja",
base62_sha1s: [upload.base62_sha1],
},
) { Jobs::ProcessLocalizedCooked.new.execute(post_localization_id: localization.id) }
doc = Nokogiri::HTML5.fragment(localization.reload.cooked)
image = doc.at_css("img[data-base62-sha1='#{upload.base62_sha1}']")
expect(image["aria-description"]).to include(description)
expect(AiPostImageCaption.exists?(post_id: post.id, locale: "ja")).to eq(false)
end
it "deletes descriptions after all post images are removed" do
store_description("A stored description")
described_class.process_cooked(Nokogiri::HTML5.fragment("<p>No image</p>"), post, locale: "en")
expect(AiPostImageCaption.exists?(post_id: post.id, base62_sha1: upload.base62_sha1)).to eq(
false,
)
end
it "keeps stored descriptions when the feature is disabled" do
store_description("A stored description")
SiteSetting.ai_post_image_captions_enabled = false
described_class.process_cooked(Nokogiri::HTML5.fragment("<p>No image</p>"), post, locale: "en")
expect(AiPostImageCaption.exists?(post_id: post.id, base62_sha1: upload.base62_sha1)).to eq(
true,
)
end
it "deletes descriptions only when the post is permanently destroyed", :aggregate_failures do
store_description("A stored description")
admin = Fabricate(:admin)
PostDestroyer.new(admin, post).destroy
expect(AiPostImageCaption.exists?(post_id: post.id)).to eq(true)
PostDestroyer.new(admin, post.reload, force_destroy: true).destroy
expect(AiPostImageCaption.exists?(post_id: post.id)).to eq(false)
end
end