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

Commit 5cbc919

Browse files
authored
FEATURE: RAG search within tools (#802)
This allows custom tools access to uploads and sophisticated searches using embedding. It introduces: - A shared front end for listing and uploading files (shared with personas) - Backend implementation of index.search function within a custom tool. Custom tools now may search through uploaded files function invoke(params) { return index.search(params.query) } This means that RAG implementers now may preload tools with knowledge and have high fidelity over the search. The search function support specifying max results specifying a subset of files to search (from uploads) Also - Improved documentation for tools (when creating a tool a preamble explains all the functionality) - uploads were a bit finicky, fixed an edge case where the UI would not show them as updated
1 parent 18ecc84 commit 5cbc919

File tree

22 files changed

+631
-269
lines changed

22 files changed

+631
-269
lines changed

app/controllers/discourse_ai/admin/ai_personas_controller.rb

Lines changed: 1 addition & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ module Admin
55
class AiPersonasController < ::Admin::AdminController
66
requires_plugin ::DiscourseAi::PLUGIN_NAME
77

8-
before_action :find_ai_persona,
9-
only: %i[show update destroy create_user indexing_status_check]
8+
before_action :find_ai_persona, only: %i[show update destroy create_user]
109

1110
def index
1211
ai_personas =
@@ -75,37 +74,6 @@ def destroy
7574
end
7675
end
7776

78-
def upload_file
79-
file = params[:file] || params[:files].first
80-
81-
if !SiteSetting.ai_embeddings_enabled?
82-
raise Discourse::InvalidAccess.new("Embeddings not enabled")
83-
end
84-
85-
validate_extension!(file.original_filename)
86-
validate_file_size!(file.tempfile.size)
87-
88-
hijack do
89-
upload =
90-
UploadCreator.new(
91-
file.tempfile,
92-
file.original_filename,
93-
type: "discourse_ai_rag_upload",
94-
skip_validations: true,
95-
).create_for(current_user.id)
96-
97-
if upload.persisted?
98-
render json: UploadSerializer.new(upload)
99-
else
100-
render json: failed_json.merge(errors: upload.errors.full_messages), status: 422
101-
end
102-
end
103-
end
104-
105-
def indexing_status_check
106-
render json: RagDocumentFragment.indexing_status(@ai_persona, @ai_persona.uploads)
107-
end
108-
10977
private
11078

11179
def find_ai_persona
@@ -163,31 +131,6 @@ def permit_tools(tools)
163131
end
164132
end
165133
end
166-
167-
def validate_extension!(filename)
168-
extension = File.extname(filename)[1..-1] || ""
169-
authorized_extensions = %w[txt md]
170-
if !authorized_extensions.include?(extension)
171-
raise Discourse::InvalidParameters.new(
172-
I18n.t(
173-
"upload.unauthorized",
174-
authorized_extensions: authorized_extensions.join(" "),
175-
),
176-
)
177-
end
178-
end
179-
180-
def validate_file_size!(filesize)
181-
max_size_bytes = 20.megabytes
182-
if filesize > max_size_bytes
183-
raise Discourse::InvalidParameters.new(
184-
I18n.t(
185-
"upload.attachments.too_large_humanized",
186-
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes),
187-
),
188-
)
189-
end
190-
end
191134
end
192135
end
193136
end

app/controllers/discourse_ai/admin/ai_tools_controller.rb

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,20 @@ def show
1717
end
1818

1919
def create
20-
ai_tool = AiTool.new(ai_tool_params)
20+
ai_tool = AiTool.new(ai_tool_params.except(:rag_uploads))
2121
ai_tool.created_by_id = current_user.id
2222

2323
if ai_tool.save
24+
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
2425
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
2526
else
2627
render_json_error ai_tool
2728
end
2829
end
2930

3031
def update
31-
if @ai_tool.update(ai_tool_params)
32+
if @ai_tool.update(ai_tool_params.except(:rag_uploads))
33+
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
3234
render_serialized(@ai_tool, AiCustomToolSerializer)
3335
else
3436
render_json_error @ai_tool
@@ -71,6 +73,10 @@ def test
7173

7274
private
7375

76+
def attached_upload_ids
77+
ai_tool_params[:rag_uploads].to_a.map { |h| h[:id] }
78+
end
79+
7480
def find_ai_tool
7581
@ai_tool = AiTool.find(params[:id])
7682
end
@@ -81,6 +87,9 @@ def ai_tool_params
8187
:description,
8288
:script,
8389
:summary,
90+
:rag_chunk_tokens,
91+
:rag_chunk_overlap_tokens,
92+
rag_uploads: [:id],
8493
parameters: [:name, :type, :description, :required, enum: []],
8594
)
8695
end
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
module DiscourseAi
4+
module Admin
5+
class RagDocumentFragmentsController < ::Admin::AdminController
6+
requires_plugin ::DiscourseAi::PLUGIN_NAME
7+
8+
def indexing_status_check
9+
if params[:target_type] == "AiPersona"
10+
@target = AiPersona.find(params[:target_id])
11+
elsif params[:target_type] == "AiTool"
12+
@target = AiTool.find(params[:target_id])
13+
else
14+
raise Discourse::InvalidParameters.new("Invalid target type")
15+
end
16+
17+
render json: RagDocumentFragment.indexing_status(@target, @target.uploads)
18+
end
19+
20+
def upload_file
21+
file = params[:file] || params[:files].first
22+
23+
if !SiteSetting.ai_embeddings_enabled?
24+
raise Discourse::InvalidAccess.new("Embeddings not enabled")
25+
end
26+
27+
validate_extension!(file.original_filename)
28+
validate_file_size!(file.tempfile.size)
29+
30+
hijack do
31+
upload =
32+
UploadCreator.new(
33+
file.tempfile,
34+
file.original_filename,
35+
type: "discourse_ai_rag_upload",
36+
skip_validations: true,
37+
).create_for(current_user.id)
38+
39+
if upload.persisted?
40+
render json: UploadSerializer.new(upload)
41+
else
42+
render json: failed_json.merge(errors: upload.errors.full_messages), status: 422
43+
end
44+
end
45+
end
46+
47+
private
48+
49+
def validate_extension!(filename)
50+
extension = File.extname(filename)[1..-1] || ""
51+
authorized_extensions = %w[txt md]
52+
if !authorized_extensions.include?(extension)
53+
raise Discourse::InvalidParameters.new(
54+
I18n.t(
55+
"upload.unauthorized",
56+
authorized_extensions: authorized_extensions.join(" "),
57+
),
58+
)
59+
end
60+
end
61+
62+
def validate_file_size!(filesize)
63+
max_size_bytes = 20.megabytes
64+
if filesize > max_size_bytes
65+
raise Discourse::InvalidParameters.new(
66+
I18n.t(
67+
"upload.attachments.too_large_humanized",
68+
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes),
69+
),
70+
)
71+
end
72+
end
73+
end
74+
end
75+
end

app/jobs/regular/digest_rag_upload.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ def chunk_document(file:, tokenizer:, chunk_tokens:, overlap_tokens:)
126126

127127
while overlap_token_ids.present?
128128
begin
129-
overlap = tokenizer.decode(overlap_token_ids) + split_char
129+
padding = split_char
130+
padding = " " if padding.empty?
131+
overlap = tokenizer.decode(overlap_token_ids) + padding
130132
break if overlap.encoding == Encoding::UTF_8
131133
rescue StandardError
132134
# it is possible that we truncated mid char
@@ -135,7 +137,7 @@ def chunk_document(file:, tokenizer:, chunk_tokens:, overlap_tokens:)
135137
end
136138

137139
# remove first word it is probably truncated
138-
overlap = overlap.split(" ", 2).last
140+
overlap = overlap.split(/\s/, 2).last.to_s.lstrip
139141
end
140142
end
141143

app/models/ai_tool.rb

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ class AiTool < ActiveRecord::Base
77
validates :script, presence: true, length: { maximum: 100_000 }
88
validates :created_by_id, presence: true
99
belongs_to :created_by, class_name: "User"
10+
has_many :rag_document_fragments, dependent: :destroy, as: :target
11+
has_many :upload_references, as: :target, dependent: :destroy
12+
has_many :uploads, through: :upload_references
13+
before_update :regenerate_rag_fragments
1014

1115
def signature
1216
{ name: name, description: description, parameters: parameters.map(&:symbolize_keys) }
@@ -28,6 +32,82 @@ def bump_persona_cache
2832
AiPersona.persona_cache.flush!
2933
end
3034

35+
def regenerate_rag_fragments
36+
if rag_chunk_tokens_changed? || rag_chunk_overlap_tokens_changed?
37+
RagDocumentFragment.where(target: self).delete_all
38+
end
39+
end
40+
41+
def self.preamble
42+
<<~JS
43+
/**
44+
* Tool API Quick Reference
45+
*
46+
* Entry Functions
47+
*
48+
* invoke(parameters): Main function. Receives parameters (Object). Must return a JSON-serializable value.
49+
* Example:
50+
* function invoke(parameters) { return "result"; }
51+
*
52+
* details(): Optional. Returns a string describing the tool.
53+
* Example:
54+
* function details() { return "Tool description."; }
55+
*
56+
* Provided Objects
57+
*
58+
* 1. http
59+
* http.get(url, options?): Performs an HTTP GET request.
60+
* Parameters:
61+
* url (string): The request URL.
62+
* options (Object, optional):
63+
* headers (Object): Request headers.
64+
* Returns:
65+
* { status: number, body: string }
66+
*
67+
* http.post(url, options?): Performs an HTTP POST request.
68+
* Parameters:
69+
* url (string): The request URL.
70+
* options (Object, optional):
71+
* headers (Object): Request headers.
72+
* body (string): Request body.
73+
* Returns:
74+
* { status: number, body: string }
75+
*
76+
* Note: Max 20 HTTP requests per execution.
77+
*
78+
* 2. llm
79+
* llm.truncate(text, length): Truncates text to a specified token length.
80+
* Parameters:
81+
* text (string): Text to truncate.
82+
* length (number): Max tokens.
83+
* Returns:
84+
* Truncated string.
85+
*
86+
* 3. index
87+
* index.search(query, options?): Searches indexed documents.
88+
* Parameters:
89+
* query (string): Search query.
90+
* options (Object, optional):
91+
* filenames (Array): Limit search to specific files.
92+
* limit (number): Max fragments (up to 200).
93+
* Returns:
94+
* Array of { fragment: string, metadata: string }
95+
*
96+
* Constraints
97+
*
98+
* Execution Time: ≤ 2000ms
99+
* Memory: ≤ 10MB
100+
* HTTP Requests: ≤ 20 per execution
101+
* Exceeding limits will result in errors or termination.
102+
*
103+
* Security
104+
*
105+
* Sandboxed Environment: No access to system or global objects.
106+
* No File System Access: Cannot read or write files.
107+
*/
108+
JS
109+
end
110+
31111
def self.presets
32112
[
33113
{
@@ -38,6 +118,7 @@ def self.presets
38118
{ name: "url", type: "string", required: true, description: "The URL to browse" },
39119
],
40120
script: <<~SCRIPT,
121+
#{preamble}
41122
let url;
42123
function invoke(p) {
43124
url = p.url;
@@ -70,6 +151,7 @@ def self.presets
70151
{ name: "amount", type: "number", description: "Amount to convert eg: 123.45" },
71152
],
72153
script: <<~SCRIPT,
154+
#{preamble}
73155
// note: this script uses the open.er-api.com service, it is only updated
74156
// once every 24 hours, for more up to date rates see: https://www.exchangerate-api.com
75157
function invoke(params) {
@@ -118,6 +200,7 @@ def self.presets
118200
},
119201
],
120202
script: <<~SCRIPT,
203+
#{preamble}
121204
function invoke(params) {
122205
const apiKey = 'YOUR_ALPHAVANTAGE_API_KEY'; // Replace with your actual API key
123206
const url = `https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=${params.symbol}&apikey=${apiKey}`;
@@ -154,6 +237,7 @@ def self.presets
154237
summary: "Get real-time stock quotes using AlphaVantage API",
155238
},
156239
{ preset_id: "empty_tool", script: <<~SCRIPT },
240+
#{preamble}
157241
function invoke(params) {
158242
// logic here
159243
return params;
@@ -173,14 +257,16 @@ def self.presets
173257
#
174258
# Table name: ai_tools
175259
#
176-
# id :bigint not null, primary key
177-
# name :string not null
178-
# description :string not null
179-
# summary :string not null
180-
# parameters :jsonb not null
181-
# script :text not null
182-
# created_by_id :integer not null
183-
# enabled :boolean default(TRUE), not null
184-
# created_at :datetime not null
185-
# updated_at :datetime not null
260+
# id :bigint not null, primary key
261+
# name :string not null
262+
# description :string not null
263+
# summary :string not null
264+
# parameters :jsonb not null
265+
# script :text not null
266+
# created_by_id :integer not null
267+
# enabled :boolean default(TRUE), not null
268+
# created_at :datetime not null
269+
# updated_at :datetime not null
270+
# rag_chunk_tokens :integer default(374), not null
271+
# rag_chunk_overlap_tokens :integer default(10), not null
186272
#

app/models/rag_document_fragment.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,7 @@ def indexing_status(persona, uploads)
7272
end
7373

7474
def publish_status(upload, status)
75-
MessageBus.publish(
76-
"/discourse-ai/ai-persona-rag/#{upload.id}",
77-
status,
78-
user_ids: [upload.user_id],
79-
)
75+
MessageBus.publish("/discourse-ai/rag/#{upload.id}", status, user_ids: [upload.user_id])
8076
end
8177
end
8278
end

0 commit comments

Comments
 (0)