From b976d5a829917830794572c644598f1958d43b77 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Tue, 28 Jan 2025 17:09:31 +1100 Subject: [PATCH] work in progress artifact system revamp --- lib/ai_bot/artifact_update_strategies/base.rb | 49 +++++ lib/ai_bot/artifact_update_strategies/diff.rb | 158 ++++++++++++++ lib/ai_bot/artifact_update_strategies/full.rb | 144 +++++++++++++ lib/ai_bot/tools/create_artifact.rb | 4 + lib/ai_bot/tools/update_artifact.rb | 202 +++--------------- .../ai_bot/tools/create_artifact_spec.rb | 1 - .../ai_bot/tools/update_artifact_spec.rb | 45 +++- 7 files changed, 426 insertions(+), 177 deletions(-) create mode 100644 lib/ai_bot/artifact_update_strategies/base.rb create mode 100644 lib/ai_bot/artifact_update_strategies/diff.rb create mode 100644 lib/ai_bot/artifact_update_strategies/full.rb diff --git a/lib/ai_bot/artifact_update_strategies/base.rb b/lib/ai_bot/artifact_update_strategies/base.rb new file mode 100644 index 000000000..d420e27b5 --- /dev/null +++ b/lib/ai_bot/artifact_update_strategies/base.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +module DiscourseAi + module AiBot + module ArtifactUpdateStrategies + class InvalidFormatError < StandardError + end + class Base + attr_reader :post, :user, :artifact, :artifact_version, :instructions, :llm + + def initialize(llm:, post:, user:, artifact:, artifact_version:, instructions:) + @llm = llm + @post = post + @user = user + @artifact = artifact + @artifact_version = artifact_version + @instructions = instructions + end + + def apply + changes = generate_changes + parsed_changes = parse_changes(changes) + apply_changes(parsed_changes) + end + + private + + def generate_changes + response = +"" + llm.generate(build_prompt, user: user) { |partial| response << partial } + end + + def build_prompt + # To be implemented by subclasses + raise NotImplementedError + end + + def parse_changes(response) + # To be implemented by subclasses + raise NotImplementedError + end + + def apply_changes(changes) + # To be implemented by subclasses + raise NotImplementedError + end + end + end + end +end diff --git a/lib/ai_bot/artifact_update_strategies/diff.rb b/lib/ai_bot/artifact_update_strategies/diff.rb new file mode 100644 index 000000000..42ed53ed1 --- /dev/null +++ b/lib/ai_bot/artifact_update_strategies/diff.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true +module DiscourseAi + module AiBot + module ArtifactUpdateStrategies + class Diff < Base + private + + def build_prompt + DiscourseAi::Completions::Prompt.new( + system_prompt, + messages: [ + { type: :user, content: current_artifact_content }, + { type: :model, content: "Please explain the changes you would like to generate:" }, + { type: :user, content: instructions }, + ], + post_id: post.id, + topic_id: post.topic_id, + ) + end + + def parse_changes(response) + sections = { html: nil, css: nil, javascript: nil } + current_section = nil + lines = [] + + response.each_line do |line| + case line + when /^--- (HTML|CSS|JavaScript) ---$/ + sections[current_section] = lines.join if current_section && !lines.empty? + current_section = line.match(/^--- (.+) ---$/)[1].downcase.to_sym + lines = [] + else + lines << line if current_section + end + end + + sections[current_section] = lines.join if current_section && !lines.empty? + + sections.transform_values do |content| + next nil if content.nil? + blocks = extract_search_replace_blocks(content) + raise InvalidFormatError, "Invalid format in #{current_section} section" if blocks.nil? + blocks + end + end + + def apply_changes(changes) + source = artifact_version || artifact + updated_content = { js: source.js, html: source.html, css: source.css } + + %i[html css javascript].each do |section| + blocks = changes[section] + next unless blocks + + content = source.public_send(section == :javascript ? :js : section) + blocks.each do |block| + content = + DiscourseAi::Utils::DiffUtils::SimpleDiff.apply( + content, + block[:search], + block[:replace], + ) + end + updated_content[section == :javascript ? :js : section] = content + end + + artifact.create_new_version( + html: updated_content[:html], + css: updated_content[:css], + js: updated_content[:js], + change_description: instructions, + ) + end + + private + + def extract_search_replace_blocks(content) + return nil if content.blank? + + blocks = [] + remaining = content + + while remaining =~ /<<<<<<< SEARCH\n(.*?)\n=======\n(.*?)\n>>>>>>> REPLACE/m + blocks << { search: $1, replace: $2 } + remaining = $' + end + + blocks.empty? ? nil : blocks + end + + def system_prompt + <<~PROMPT + You are a web development expert generating precise search/replace changes for updating HTML, CSS, and JavaScript code. + + Important rules: + 1. Use the format <<<<<<< SEARCH / ======= / >>>>>>> REPLACE for each change + 2. You can specify multiple search/replace blocks per section + 3. Generate three sections: HTML, CSS, and JavaScript + 4. Only include sections that have changes + 5. Keep changes minimal and focused + 6. Use exact matches for the search content + + Format: + --- HTML --- + (changes or empty if no changes) + --- CSS --- + (changes or empty if no changes) + --- JavaScript --- + (changes or empty if no changes) + + + Example - Multiple changes in one file: + --- JavaScript --- + <<<<<<< SEARCH + console.log('old1'); + ======= + console.log('new1'); + >>>>>>> REPLACE + <<<<<<< SEARCH + console.log('old2'); + ======= + console.log('new2'); + >>>>>>> REPLACE + + Example - CSS with multiple blocks: + --- CSS --- + <<<<<<< SEARCH + .button { color: blue; } + ======= + .button { color: red; } + >>>>>>> REPLACE + <<<<<<< SEARCH + .text { font-size: 12px; } + ======= + .text { font-size: 16px; } + >>>>>>> REPLACE + PROMPT + end + + def current_artifact_content + source = artifact_version || artifact + <<~CONTENT + Current artifact code: + + --- HTML --- + #{source.html} + + --- CSS --- + #{source.css} + + --- JavaScript --- + #{source.js} + CONTENT + end + end + end + end +end diff --git a/lib/ai_bot/artifact_update_strategies/full.rb b/lib/ai_bot/artifact_update_strategies/full.rb new file mode 100644 index 000000000..5f5b6d675 --- /dev/null +++ b/lib/ai_bot/artifact_update_strategies/full.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true +module DiscourseAi + module AiBot + module ArtifactUpdateStrategies + class Full < Base + private + + def build_prompt + DiscourseAi::Completions::Prompt.new( + system_prompt, + messages: [ + { type: :user, content: "#{current_artifact_content}\n\n\n#{instructions}" }, + ], + post_id: post.id, + topic_id: post.topic_id, + ) + end + + def parse_changes(response) + sections = { html: nil, css: nil, javascript: nil } + current_section = nil + lines = [] + + response.each_line do |line| + case line + when /^\[(HTML|CSS|JavaScript)\]$/ + sections[current_section] = lines.join if current_section && !lines.empty? + current_section = line.match(/^\[(.+)\]$/)[1].downcase.to_sym + lines = [] + when %r{^\[/(HTML|CSS|JavaScript)\]$} + sections[current_section] = lines.join if current_section && !lines.empty? + current_section = nil + lines = [] + else + lines << line if current_section + end + end + + sections + end + + def apply_changes(changes) + source = artifact_version || artifact + updated_content = { js: source.js, html: source.html, css: source.css } + + %i[html css javascript].each do |section| + content = changes[section]&.strip + next if content.blank? + updated_content[section == :javascript ? :js : section] = content + end + + artifact.create_new_version( + html: updated_content[:html], + css: updated_content[:css], + js: updated_content[:js], + change_description: instructions, + ) + end + + private + + def system_prompt + <<~PROMPT + You are a web development expert generating updated HTML, CSS, and JavaScript code. + + Important rules: + 1. Provide full source code for each changed section + 2. Generate up to three sections: HTML, CSS, and JavaScript + 3. Only include sections that need changes + 4. Keep changes focused on the requirements + 5. NEVER EVER BE LAZY, always include ALL the source code with any update you make. If you are lazy you will break the artifact. + 6. Do not print out any reasoning, just the changed code, you will be parsed via a program. + 7. Sections must start and end with exact tags: [HTML] [/HTML], [CSS] [/CSS], [JavaScript] [/JavaScript] + + Always adhere to the format when replying: + + [HTML] + complete html code, omit if no changes + [/HTML] + + [CSS] + complete css code, omit if no changes + [/CSS] + + [JavaScript] + complete js code, omit if no changes + [/JavaScript] + + Examples: + + Example 1 (HTML only change): + [HTML] +
+

Title

+
+ [/HTML] + + Example 2 (CSS and JavaScript changes): + [CSS] + .container { padding: 20px; } + .title { color: blue; } + [/CSS] + [JavaScript] + function init() { + console.log("loaded"); + } + [/JavaScript] + + Example 3 (All sections): + [HTML] +
+ [/HTML] + [CSS] + #app { margin: 0; } + [/CSS] + [JavaScript] + const app = document.getElementById("app"); + [/JavaScript] + + PROMPT + end + + def current_artifact_content + source = artifact_version || artifact + <<~CONTENT + Current artifact code: + + [HTML] + #{source.html} + [/HTML] + + [CSS] + #{source.css} + [/CSS] + + [JavaScript] + #{source.js} + [/JavaScript] + CONTENT + end + end + end + end +end diff --git a/lib/ai_bot/tools/create_artifact.rb b/lib/ai_bot/tools/create_artifact.rb index 0fc098e1b..3cc6611a6 100644 --- a/lib/ai_bot/tools/create_artifact.rb +++ b/lib/ai_bot/tools/create_artifact.rb @@ -85,6 +85,10 @@ def invoke end end + def chain_next_response? + false + end + def description_args { name: parameters[:name], specification: parameters[:specification] } end diff --git a/lib/ai_bot/tools/update_artifact.rb b/lib/ai_bot/tools/update_artifact.rb index 962d5415c..e07552027 100644 --- a/lib/ai_bot/tools/update_artifact.rb +++ b/lib/ai_bot/tools/update_artifact.rb @@ -8,36 +8,6 @@ def self.name "update_artifact" end - def self.diff_examples - <<~EXAMPLES - Example - Multiple changes in one file: - --- JavaScript --- - <<<<<<< SEARCH - console.log('old1'); - ======= - console.log('new1'); - >>>>>>> REPLACE - <<<<<<< SEARCH - console.log('old2'); - ======= - console.log('new2'); - >>>>>>> REPLACE - - Example - CSS with multiple blocks: - --- CSS --- - <<<<<<< SEARCH - .button { color: blue; } - ======= - .button { color: red; } - >>>>>>> REPLACE - <<<<<<< SEARCH - .text { font-size: 12px; } - ======= - .text { font-size: 16px; } - >>>>>>> REPLACE - EXAMPLES - end - def self.signature { name: "update_artifact", @@ -56,12 +26,19 @@ def self.signature type: "string", required: true, }, + { + name: "version", + description: + "The version number of the artifact to update, if not supplied latest version will be updated", + type: "integer", + required: false, + }, ], } end def invoke - yield "Updating Artifact" + yield "Updating Artifact\n#{parameters[:instructions]}" post = Post.find_by(id: context[:post_id]) return error_response("No post context found") unless post @@ -69,156 +46,41 @@ def invoke artifact = AiArtifact.find_by(id: parameters[:artifact_id]) return error_response("Artifact not found") unless artifact + artifact_version = nil + if version = parameters[:version] + artifact_version = artifact.versions.find_by(version_number: version) + return error_response("Version not found") unless version + else + artifact_version = artifact.versions.order(version_number: :desc).first + end + if artifact.post.topic.id != post.topic.id return error_response("Attempting to update an artifact you are not allowed to") end - changes = generate_changes(post: post, user: post.user, artifact: artifact) - return error_response(changes[:error]) if changes[:error] - begin - version = apply_changes(artifact, changes) - update_custom_html(artifact, version) - success_response(artifact, version) - rescue => e + new_version = + ArtifactUpdateStrategies::Full.new( + llm: llm, + post: post, + user: post.user, + artifact: artifact, + artifact_version: artifact_version, + instructions: parameters[:instructions], + ).apply + + update_custom_html(artifact, new_version) + success_response(artifact, new_version) + rescue StandardError => e error_response(e.message) end end - private - - def generate_changes(post:, user:, artifact:) - prompt = build_changes_prompt(post: post, artifact: artifact) - response = +"" - - llm.generate(prompt, user: user, feature_name: "update_artifact") do |partial| - response << partial - end - - parse_changes(response) - end - - def parse_changes(response) - sections = { html: nil, css: nil, javascript: nil } - current_section = nil - lines = [] - - response.each_line do |line| - case line - when /^--- (HTML|CSS|JavaScript) ---$/ - sections[current_section] = lines.join if current_section && !lines.empty? - current_section = line.match(/^--- (.+) ---$/)[1].downcase.to_sym - lines = [] - else - lines << line if current_section - end - end - - sections[current_section] = lines.join if current_section && !lines.empty? - - # Validate and extract all search/replace blocks - sections.transform_values do |content| - next nil if content.nil? - - puts content - - blocks = extract_search_replace_blocks(content) - return { error: "Invalid format in #{current_section} section" } if blocks.nil? - - puts "GOOD" - blocks - end + def chain_next_response? + false end - def extract_search_replace_blocks(content) - return nil if content.blank? - - blocks = [] - remaining = content - - while remaining =~ /<<<<<<< SEARCH\n(.*?)\n=======\n(.*?)\n>>>>>>> REPLACE/m - blocks << { search: $1, replace: $2 } - remaining = $' - end - - blocks.empty? ? nil : blocks - end - - def apply_changes(artifact, changes) - updated_content = {} - - %i[html css javascript].each do |section| - blocks = changes[section] - next unless blocks - - content = artifact.send(section == :javascript ? :js : section) - blocks.each do |block| - content = - DiscourseAi::Utils::DiffUtils::SimpleDiff.apply( - content, - block[:search], - block[:replace], - ) - end - updated_content[section == :javascript ? :js : section] = content - end - - artifact.create_new_version( - html: updated_content[:html], - css: updated_content[:css], - js: updated_content[:js], - change_description: parameters[:instructions], - ) - end - - def build_changes_prompt(post:, artifact:) - DiscourseAi::Completions::Prompt.new( - changes_system_prompt, - messages: [ - { type: :user, content: <<~CONTENT }, - Current artifact code: - - --- HTML --- - #{artifact.html} - - --- CSS --- - #{artifact.css} - - --- JavaScript --- - #{artifact.js} - CONTENT - { type: :model, content: "Please explain the changes you would like to generate:" }, - { type: :user, content: parameters[:instructions] }, - ], - post_id: post.id, - topic_id: post.topic_id, - ) - end - - def changes_system_prompt - <<~PROMPT - You are a web development expert generating precise search/replace changes for updating HTML, CSS, and JavaScript code. - - Important rules: - 1. Use the format <<<<<<< SEARCH / ======= / >>>>>>> REPLACE for each change - 2. You can specify multiple search/replace blocks per section - 3. Generate three sections: HTML, CSS, and JavaScript - 4. Only include sections that have changes - 5. Keep changes minimal and focused - 6. Use exact matches for the search content - - Format: - --- HTML --- - (changes or empty if no changes) - --- CSS --- - (changes or empty if no changes) - --- JavaScript --- - (changes or empty if no changes) - - Example changes: - #{self.class.diff_examples} - PROMPT - end + private def update_custom_html(artifact, version) content = [] diff --git a/spec/lib/modules/ai_bot/tools/create_artifact_spec.rb b/spec/lib/modules/ai_bot/tools/create_artifact_spec.rb index 8fe1e8431..6c27dbe2b 100644 --- a/spec/lib/modules/ai_bot/tools/create_artifact_spec.rb +++ b/spec/lib/modules/ai_bot/tools/create_artifact_spec.rb @@ -2,7 +2,6 @@ RSpec.describe DiscourseAi::AiBot::Tools::CreateArtifact do fab!(:llm_model) - let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } fab!(:post) diff --git a/spec/lib/modules/ai_bot/tools/update_artifact_spec.rb b/spec/lib/modules/ai_bot/tools/update_artifact_spec.rb index b72b88dc4..c6d9ea5dc 100644 --- a/spec/lib/modules/ai_bot/tools/update_artifact_spec.rb +++ b/spec/lib/modules/ai_bot/tools/update_artifact_spec.rb @@ -3,7 +3,6 @@ RSpec.describe DiscourseAi::AiBot::Tools::UpdateArtifact do fab!(:llm_model) let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } - let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } fab!(:post) fab!(:artifact) do AiArtifact.create!( @@ -56,7 +55,7 @@ instructions: "Change the text to Updated and color to red", }, bot_user: bot_user, - llm: llm, + llm: llm_model.to_llm, context: { post_id: post.id, }, @@ -66,7 +65,7 @@ expect(result[:status]).to eq("success") end - version = artifact.versions.last + version = artifact.versions.order(:version_number).last expect(version.html).to eq("
Updated
") expect(version.css).to eq(".test { color: red; }") expect(version.js).to eq(<<~JS.strip) @@ -81,6 +80,40 @@ expect(tool.custom_raw).to include("### CSS Changes") expect(tool.custom_raw).to include("### JS Changes") expect(tool.custom_raw).to include("
>>>>>> REPLACE + TXT + + DiscourseAi::Completions::Llm.with_prepared_responses(responses) do + tool = + described_class.new( + { artifact_id: artifact.id, instructions: "Change second line of JS" }, + bot_user: bot_user, + llm: llm_model.to_llm, + context: { + post_id: post.id, + }, + ) + + result = tool.invoke {} + expect(result[:status]).to eq("success") + + version = artifact.versions.order(:version_number).last + expect(version.html).to eq("
Updated
") + expect(version.css).to eq(".test { color: red; }") + expect(version.js).to eq(<<~JS.strip) + console.log('updated'); + console.log('replaced world'); + console.log('updated2'); + JS + end end it "handles invalid search/replace format" do @@ -91,7 +124,7 @@ described_class.new( { artifact_id: artifact.id, instructions: "Invalid update" }, bot_user: bot_user, - llm: llm, + llm: llm_model.to_llm, context: { post_id: post.id, }, @@ -108,7 +141,7 @@ described_class.new( { artifact_id: -1, instructions: "Update something" }, bot_user: bot_user, - llm: llm, + llm: llm_model.to_llm, context: { post_id: post.id, }, @@ -136,7 +169,7 @@ described_class.new( { artifact_id: artifact.id, instructions: "Just update the HTML" }, bot_user: bot_user, - llm: llm, + llm: llm_model.to_llm, context: { post_id: post.id, },