diff --git a/lib/generators/ruby_llm/generator_helpers.rb b/lib/generators/ruby_llm/generator_helpers.rb index b8e6e5b48..7591c086e 100644 --- a/lib/generators/ruby_llm/generator_helpers.rb +++ b/lib/generators/ruby_llm/generator_helpers.rb @@ -52,8 +52,10 @@ def parse_model_mappings def acts_as_chat_declaration params = [] - add_association_params(params, :messages, message_table_name, message_model_name, plural: true) - add_association_params(params, :model, model_table_name, model_model_name) + add_association_params(params, :messages, message_table_name, message_model_name, + owner_table: chat_table_name, plural: true) + add_association_params(params, :model, model_table_name, model_model_name, + owner_table: chat_table_name) "acts_as_chat#{" #{params.join(', ')}" if params.any?}" end @@ -61,9 +63,12 @@ def acts_as_chat_declaration def acts_as_message_declaration params = [] - add_association_params(params, :chat, chat_table_name, chat_model_name) - add_association_params(params, :tool_calls, tool_call_table_name, tool_call_model_name, plural: true) - add_association_params(params, :model, model_table_name, model_model_name) + add_association_params(params, :chat, chat_table_name, chat_model_name, + owner_table: message_table_name) + add_association_params(params, :tool_calls, tool_call_table_name, tool_call_model_name, + owner_table: message_table_name, plural: true) + add_association_params(params, :model, model_table_name, model_model_name, + owner_table: message_table_name) "acts_as_message#{" #{params.join(', ')}" if params.any?}" end @@ -71,7 +76,8 @@ def acts_as_message_declaration def acts_as_model_declaration params = [] - add_association_params(params, :chats, chat_table_name, chat_model_name, plural: true) + add_association_params(params, :chats, chat_table_name, chat_model_name, + owner_table: model_table_name, plural: true) "acts_as_model#{" #{params.join(', ')}" if params.any?}" end @@ -79,7 +85,8 @@ def acts_as_model_declaration def acts_as_tool_call_declaration params = [] - add_association_params(params, :message, message_table_name, message_model_name) + add_association_params(params, :message, message_table_name, message_model_name, + owner_table: tool_call_table_name) "acts_as_tool_call#{" #{params.join(', ')}" if params.any?}" end @@ -134,13 +141,21 @@ def table_exists?(table_name) private - def add_association_params(params, default_assoc, table_name, model_name, plural: false) + def add_association_params(params, default_assoc, table_name, model_name, owner_table:, plural: false) # rubocop:disable Metrics/ParameterLists assoc = plural ? table_name.to_sym : table_name.singularize.to_sym - return if assoc == default_assoc + default_foreign_key = "#{default_assoc}_id" + # has_many/has_one: foreign key is on the associated table pointing back to owner + # belongs_to: foreign key is on the owner table pointing to associated table + foreign_key = if plural || default_assoc.to_s.pluralize == default_assoc.to_s # has_many or has_one + "#{owner_table.singularize}_id" + else # belongs_to + "#{table_name.singularize}_id" + end - params << "#{default_assoc}: :#{assoc}" + params << "#{default_assoc}: :#{assoc}" if assoc != default_assoc params << "#{default_assoc.to_s.singularize}_class: '#{model_name}'" if model_name != assoc.to_s.classify + params << "#{default_assoc}_foreign_key: :#{foreign_key}" if foreign_key != default_foreign_key end # Convert namespaced model names to proper table names diff --git a/lib/ruby_llm/active_record/acts_as.rb b/lib/ruby_llm/active_record/acts_as.rb index 5650c68dd..8086957c3 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -31,8 +31,8 @@ def load_from_database! end class_methods do # rubocop:disable Metrics/BlockLength - def acts_as_chat(messages: :messages, message_class: nil, - model: :model, model_class: nil) + def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, # rubocop:disable Metrics/ParameterLists + model: :model, model_class: nil, model_foreign_key: nil) include RubyLLM::ActiveRecord::ChatMethods class_attribute :messages_association_name, :model_association_name, :message_class, :model_class @@ -45,12 +45,12 @@ def acts_as_chat(messages: :messages, message_class: nil, has_many messages, -> { order(created_at: :asc) }, class_name: self.message_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize), + foreign_key: messages_foreign_key, dependent: :destroy belongs_to model, class_name: self.model_class, - foreign_key: ActiveSupport::Inflector.foreign_key(model.to_s.singularize), + foreign_key: model_foreign_key, optional: true delegate :add_message, to: :to_llm @@ -68,7 +68,7 @@ def acts_as_chat(messages: :messages, message_class: nil, end end - def acts_as_model(chats: :chats, chat_class: nil) + def acts_as_model(chats: :chats, chat_class: nil, chats_foreign_key: nil) include RubyLLM::ActiveRecord::ModelMethods class_attribute :chats_association_name, :chat_class @@ -80,18 +80,16 @@ def acts_as_model(chats: :chats, chat_class: nil) validates :provider, presence: true validates :name, presence: true - has_many chats, - class_name: self.chat_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize) + has_many chats, class_name: self.chat_class, foreign_key: chats_foreign_key define_method :chats_association do send(chats_association_name) end end - def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists - tool_calls: :tool_calls, tool_call_class: nil, - model: :model, model_class: nil) + def acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists + tool_calls: :tool_calls, tool_call_class: nil, tool_calls_foreign_key: nil, + model: :model, model_class: nil, model_foreign_key: nil) include RubyLLM::ActiveRecord::MessageMethods class_attribute :chat_association_name, :tool_calls_association_name, :model_association_name, @@ -106,12 +104,12 @@ def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:d belongs_to chat, class_name: self.chat_class, - foreign_key: ActiveSupport::Inflector.foreign_key(chat.to_s.singularize), + foreign_key: chat_foreign_key, touch: touch_chat has_many tool_calls, class_name: self.tool_call_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize), + foreign_key: tool_calls_foreign_key, dependent: :destroy belongs_to :parent_tool_call, @@ -126,7 +124,7 @@ def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:d belongs_to model, class_name: self.model_class, - foreign_key: ActiveSupport::Inflector.foreign_key(model.to_s.singularize), + foreign_key: model_foreign_key, optional: true delegate :tool_call?, :tool_result?, to: :to_llm @@ -144,8 +142,8 @@ def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:d end end - def acts_as_tool_call(message: :message, message_class: nil, - result: :result, result_class: nil) + def acts_as_tool_call(message: :message, message_class: nil, message_foreign_key: nil, # rubocop:disable Metrics/ParameterLists + result: :result, result_class: nil, result_foreign_key: nil) class_attribute :message_association_name, :result_association_name, :message_class, :result_class self.message_association_name = message @@ -155,11 +153,11 @@ def acts_as_tool_call(message: :message, message_class: nil, belongs_to message, class_name: self.message_class, - foreign_key: ActiveSupport::Inflector.foreign_key(message.to_s.singularize) + foreign_key: message_foreign_key has_one result, class_name: self.result_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize), + foreign_key: result_foreign_key, dependent: :nullify define_method :message_association do diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 7bea5dbc1..24045776f 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -393,6 +393,76 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition end end + describe 'namespaced chat models with custom foreign keys' do + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + # Create additional tables for testing edge cases + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Migration.create_table :support_conversations, force: true do |t| + t.string :model_id + t.timestamps + end + + ActiveRecord::Migration.create_table :support_messages, force: true do |t| + t.references :conversation, foreign_key: { to_table: :support_conversations } + t.string :role + t.text :content + t.string :model_id + t.integer :input_tokens + t.integer :output_tokens + t.references :tool_call, foreign_key: { to_table: :support_tool_calls } + t.timestamps + end + + ActiveRecord::Migration.create_table :support_tool_calls, force: true do |t| + t.references :message, foreign_key: { to_table: :support_messages } + t.string :tool_call_id + t.string :name + t.json :arguments + t.timestamps + end + end + end + + after(:all) do # rubocop:disable RSpec/BeforeAfterAll + ActiveRecord::Migration.suppress_messages do + if ActiveRecord::Base.connection.table_exists?(:support_tool_calls) + ActiveRecord::Migration.drop_table :support_tool_calls + end + if ActiveRecord::Base.connection.table_exists?(:support_messages) + ActiveRecord::Migration.drop_table :support_messages + end + if ActiveRecord::Base.connection.table_exists?(:support_conversations) + ActiveRecord::Migration.drop_table :support_conversations + end + end + end + + module Support # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration + def self.table_name_prefix + 'support_' + end + + class Conversation < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + acts_as_chat message_class: 'Support::Message' + end + + class Message < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + acts_as_message chat: :conversation, chat_class: 'Support::Conversation', tool_call_class: 'Support::ToolCall' + end + + class ToolCall < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + acts_as_tool_call message_class: 'Support::Message' + end + end + + it 'creates messages successfully' do + conversation = Support::Conversation.create!(model: model) + + expect { conversation.messages.create!(role: 'user', content: 'Test') }.not_to raise_error + expect(conversation.messages.count).to eq(1) + end + end + describe 'to_llm conversion' do it 'correctly converts custom messages to RubyLLM format' do bot_chat = Assistants::BotChat.create!(model: model)