0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 22:42:07 +08:00
discourse/plugins/discourse-ai/lib/post_image_captions.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

681 lines
22 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module PostImageCaptions
CAPTION_CLASS = "ai-image-caption"
CAPTION_ID_PREFIX = "ai-image-caption"
LEGACY_DESCRIPTION_CLASS = "ai-image-description"
LEGACY_DESCRIPTION_ID_PREFIX = "ai-image-description"
ARIA_DESCRIPTION_ATTRIBUTE = "aria-description"
LOOKUP_INDEX = "idx_ai_post_image_captions_lookup"
REUSE_INDEX = "idx_ai_post_image_captions_reuse"
MAX_ATTEMPTS = 3
RETRY_AFTER = 1.day
COOK_ENQUEUE_POST_MAX_AGE = 1.day
BACKFILL_RUNS_PER_HOUR = 4
SUPPORTED_EXTENSIONS = %w[jpg jpeg png gif webp].freeze
MAX_CAPTION_LENGTH = 1_000
module_function
def enabled?
SiteSetting.discourse_ai_enabled && SiteSetting.ai_post_image_captions_enabled &&
SiteSetting.ai_image_caption_agent.present?
end
def generation_enabled?
return false if !enabled?
agent = image_caption_agent
return false if agent.blank?
llm_model = image_caption_llm_model(agent)
agent.class_instance&.vision_enabled && llm_model&.vision_enabled?
end
def process_cooked(doc, post, locale:)
remove_existing_caption_metadata(doc)
return if !enabled?
return if !captionable_post?(post)
base62_sha1s = image_base62_sha1s(doc, post_id: post.id)
base62_sha1s = capped_base62_sha1s(base62_sha1s)
delete_removed_captions(post, locale, base62_sha1s)
return if base62_sha1s.blank?
captions = captions_for(post.id, locale, base62_sha1s)
exact_caption_base62_sha1s = captions.keys
captions = captions_with_locale_fallback(post, locale, base62_sha1s, captions)
decorate(doc, captions:, locale:)
enqueue_missing(
post,
locale: locale,
base62_sha1s: base62_sha1s,
existing_base62_sha1s: exact_caption_base62_sha1s,
)
end
def decorate(doc, captions:, locale:)
return if captions.blank?
image_nodes(doc).each do |img|
base62_sha1 = image_node_base62_sha1(img)
description = captions[base62_sha1]
next if description.blank?
aria_description = aria_description(description, locale)
img[ARIA_DESCRIPTION_ATTRIBUTE] = aria_description
end
end
def enqueue_missing(post, locale:, base62_sha1s:, existing_base62_sha1s: nil)
return if base62_sha1s.blank?
return if !generation_enabled?
return if post.updated_at < COOK_ENQUEUE_POST_MAX_AGE.ago
missing_base62_sha1s = pending_base62_sha1s(post.id, locale, base62_sha1s)
missing_base62_sha1s -= existing_base62_sha1s if existing_base62_sha1s
return if missing_base62_sha1s.blank?
Jobs.enqueue(
:generate_post_image_captions,
post_id: post.id,
locale: locale,
base62_sha1s: missing_base62_sha1s,
)
end
def generate_missing(post, locale:, base62_sha1s: nil)
return 0 if !generation_enabled?
return 0 if !captionable_post?(post)
return 0 if !credits_available?
current_base62_sha1s =
image_base62_sha1s(Nokogiri::HTML5.fragment(post.cooked), post_id: post.id)
current_base62_sha1s = capped_base62_sha1s(current_base62_sha1s)
current_base62_sha1s &= base62_sha1s if base62_sha1s.present?
if current_base62_sha1s.blank? && base62_sha1s.blank?
record_uncaptionable_backfill_candidate(post, locale)
end
return 0 if current_base62_sha1s.blank?
missing_base62_sha1s = pending_base62_sha1s(post.id, locale, current_base62_sha1s)
return 0 if missing_base62_sha1s.blank?
uploads_by_base62_sha1 = uploads_by_base62_sha1(missing_base62_sha1s)
generated_count = reuse_captions(post, locale, missing_base62_sha1s, uploads_by_base62_sha1)
missing_base62_sha1s = pending_base62_sha1s(post.id, locale, current_base62_sha1s)
return 0 if generated_count == 0 && missing_base62_sha1s.blank?
assistant = DiscourseAi::AiHelper::Assistant.new if missing_base62_sha1s.present?
missing_base62_sha1s.each do |base62_sha1|
upload = uploads_by_base62_sha1[base62_sha1]
next if upload.blank?
if !captionable_upload?(upload, post)
record_attempt(post, upload, base62_sha1, locale, error: "upload_not_captionable")
next
end
begin
description =
assistant.generate_image_caption(
upload,
Discourse.system_user,
locale: locale,
post: post,
skip_access_check: true,
)
if description.blank?
record_attempt(post, upload, base62_sha1, locale, error: "blank_response")
else
record_attempt(post, upload, base62_sha1, locale, description: description)
generated_count += 1
end
rescue StandardError => e
record_attempt(post, upload, base62_sha1, locale, error: e.message)
end
end
return 0 if generated_count == 0
SearchIndexer.index(post, force: true)
refresh_cooked(post, locale)
generated_count
end
def append_to_search_text(text, post_id, cooked, locale: SiteSetting.default_locale)
return text if !enabled?
return text if cooked.blank? || !cooked.include?("data-base62-sha1")
locale = locale.presence || SiteSetting.default_locale
base62_sha1s = image_base62_sha1s(Nokogiri::HTML5.fragment(cooked), post_id: post_id)
base62_sha1s = capped_base62_sha1s(base62_sha1s)
return text if base62_sha1s.blank?
captions =
AiPostImageCaption
.where(post_id: post_id, locale: locale, base62_sha1: base62_sha1s)
.where.not(description: nil)
.distinct
.pluck(:description)
return text if captions.blank?
"#{text} #{captions.join(" ")}"
end
def editable_captions(post, locale)
return [] if !enabled?
return [] if !captionable_post?(post)
locale = locale.presence || original_locale(post)
base62_sha1s = current_base62_sha1s(post)
return [] if base62_sha1s.blank?
captions = captions_for(post.id, locale, base62_sha1s)
base62_sha1s.filter_map do |base62_sha1|
description = captions[base62_sha1]
next if description.blank?
{ base62_sha1: base62_sha1, description: description }
end
end
def update_caption(post, locale, base62_sha1, description)
return if !enabled?
return if !captionable_post?(post)
return if !current_base62_sha1s(post).include?(base62_sha1)
locale = locale.presence || original_locale(post)
image_caption =
AiPostImageCaption.find_by(post_id: post.id, locale: locale, base62_sha1: base62_sha1)
return if image_caption.blank? || image_caption.description.blank?
image_caption.update!(description: description, last_error: nil)
SearchIndexer.index(post, force: true)
refresh_cooked(post, locale)
image_caption
end
def backfill_limit
hourly_rate = SiteSetting.ai_post_image_captions_backfill_hourly_rate.to_i
return 0 if hourly_rate <= 0
used_budget = DB.query_single(<<~SQL, threshold: 1.hour.ago).first.to_i
SELECT COUNT(*)
FROM (
SELECT DISTINCT post_id, locale
FROM ai_post_image_captions
WHERE created_at > :threshold OR last_attempted_at > :threshold
) recent_targets
SQL
remaining_budget = hourly_rate - used_budget
return 0 if remaining_budget <= 0
per_run_limit = [hourly_rate / BACKFILL_RUNS_PER_HOUR, 1].max
[per_run_limit, remaining_budget].min
end
def backfill_targets(limit:)
return [] if limit.to_i <= 0
connection = ActiveRecord::Base.connection
default_locale = connection.quote(SiteSetting.default_locale)
retry_after = connection.quote(RETRY_AFTER.ago)
max_age_days = SiteSetting.ai_post_image_captions_backfill_max_age_days.to_i
original_locale_sql = "COALESCE(NULLIF(posts.locale, ''), #{default_locale})"
supported_extensions =
SUPPORTED_EXTENSIONS.map { |extension| connection.quote(extension) }.join(", ")
localized_targets_sql =
if SiteSetting.content_localization_enabled
<<~SQL
UNION ALL
SELECT posts.id AS post_id,
posts.image_upload_id AS upload_id,
post_localizations.locale AS locale
FROM posts
INNER JOIN topics ON topics.id = posts.topic_id
INNER JOIN uploads ON uploads.id = posts.image_upload_id
INNER JOIN post_localizations ON post_localizations.post_id = posts.id
WHERE posts.post_type = #{Post.types[:regular]}
AND posts.deleted_at IS NULL
AND topics.deleted_at IS NULL
AND topics.archetype <> #{connection.quote(Archetype.private_message)}
AND posts.created_at > #{connection.quote(max_age_days.days.ago)}
AND LOWER(uploads.extension) IN (#{supported_extensions})
AND post_localizations.locale <> #{original_locale_sql}
SQL
else
""
end
DB
.query(<<~SQL, limit: limit.to_i)
WITH candidate_targets AS (
SELECT posts.id AS post_id,
posts.image_upload_id AS upload_id,
#{original_locale_sql} AS locale
FROM posts
INNER JOIN topics ON topics.id = posts.topic_id
INNER JOIN uploads ON uploads.id = posts.image_upload_id
WHERE posts.post_type = #{Post.types[:regular]}
AND posts.deleted_at IS NULL
AND topics.deleted_at IS NULL
AND topics.archetype <> #{connection.quote(Archetype.private_message)}
AND posts.created_at > #{connection.quote(max_age_days.days.ago)}
AND LOWER(uploads.extension) IN (#{supported_extensions})
#{localized_targets_sql}
)
SELECT candidate_targets.post_id,
candidate_targets.locale
FROM candidate_targets
LEFT JOIN ai_post_image_captions existing_captions
ON existing_captions.post_id = candidate_targets.post_id
AND existing_captions.upload_id = candidate_targets.upload_id
AND existing_captions.locale = candidate_targets.locale
AND (
existing_captions.description IS NOT NULL OR
existing_captions.attempts >= #{MAX_ATTEMPTS} OR
existing_captions.last_attempted_at > #{retry_after}
)
WHERE existing_captions.post_id IS NULL
OR EXISTS (
SELECT 1
FROM ai_post_image_captions retryable_captions
WHERE retryable_captions.post_id = candidate_targets.post_id
AND retryable_captions.locale = candidate_targets.locale
AND retryable_captions.description IS NULL
AND retryable_captions.attempts < #{MAX_ATTEMPTS}
AND (
retryable_captions.last_attempted_at IS NULL OR
retryable_captions.last_attempted_at <= #{retry_after}
)
)
GROUP BY candidate_targets.post_id, candidate_targets.locale
ORDER BY candidate_targets.post_id DESC, candidate_targets.locale
LIMIT :limit
SQL
.map { |target| { post_id: target.post_id, locale: target.locale } }
end
def original_locale(post)
post.locale.presence || SiteSetting.default_locale
end
def image_nodes(doc)
nodes = doc.css("img[data-base62-sha1]")
nodes -= doc.css(".quote img")
nodes -= doc.css(".onebox img, .onebox-body img")
nodes -= doc.css("img.avatar, img.emoji, img.site-icon, img.onebox-avatar")
nodes -= doc.css("img.onebox-avatar-inline")
nodes.select { |img| img["data-base62-sha1"].present? }
end
def image_base62_sha1s(doc, post_id: nil)
sha1s_by_base62_sha1 =
image_nodes(doc).each_with_object({}) do |img, result|
base62_sha1 = image_node_base62_sha1(img)
next if base62_sha1.blank?
result[base62_sha1] = sha1_from_base62_sha1(base62_sha1)
end
return [] if sha1s_by_base62_sha1.blank?
if post_id.present?
post_upload_sha1s =
Upload
.joins(:upload_references)
.where(sha1: sha1s_by_base62_sha1.values)
.where(upload_references: { target_type: "Post", target_id: post_id })
.pluck(:sha1)
.to_set
sha1s_by_base62_sha1.select! { |_, sha1| post_upload_sha1s.include?(sha1) }
end
sha1s_by_base62_sha1.keys
end
def captions_for(post_id, locale, base62_sha1s)
AiPostImageCaption
.where(post_id: post_id, locale: locale, base62_sha1: base62_sha1s)
.where.not(description: nil)
.pluck(:base62_sha1, :description)
.to_h
end
def captions_with_locale_fallback(post, locale, base62_sha1s, captions)
return captions if locale == original_locale(post)
missing_base62_sha1s = base62_sha1s - captions.keys
return captions if missing_base62_sha1s.blank?
captions_for(post.id, original_locale(post), missing_base62_sha1s).merge(captions)
end
def capped_base62_sha1s(base62_sha1s)
base62_sha1s.first(SiteSetting.ai_post_image_captions_per_post_limit.to_i)
end
def current_base62_sha1s(post)
return [] if post.cooked.blank?
base62_sha1s = image_base62_sha1s(Nokogiri::HTML5.fragment(post.cooked), post_id: post.id)
capped_base62_sha1s(base62_sha1s)
end
def pending_base62_sha1s(post_id, locale, base62_sha1s)
rows =
AiPostImageCaption
.where(post_id: post_id, locale: locale, base62_sha1: base62_sha1s)
.pluck(:base62_sha1, :description, :attempts, :last_attempted_at)
.index_by(&:first)
base62_sha1s.select do |base62_sha1|
row = rows[base62_sha1]
row.blank? || retryable_row?(row)
end
end
def retryable_row?(row)
_, description, attempts, last_attempted_at = row
return false if description.present?
return false if attempts.to_i >= MAX_ATTEMPTS
last_attempted_at.blank? || last_attempted_at <= RETRY_AFTER.ago
end
def uploads_by_base62_sha1(base62_sha1s)
sha1s_by_base62_sha1 =
base62_sha1s.each_with_object({}) do |base62_sha1, result|
sha1 = sha1_from_base62_sha1(base62_sha1)
result[base62_sha1] = sha1 if sha1.present?
end
uploads_by_sha1 = Upload.where(sha1: sha1s_by_base62_sha1.values).index_by(&:sha1)
sha1s_by_base62_sha1.transform_values { |sha1| uploads_by_sha1[sha1] }.compact
end
def reuse_captions(post, locale, base62_sha1s, uploads_by_base62_sha1)
reusable_captions = reusable_captions_for(post.id, locale, base62_sha1s)
return 0 if reusable_captions.blank?
reused_count = 0
reusable_captions.each do |base62_sha1, description|
upload = uploads_by_base62_sha1[base62_sha1]
next if upload.blank? || !captionable_upload?(upload, post)
record_reused_caption(post, upload, base62_sha1, locale, description)
reused_count += 1
end
reused_count
end
def reusable_captions_for(post_id, locale, base62_sha1s)
DB
.query(<<~SQL, post_id: post_id, locale: locale, base62_sha1s: base62_sha1s)
SELECT DISTINCT ON (base62_sha1) base62_sha1, description
FROM ai_post_image_captions
WHERE locale = :locale
AND base62_sha1 IN (:base62_sha1s)
AND post_id <> :post_id
AND description IS NOT NULL
ORDER BY base62_sha1, updated_at DESC, id DESC
SQL
.map { |row| [row.base62_sha1, row.description] }
.to_h
end
def captionable_upload?(upload, post)
return false if upload.blank?
return false if !SUPPORTED_EXTENSIONS.include?(upload.extension.to_s.downcase)
return false if upload.secure? && upload.access_control_post_id != post.id
true
end
def captionable_post?(post)
post.present? && post.raw.present? && post.deleted_at.blank? && post.topic.present? &&
post.topic.deleted_at.blank? && post.post_type == Post.types[:regular] &&
!post.topic.private_message?
end
def record_uncaptionable_backfill_candidate(post, locale)
upload = Upload.find_by(id: post.image_upload_id)
return if upload.blank?
record_attempt(post, upload, upload.base62_sha1, locale, error: "no_post_image_nodes")
end
def record_attempt(post, upload, base62_sha1, locale, description: nil, error: nil)
now = Time.zone.now
DB.exec(
<<~SQL,
INSERT INTO ai_post_image_captions
(
post_id,
upload_id,
base62_sha1,
locale,
description,
attempts,
last_attempted_at,
last_error,
created_at,
updated_at
)
VALUES
(
:post_id,
:upload_id,
:base62_sha1,
:locale,
:description,
1,
:now,
:last_error,
:now,
:now
)
ON CONFLICT (post_id, locale, base62_sha1) DO UPDATE SET
upload_id = EXCLUDED.upload_id,
description = EXCLUDED.description,
attempts = ai_post_image_captions.attempts + 1,
last_attempted_at = EXCLUDED.last_attempted_at,
last_error = EXCLUDED.last_error,
updated_at = EXCLUDED.updated_at
SQL
post_id: post.id,
upload_id: upload.id,
base62_sha1: base62_sha1,
locale: locale,
description: description,
last_error: error,
now: now,
)
end
def record_reused_caption(post, upload, base62_sha1, locale, description)
now = Time.zone.now
DB.exec(
<<~SQL,
INSERT INTO ai_post_image_captions
(
post_id,
upload_id,
base62_sha1,
locale,
description,
attempts,
created_at,
updated_at
)
VALUES
(
:post_id,
:upload_id,
:base62_sha1,
:locale,
:description,
0,
:now,
:now
)
ON CONFLICT (post_id, locale, base62_sha1) DO UPDATE SET
upload_id = EXCLUDED.upload_id,
description = EXCLUDED.description,
attempts = 0,
last_attempted_at = NULL,
last_error = NULL,
updated_at = EXCLUDED.updated_at
SQL
post_id: post.id,
upload_id: upload.id,
base62_sha1: base62_sha1,
locale: locale,
description: description,
now: now,
)
end
def image_caption_agent
AiAgent.find_by(id: SiteSetting.ai_image_caption_agent.to_i)
end
def image_caption_llm_model(agent = image_caption_agent)
return if agent.blank?
DiscourseAi::AiHelper::Assistant.find_ai_helper_model(
DiscourseAi::AiHelper::Assistant::IMAGE_CAPTION,
agent.class_instance,
)
end
def credits_available?
LlmCreditAllocation.credits_available?(image_caption_llm_model)
end
def delete_for_post(post_id)
AiPostImageCaption.where(post_id: post_id).delete_all
end
def aria_description(description, locale)
I18n.with_locale(locale) do
I18n.t("discourse_ai.ai_helper.image_caption.aria_description", description: description)
end
end
def image_node_base62_sha1(img)
base62_sha1 = img["data-base62-sha1"]
sha1 = sha1_from_base62_sha1(base62_sha1)
return if sha1.blank?
return if image_node_upload_sha1(img) != sha1
base62_sha1
end
def sha1_from_base62_sha1(base62_sha1)
return if base62_sha1.blank?
return if base62_sha1.length > Upload::MAX_BASE62_SHA1_LENGTH
return if !base62_sha1.match?(/\A[0-9a-zA-Z]+\z/)
Upload.sha1_from_base62_encoded(base62_sha1)
end
def image_node_upload_sha1(img)
src = img["src"].to_s
if src.end_with?("/images/transparent.png") && img["data-orig-src"].present?
src = img["data-orig-src"]
end
return Upload.sha1_from_short_url(src) if src.start_with?("upload://")
path =
begin
URI(UrlHelper.unencode(src))&.path
rescue URI::Error
src
end
return if path.blank?
OptimizedImage.extract_sha1(path) || Upload.extract_sha1(path) ||
Upload.sha1_from_short_path(path)
end
def delete_removed_captions(post, locale, base62_sha1s)
scope = AiPostImageCaption.where(post_id: post.id)
scope = scope.where(locale: locale) if locale != original_locale(post)
scope = scope.where.not(base62_sha1: base62_sha1s) if base62_sha1s.present?
scope.delete_all
end
def remove_existing_caption_metadata(doc)
doc.css("span.#{CAPTION_CLASS}, span.#{LEGACY_DESCRIPTION_CLASS}").remove
doc.css("[aria-describedby]").each { |node| remove_ai_describedby(node) }
doc
.css("img[data-base62-sha1][#{ARIA_DESCRIPTION_ATTRIBUTE}]")
.each do |img|
lightbox = img.ancestors("a.lightbox").first
img.remove_attribute(ARIA_DESCRIPTION_ATTRIBUTE)
lightbox.remove_attribute(ARIA_DESCRIPTION_ATTRIBUTE) if lightbox
end
end
def remove_ai_describedby(node)
ids =
node["aria-describedby"].to_s.split.reject do |id|
id.start_with?(CAPTION_ID_PREFIX) || id.start_with?(LEGACY_DESCRIPTION_ID_PREFIX)
end
if ids.present?
node["aria-describedby"] = ids.join(" ")
else
node.remove_attribute("aria-describedby")
end
end
def refresh_cooked(post, locale)
if locale == original_locale(post)
post.rebake!(skip_publish_rebaked_changes: true)
else
PostLocalization
.where(post_id: post.id, locale: locale)
.pluck(:id)
.each do |post_localization_id|
Jobs.enqueue(
:process_localized_cooked,
post_localization_id: post_localization_id,
recook: true,
)
end
end
end
end
end