mirror of
https://github.com/discourse/discourse.git
synced 2026-08-09 21:45:25 +08:00
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
448 lines
15 KiB
Ruby
Vendored
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
|