DEV: Use a PORO to represent modules/features. (#1421)

Additional changes:

Adds a "#features" method in AiPersona to find which features are using that persona.
Serializes a basic version of a LlmModel in the persona's "#default_llm" serializer attribute.
This commit is contained in:
Roman Rizzi 2025-06-10 14:37:53 -03:00 committed by GitHub
parent b54db133cd
commit f7e0ea888d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 289 additions and 126 deletions

View File

@ -6,27 +6,40 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
def index
render json: serialize_features(DiscourseAi::Features.features)
render json: serialize_modules(DiscourseAi::Configuration::Module.all)
end
def edit
raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
render json: serialize_module(DiscourseAi::Features.find_module_by_id(params[:id].to_i))
a_module = DiscourseAi::Configuration::Module.find_by(id: params[:id].to_i)
render json: serialize_module(a_module)
end
private
def serialize_features(modules)
def serialize_modules(modules)
modules.map { |a_module| serialize_module(a_module) }
end
def serialize_module(a_module)
return nil if a_module.blank?
a_module.merge(
features:
a_module[:features].map { |f| f.merge(persona: serialize_persona(f[:persona])) },
)
{
id: a_module.id,
module_name: a_module.name,
module_enabled: a_module.enabled?,
features: a_module.features.map { |f| serialize_feature(f) },
}
end
def serialize_feature(feature)
{
name: feature.name,
persona: serialize_persona(persona_id_obj_hash[feature.persona_id]),
enabled: feature.enabled?,
}
end
def serialize_persona(persona)
@ -34,6 +47,18 @@ module DiscourseAi
serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
end
private
def persona_id_obj_hash
@persona_id_obj_hash ||=
begin
setting_names = DiscourseAi::Configuration::Feature.all_persona_setting_names
ids = setting_names.map { |sn| SiteSetting.public_send(sn) }
AiPersona.where(id: ids).index_by(&:id)
end
end
end
end
end

View File

@ -316,6 +316,10 @@ class AiPersona < ActiveRecord::Base
end
end
def features
DiscourseAi::Configuration::Feature.find_features_using(persona_id: id)
end
private
def chat_preconditions

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class AiFeaturesPersonaSerializer < ApplicationSerializer
attributes :id, :name, :system_prompt, :allowed_groups, :enabled
attributes :id, :name, :allowed_groups
def allowed_groups
Group

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class BasicLlmModelSerializer < ApplicationSerializer
attributes :id, :display_name
end

View File

@ -36,6 +36,7 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :rag_uploads, serializer: UploadSerializer, embed: :object
has_one :default_llm, serializer: BasicLlmModelSerializer, embed: :object
def rag_uploads
object.uploads
@ -48,4 +49,8 @@ class LocalizedAiPersonaSerializer < ApplicationSerializer
def description
object.class_instance.description
end
def default_llm
LlmModel.find_by(id: object.default_llm_id)
end
end

View File

@ -11,6 +11,11 @@ export default {
withPluginApi("1.1.0", (api) => {
api.addAdminPluginConfigurationNav("discourse-ai", [
{
label: "discourse_ai.features.short_title",
route: "adminPlugins.show.discourse-ai-features",
description: "discourse_ai.features.description",
},
{
label: "discourse_ai.usage.short_title",
route: "adminPlugins.show.discourse-ai-usage",
@ -41,11 +46,6 @@ export default {
route: "adminPlugins.show.discourse-ai-spam",
description: "discourse_ai.spam.spam_description",
},
{
label: "discourse_ai.features.short_title",
route: "adminPlugins.show.discourse-ai-features",
description: "discourse_ai.features.description",
},
]);
});
},

View File

@ -0,0 +1,159 @@
# 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_persona",
DiscourseAi::Configuration::Module::SUMMARIZATION_ID,
DiscourseAi::Configuration::Module::SUMMARIZATION,
),
new(
"gists",
"ai_summary_gists_persona",
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_bot_discover_persona",
DiscourseAi::Configuration::Module::SEARCH_ID,
DiscourseAi::Configuration::Module::SEARCH,
),
]
end
def discord_features
feature_cache[:discord] ||= [
new(
"search",
"ai_discord_search_persona",
DiscourseAi::Configuration::Module::DISCORD_ID,
DiscourseAi::Configuration::Module::DISCORD,
),
]
end
def inference_features
feature_cache[:inference] ||= [
new(
"generate_concepts",
"inferred_concepts_generate_persona",
DiscourseAi::Configuration::Module::INFERENCE_ID,
DiscourseAi::Configuration::Module::INFERENCE,
),
new(
"match_concepts",
"inferred_concepts_match_persona",
DiscourseAi::Configuration::Module::INFERENCE_ID,
DiscourseAi::Configuration::Module::INFERENCE,
),
new(
"deduplicate_concepts",
"inferred_concepts_deduplicate_persona",
DiscourseAi::Configuration::Module::INFERENCE_ID,
DiscourseAi::Configuration::Module::INFERENCE,
),
]
end
def ai_helper_features
feature_cache[:ai_helper] ||= [
new(
"proofread",
"ai_helper_proofreader_persona",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"title_suggestions",
"ai_helper_title_suggestions_persona",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"explain",
"ai_helper_explain_persona",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"smart_dates",
"ai_helper_smart_dates_persona",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"markdown_tables",
"ai_helper_markdown_tables_persona",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"custom_prompt",
"ai_helper_custom_prompt_persona",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
new(
"image_caption",
"ai_helper_image_caption_persona",
DiscourseAi::Configuration::Module::AI_HELPER_ID,
DiscourseAi::Configuration::Module::AI_HELPER,
),
]
end
def all
[
summarization_features,
search_features,
discord_features,
inference_features,
ai_helper_features,
].flatten
end
def all_persona_setting_names
all.map(&:persona_setting)
end
def find_features_using(persona_id:)
all.select { |feature| feature.persona_id == persona_id }
end
end
def initialize(name, persona_setting, module_id, module_name, enabled_by_setting: "")
@name = name
@persona_setting = persona_setting
@module_id = module_id
@module_name = module_name
@enabled_by_setting = enabled_by_setting
end
attr_reader :name, :persona_setting, :module_id, :module_name
def enabled?
@enabled_by_setting.blank? || SiteSetting.get(@enabled_by_setting)
end
def persona_id
SiteSetting.get(persona_setting).to_i
end
end
end
end

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
module DiscourseAi
module Configuration
class Module
SUMMARIZATION = "summarization"
SEARCH = "search"
DISCORD = "discord"
INFERENCE = "inference"
AI_HELPER = "ai_helper"
NAMES = [SUMMARIZATION, SEARCH, DISCORD, INFERENCE, AI_HELPER]
SUMMARIZATION_ID = 1
SEARCH_ID = 2
DISCORD_ID = 3
INFERENCE_ID = 4
AI_HELPER_ID = 5
class << self
def all
[
new(
SUMMARIZATION_ID,
SUMMARIZATION,
"ai_summarization_enabled",
features: DiscourseAi::Configuration::Feature.summarization_features,
),
new(
SEARCH_ID,
SEARCH,
"ai_bot_enabled",
features: DiscourseAi::Configuration::Feature.search_features,
),
new(
DISCORD_ID,
DISCORD,
"ai_discord_search_enabled",
features: DiscourseAi::Configuration::Feature.discord_features,
),
new(
INFERENCE_ID,
INFERENCE,
"inferred_concepts_enabled",
features: DiscourseAi::Configuration::Feature.inference_features,
),
new(
AI_HELPER_ID,
AI_HELPER,
"ai_helper_enabled",
features: DiscourseAi::Configuration::Feature.ai_helper_features,
),
]
end
def find_by(id:)
all.find { |m| m.id == id }
end
end
def initialize(id, name, enabled_by_setting, features: [])
@id = id
@name = name
@enabled_by_setting = enabled_by_setting
@features = features
end
attr_reader :id, :name, :enabled_by_setting, :features
def enabled?
SiteSetting.get(enabled_by_setting)
end
end
end
end

View File

@ -1,108 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Features
def self.features_config
[
{
id: 1,
module_name: "summarization",
module_enabled: "ai_summarization_enabled",
features: [
{ name: "topic_summaries", persona_setting_name: "ai_summarization_persona" },
{
name: "gists",
persona_setting_name: "ai_summary_gists_persona",
enabled: "ai_summary_gists_enabled",
},
],
},
{
id: 2,
module_name: "search",
module_enabled: "ai_bot_enabled",
features: [{ name: "discoveries", persona_setting_name: "ai_bot_discover_persona" }],
},
{
id: 3,
module_name: "discord",
module_enabled: "ai_discord_search_enabled",
features: [{ name: "search", persona_setting_name: "ai_discord_search_persona" }],
},
{
id: 4,
module_name: "inference",
module_enabled: "inferred_concepts_enabled",
features: [
{
name: "generate_concepts",
persona_setting_name: "inferred_concepts_generate_persona",
},
{ name: "match_concepts", persona_setting_name: "inferred_concepts_match_persona" },
{
name: "deduplicate_concepts",
persona_setting_name: "inferred_concepts_deduplicate_persona",
},
],
},
{
id: 5,
module_name: "ai_helper",
module_enabled: "ai_helper_enabled",
features: [
{ name: "proofread", persona_setting_name: "ai_helper_proofreader_persona" },
{
name: "title_suggestions",
persona_setting_name: "ai_helper_title_suggestions_persona",
},
{ name: "explain", persona_setting_name: "ai_helper_explain_persona" },
{ name: "illustrate_post", persona_setting_name: "ai_helper_post_illustrator_persona" },
{ name: "smart_dates", persona_setting_name: "ai_helper_smart_dates_persona" },
{ name: "translate", persona_setting_name: "ai_helper_translator_persona" },
{ name: "markdown_tables", persona_setting_name: "ai_helper_markdown_tables_persona" },
{ name: "custom_prompt", persona_setting_name: "ai_helper_custom_prompt_persona" },
{ name: "image_caption", persona_setting_name: "ai_helper_image_caption_persona" },
],
},
]
end
def self.features
features_config.map do |a_module|
{
id: a_module[:id],
module_name: a_module[:module_name],
module_enabled: SiteSetting.get(a_module[:module_enabled]),
features:
a_module[:features].map do |feature|
{
name: feature[:name],
persona: AiPersona.find_by(id: SiteSetting.get(feature[:persona_setting_name])),
enabled: feature[:enabled].present? ? SiteSetting.get(feature[:enabled]) : true,
}
end,
}
end
end
def self.find_module_by_id(id)
lookup = features.index_by { |f| f[:id] }
lookup[id]
end
def self.find_module_by_name(module_name)
lookup = features.index_by { |f| f[:module] }
lookup[module_name]
end
def self.find_module_id_by_name(module_name)
find_module_by_name(module_name)&.dig(:id)
end
def self.feature_area(module_name)
name_s = module_name.to_s
find_module_by_name(name_s) || raise(ArgumentError, "Feature not found: #{name_s}")
"ai-features/#{name_s}"
end
end
end

View File

@ -72,10 +72,10 @@ end
Rails.autoloaders.main.push_dir(File.join(__dir__, "lib"), namespace: ::DiscourseAi)
require_relative "lib/engine"
require_relative "lib/features"
require_relative "lib/configuration/module"
DiscourseAi::Features.features_config.each do |feature|
register_site_setting_area("ai-features/#{feature[:module_name]}")
::DiscourseAi::Configuration::Module::NAMES.each do |module_name|
register_site_setting_area("ai-features/#{module_name}")
end
after_initialize do

View File

@ -24,9 +24,7 @@ RSpec.describe AiFeaturesPersonaSerializer do
serialized = described_class.new(ai_persona, scope: Guardian.new(admin), root: nil)
expect(serialized.id).to eq(ai_persona.id)
expect(serialized.name).to eq(ai_persona.name)
expect(serialized.system_prompt).to eq(ai_persona.system_prompt)
expect(serialized.allowed_groups).to eq(allowed_groups)
expect(serialized.enabled).to eq(ai_persona.enabled)
end
end
end