diff --git a/app/models/account.rb b/app/models/account.rb index 499b1b2cf..4744843c2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -28,6 +28,7 @@ class Account < ApplicationRecord include Reportable include Featurable include CacheKeys + include CaptainFeaturable SETTINGS_PARAMS_SCHEMA = { 'type': 'object', @@ -41,6 +42,30 @@ class Account < ApplicationRecord 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } + }, + 'captain_models': { + 'type': %w[object null], + 'properties': { + 'editor': { 'type': %w[string null] }, + 'assistant': { 'type': %w[string null] }, + 'copilot': { 'type': %w[string null] }, + 'label_suggestion': { 'type': %w[string null] }, + 'audio_transcription': { 'type': %w[string null] }, + 'help_center_search': { 'type': %w[string null] } + }, + 'additionalProperties': false + }, + 'captain_features': { + 'type': %w[object null], + 'properties': { + 'editor': { 'type': %w[boolean null] }, + 'assistant': { 'type': %w[boolean null] }, + 'copilot': { 'type': %w[boolean null] }, + 'label_suggestion': { 'type': %w[boolean null] }, + 'audio_transcription': { 'type': %w[boolean null] }, + 'help_center_search': { 'type': %w[boolean null] } + }, + 'additionalProperties': false } }, 'required': [], diff --git a/app/models/concerns/captain_featurable.rb b/app/models/concerns/captain_featurable.rb new file mode 100644 index 000000000..23795c09b --- /dev/null +++ b/app/models/concerns/captain_featurable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module CaptainFeaturable + extend ActiveSupport::Concern + + included do + # Dynamically define accessor methods for each captain feature + Llm::ConfigService.feature_keys.each do |feature_key| + # Define enabled? methods (e.g., captain_editor_enabled?) + define_method("captain_#{feature_key}_enabled?") do + captain_features_with_defaults[feature_key] + end + + # Define model accessor methods (e.g., captain_editor_model) + define_method("captain_#{feature_key}_model") do + captain_models_with_defaults[feature_key] + end + end + end +end diff --git a/spec/models/concerns/captain_featurable_spec.rb b/spec/models/concerns/captain_featurable_spec.rb new file mode 100644 index 000000000..5b12c257d --- /dev/null +++ b/spec/models/concerns/captain_featurable_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CaptainFeaturable do + let(:account) { create(:account) } + + describe 'dynamic method generation' do + it 'generates enabled? methods for all features' do + Llm::ConfigService.feature_keys.each do |feature_key| + expect(account).to respond_to("captain_#{feature_key}_enabled?") + end + end + + it 'generates model accessor methods for all features' do + Llm::ConfigService.feature_keys.each do |feature_key| + expect(account).to respond_to("captain_#{feature_key}_model") + end + end + end + + describe 'feature enabled methods' do + context 'when no features are explicitly enabled' do + it 'returns false for all features' do + Llm::ConfigService.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_enabled?")).to be false + end + end + end + + context 'when features are explicitly enabled' do + before do + account.update!(captain_features: { 'editor' => true, 'assistant' => true }) + end + + it 'returns true for enabled features' do + expect(account.captain_editor_enabled?).to be true + expect(account.captain_assistant_enabled?).to be true + end + + it 'returns false for disabled features' do + expect(account.captain_copilot_enabled?).to be false + expect(account.captain_label_suggestion_enabled?).to be false + end + end + + context 'when captain_features is nil' do + before do + account.update!(captain_features: nil) + end + + it 'returns false for all features' do + Llm::ConfigService.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_enabled?")).to be false + end + end + end + end + + describe 'model accessor methods' do + context 'when no models are explicitly configured' do + it 'returns default models for all features' do + Llm::ConfigService.feature_keys.each do |feature_key| + expected_default = Llm::ConfigService.default_model_for_feature(feature_key) + expect(account.send("captain_#{feature_key}_model")).to eq(expected_default) + end + end + end + + context 'when models are explicitly configured' do + before do + account.update!(captain_models: { + 'editor' => 'gpt-4.1-mini', + 'assistant' => 'gpt-5.1', + 'label_suggestion' => 'gpt-4.1-nano' + }) + end + + it 'returns configured models for configured features' do + expect(account.captain_editor_model).to eq('gpt-4.1-mini') + expect(account.captain_assistant_model).to eq('gpt-5.1') + expect(account.captain_label_suggestion_model).to eq('gpt-4.1-nano') + end + + it 'returns default models for unconfigured features' do + expect(account.captain_copilot_model).to eq(Llm::ConfigService.default_model_for_feature('copilot')) + expect(account.captain_audio_transcription_model).to eq(Llm::ConfigService.default_model_for_feature('audio_transcription')) + end + end + + context 'when configured with invalid model' do + before do + account.captain_models = { 'editor' => 'invalid-model' } + end + + it 'falls back to default model' do + expect(account.captain_editor_model).to eq(Llm::ConfigService.default_model_for_feature('editor')) + end + end + + context 'when captain_models is nil' do + before do + account.update!(captain_models: nil) + end + + it 'returns default models for all features' do + Llm::ConfigService.feature_keys.each do |feature_key| + expected_default = Llm::ConfigService.default_model_for_feature(feature_key) + expect(account.send("captain_#{feature_key}_model")).to eq(expected_default) + end + end + end + end + + describe 'integration with existing captain_preferences' do + it 'enabled? methods use the same logic as captain_preferences[:features]' do + account.update!(captain_features: { 'editor' => true, 'copilot' => true }) + prefs = account.captain_preferences + + Llm::ConfigService.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_enabled?")).to eq(prefs[:features][feature_key]) + end + end + + it 'model methods use the same logic as captain_preferences[:models]' do + account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' }) + prefs = account.captain_preferences + + Llm::ConfigService.feature_keys.each do |feature_key| + expect(account.send("captain_#{feature_key}_model")).to eq(prefs[:models][feature_key]) + end + end + end +end