diff --git a/lib/ruby_llm/active_record/acts_as_legacy.rb b/lib/ruby_llm/active_record/acts_as_legacy.rb index dc576f878..ae91e7047 100644 --- a/lib/ruby_llm/active_record/acts_as_legacy.rb +++ b/lib/ruby_llm/active_record/acts_as_legacy.rb @@ -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 @@ -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 diff --git a/lib/ruby_llm/active_record/content_extraction.rb b/lib/ruby_llm/active_record/content_extraction.rb new file mode 100644 index 000000000..380864e77 --- /dev/null +++ b/lib/ruby_llm/active_record/content_extraction.rb @@ -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 diff --git a/lib/ruby_llm/active_record/message_methods.rb b/lib/ruby_llm/active_record/message_methods.rb index f6ca9a114..729e9d043 100644 --- a/lib/ruby_llm/active_record/message_methods.rb +++ b/lib/ruby_llm/active_record/message_methods.rb @@ -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 @@ -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 diff --git a/spec/dummy/db/migrate/20260506120000_create_action_text_tables.action_text.rb b/spec/dummy/db/migrate/20260506120000_create_action_text_tables.action_text.rb new file mode 100644 index 000000000..255266dfe --- /dev/null +++ b/spec/dummy/db/migrate/20260506120000_create_action_text_tables.action_text.rb @@ -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 diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 52e1bb7f6..01a54fc68 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -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 diff --git a/spec/ruby_llm/active_record/acts_as_action_text_spec.rb b/spec/ruby_llm/active_record/acts_as_action_text_spec.rb index 582267bd1..44e01922f 100644 --- a/spec/ruby_llm/active_record/acts_as_action_text_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_action_text_spec.rb @@ -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: '