diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index 6ec64bf9..0ebc4efc 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -98,7 +98,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.32.0) + async (2.34.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -106,7 +106,7 @@ GEM traces (~> 0.18) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.2.3) + bigdecimal (3.3.1) builder (3.3.0) childprocess (5.1.0) logger (~> 1.5) @@ -129,10 +129,10 @@ GEM docile (1.4.1) dotenv (3.1.8) drb (2.2.3) - erb (5.0.2) + erb (5.0.3) erubi (1.13.1) event_stream_parser (1.0.0) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -148,6 +148,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -185,7 +186,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.15.0) + json (2.15.1) json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -208,13 +209,13 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.26.0) multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.10) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -224,6 +225,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -236,10 +239,10 @@ GEM ast (~> 2.4.1) racc path_expander (1.1.3) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.5.2) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -248,7 +251,7 @@ GEM stringio public_suffix (6.0.2) racc (1.8.1) - rack (3.2.1) + rack (3.2.3) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -287,9 +290,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) @@ -307,7 +311,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) - rubocop (1.81.0) + rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -355,17 +359,19 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.4-arm64-darwin) sqlite3 (2.7.4-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) timeout (0.4.3) traces (0.18.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) vcr (6.3.1) base64 webmock (3.25.1) @@ -380,6 +386,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -416,4 +423,4 @@ DEPENDENCIES webmock (~> 3.18) BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/gemfiles/rails_7.2.gemfile.lock b/gemfiles/rails_7.2.gemfile.lock index 5c672e34..56868ca2 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -92,7 +92,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.32.0) + async (2.34.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -100,7 +100,7 @@ GEM traces (~> 0.18) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.2.3) + bigdecimal (3.3.1) builder (3.3.0) childprocess (5.1.0) logger (~> 1.5) @@ -123,10 +123,10 @@ GEM docile (1.4.1) dotenv (3.1.8) drb (2.2.3) - erb (5.0.2) + erb (5.0.3) erubi (1.13.1) event_stream_parser (1.0.0) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -142,6 +142,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -179,7 +180,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.15.0) + json (2.15.1) json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -202,12 +203,12 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.26.0) multi_json (1.17.0) multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.10) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -217,6 +218,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -229,10 +232,10 @@ GEM ast (~> 2.4.1) racc path_expander (1.1.3) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.5.2) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -241,7 +244,7 @@ GEM stringio public_suffix (6.0.2) racc (1.8.1) - rack (3.1.16) + rack (3.1.18) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -280,9 +283,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) @@ -300,7 +304,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) - rubocop (1.81.0) + rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -348,17 +352,19 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.4-arm64-darwin) sqlite3 (2.7.4-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) timeout (0.4.3) traces (0.18.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) useragent (0.16.11) vcr (6.3.1) base64 @@ -374,6 +380,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -410,4 +417,4 @@ DEPENDENCIES webmock (~> 3.18) BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/gemfiles/rails_8.0.gemfile.lock b/gemfiles/rails_8.0.gemfile.lock index 86db7d4a..e8f48501 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -92,7 +92,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.32.0) + async (2.34.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -100,7 +100,7 @@ GEM traces (~> 0.18) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.2.3) + bigdecimal (3.3.1) builder (3.3.0) childprocess (5.1.0) logger (~> 1.5) @@ -123,10 +123,10 @@ GEM docile (1.4.1) dotenv (3.1.8) drb (2.2.3) - erb (5.0.2) + erb (5.0.3) erubi (1.13.1) event_stream_parser (1.0.0) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -142,6 +142,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -179,7 +180,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.15.0) + json (2.15.1) json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -202,12 +203,12 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.26.0) multi_json (1.17.0) multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.10) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -217,6 +218,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -229,10 +232,10 @@ GEM ast (~> 2.4.1) racc path_expander (1.1.3) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.5.2) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -241,7 +244,7 @@ GEM stringio public_suffix (6.0.2) racc (1.8.1) - rack (3.2.1) + rack (3.2.3) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -281,9 +284,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) @@ -301,7 +305,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) - rubocop (1.81.0) + rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -349,6 +353,7 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.4-arm64-darwin) sqlite3 (2.7.4-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) @@ -360,7 +365,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) useragent (0.16.11) vcr (6.3.1) base64 @@ -376,6 +381,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -412,4 +418,4 @@ DEPENDENCIES webmock (~> 3.18) BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/lib/generators/ruby_llm/generator_helpers.rb b/lib/generators/ruby_llm/generator_helpers.rb index cea5da71..d00bb2f9 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 @@ -128,13 +135,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 40442755..f1688960 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -31,8 +31,8 @@ def read_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 bb8b3423..adce2a68 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -367,6 +367,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)