Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions lib/ruby_llm/active_record/acts_as_legacy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ def prepare_content_for_storage(content)
# Methods mixed into message models.
module MessageLegacyMethods
extend ActiveSupport::Concern
include ContentExtraction

class_methods do
attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
Expand Down Expand Up @@ -494,20 +495,23 @@ def extract_tool_call_id
end

def extract_content
text_content = if content.respond_to?(:to_plain_text)
content.to_plain_text
else
content
end
content_value = content
text_content = plain_text_content(content_value)
action_text_attachments = action_text_attachment_sources(content_value)

return text_content unless respond_to?(:attachments) && attachments.attached?
has_active_storage_attachments = respond_to?(:attachments) && attachments.attached?
return text_content if action_text_attachments.empty? && !has_active_storage_attachments

RubyLLM::Content.new(text_content).tap do |content_obj|
@_tempfiles = []

attachments.each do |attachment|
tempfile = download_attachment(attachment)
content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
add_action_text_attachments(content_obj, action_text_attachments)

if has_active_storage_attachments
attachments.each do |attachment|
tempfile = download_attachment(attachment)
content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
end
end
end
end
Expand Down
65 changes: 65 additions & 0 deletions lib/ruby_llm/active_record/content_extraction.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# frozen_string_literal: true

module RubyLLM
module ActiveRecord
# Shared helpers for converting persisted Active Record message content into RubyLLM content.
module ContentExtraction
private

def plain_text_content(content_value)
return content_value.to_plain_text if content_value.respond_to?(:to_plain_text)

content_value
end

def action_text_attachment_sources(content_value)
return [] unless content_value.respond_to?(:body)

body = content_value.body
return [] unless body.respond_to?(:attachables)

body.attachables.filter_map { |attachable| active_storage_source_for(attachable) }.flatten
end

def active_storage_source_for(attachable)
return unless defined?(::ActiveStorage)

return attachable if active_storage_blob?(attachable)
return attachable.blob if active_storage_attachment?(attachable)
return attachable.blob if active_storage_attached_one?(attachable)
return attachable.blobs if active_storage_attached_many?(attachable)

attachable.blob if attachable.respond_to?(:blob)
end

def active_storage_blob?(source)
defined?(::ActiveStorage::Blob) && source.is_a?(::ActiveStorage::Blob)
end

def active_storage_attachment?(source)
defined?(::ActiveStorage::Attachment) && source.is_a?(::ActiveStorage::Attachment)
end

def active_storage_attached_one?(source)
defined?(::ActiveStorage::Attached::One) && source.is_a?(::ActiveStorage::Attached::One)
end

def active_storage_attached_many?(source)
defined?(::ActiveStorage::Attached::Many) && source.is_a?(::ActiveStorage::Attached::Many)
end

def add_action_text_attachments(content_obj, sources)
sources.each do |source|
content_obj.add_attachment(source, filename: active_storage_filename(source))
end
end

def active_storage_filename(source)
return source.filename.to_s if active_storage_blob?(source)
return source.blob.filename.to_s if active_storage_attachment?(source)

source.blob.filename.to_s if active_storage_attached_one?(source) && source.blob
end
end
end
end
19 changes: 13 additions & 6 deletions lib/ruby_llm/active_record/message_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module ActiveRecord
module MessageMethods
extend ActiveSupport::Concern
include PayloadHelpers
include ContentExtraction

class_methods do
attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
Expand Down Expand Up @@ -115,16 +116,22 @@ def extract_content
return RubyLLM::Content::Raw.new(content_raw) if has_attribute?(:content_raw) && content_raw.present?

content_value = content
content_value = content_value.to_plain_text if content_value.respond_to?(:to_plain_text)
content_text = plain_text_content(content_value)
action_text_attachments = action_text_attachment_sources(content_value)

return content_value unless respond_to?(:attachments) && attachments.attached?
has_active_storage_attachments = respond_to?(:attachments) && attachments.attached?
return content_text if action_text_attachments.empty? && !has_active_storage_attachments

RubyLLM::Content.new(content_value).tap do |content_obj|
RubyLLM::Content.new(content_text).tap do |content_obj|
@_tempfiles = []

attachments.each do |attachment|
tempfile = download_attachment(attachment)
content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
add_action_text_attachments(content_obj, action_text_attachments)

if has_active_storage_attachments
attachments.each do |attachment|
tempfile = download_attachment(attachment)
content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[7.0]
def change
create_table :action_text_rich_texts do |t|
t.string :name, null: false
t.text :body
t.references :record, null: false, polymorphic: true, index: false

t.timestamps

t.index %i[record_type record_id name], name: :index_action_text_rich_texts_uniqueness, unique: true
end
end
end
12 changes: 11 additions & 1 deletion spec/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.1].define(version: 20_251_021_170_000) do
ActiveRecord::Schema[7.1].define(version: 20_260_506_120_000) do
create_table 'action_text_rich_texts', force: :cascade do |t|
t.string 'name', null: false
t.text 'body'
t.string 'record_type', null: false
t.bigint 'record_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.index %w[record_type record_id name], name: 'index_action_text_rich_texts_uniqueness', unique: true
end

create_table 'active_storage_attachments', force: :cascade do |t|
t.string 'name', null: false
t.string 'record_type', null: false
Expand Down
131 changes: 109 additions & 22 deletions spec/ruby_llm/active_record/acts_as_action_text_spec.rb
Original file line number Diff line number Diff line change
@@ -1,60 +1,147 @@
# frozen_string_literal: true

require 'rails_helper'
require 'stringio'

RSpec.describe RubyLLM::ActiveRecord::ActsAs do
include_context 'with configured RubyLLM'

let(:chat) { Chat.create! }
before(:all) do # rubocop:disable RSpec/BeforeAfterAll
ActiveRecord::Migration.suppress_messages do
ActiveRecord::Migration.create_table :action_text_chats, force: true do |t|
t.references :model
t.timestamps
end

ActiveRecord::Migration.create_table :action_text_messages, force: true do |t|
t.references :action_text_chat
t.string :role
t.text :content
t.json :content_raw
t.references :model
t.integer :input_tokens
t.integer :output_tokens
t.integer :cached_tokens
t.integer :cache_creation_tokens
t.text :thinking_signature
t.text :thinking_text
t.integer :thinking_tokens
t.references :action_text_tool_call
t.timestamps
end

ActiveRecord::Migration.create_table :action_text_tool_calls, force: true do |t|
t.references :action_text_message
t.string :tool_call_id
t.string :name
t.text :thought_signature
t.json :arguments
t.timestamps
end
end
end

def mock_action_text(plain_text)
instance_double(ActionText::RichText).tap do |mock|
allow(mock).to receive(:to_plain_text).and_return(plain_text)
after(:all) do # rubocop:disable RSpec/BeforeAfterAll
ActiveRecord::Migration.suppress_messages do
if ActiveRecord::Base.connection.table_exists?(:action_text_tool_calls)
ActiveRecord::Migration.drop_table :action_text_tool_calls
end
if ActiveRecord::Base.connection.table_exists?(:action_text_messages)
ActiveRecord::Migration.drop_table :action_text_messages
end
if ActiveRecord::Base.connection.table_exists?(:action_text_chats)
ActiveRecord::Migration.drop_table :action_text_chats
end
end
end

def create_message_with_action_text(content_text)
message = chat.messages.create!(role: :user)
action_text_content = mock_action_text(content_text)
allow(message).to receive(:content).and_return(action_text_content)
[message, action_text_content]
class ActionTextChat < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
acts_as_chat messages: :action_text_messages, message_class: 'ActionTextMessage'
end

class ActionTextMessage < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
acts_as_message chat: :action_text_chat,
chat_class: 'ActionTextChat',
tool_calls: :action_text_tool_calls,
tool_call_class: 'ActionTextToolCall'
has_rich_text :content
has_many_attached :attachments
end

class ActionTextToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration
acts_as_tool_call message: :action_text_message, message_class: 'ActionTextMessage'
end

let(:chat) { ActionTextChat.create! }

def create_blob(content: 'test data', filename: 'test.txt', content_type: 'text/plain')
ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(content),
filename: filename,
content_type: content_type
)
end

describe 'Action Text content extraction' do
context 'when content responds to to_plain_text' do
context 'when the message model has rich text content' do
it 'extracts plain text from Action Text content' do
message, action_text_content = create_message_with_action_text('This is plain text')
message = chat.action_text_messages.create!(
role: :user,
content: '<div>This is <strong>plain</strong> text</div>'
)

llm_message = message.to_llm

expect(action_text_content).to have_received(:to_plain_text)
expect(message.content).to be_a(ActionText::RichText)
expect(message[:content]).to be_nil
expect(llm_message.content).to eq('This is plain text')
end
end

context 'when content is a regular string' do
context 'when the message model uses a regular text column' do
it 'returns content unchanged' do
message = chat.messages.create!(role: :user, content: 'Regular text content')
message = Chat.create!.messages.create!(role: :user, content: 'Regular text content')

expect(message.content).to eq('Regular text content')
expect(message.to_llm.content).to eq('Regular text content')
end
end

context 'when Action Text content has attachments' do
let(:test_attachment) do
{ io: StringIO.new('test data'), filename: 'test.txt', content_type: 'text/plain' }
context 'when the rich text message has Active Storage attachments' do
it 'combines Action Text content with model attachments into RubyLLM::Content' do
message = chat.action_text_messages.create!(
role: :user,
content: '<div>Rich text with attachment</div>'
)
message.attachments.attach(
io: StringIO.new('test data'),
filename: 'test.txt',
content_type: 'text/plain'
)

llm_message = message.to_llm

expect(llm_message.content).to be_a(RubyLLM::Content)
expect(llm_message.content.text).to eq('Rich text with attachment')
expect(llm_message.content.attachments.first.mime_type).to eq('text/plain')
end

it 'combines Action Text with attachments into RubyLLM::Content' do
message, action_text_content = create_message_with_action_text('Rich text with attachment')
message.attachments.attach(test_attachment)
it 'extracts embedded Action Text attachments into RubyLLM::Content' do
blob = create_blob(content: 'embedded file data', filename: 'embedded.txt')
attachment = ActionText::Attachment.from_attachable(blob)
message = chat.action_text_messages.create!(
role: :user,
content: "<div>See file:</div>#{attachment.to_html}"
)

llm_message = message.to_llm

expect(action_text_content).to have_received(:to_plain_text)
expect(message.content.body.attachables).to include(blob)
expect(llm_message.content).to be_a(RubyLLM::Content)
expect(llm_message.content.text).to eq('Rich text with attachment')
expect(llm_message.content.text).to include('See file:')
expect(llm_message.content.attachments.first.filename).to eq('embedded.txt')
expect(llm_message.content.attachments.first.mime_type).to eq('text/plain')
expect(llm_message.content.attachments.first.content).to eq('embedded file data')
end
end
end
Expand Down
Loading