Skip to content
35 changes: 25 additions & 10 deletions lib/generators/ruby_llm/generator_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,34 +52,41 @@ 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

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

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

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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 16 additions & 18 deletions lib/ruby_llm/active_record/acts_as.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
70 changes: 70 additions & 0 deletions spec/ruby_llm/active_record/acts_as_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down