mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-06-19 04:03:45 +08:00
Adds an AI feature, agent, to discourse-ai. This feature is meant to be used on the new admin dashboard, but is owned by the discourse-ai plugin. The PR includes an outlet for discourse-ai to hook up to. The agent introduced will take crafted data in for now to formulate the headline and we will adjust this accordingly. Note, we're not doing any streaming here for now. The results are cached for 6h and per locale. The AI feature is "hidden" for now
439 lines
15 KiB
Ruby
Vendored
439 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(
|
|
"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 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(
|
|
"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,
|
|
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::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
|