diff --git a/README.md b/README.md index 424b3c8..c71a374 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ require "ai-chat" x = AI::Chat.new # Add system-level instructions -x.system("You are a helpful assistant that speaks like Shakespeare.") +x.add("You are a helpful assistant that speaks like Shakespeare.", role: "system") # Add a user message to the chat -x.user("Hi there!") +x.add("Hi there!", role: "user") # Get the next message from the model -x.assistant! # => "Greetings, good sir or madam! How dost thou fare on this fine day? Pray, tell me how I may be of service to thee." +x.generate! # => "Greetings, good sir or madam! How dost thou fare on this fine day? Pray, tell me how I may be of service to thee." # Access the messages so far x.messages # => @@ -54,8 +54,8 @@ x.messages # => # ] # Rinse and repeat! -x.user("What's the best pizza in Chicago?") -x.assistant! # => "Ah, the fair and bustling city of Chicago, renowned for its deep-dish delight that hath captured hearts and stomachs aplenty. Amongst the many offerings of this great city, 'tis often said that Lou Malnati's and Giordano's...." +x.add("What's the best pizza in Chicago?", role: "user") +x.generate! # => "Ah, the fair and bustling city of Chicago, renowned for its deep-dish delight that hath captured hearts and stomachs aplenty. Amongst the many offerings of this great city, 'tis often said that Lou Malnati's and Giordano's...." ``` ## Configuration @@ -91,26 +91,26 @@ Get back Structured Output by setting the `schema` attribute (I suggest using [O ```ruby x = AI::Chat.new -x.system("You are an expert nutritionist. The user will describe a meal. Estimate the calories, carbs, fat, and protein.") +x.add("You are an expert nutritionist. The user will describe a meal. Estimate the calories, carbs, fat, and protein.", role: "system") x.schema = '{"name": "nutrition_values","strict": true,"schema": {"type": "object","properties": { "fat": { "type": "number", "description": "The amount of fat in grams." }, "protein": { "type": "number", "description": "The amount of protein in grams." }, "carbs": { "type": "number", "description": "The amount of carbohydrates in grams." }, "total_calories": { "type": "number", "description": "The total calories calculated based on fat, protein, and carbohydrates." }},"required": [ "fat", "protein", "carbs", "total_calories"],"additionalProperties": false}}' -x.user("1 slice of pizza") +x.add("1 slice of pizza", role: "user") -x.assistant! +x.generate! # => {"fat"=>15, "protein"=>5, "carbs"=>50, "total_calories"=>350} ``` ## Include images -You can include images in your chat messages using the `user` method with the `image` or `images` parameter: +You can include images in your chat messages using the `add` method with `role: "user"` and the `image` or `images` parameter: ```ruby # Send a single image -x.user("What's in this image?", image: "path/to/local/image.jpg") +x.add("What's in this image?", role: "user", image: "path/to/local/image.jpg") # Send multiple images -x.user("What are these images showing?", images: ["path/to/image1.jpg", "https://example.com/image2.jpg"]) +x.add("What are these images showing?", role: "user", images: ["path/to/image1.jpg", "https://example.com/image2.jpg"]) ``` The gem supports three types of image inputs: @@ -123,30 +123,32 @@ You can send multiple images, and place them between bits of text, in a single c ```ruby z = AI::Chat.new -z.user( +z.add( [ {"image" => "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/Eubalaena_glacialis_with_calf.jpg/215px-Eubalaena_glacialis_with_calf.jpg"}, {"text" => "What is in the above image? What is in the below image?"}, {"image" => "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Elephant_Diversity.jpg/305px-Elephant_Diversity.jpg"}, {"text" => "What are the differences between the images?"} - ] + ], + role: "user" ) -z.assistant! +z.generate! ``` Both string and symbol keys are supported for the hash items: ```ruby z = AI::Chat.new -z.user( +z.add( [ {image: "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/Eubalaena_glacialis_with_calf.jpg/215px-Eubalaena_glacialis_with_calf.jpg"}, {text: "What is in the above image? What is in the below image?"}, {image: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Elephant_Diversity.jpg/305px-Elephant_Diversity.jpg"}, {text: "What are the differences between the images?"} - ] + ], + role: "user" ) -z.assistant! +z.generate! ``` ## Set assistant messages manually @@ -158,20 +160,20 @@ You can manually add assistant messages without making API calls, which is usefu y = AI::Chat.new # Add previous messages -y.system("You are a helpful assistant who provides information about planets.") +y.add("You are a helpful assistant who provides information about planets.", role: "system") -y.user("Tell me about Mars.") -y.assistant("Mars is the fourth planet from the Sun....") +y.add("Tell me about Mars.", role: "user") +y.add("Mars is the fourth planet from the Sun....", role: "assistant") -y.user("What's the atmosphere like?") -y.assistant("Mars has a very thin atmosphere compared to Earth....") +y.add("What's the atmosphere like?", role: "user") +y.add("Mars has a very thin atmosphere compared to Earth....", role: "assistant") -y.user("Could it support human life?") -y.assistant("Mars currently can't support human life without....") +y.add("Could it support human life?", role: "user") +y.add("Mars currently can't support human life without....", role: "assistant") # Now continue the conversation with an API-generated response -y.user("Are there any current missions to go there?") -response = y.assistant! +y.add("Are there any current missions to go there?", role: "user") +response = y.generate! puts response ``` @@ -186,8 +188,8 @@ x = AI::Chat.new x.model = "o4-mini" x.reasoning_effort = "medium" # Can be "low", "medium", or "high" -x.user("Write a bash script that transposes a matrix represented as '[1,2],[3,4],[5,6]'") -x.assistant! +x.add("Write a bash script that transposes a matrix represented as '[1,2],[3,4],[5,6]'", role: "user") +x.generate! ``` The `reasoning_effort` parameter guides the model on how many reasoning tokens to generate before creating a response to the prompt. Options are: @@ -221,8 +223,8 @@ chat.messages = [ ] # Now continue the conversation with an API-generated response -chat.user("Are there any current missions to go there?") -response = chat.assistant! +chat.add("Are there any current missions to go there?", role: "user") +response = chat.generate! puts response ``` @@ -265,7 +267,7 @@ If your chat history is contained in an `ActiveRecord::Relation`, you can assign ```ruby chat = AI::Chat.new chat.messages = @thread.posts.order(:created_at) -chat.assistant! +chat.generate! ``` In order to work: @@ -300,6 +302,16 @@ Do stuff to capture reasoning summaries. Add a way to access the whole API response body (rather than just the message content). Useful for keepig track of tokens, etc. +## Deprecated Methods + +The following methods are deprecated and will be removed in a future version: +- `system(content)`: Use `add(content, role: "system")` instead. +- `user(content, image: nil, images: nil)`: Use `add(content, role: "user", image: image, images: images)` instead. +- `assistant(content)`: Use `add(content, role: "assistant")` instead. +- `assistant!`: Use `generate!` instead. + +Please update your code to use the new API. + ## Testing with Real API Calls While this gem includes specs, they use mocked API responses. To test with real API calls: diff --git a/lib/ai/chat.rb b/lib/ai/chat.rb index 6f72479..6d75087 100644 --- a/lib/ai/chat.rb +++ b/lib/ai/chat.rb @@ -33,88 +33,81 @@ def reasoning_effort=(value) end def system(content) - messages.push({role: "system", content: content}) + warn "The `system` method is deprecated. Use `add(content, role: \"system\")` instead." + add(content, role: "system") end def user(content, image: nil, images: nil) - if content.is_a?(Array) - processed_content = content.map do |item| - if item.key?("image") || item.key?(:image) - image_value = item.fetch("image") { item.fetch(:image) } - { - type: "image_url", - image_url: { - url: process_image(image_value) - } - } - elsif item.key?("text") || item.key?(:text) - text_value = item.fetch("text") { item.fetch(:text) } - { - type: "text", - text: text_value - } - else - item - end - end + warn "The `user` method is deprecated. Use `add(content, role: \"user\", image: image, images: images)` instead." + add(content, role: "user", image: image, images: images) + end - messages.push( - { - role: "user", - content: processed_content - } - ) - elsif image.nil? && images.nil? - messages.push( - { - role: "user", - content: content - } - ) - else - text_and_images_array = [ - { - type: "text", - text: content - } - ] - - if images && !images.empty? - images_array = images.map do |image| - { - type: "image_url", - image_url: { - url: process_image(image) + def assistant(content) + warn "The `assistant` method is deprecated. Use `add(content, role: \"assistant\")` instead." + add(content, role: "assistant") + end + + def add(content, role: "user", image: nil, images: nil) # Added image and images params here to match user method's capabilities + if role.to_s == "user" + if content.is_a?(Array) + processed_content = content.map do |item| + if item.key?("image") || item.key?(:image) + image_value = item.fetch("image") { item.fetch(:image) } + { + type: "image_url", + image_url: { + url: process_image(image_value) + } } - } + elsif item.key?("text") || item.key?(:text) + text_value = item.fetch("text") { item.fetch(:text) } + { + type: "text", + text: text_value + } + else + item # Pass through unknown items + end end - - text_and_images_array += images_array + messages.push({role: "user", content: processed_content}) + elsif image.nil? && images.nil? + messages.push({role: "user", content: content}) else - text_and_images_array.push( - { - type: "image_url", - image_url: { - url: process_image(image) + text_and_images_array = [{type: "text", text: content}] + if images && !images.empty? + images_array = images.map do |img| + { + type: "image_url", + image_url: { + url: process_image(img) + } + } + end + text_and_images_array += images_array + elsif image # Ensure image is not nil before processing + text_and_images_array.push( + { + type: "image_url", + image_url: { + url: process_image(image) + } } - } - ) + ) + end + messages.push({role: "user", content: text_and_images_array}) end - - messages.push( - { - role: "user", - content: text_and_images_array - } - ) + else + messages.push({role: role.to_s, content: content}) end end - def assistant(content) - messages.push({role: "assistant", content: content}) + # This method is now an alias for generate! and will be removed in a future version. + def assistant! + warn "The `assistant!` method is deprecated. Use `generate!` instead." + generate! end - def assistant! + def generate! request_headers_hash = { "Authorization" => "Bearer #{@api_key}", "content-type" => "application/json" diff --git a/spec/ai/chat/basic_functionality_spec.rb b/spec/ai/chat/basic_functionality_spec.rb index 4b3b0d5..d40ab92 100644 --- a/spec/ai/chat/basic_functionality_spec.rb +++ b/spec/ai/chat/basic_functionality_spec.rb @@ -35,38 +35,161 @@ end end - describe "#system" do - it "adds a system message to messages array" do - chat.system(test_system_message) + describe "#add" do + context "when adding a system message" do + it "adds a system message to messages array" do + chat.add(test_system_message, role: "system") - expect(chat.messages.length).to eq(1) - expect(chat.messages.first[:role]).to eq("system") - expect(chat.messages.first[:content]).to eq(test_system_message) + expect(chat.messages.length).to eq(1) + expect(chat.messages.first[:role]).to eq("system") + expect(chat.messages.first[:content]).to eq(test_system_message) + end end - end - describe "#user" do - context "with text-only content" do + context "when adding a user message" do it "adds a user message with simple text content" do - chat.user(test_user_message) + chat.add(test_user_message, role: "user") expect(chat.messages.length).to eq(1) expect(chat.messages.first[:role]).to eq("user") expect(chat.messages.first[:content]).to eq(test_user_message) end + + # Basic test for image handling with #add; more detailed tests are in image_handling_spec.rb + it "adds a user message with text and an image" do + allow(chat).to receive(:process_image).with("image.jpg").and_return("processed_image_data") + chat.add("User message with image", role: "user", image: "image.jpg") + + expect(chat.messages.length).to eq(1) + expect(chat.messages.first[:role]).to eq("user") + expect(chat.messages.first[:content]).to be_an(Array) + expect(chat.messages.first[:content]).to include({type: "text", text: "User message with image"}) + expect(chat.messages.first[:content]).to include({type: "image_url", image_url: {url: "processed_image_data"}}) + end + end + + context "when adding an assistant message" do + it "adds an assistant message to messages array" do + chat.add(test_assistant_message, role: "assistant") + + expect(chat.messages.length).to eq(1) + expect(chat.messages.first[:role]).to eq("assistant") + expect(chat.messages.first[:content]).to eq(test_assistant_message) + end end end - describe "#assistant" do - it "adds an assistant message to messages array" do - chat.assistant(test_assistant_message) + describe "deprecated methods" do + describe "#system (deprecated)" do + it "adds a system message and prints a deprecation warning" do + expect { chat.system(test_system_message) }.to output( + "The `system` method is deprecated. Use `add(content, role: \"system\")` instead.\n" + ).to_stderr - expect(chat.messages.length).to eq(1) - expect(chat.messages.first[:role]).to eq("assistant") - expect(chat.messages.first[:content]).to eq(test_assistant_message) + expect(chat.messages.length).to eq(1) + expect(chat.messages.first[:role]).to eq("system") + expect(chat.messages.first[:content]).to eq(test_system_message) + end + end + + describe "#user (deprecated)" do + context "with text-only content" do + it "adds a user message and prints a deprecation warning" do + expect { chat.user(test_user_message) }.to output( + "The `user` method is deprecated. Use `add(content, role: \"user\", image: image, images: images)` instead.\n" + ).to_stderr + + expect(chat.messages.length).to eq(1) + expect(chat.messages.first[:role]).to eq("user") + expect(chat.messages.first[:content]).to eq(test_user_message) + end + end + + context "with image content" do + let(:image_path) { "path/to/image.jpg" } + let(:processed_image_data) { "data:image/jpeg;base64,processed_data" } + + before do + allow(chat).to receive(:process_image).with(image_path).and_return(processed_image_data) + end + + it "adds a user message with an image and prints a deprecation warning" do + expect { chat.user(test_user_message, image: image_path) }.to output( + "The `user` method is deprecated. Use `add(content, role: \"user\", image: image, images: images)` instead.\n" + ).to_stderr + + expect(chat.messages.length).to eq(1) + expect(chat.messages.first[:role]).to eq("user") + expect(chat.messages.first[:content]).to be_an(Array) + expect(chat.messages.first[:content].first[:type]).to eq("text") + expect(chat.messages.first[:content].first[:text]).to eq(test_user_message) + expect(chat.messages.first[:content].last[:type]).to eq("image_url") + expect(chat.messages.first[:content].last[:image_url][:url]).to eq(processed_image_data) + end + end + end + + describe "#assistant (deprecated)" do + it "adds an assistant message and prints a deprecation warning" do + expect { chat.assistant(test_assistant_message) }.to output( + "The `assistant` method is deprecated. Use `add(content, role: \"assistant\")` instead.\n" + ).to_stderr + + expect(chat.messages.length).to eq(1) + expect(chat.messages.first[:role]).to eq("assistant") + expect(chat.messages.first[:content]).to eq(test_assistant_message) + end + end + + describe "#assistant! (deprecated)" do + before do + # Stub the actual API call within generate! to avoid external HTTP requests + allow(chat).to receive(:generate!).and_call_original # So we can check if it was called + allow(Net::HTTP).to receive(:start).and_return( + instance_double(Net::HTTPResponse, code: "200", body: { + "output" => [{ + "type" => "message", + "content" => [{"type" => "output_text", "text" => "Generated response"}] + }] + }.to_json, message: "OK") + ) + end + + it "calls generate! and prints a deprecation warning" do + expect(chat).to receive(:generate!).and_call_original + expect { chat.assistant! }.to output( + "The `assistant!` method is deprecated. Use `generate!` instead.\n" + ).to_stderr + # Verify that a message was added by generate! + expect(chat.messages.last[:role]).to eq("assistant") + expect(chat.messages.last[:content]).to eq("Generated response") + end end end + describe "#generate!" do + # Minimal test for generate! as its core functionality is tested by the deprecated assistant! tests for now + # and more detailed generation tests are likely in other spec files. + before do + allow(Net::HTTP).to receive(:start).and_return( + instance_double(Net::HTTPResponse, code: "200", body: { + "output" => [{ + "type" => "message", + "content" => [{"type" => "output_text", "text" => "Generated response from generate!"}] + }] + }.to_json, message: "OK") + ) + end + + it "makes an API call and adds an assistant message" do + chat.add("Prompt for generate!", role: "user") + chat.generate! + expect(chat.messages.last[:role]).to eq("assistant") + expect(chat.messages.last[:content]).to eq("Generated response from generate!") + end + end + + describe "#reasoning_effort=" do it "accepts valid reasoning effort values as symbols" do [:low, :medium, :high].each do |value|