mirror of
https://github.com/discourse/discourse.git
synced 2026-08-14 13:58:53 +08:00
Right now, AI modules and features are hardcoded in
`Configuration::Feature` and `Configuration::Module`. If another plugin
wants to register an AI-powered feature (with its own agent, LLM config,
and a spot on the AI features admin page), there's no way to do it
without modifying discourse-ai directly.
Data explorer needs a query generation agent, and other plugins will be
able to add their own agents too. So we need a proper extensible API
here.
The consumer code ends up looking like this:
```
DiscoursePluginRegistry.register_external_ai_feature(
{
module_name: :data_explorer,
feature: :query_generation,
agent_klass: DiscourseDataExplorer::AiQueryGenerator,
enabled_by_setting: "data_explorer_ai_queries_enabled",
},
self,
)
```
<img width="658" height="258" alt="Screenshot 2026-03-26 at 2 25 19 PM"
src="https://github.com/user-attachments/assets/9239b903-78a5-4e8b-9bf0-e385a030920b"
/>
---------
Co-authored-by: Roman Rizzi <rizziromanalejandro@gmail.com>
416 lines
14 KiB
Ruby
Vendored
416 lines
14 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(
|
|
"image_caption",
|
|
"ai_helper_image_caption_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 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 lookup_bot_agent_ids
|
|
AiAgent
|
|
.where(enabled: true)
|
|
.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,
|
|
translation_features,
|
|
bot_features,
|
|
spam_features,
|
|
embeddings_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
|
|
)
|
|
@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
|
|
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::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 true if @enabled_by_setting.blank?
|
|
return false unless SiteSetting.respond_to?(@enabled_by_setting)
|
|
SiteSetting.get(@enabled_by_setting)
|
|
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
|