Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 6cbfcac

Browse files
committed
add artifact versions table
1 parent 9b8de99 commit 6cbfcac

File tree

5 files changed

+495
-1
lines changed

5 files changed

+495
-1
lines changed

app/models/ai_artifact.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
class AiArtifact < ActiveRecord::Base
4+
has_many :versions, class_name: "AiArtifactVersion", dependent: :destroy
45
belongs_to :user
56
belongs_to :post
67
validates :html, length: { maximum: 65_535 }
@@ -33,6 +34,38 @@ def self.unshare_publicly(id:)
3334
def url
3435
self.class.url(id)
3536
end
37+
38+
def apply_diff(html_diff: nil, css_diff: nil, js_diff: nil, change_description: nil)
39+
differ = DiscourseAi::Utils::DiffUtils
40+
41+
html = html_diff ? differ.apply_hunk(self.html, html_diff) : self.html
42+
css = css_diff ? differ.apply_hunk(self.css, css_diff) : self.css
43+
js = js_diff ? differ.apply_hunk(self.js, js_diff) : self.js
44+
45+
create_new_version(html: html, css: css, js: js, change_description: change_description)
46+
end
47+
48+
def create_new_version(html: nil, css: nil, js: nil, change_description: nil)
49+
latest_version = versions.order(version_number: :desc).first
50+
new_version_number = latest_version ? latest_version.version_number + 1 : 1
51+
52+
transaction do
53+
# Create the version record
54+
versions.create!(
55+
version_number: new_version_number,
56+
html: self.html,
57+
css: self.css,
58+
js: self.js,
59+
change_description: change_description,
60+
)
61+
62+
# Update the main artifact
63+
self.html = html if html.present?
64+
self.css = css if css.present?
65+
self.js = js if js.present?
66+
save!
67+
end
68+
end
3669
end
3770

3871
# == Schema Information
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
class AddArtifactVersions < ActiveRecord::Migration[7.0]
3+
def change
4+
create_table :ai_artifact_versions do |t|
5+
t.bigint :ai_artifact_id, null: false
6+
t.integer :version_number, null: false
7+
t.string :html, limit: 65535
8+
t.string :css, limit: 65535
9+
t.string :js, limit: 65535
10+
t.jsonb :metadata
11+
t.string :change_description
12+
t.timestamps
13+
14+
t.index [:ai_artifact_id, :version_number], unique: true
15+
end
16+
end
17+
end

lib/utils/diff_utils.rb

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# frozen_string_literal: true
2+
# Inspired by Aider https://github.com/Aider-AI/aider
3+
4+
module DiscourseAi
5+
module Utils
6+
module DiffUtils
7+
# Custom errors with detailed information for LLM feedback
8+
class DiffError < StandardError
9+
attr_reader :original_text, :diff_text, :context
10+
11+
def initialize(message, original_text:, diff_text:, context: {})
12+
@original_text = original_text
13+
@diff_text = diff_text
14+
@context = context
15+
super(message)
16+
end
17+
18+
def to_llm_message
19+
original_text = @original_text
20+
if @original_text.length > 1000
21+
original_text = @original_text[0..1000] + "..."
22+
end
23+
24+
<<~MESSAGE
25+
#{message}
26+
27+
Original text:
28+
```
29+
#{original_text}
30+
```
31+
32+
Attempted diff:
33+
```
34+
#{diff_text}
35+
```
36+
37+
#{context_message}
38+
39+
Please provide a corrected diff that:
40+
1. Has the correct context lines
41+
2. Contains all necessary removals (-) and additions (+)
42+
MESSAGE
43+
end
44+
45+
private
46+
47+
def context_message
48+
return "" if context.empty?
49+
50+
context.map { |key, value| "#{key}: #{value}" }.join("\n")
51+
end
52+
end
53+
54+
class NoMatchingContextError < DiffError
55+
def initialize(original_text:, diff_text:)
56+
super(
57+
"Could not find the context lines in the original text",
58+
original_text: original_text,
59+
diff_text: diff_text,
60+
)
61+
end
62+
end
63+
64+
class AmbiguousMatchError < DiffError
65+
def initialize(original_text:, diff_text:)
66+
super(
67+
"Found multiple possible locations for this change",
68+
original_text: original_text,
69+
diff_text: diff_text,
70+
)
71+
end
72+
end
73+
74+
class MalformedDiffError < DiffError
75+
def initialize(original_text:, diff_text:, issue:)
76+
super(
77+
"The diff format is invalid",
78+
original_text: original_text,
79+
diff_text: diff_text,
80+
context: {
81+
"Issue" => issue,
82+
},
83+
)
84+
end
85+
end
86+
87+
def self.apply_hunk(text, diff)
88+
text = text.encode(universal_newline: true)
89+
diff = diff.encode(universal_newline: true)
90+
# we need this for matching
91+
text = text + "\n" unless text.end_with?("\n")
92+
93+
diff_lines = parse_diff_lines(diff, text)
94+
95+
validate_diff_format!(text, diff, diff_lines)
96+
97+
lines_to_match = diff_lines.select { |marker, _| ["-", " "].include?(marker) }.map(&:last)
98+
match_start, match_end = find_unique_match(text, lines_to_match, diff)
99+
new_hunk = diff_lines.select { |marker, _| ["+", " "].include?(marker) }.map(&:last).join
100+
101+
new_hunk = +""
102+
103+
diff_lines_index = 0
104+
text[match_start..match_end].lines.each do |line|
105+
diff_marker, diff_content = diff_lines[diff_lines_index]
106+
107+
while diff_marker == "+"
108+
new_hunk << diff_content
109+
diff_lines_index += 1
110+
diff_marker, diff_content = diff_lines[diff_lines_index]
111+
end
112+
113+
if diff_marker == " "
114+
new_hunk << line
115+
end
116+
117+
diff_lines_index += 1
118+
end
119+
120+
# leftover additions
121+
diff_marker, diff_content = diff_lines[diff_lines_index]
122+
while diff_marker == "+"
123+
diff_lines_index += 1
124+
new_hunk << diff_content
125+
diff_marker, diff_content = diff_lines[diff_lines_index]
126+
end
127+
128+
(text[0...match_start].to_s + new_hunk + text[match_end..-1].to_s).strip
129+
end
130+
131+
private_class_method def self.parse_diff_lines(diff, text)
132+
diff.lines.map do |line|
133+
marker = line[0]
134+
content = line[1..]
135+
136+
if !["-", "+", " "].include?(marker)
137+
marker = " "
138+
content = line
139+
end
140+
141+
[marker, content]
142+
end
143+
end
144+
145+
private_class_method def self.validate_diff_format!(text, diff, diff_lines)
146+
if diff_lines.empty?
147+
raise MalformedDiffError.new(original_text: text, diff_text: diff, issue: "Diff is empty")
148+
end
149+
150+
unless diff_lines.any? { |marker, _| %w[- +].include?(marker) }
151+
raise MalformedDiffError.new(
152+
original_text: text,
153+
diff_text: diff,
154+
issue: "Diff must contain at least one change (+ or -)",
155+
)
156+
end
157+
end
158+
159+
private_class_method def self.find_unique_match(text, context_lines, diff)
160+
return 0 if context_lines.empty? && removals.empty?
161+
162+
pattern = context_lines.map { |line| "^\\s*" + Regexp.escape(line.strip) + "\s*$\n" }.join
163+
matches =
164+
text
165+
.enum_for(:scan, /#{pattern}/m)
166+
.map do
167+
match = Regexp.last_match
168+
[match.begin(0), match.end(0)]
169+
end
170+
171+
case matches.length
172+
when 0
173+
raise NoMatchingContextError.new(original_text: text, diff_text: diff)
174+
when 1
175+
matches.first
176+
else
177+
raise AmbiguousMatchError.new(
178+
original_text: text,
179+
diff_text: diff,
180+
)
181+
end
182+
end
183+
end
184+
end
185+
end

lib/utils/dns_srv.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def self.lookup(domain)
1919

2020
def self.dns_srv_lookup_for_domain(domain)
2121
resolver = Resolv::DNS.new
22-
resources = resolver.getresources(domain, Resolv::DNS::Resource::IN::SRV)
22+
resolver.getresources(domain, Resolv::DNS::Resource::IN::SRV)
2323
end
2424

2525
def self.select_server(resources)

0 commit comments

Comments
 (0)