discourse-ai/spec/lib/completions/prompt_messages_builder_spe...

589 lines
19 KiB
Ruby

# 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