# frozen_string_literal: true describe DiscourseAi::Completions::PromptMessagesBuilder do let(:builder) { DiscourseAi::Completions::PromptMessagesBuilder.new } fab!(:user) fab!(:admin) fab!(:bot_user) { Fabricate(:user) } fab!(:other_user) { Fabricate(:user) } fab!(:image_upload1) do Fabricate(:upload, user: user, original_filename: "image.png", extension: "png") end fab!(:image_upload2) do Fabricate(:upload, user: user, original_filename: "image.png", extension: "png") end it "correctly merges user messages with uploads" do builder.push(type: :user, content: "Hello", id: "Alice", upload_ids: [1]) builder.push(type: :user, content: "World", id: "Bob", upload_ids: [2]) messages = builder.to_a # Check the structure of the merged message expect(messages.length).to eq(1) expect(messages[0][:type]).to eq(:user) # The content should contain the text and both uploads content = messages[0][:content] expect(content).to be_an(Array) expect(content[0]).to eq("Alice: Hello") expect(content[1]).to eq({ upload_id: 1 }) expect(content[2]).to eq("\nBob: World") expect(content[3]).to eq({ upload_id: 2 }) end it "should allow merging user messages" do builder.push(type: :user, content: "Hello", id: "Alice") builder.push(type: :user, content: "World", id: "Bob") expect(builder.to_a).to eq([{ type: :user, content: "Alice: Hello\nBob: World" }]) end it "should allow adding uploads" do builder.push(type: :user, content: "Hello", name: "Alice", upload_ids: [1, 2]) expect(builder.to_a).to eq( [{ type: :user, content: ["Hello", { upload_id: 1 }, { upload_id: 2 }], name: "Alice" }], ) end it "should support function calls" do builder.push(type: :user, content: "Echo 123 please", name: "Alice") builder.push(type: :tool_call, content: "echo(123)", name: "echo", id: 1) builder.push(type: :tool, content: "123", name: "echo", id: 1) builder.push(type: :user, content: "Hello", name: "Alice") expected = [ { type: :user, content: "Echo 123 please", name: "Alice" }, { type: :tool_call, content: "echo(123)", name: "echo", id: "1" }, { type: :tool, content: "123", name: "echo", id: "1" }, { type: :user, content: "Hello", name: "Alice" }, ] expect(builder.to_a).to eq(expected) end it "should drop a tool call if it is not followed by tool" do builder.push(type: :user, content: "Echo 123 please", id: "Alice") builder.push(type: :tool_call, content: "echo(123)", name: "echo", id: 1) builder.push(type: :user, content: "OK", id: "James") expected = [{ type: :user, content: "Alice: Echo 123 please\nJames: OK" }] expect(builder.to_a).to eq(expected) end it "should format messages for topic style" do # Create a topic with tags topic = Fabricate(:topic, title: "This is an Example Topic") # Add tags to the topic topic.tags = [Fabricate(:tag, name: "tag1"), Fabricate(:tag, name: "tag2")] topic.save! builder.topic = topic builder.push(type: :user, content: "I like frogs", id: "Bob") builder.push(type: :user, content: "How do I solve this?", id: "Alice") result = builder.to_a(style: :topic) content = result[0][:content] expect(content).to include("This is an Example Topic") expect(content).to include("tag1") expect(content).to include("tag2") expect(content).to include("Bob: I like frogs") expect(content).to include("Alice") expect(content).to include("How do I solve this") end describe "chat context posts in direct messages" do fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, bot_user]) } fab!(:dm_message) do Fabricate( :chat_message, chat_channel: dm_channel, user: user, message: "I have a question about the topic", ) end fab!(:topic) { Fabricate(:topic, title: "Important topic for context") } fab!(:post1) { Fabricate(:post, topic: topic, user: other_user, raw: "This is the first post") } fab!(:post2) { Fabricate(:post, topic: topic, user: user, raw: "And here's a follow-up") } it "correctly includes topic posts as context in direct message channels" do context = described_class.messages_from_chat( dm_message, channel: dm_channel, context_post_ids: [post1.id, post2.id], max_messages: 10, include_uploads: false, bot_user_ids: [bot_user.id], instruction_message: nil, ) expect(context.length).to eq(1) content = context.first[:content] # First part should contain the context intro expect(content).to include("You are replying inside a Discourse chat") expect(content).to include( "This chat is in the context of the Discourse topic 'Important topic for context'", ) expect(content).to include(post1.username) expect(content).to include("This is the first post") expect(content).to include(post2.username) expect(content).to include("And here's a follow-up") # Last part should have the user's message expect(content).to include("I have a question about the topic") end it "includes uploads from context posts when include_uploads is true" do upload = Fabricate(:upload, user: user) UploadReference.create!(target: post1, upload: upload) context = described_class.messages_from_chat( dm_message, channel: dm_channel, context_post_ids: [post1.id], max_messages: 10, include_uploads: true, bot_user_ids: [bot_user.id], instruction_message: nil, ) # Verify the upload reference is included upload_hashes = context.first[:content].select { |item| item.is_a?(Hash) && item[:upload_id] } expect(upload_hashes).to be_present expect(upload_hashes.first[:upload_id]).to eq(upload.id) end end describe ".messages_from_chat" do fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user, bot_user]) } fab!(:dm_message1) do Fabricate(:chat_message, chat_channel: dm_channel, user: user, message: "Hello bot") end fab!(:dm_message2) do Fabricate(:chat_message, chat_channel: dm_channel, user: bot_user, message: "Hello human") end fab!(:dm_message3) do Fabricate(:chat_message, chat_channel: dm_channel, user: user, message: "How are you?") end fab!(:public_channel) { Fabricate(:category_channel) } fab!(:public_message1) do Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Hello everyone") end fab!(:public_message2) do Fabricate(:chat_message, chat_channel: public_channel, user: bot_user, message: "Hi there") end fab!(:thread_original) do Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Thread starter") end fab!(:thread) do Fabricate(:chat_thread, channel: public_channel, original_message: thread_original) end fab!(:thread_reply1) do Fabricate( :chat_message, chat_channel: public_channel, user: other_user, message: "Thread reply", thread: thread, ) end fab!(:upload) { Fabricate(:upload, user: user) } fab!(:message_with_upload) do Fabricate( :chat_message, chat_channel: dm_channel, user: user, message: "Check this image", upload_ids: [upload.id], ) end it "processes messages from direct message channels" do context = described_class.messages_from_chat( dm_message3, channel: dm_channel, context_post_ids: nil, max_messages: 10, include_uploads: false, bot_user_ids: [bot_user.id], instruction_message: nil, ) # this is all we got cause it is assuming threading expect(context).to eq([{ type: :user, content: "How are you?", id: user.username }]) end it "includes uploads when include_uploads is true" do message_with_upload.reload expect(message_with_upload.uploads).to include(upload) context = described_class.messages_from_chat( message_with_upload, channel: dm_channel, context_post_ids: nil, max_messages: 10, include_uploads: true, bot_user_ids: [bot_user.id], instruction_message: nil, ) # Find the message with upload message = context.find do |m| m[:content] == ["Check this image -- uploaded(#{upload.short_url})", { upload_id: upload.id }] end expect(message).to be_present end it "doesn't include uploads when include_uploads is false" do # Make sure the upload is associated with the message message_with_upload.reload expect(message_with_upload.uploads).to include(upload) context = described_class.messages_from_chat( message_with_upload, channel: dm_channel, context_post_ids: nil, max_messages: 10, include_uploads: false, bot_user_ids: [bot_user.id], instruction_message: nil, ) # Find the message with upload message = context.find { |m| m[:content] == "Check this image -- uploaded(#{upload.short_url})" } expect(message).to be_present expect(message[:upload_ids]).to be_nil end it "properly handles uploads in public channels with multiple users" do _first_message = Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "First message") _message_with_upload = Fabricate( :chat_message, chat_channel: public_channel, user: other_user, message: "Message with image", upload_ids: [upload.id], ) last_message = Fabricate(:chat_message, chat_channel: public_channel, user: user, message: "Final message") context = described_class.messages_from_chat( last_message, channel: public_channel, context_post_ids: nil, max_messages: 3, include_uploads: true, bot_user_ids: [bot_user.id], instruction_message: nil, ) expect(context.length).to eq(1) content = context.first[:content] expect(content.length).to eq(3) expect(content[0]).to include("First message") expect(content[0]).to include("Message with image") expect(content[1]).to include({ upload_id: upload.id }) expect(content[2]).to include("Final message") end end describe "upload limits in messages_from_chat" do fab!(:test_channel) { Fabricate(:category_channel) } fab!(:test_user) { Fabricate(:user) } # Create MAX_CHAT_UPLOADS + 1 uploads fab!(:uploads) do (described_class::MAX_CHAT_UPLOADS + 1).times.map do |i| Fabricate(:upload, user: test_user, original_filename: "image#{i}.png", extension: "png") end end # Create MAX_CHAT_UPLOADS + 1 messages with those uploads fab!(:messages_with_uploads) do uploads.map do |upload| Fabricate( :chat_message, chat_channel: test_channel, user: test_user, message: "Message with upload #{upload.id}", ).tap do |msg| UploadReference.create!(target: msg, upload: upload) msg.update!(upload_ids: [upload.id]) end end end let(:max_uploads) { described_class::MAX_CHAT_UPLOADS } it "limits uploads to MAX_CHAT_UPLOADS in the final result" do last_message = messages_with_uploads.last # Make sure uploads are properly associated messages_with_uploads.each_with_index do |msg, i| expect(msg.uploads.first.id).to eq(uploads[i].id) end context = described_class.messages_from_chat( last_message, channel: test_channel, context_post_ids: nil, max_messages: messages_with_uploads.size, include_uploads: true, bot_user_ids: [], instruction_message: nil, ) # We should have one message containing all message content expect(context.length).to eq(1) content = context.first[:content] # Count the upload hashes in the content upload_hashes = content.select { |item| item.is_a?(Hash) && item[:upload_id] } # Should have exactly MAX_CHAT_UPLOADS upload references expect(upload_hashes.size).to eq(max_uploads) # The most recent uploads should be preserved (not the oldest) expected_upload_ids = uploads.last(max_uploads).map(&:id) actual_upload_ids = upload_hashes.map { |h| h[:upload_id] } expect(actual_upload_ids).to match_array(expected_upload_ids) end end describe ".messages_from_post" do fab!(:pm) do Fabricate( :private_message_topic, title: "This is my special PM", user: user, topic_allowed_users: [ Fabricate.build(:topic_allowed_user, user: user), Fabricate.build(:topic_allowed_user, user: bot_user), ], ) end fab!(:first_post) do Fabricate(:post, topic: pm, user: user, post_number: 1, raw: "This is a reply by the user") end fab!(:second_post) do Fabricate(:post, topic: pm, user: bot_user, post_number: 2, raw: "This is a bot reply") end fab!(:third_post) do Fabricate( :post, topic: pm, user: user, post_number: 3, raw: "This is a second reply by the user", ) end it "provides rich context for for style topic messages" do freeze_time user.update!(trust_level: 2, created_at: 1.year.ago) admin.update!(trust_level: 4, created_at: 1.month.ago) user.user_stat.update!(post_count: 10, days_visited: 50) reply_to_first_post = Fabricate( :post, topic: pm, user: admin, reply_to_post_number: first_post.post_number, raw: "This is a reply to the first post", ) context = described_class.messages_from_post( reply_to_first_post, style: :topic, max_posts: 10, bot_usernames: [bot_user.username], include_uploads: false, ) expect(context.length).to eq(1) content = context[0][:content] expect(content).to include(user.name) expect(content).to include("Trust level 2") expect(content).to include("account age: 1 year") # I am mixed on asserting everything cause the test # will be brittle, but open to changing this end it "handles uploads correctly in topic style messages (and times)" do freeze_time 1.month.ago # Use Discourse's upload format in the post raw content upload_markdown = "![test|658x372](#{image_upload1.short_url})" post_with_upload = Fabricate( :post, topic: pm, user: admin, raw: "This is the original #{upload_markdown} I just added", ) UploadReference.create!(target: post_with_upload, upload: image_upload1) upload2_markdown = "![test|658x372](#{image_upload2.short_url})" freeze_time 1.month.from_now post2_with_upload = Fabricate( :post, topic: pm, user: admin, raw: "This post has a different image #{upload2_markdown} I just added", ) UploadReference.create!(target: post2_with_upload, upload: image_upload2) messages = described_class.messages_from_post( post2_with_upload, style: :topic, max_posts: 3, bot_usernames: [bot_user.username], include_uploads: true, ) # this is not quite ideal yet, images are attached at the end of the post # long term we may want to extract them out using a regex and create N parts # so people can talk about multiple images in a single post # this is the initial ground work though expect(messages.length).to eq(1) content = messages[0][:content] # first part # first image # second part # second image expect(content.length).to eq(4) expect(content[0]).to include("This is the original") expect(content[0]).to include("(1 month ago)") expect(content[1]).to eq({ upload_id: image_upload1.id }) expect(content[2]).to include("different image") expect(content[3]).to eq({ upload_id: image_upload2.id }) end context "with limited context" do it "respects max_context_posts" do context = described_class.messages_from_post( third_post, max_posts: 1, bot_usernames: [bot_user.username], include_uploads: false, ) expect(context).to contain_exactly( *[{ type: :user, id: user.username, content: third_post.raw }], ) end end it "includes previous posts ordered by post_number" do context = described_class.messages_from_post( third_post, max_posts: 10, bot_usernames: [bot_user.username], include_uploads: false, ) expect(context).to eq( [ { type: :user, content: "This is a reply by the user", id: user.username }, { type: :model, content: "This is a bot reply" }, { type: :user, content: "This is a second reply by the user", id: user.username }, ], ) end it "only include regular posts" do first_post.update!(post_type: Post.types[:whisper]) context = described_class.messages_from_post( third_post, max_posts: 10, bot_usernames: [bot_user.username], include_uploads: false, ) # skips leading model reply which makes no sense cause first post was whisper expect(context).to eq( [{ type: :user, content: "This is a second reply by the user", id: user.username }], ) end context "with custom prompts" do it "When post custom prompt is present, we use that instead of the post content" do custom_prompt = [ [ { name: "time", arguments: { name: "time", timezone: "Buenos Aires" } }.to_json, "time", "tool_call", ], [ { args: { timezone: "Buenos Aires" }, time: "2023-12-14 17:24:00 -0300" }.to_json, "time", "tool", ], ["I replied to the time command", bot_user.username], ] PostCustomPrompt.create!(post: second_post, custom_prompt: custom_prompt) context = described_class.messages_from_post( third_post, max_posts: 10, bot_usernames: [bot_user.username], include_uploads: false, ) expect(context).to eq( [ { type: :user, content: "This is a reply by the user", id: user.username }, { type: :tool_call, content: custom_prompt.first.first, id: "time" }, { type: :tool, id: "time", content: custom_prompt.second.first }, { type: :model, content: custom_prompt.third.first }, { type: :user, content: "This is a second reply by the user", id: user.username }, ], ) end end end end