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

Commit 8d7baa2

Browse files
committed
WIP: automation triage using ai tool
1 parent 5e80f93 commit 8d7baa2

File tree

8 files changed

+240
-56
lines changed

8 files changed

+240
-56
lines changed

config/locales/client.en.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ en:
8181
label: "Top P"
8282
description: "Top P to use for the LLM, increase to increase randomness (0 to use model default)"
8383

84+
llm_tool_triage:
85+
fields:
86+
model:
87+
label: "Model"
88+
description: "The default language model used for triage"
89+
tool:
90+
label: "Tool"
91+
description: "Tool to use for triage (tool must have no parameters defined)"
92+
8493
llm_triage:
8594
fields:
8695
system_prompt:
@@ -122,7 +131,6 @@ en:
122131
model:
123132
label: "Model"
124133
description: "Language model used for triage"
125-
126134
discourse_ai:
127135
title: "AI"
128136

config/locales/server.en.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ en:
66
spam: "Flag as spam and hide post"
77
spam_silence: "Flag as spam, hide post and silence user"
88
scriptables:
9+
llm_tool_triage:
10+
title: Triage posts using AI Tool
11+
description: "Triage posts using custom logic in an AI tool"
912
llm_triage:
1013
title: Triage posts using AI
1114
description: "Triage posts using a large language model"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
3+
if defined?(DiscourseAutomation)
4+
DiscourseAutomation::Scriptable.add("llm_tool_triage") do
5+
version 1
6+
run_in_background
7+
8+
triggerables %i[post_created_edited]
9+
10+
field :model,
11+
component: :choices,
12+
required: true,
13+
extra: {
14+
content: DiscourseAi::Automation.available_models,
15+
}
16+
17+
field :tool,
18+
component: :choices,
19+
required: true,
20+
extra: {
21+
content: DiscourseAi::Automation.available_custom_tools,
22+
}
23+
24+
script do |context, fields|
25+
model = fields["model"]["value"]
26+
tool_id = fields["tool"]["value"]
27+
28+
category_id = fields.dig("category", "value")
29+
tags = fields.dig("tags", "value")
30+
31+
if post.topic.private_message?
32+
include_personal_messages = fields.dig("include_personal_messages", "value")
33+
next if !include_personal_messages
34+
end
35+
36+
begin
37+
RateLimiter.new(
38+
Discourse.system_user,
39+
"llm_tool_triage_#{post.id}",
40+
SiteSetting.ai_automation_max_triage_per_post_per_minute,
41+
1.minute,
42+
).performed!
43+
44+
RateLimiter.new(
45+
Discourse.system_user,
46+
"llm_tool_triage",
47+
SiteSetting.ai_automation_max_triage_per_minute,
48+
1.minute,
49+
).performed!
50+
51+
DiscourseAi::Automation::LlmToolTriage.handle(
52+
post: post,
53+
model: model,
54+
tool_id: tool_id,
55+
category_id: category_id,
56+
tags: tags,
57+
automation: self.automation,
58+
)
59+
rescue => e
60+
Discourse.warn_exception(e, message: "llm_tool_triage: skipped triage on post #{post.id}")
61+
end
62+
end
63+
end
64+
end

lib/ai_bot/tool_runner.rb

Lines changed: 105 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def framework_script
5656
5757
const llm = {
5858
truncate: _llm_truncate,
59+
generate: _llm_generate,
5960
};
6061
6162
const index = {
@@ -175,20 +176,60 @@ def attach_truncate(mini_racer_context)
175176
"_llm_truncate",
176177
->(text, length) { @llm.tokenizer.truncate(text, length) },
177178
)
179+
180+
mini_racer_context.attach(
181+
"_llm_generate",
182+
->(prompt) do
183+
in_attached_function do
184+
@llm.generate(
185+
convert_js_prompt_to_ruby(prompt),
186+
user: llm_user,
187+
feature_name: "custom_tool_#{tool.name}",
188+
)
189+
end
190+
end,
191+
)
192+
end
193+
194+
def convert_js_prompt_to_ruby(prompt)
195+
if prompt.is_a?(String)
196+
prompt
197+
elsif prompt.is_a?(Hash)
198+
messages = prompt["messages"]
199+
if messages.blank? || !messages.is_a?(Array)
200+
raise Discourse::InvalidParameters.new("Prompt must have messages")
201+
end
202+
messages.each(&:symbolize_keys!)
203+
messages.each { |message| message[:type] = message[:type].to_sym }
204+
DiscourseAi::Completions::Prompt.new(messages: prompt["messages"])
205+
else
206+
raise Discourse::InvalidParameters.new("Prompt must be a string or a hash")
207+
end
208+
end
209+
210+
def llm_user
211+
@llm_user ||=
212+
begin
213+
@context[:llm_user] || post&.user || @bot_user
214+
end
215+
end
216+
217+
def post
218+
return @post if defined?(@post)
219+
post_id = @context[:post_id]
220+
@post = post_id && Post.find_by(id: post_id)
178221
end
179222

180223
def attach_index(mini_racer_context)
181224
mini_racer_context.attach(
182225
"_index_search",
183226
->(*params) do
184-
begin
227+
in_attached_function do
185228
query, options = params
186229
self.running_attached_function = true
187230
options ||= {}
188231
options = options.symbolize_keys
189232
self.rag_search(query, **options)
190-
ensure
191-
self.running_attached_function = false
192233
end
193234
end,
194235
)
@@ -203,26 +244,25 @@ def attach_upload(mini_racer_context)
203244
"_upload_create",
204245
->(filename, base_64_content) do
205246
begin
206-
self.running_attached_function = true
207-
# protect against misuse
208-
filename = File.basename(filename)
209-
210-
Tempfile.create(filename) do |file|
211-
file.binmode
212-
file.write(Base64.decode64(base_64_content))
213-
file.rewind
214-
215-
upload =
216-
UploadCreator.new(
217-
file,
218-
filename,
219-
for_private_message: @context[:private_message],
220-
).create_for(@bot_user.id)
221-
222-
{ id: upload.id, short_url: upload.short_url, url: upload.url }
247+
in_attached_function do
248+
# protect against misuse
249+
filename = File.basename(filename)
250+
251+
Tempfile.create(filename) do |file|
252+
file.binmode
253+
file.write(Base64.decode64(base_64_content))
254+
file.rewind
255+
256+
upload =
257+
UploadCreator.new(
258+
file,
259+
filename,
260+
for_private_message: @context[:private_message],
261+
).create_for(@bot_user.id)
262+
263+
{ id: upload.id, short_url: upload.short_url, url: upload.url }
264+
end
223265
end
224-
ensure
225-
self.running_attached_function = false
226266
end
227267
end,
228268
)
@@ -238,18 +278,20 @@ def attach_http(mini_racer_context)
238278
raise TooManyRequestsError.new("Tool made too many HTTP requests")
239279
end
240280

241-
self.running_attached_function = true
242-
headers = (options && options["headers"]) || {}
281+
in_attached_function do
282+
headers = (options && options["headers"]) || {}
243283

244-
result = {}
245-
DiscourseAi::AiBot::Tools::Tool.send_http_request(url, headers: headers) do |response|
246-
result[:body] = response.body
247-
result[:status] = response.code.to_i
248-
end
284+
result = {}
285+
DiscourseAi::AiBot::Tools::Tool.send_http_request(
286+
url,
287+
headers: headers,
288+
) do |response|
289+
result[:body] = response.body
290+
result[:status] = response.code.to_i
291+
end
249292

250-
result
251-
ensure
252-
self.running_attached_function = false
293+
result
294+
end
253295
end
254296
end,
255297
)
@@ -264,35 +306,43 @@ def attach_http(mini_racer_context)
264306
raise TooManyRequestsError.new("Tool made too many HTTP requests")
265307
end
266308

267-
self.running_attached_function = true
268-
headers = (options && options["headers"]) || {}
269-
body = options && options["body"]
270-
271-
result = {}
272-
DiscourseAi::AiBot::Tools::Tool.send_http_request(
273-
url,
274-
method: method,
275-
headers: headers,
276-
body: body,
277-
) do |response|
278-
result[:body] = response.body
279-
result[:status] = response.code.to_i
309+
in_attached_function do
310+
headers = (options && options["headers"]) || {}
311+
body = options && options["body"]
312+
313+
result = {}
314+
DiscourseAi::AiBot::Tools::Tool.send_http_request(
315+
url,
316+
method: method,
317+
headers: headers,
318+
body: body,
319+
) do |response|
320+
result[:body] = response.body
321+
result[:status] = response.code.to_i
322+
end
323+
324+
result
325+
rescue => e
326+
if Rails.env.development?
327+
p url
328+
p options
329+
p e
330+
puts e.backtrace
331+
end
332+
raise e
280333
end
281-
282-
result
283-
rescue => e
284-
p url
285-
p options
286-
p e
287-
puts e.backtrace
288-
raise e
289-
ensure
290-
self.running_attached_function = false
291334
end
292335
end,
293336
)
294337
end
295338
end
339+
340+
def in_attached_function
341+
self.running_attached_function = true
342+
yield
343+
ensure
344+
self.running_attached_function = false
345+
end
296346
end
297347
end
298348
end

lib/automation.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ def self.flag_types
1212
},
1313
]
1414
end
15+
16+
def self.available_custom_tools
17+
AiTool
18+
.where(enabled: true)
19+
.where("parameters = '[]'::jsonb")
20+
.pluck(:id, :name, :description)
21+
.map { |id, name, description| { id: id, translated_name: name, description: description } }
22+
end
23+
1524
def self.available_models
1625
values = DB.query_hash(<<~SQL)
1726
SELECT display_name AS translated_name, id AS id

lib/completions/prompt.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ def encoded_uploads(message)
6565
UploadEncoder.encode(upload_ids: message[:upload_ids], max_pixels: max_pixels)
6666
end
6767

68+
def ==(other)
69+
return false unless other.is_a?(Prompt)
70+
messages == other.messages && tools == other.tools && topic_id == other.topic_id &&
71+
post_id == other.post_id && max_pixels == other.max_pixels &&
72+
tool_choice == other.tool_choice
73+
end
74+
75+
def eql?(other)
76+
self == other
77+
end
78+
79+
def hash
80+
[messages, tools, topic_id, post_id, max_pixels, tool_choice].hash
81+
end
82+
6883
private
6984

7085
def validate_message(message)

plugin.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def self.public_asset_path(name)
6565
# do not autoload this cause we may have no namespace
6666
require_relative "discourse_automation/llm_triage"
6767
require_relative "discourse_automation/llm_report"
68+
require_relative "discourse_automation/llm_tool_triage"
6869

6970
add_admin_route("discourse_ai.title", "discourse-ai", { use_new_show_route: true })
7071

0 commit comments

Comments
 (0)