0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/plugins/discourse-ai/lib/configuration/feature.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

448 lines
15 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Configuration
class Feature
class << self
def feature_cache
@feature_cache ||= DiscourseAi::MultisiteHash.new("feature_cache")
end
def summarization_features
feature_cache[:summarization] ||= [
new(
"topic_summaries",
"ai_summarization_agent",
DiscourseAi::Configuration::Module::SUMMARIZATION_ID,
DiscourseAi::Configuration::Module::SUMMARIZATION,
),
new(
"gists",
"ai_summary_gists_agent",
DiscourseAi::Configuration::Module::SUMMARIZATION_ID,
DiscourseAi::Configuration::Module::SUMMARIZATION,
enabled_by_setting: "ai_summary_gists_enabled",
),
]
end
def search_features
feature_cache[:search] ||= [
new(
"discoveries",
"ai_discover_agent",
DiscourseAi::Configuration::Module::SEARCH_ID,
DiscourseAi::Configuration::Module::SEARCH,
),
]
end
def discord_features
feature_cache[:discord] ||= [
new(
"search",
"ai_discord_search_agent",
DiscourseAi::Configuration::Module::DISCORD_ID,
DiscourseAi::Configuration::Module::DISCORD,
),
]
end
def inference_features
feature_cache[:inference] ||= [
new(
"generate_concepts",
"inferred_concepts_generate_agent",
DiscourseAi::Configuration::Module::INFERENCE_ID,
DiscourseAi::Configuration::Module::INFERENCE,
),
new(
"match_concepts",
"inferred_concepts_match_agent",
DiscourseAi::Configuration::Module::INFERENCE_ID,
DiscourseAi::Configuration::Module::INFERENCE,
),
new(
"deduplicate_concepts",
"inferred_concepts_deduplicate_agent",
DiscourseAi::Configuration::Module::INFERENCE_ID,
DiscourseAi::Configuration::Module::INFERENCE,
),
]
end
def ai_helper_features
feature_cache[:ai_helper] ||= [
new(
"proofread",
"ai_helper_proofreader_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"title_suggestions",
"ai_helper_title_suggestions_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"explain",
"ai_helper_explain_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"smart_dates",
"ai_helper_smart_dates_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"markdown_tables",
"ai_helper_markdown_tables_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"translator",
"ai_helper_translator_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"custom_prompt",
"ai_helper_custom_prompt_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"post_illustrator",
"ai_helper_post_illustrator_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"chat_thread_titles",
"ai_helper_chat_thread_title_agent",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
enabled_by_setting: "ai_helper_automatic_chat_thread_title",
),
]
end
def bot_features
feature_cache[:bot] ||= [
new(
"bot",
nil,
DiscourseAi::Configuration::Module::BOT_ID,
DiscourseAi::Configuration::Module::BOT,
agent_ids_lookup: -> { lookup_bot_agent_ids },
llm_models_lookup: -> { lookup_bot_llms },
),
]
end
def image_caption_features
feature_cache[:image_caption] ||= [
new(
"post_image_captions",
"ai_image_caption_agent",
DiscourseAi::Configuration::Module::IMAGE_CAPTION_ID,
DiscourseAi::Configuration::Module::IMAGE_CAPTION,
),
]
end
def spam_features
feature_cache[:spam] ||= [
new(
"inspect_posts",
nil,
DiscourseAi::Configuration::Module::SPAM_ID,
DiscourseAi::Configuration::Module::SPAM,
agent_ids_lookup: -> { [AiModerationSetting.spam&.ai_agent_id].compact },
llm_models_lookup: -> { [AiModerationSetting.spam&.llm_model].compact },
),
]
end
def embeddings_features
feature_cache[:embeddings] ||= [
new(
"hyde",
"ai_embeddings_semantic_search_hyde_agent",
DiscourseAi::Configuration::Module::EMBEDDINGS_ID,
DiscourseAi::Configuration::Module::EMBEDDINGS,
),
]
end
def admin_dashboard_features
feature_cache[:admin_dashboard] ||= [
new(
"highlights",
"ai_admin_dashboard_highlights_agent",
DiscourseAi::Configuration::Module::ADMIN_DASHBOARD_ID,
DiscourseAi::Configuration::Module::ADMIN_DASHBOARD,
enabled_by_setting: "ai_admin_dashboard_enabled",
require_enabled_agent: true,
),
]
end
def lookup_bot_agent_ids
AiAgent
.where(enabled: true)
.where.not(id: SiteSetting.ai_image_caption_agent.to_i)
.where(
"allow_chat_channel_mentions OR allow_chat_direct_messages OR allow_topic_mentions OR allow_personal_messages",
)
.pluck(:id)
end
def lookup_bot_llms
agent_llms = AiAgent.where(id: lookup_bot_agent_ids).pluck(:default_llm_id)
enabled_chat_bot_llm_ids = LlmModel.enabled_chat_bot_ids
LlmModel.where(id: (agent_llms + enabled_chat_bot_llm_ids).uniq).to_a
end
def translation_features
feature_cache[:translation] ||= [
new(
"locale_detector",
"ai_translation_locale_detector_agent",
DiscourseAi::Configuration::Module::TRANSLATION_ID,
DiscourseAi::Configuration::Module::TRANSLATION,
),
new(
"post_raw_translator",
"ai_translation_post_raw_translator_agent",
DiscourseAi::Configuration::Module::TRANSLATION_ID,
DiscourseAi::Configuration::Module::TRANSLATION,
),
new(
"topic_title_translator",
"ai_translation_topic_title_translator_agent",
DiscourseAi::Configuration::Module::TRANSLATION_ID,
DiscourseAi::Configuration::Module::TRANSLATION,
),
new(
"short_text_translator",
"ai_translation_short_text_translator_agent",
DiscourseAi::Configuration::Module::TRANSLATION_ID,
DiscourseAi::Configuration::Module::TRANSLATION,
),
]
end
def ai_automation_report_scripts
return [] if !SiteSetting.discourse_automation_enabled
feature_cache[:automation_reports] ||= begin
all_script_fields = DB.query(<<~SQL)
SELECT (fields.metadata->>'value') AS value, automations.name AS automation_name, fields.name AS name
FROM discourse_automation_fields fields
INNER JOIN discourse_automation_automations automations ON automations.id = fields.automation_id
WHERE fields.name IN ('model', 'agent_id')
AND automations.script = 'llm_report'
AND automations.enabled
LIMIT 20
SQL
all_script_fields =
all_script_fields
.take(10)
.reduce({}) do |memo, field|
memo[field.automation_name] = {} if memo[field.automation_name].nil?
memo[field.automation_name][field.name] = field.value
memo
end
all_script_fields.map do |automation_name, fields|
new(
automation_name,
nil,
DiscourseAi::Configuration::Module::AUTOMATION_REPORTS_ID,
DiscourseAi::Configuration::Module::AUTOMATION_REPORTS,
agent_ids_lookup: -> { [fields.dig("agent_id")].compact.map(&:to_i) },
llm_models_lookup: -> { [LlmModel.find_by(id: fields["model"])].compact },
)
end
end
end
def ai_automation_triage_scripts
return [] if !SiteSetting.discourse_automation_enabled
feature_cache[:automation_triage] ||= begin
all_script_fields = DB.query(<<~SQL)
SELECT (fields.metadata->>'value') AS value, automations.name AS automation_name, fields.name AS name
FROM discourse_automation_fields fields
INNER JOIN discourse_automation_automations automations ON automations.id = fields.automation_id
WHERE fields.name IN ('model', 'triage_agent', 'agent')
AND automations.script IN ('llm_triage', 'llm_agent_triage')
AND automations.enabled
LIMIT 20
SQL
all_script_fields =
all_script_fields.reduce({}) do |memo, field|
memo[field.automation_name] = {} if memo[field.automation_name].nil?
if field.name == "model"
memo[field.automation_name][field.name] = field.value
else
memo[field.automation_name]["agent_id"] = field.value
end
memo
end
all_script_fields
.take(10)
.map do |automation_name, field|
llm_models_lookup =
if field["model"].present?
-> { [LlmModel.find_by(id: field["model"])].compact }
else
nil # llm_agent_triage uses the agent default_llm_id.
end
new(
automation_name,
nil,
DiscourseAi::Configuration::Module::AUTOMATION_TRIAGE_ID,
DiscourseAi::Configuration::Module::AUTOMATION_TRIAGE,
agent_ids_lookup: -> { [field.dig("agent_id")].compact.map(&:to_i) },
llm_models_lookup: llm_models_lookup,
)
end
end
end
def all
base = [
summarization_features,
search_features,
discord_features,
inference_features,
ai_helper_features,
image_caption_features,
translation_features,
bot_features,
spam_features,
embeddings_features,
admin_dashboard_features,
ai_automation_report_scripts,
ai_automation_triage_scripts,
].flatten
# external features from plugin registry
DiscoursePluginRegistry.external_ai_features.each do |entry|
module_id = DiscourseAi::Configuration::Module.external_module_id(entry[:module_name])
setting_name = "#{entry[:module_name]}_#{entry[:feature]}_agent"
base << new(
entry[:feature].to_s,
setting_name,
module_id,
entry[:module_name].to_s,
enabled_by_setting: entry[:enabled_by_setting],
)
end
base
end
def find_features_using(agent_id:)
all.select { |feature| feature.agent_ids.include?(agent_id) }
end
end
def initialize(
name,
agent_setting,
module_id,
module_name,
enabled_by_setting: "",
agent_ids_lookup: nil,
llm_models_lookup: nil,
require_enabled_agent: false
)
@name = name
@agent_setting = agent_setting
@module_id = module_id
@module_name = module_name
@enabled_by_setting = enabled_by_setting
@agent_ids_lookup = agent_ids_lookup
@llm_models_lookup = llm_models_lookup
@require_enabled_agent = require_enabled_agent
end
def llm_models
return @llm_models_lookup.call if @llm_models_lookup
return if !agent_ids
llm_models = []
agents = AiAgent.where(id: agent_ids)
agents.each do |agent|
next if agent.blank?
agent_klass = agent.class_instance
llm_model =
case module_name
when DiscourseAi::Configuration::Module::SUMMARIZATION
DiscourseAi::Summarization.find_summarization_model(agent_klass)
when DiscourseAi::Configuration::Module::AI_HELPER
DiscourseAi::AiHelper::Assistant.find_ai_helper_model(name, agent_klass)
when DiscourseAi::Configuration::Module::IMAGE_CAPTION
DiscourseAi::PostImageCaptions.image_caption_llm_model(agent)
when DiscourseAi::Configuration::Module::TRANSLATION
DiscourseAi::Translation::BaseTranslator.preferred_llm_model(agent_klass)
when DiscourseAi::Configuration::Module::EMBEDDINGS
DiscourseAi::Embeddings::SemanticSearch.new(nil).find_ai_hyde_model(agent_klass)
end
if llm_model.blank?
llm_model_id = agent.default_llm_id || SiteSetting.ai_default_llm_model
llm_model = LlmModel.find_by(id: llm_model_id)
end
llm_models << llm_model if llm_model
end
llm_models.compact.uniq
end
attr_reader :name, :agent_setting, :module_id, :module_name
def enabled?
return agent_enabled? if @enabled_by_setting.blank?
return false unless SiteSetting.respond_to?(@enabled_by_setting)
return false if !SiteSetting.get(@enabled_by_setting)
agent_enabled?
end
def agent_enabled?
return true if !@require_enabled_agent
agent_ids.any? { |agent_id| AiAgent.find_by_id_from_cache(agent_id)&.enabled? }
end
def agent_ids
if @agent_ids_lookup
@agent_ids_lookup.call
else
return [] unless SiteSetting.respond_to?(agent_setting)
id = SiteSetting.get(agent_setting).to_i
id != 0 ? [id] : []
end
end
end
end
end