Compare commits

..
Author SHA1 Message Date
Sam Saffron 6e6ced4554 Work in progress... post embeddings and version
Feel free to continue any work here, will pick it up again next week
2023-05-12 15:28:12 +10:00
601 changed files with 4211 additions and 2262810 deletions
-6
View File
@@ -1,7 +1 @@
< 3.3.0.beta1-dev: a65c4076a63104b61d213534db7f89aa3c8acd9e
< 3.2.0.beta5-dev: c70f43f130fb98e4a1adb1afd9b621aaa49088e3
< 3.2.0.beta4-dev: 493b48477a9288f2f1b6f021954088705a6673d6
< 3.2.0.beta2-dev: 3bced1c6f525553fe2b5b79d29bd66c1cf2a47d5
3.1.999: c0415bb7eb878b1b7abf112d65cba981030df8df
< 3.2.0.beta1-dev: 3e7c99de894b03e9161d9481da118fe893991a1d
3.1.0.beta3: 057fbe1ce6
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "eslint-config-discourse"
}
-1
View File
@@ -1 +0,0 @@
module.exports = require("@discourse/lint-configs/eslint");
-21
View File
@@ -1,21 +0,0 @@
app/assets/stylesheets/vendor/
documentation/
package.json
config/locales/**/*.yml
!config/locales/**/*.en*.yml
script/import_scripts/**/*.yml
plugins/**/lib/javascripts/locale
public/
!/app/assets/javascripts/discourse/public
vendor/
app/assets/javascripts/discourse/tests/fixtures
spec/
node_modules/
dist/
tmp/
**/*.rb
**/*.html
**/*.json
**/*.md
+1
View File
@@ -0,0 +1 @@
{}
-1
View File
@@ -1 +0,0 @@
module.exports = require("@discourse/lint-configs/prettier");
-2
View File
@@ -1,4 +1,2 @@
inherit_gem:
rubocop-discourse: stree-compat.yml
RSpec/NamedSubject:
Enabled: false
+1 -1
View File
@@ -1,2 +1,2 @@
--print-width=100
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
--plugins=plugin/trailing_comma,disable_ternary
-1
View File
@@ -1 +0,0 @@
module.exports = require("@discourse/lint-configs/template-lint");
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
plugins: ["ember-template-lint-plugin-discourse"],
extends: "discourse:recommended",
};
+1
View File
@@ -5,4 +5,5 @@ source "https://rubygems.org"
group :development do
gem "rubocop-discourse"
gem "syntax_tree"
gem "syntax_tree-disable_ternary"
end
+28 -55
View File
@@ -1,77 +1,50 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.1.3.2)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.6)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.7.1)
language_server-protocol (3.17.0.3)
minitest (5.22.2)
mutex_m (0.2.0)
parallel (1.24.0)
parser (3.3.0.5)
json (2.6.2)
parallel (1.22.1)
parser (3.1.2.1)
ast (~> 2.4.1)
racc
prettier_print (1.2.1)
racc (1.7.3)
prettier_print (1.2.0)
rainbow (3.1.1)
regexp_parser (2.9.0)
rexml (3.2.6)
rubocop (1.61.0)
regexp_parser (2.6.0)
rexml (3.2.5)
rubocop (1.36.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.30.0, < 2.0)
rubocop-ast (>= 1.20.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.1)
parser (>= 3.3.0.4)
rubocop-capybara (2.20.0)
rubocop (~> 1.41)
rubocop-discourse (3.7.1)
activesupport (>= 6.1)
rubocop (>= 1.59.0)
rubocop-capybara (>= 2.0.0)
rubocop-factory_bot (>= 2.0.0)
rubocop-rspec (>= 2.25.0)
rubocop-factory_bot (2.25.1)
rubocop (~> 1.41)
rubocop-rspec (2.27.1)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
syntax_tree (6.2.0)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.21.0)
parser (>= 3.1.1.0)
rubocop-discourse (3.0)
rubocop (>= 1.1.0)
rubocop-rspec (>= 2.0.0)
rubocop-rspec (2.13.2)
rubocop (~> 1.33)
ruby-progressbar (1.11.0)
syntax_tree (5.1.0)
prettier_print (>= 1.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
syntax_tree-disable_ternary (1.0.0)
unicode-display_width (2.3.0)
PLATFORMS
arm64-darwin-20
ruby
x86_64-darwin-18
x86_64-darwin-19
x86_64-darwin-20
x86_64-linux
DEPENDENCIES
rubocop-discourse
syntax_tree
syntax_tree-disable_ternary
BUNDLED WITH
2.5.4
2.3.10
+2 -2
View File
@@ -1,5 +1,5 @@
# **Discourse AI** Plugin
# **Plugin Name** Plugin
**Plugin Summary**
For more information, please see: https://meta.discourse.org/t/discourse-ai/259214?u=falco
For more information, please see: **url to meta topic**
@@ -1,23 +0,0 @@
import { AUTO_GROUPS } from "discourse/lib/constants";
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model() {
const record = this.store.createRecord("ai-persona");
record.set("allowed_group_ids", [AUTO_GROUPS.trust_level_0.id]);
record.set("rag_uploads", []);
// these match the defaults on the table
record.set("rag_chunk_tokens", 374);
record.set("rag_chunk_overlap_tokens", 10);
record.set("rag_conversation_chunks", 10);
return record;
},
setupController(controller, model) {
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
},
});
@@ -1,19 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
async model(params) {
const allPersonas = this.modelFor(
"adminPlugins.show.discourse-ai-personas"
);
const id = parseInt(params.id, 10);
return allPersonas.findBy("id", id);
},
setupController(controller, model) {
this._super(controller, model);
controller.set(
"allPersonas",
this.modelFor("adminPlugins.show.discourse-ai-personas")
);
},
});
@@ -1,7 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class DiscourseAiAiPersonasRoute extends DiscourseRoute {
model() {
return this.store.findAll("ai-persona");
}
}
@@ -1 +0,0 @@
<AiPersonaListEditor @personas={{this.model}} />
@@ -1,4 +0,0 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>
@@ -1,4 +0,0 @@
<AiPersonaListEditor
@personas={{this.allPersonas}}
@currentPersona={{this.model}}
/>
@@ -1,180 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class AiPersonasController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_persona,
only: %i[show update destroy create_user indexing_status_check]
def index
ai_personas =
AiPersona.ordered.map do |persona|
# we use a special serializer here cause names and descriptions are
# localized for system personas
LocalizedAiPersonaSerializer.new(persona, root: false)
end
tools =
DiscourseAi::AiBot::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false)
end
llms =
DiscourseAi::Configuration::LlmEnumerator.values.map do |hash|
{ id: hash[:value], name: hash[:name] }
end
render json: { ai_personas: ai_personas, meta: { commands: tools, llms: llms } }
end
def show
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
end
def create
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
if ai_persona.save
RagDocumentFragment.link_persona_and_uploads(ai_persona, attached_upload_ids)
render json: { ai_persona: ai_persona }, status: :created
else
render_json_error ai_persona
end
end
def create_user
user = @ai_persona.create_user!
render json: BasicUserSerializer.new(user, root: "user")
end
def update
if @ai_persona.update(ai_persona_params.except(:rag_uploads))
RagDocumentFragment.update_persona_uploads(@ai_persona, attached_upload_ids)
render json: @ai_persona
else
render_json_error @ai_persona
end
end
def destroy
if @ai_persona.destroy
head :no_content
else
render_json_error @ai_persona
end
end
def upload_file
file = params[:file] || params[:files].first
if !SiteSetting.ai_embeddings_enabled?
raise Discourse::InvalidAccess.new("Embeddings not enabled")
end
validate_extension!(file.original_filename)
validate_file_size!(file.tempfile.size)
hijack do
upload =
UploadCreator.new(
file.tempfile,
file.original_filename,
type: "discourse_ai_rag_upload",
skip_validations: true,
).create_for(current_user.id)
if upload.persisted?
render json: UploadSerializer.new(upload)
else
render json: failed_json.merge(errors: upload.errors.full_messages), status: 422
end
end
end
def indexing_status_check
render json: RagDocumentFragment.indexing_status(@ai_persona, @ai_persona.uploads)
end
private
def find_ai_persona
@ai_persona = AiPersona.find(params[:id])
end
def attached_upload_ids
ai_persona_params[:rag_uploads].to_a.map { |h| h[:id] }
end
def ai_persona_params
permitted =
params.require(:ai_persona).permit(
:name,
:description,
:enabled,
:system_prompt,
:priority,
:top_p,
:temperature,
:default_llm,
:user_id,
:mentionable,
:max_context_posts,
:vision_enabled,
:vision_max_pixels,
:rag_chunk_tokens,
:rag_chunk_overlap_tokens,
:rag_conversation_chunks,
:question_consolidator_llm,
allowed_group_ids: [],
rag_uploads: [:id],
)
if commands = params.dig(:ai_persona, :commands)
permitted[:commands] = permit_commands(commands)
end
permitted
end
def permit_commands(commands)
return [] if !commands.is_a?(Array)
commands.filter_map do |command, options|
break nil if !command.is_a?(String)
options&.permit! if options && options.is_a?(ActionController::Parameters)
if options
[command, options]
else
command
end
end
end
def validate_extension!(filename)
extension = File.extname(filename)[1..-1] || ""
authorized_extensions = %w[txt md]
if !authorized_extensions.include?(extension)
raise Discourse::InvalidParameters.new(
I18n.t(
"upload.unauthorized",
authorized_extensions: authorized_extensions.join(" "),
),
)
end
end
def validate_file_size!(filesize)
max_size_bytes = 20.megabytes
if filesize > max_size_bytes
raise Discourse::InvalidParameters.new(
I18n.t(
"upload.attachments.too_large_humanized",
max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size_bytes),
),
)
end
end
end
end
end
@@ -1,12 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module Admin
class DashboardController < ::Admin::StaffController
requires_plugin DiscourseAi::PLUGIN_NAME
def sentiment
end
end
end
end
@@ -6,21 +6,6 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
def show_debug_info
post = Post.find(params[:post_id])
guardian.ensure_can_debug_ai_bot_conversation!(post)
posts =
Post
.where("post_number <= ?", post.post_number)
.where(topic_id: post.topic_id)
.order("post_number DESC")
debug_info = AiApiAuditLog.where(post: posts).order(created_at: :desc).first
render json: debug_info, status: 200
end
def stop_streaming_response
post = Post.find(params[:post_id])
guardian.ensure_can_see!(post)
@@ -29,15 +14,6 @@ module DiscourseAi
render json: {}, status: 200
end
def show_bot_username
bot_user_id = DiscourseAi::AiBot::EntryPoint.map_bot_model_to_user_id(params[:username])
raise Discourse::InvalidParameters.new(:username) if !bot_user_id
bot_username_lower = User.find(bot_user_id).username_lower
render json: { bot_username: bot_username_lower }, status: 200
end
end
end
end
@@ -1,108 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class SharedAiConversationsController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login only: %i[create update destroy]
before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, :redirect_to_login_if_required, only: %i[show]
def create
ensure_allowed_create!
RateLimiter.new(current_user, "share-ai-conversation", 10, 1.minute).performed!
shared_conversation = SharedAiConversation.share_conversation(current_user, @topic)
if shared_conversation.persisted?
render json: success_json.merge(share_key: shared_conversation.share_key)
else
render json: failed_json.merge(error: I18n.t("discourse_ai.share_ai.failed_to_share")),
status: :unprocessable_entity
end
end
def destroy
ensure_allowed_destroy!
SharedAiConversation.destroy_conversation(@shared_conversation)
render json:
success_json.merge(message: I18n.t("discourse_ai.share_ai.conversation_deleted"))
end
def show
@shared_conversation = SharedAiConversation.find_by(share_key: params[:share_key])
raise Discourse::NotFound if @shared_conversation.blank?
expires_in 1.minute, public: true
response.headers["X-Robots-Tag"] = "noindex"
if request.format.json?
render json: success_json.merge(@shared_conversation.to_json)
else
render "show", layout: false
end
end
def preview
ensure_allowed_preview!
data = SharedAiConversation.build_conversation_data(@topic, include_usernames: true)
data[:error] = @error if @error
data[:share_key] = @shared_conversation.share_key if @shared_conversation
data[:topic_id] = @topic.id
render json: data
end
private
def require_site_settings!
if !SiteSetting.discourse_ai_enabled ||
!SiteSetting.ai_bot_public_sharing_allowed_groups_map.any? ||
!SiteSetting.ai_bot_enabled
raise Discourse::NotFound
end
end
def ensure_allowed_preview!
@topic = Topic.find_by(id: params[:topic_id])
raise Discourse::NotFound if !@topic
@shared_conversation = SharedAiConversation.find_by(target: @topic)
@error = DiscourseAi::AiBot::EntryPoint.ai_share_error(@topic, guardian)
if @error == :not_allowed
raise Discourse::InvalidAccess.new(
nil,
nil,
custom_message: "discourse_ai.share_ai.errors.#{@error}",
)
end
end
def ensure_allowed_destroy!
@shared_conversation = SharedAiConversation.find_by(share_key: params[:share_key])
raise Discourse::InvalidAccess if @shared_conversation.blank?
guardian.ensure_can_destroy_shared_ai_bot_conversation!(@shared_conversation)
end
def ensure_allowed_create!
@topic = Topic.find_by(id: params[:topic_id])
raise Discourse::NotFound if !@topic
error = DiscourseAi::AiBot::EntryPoint.ai_share_error(@topic, guardian)
if error
raise Discourse::InvalidAccess.new(
nil,
nil,
custom_message: "discourse_ai.share_ai.errors.#{error}",
)
end
end
end
end
end
@@ -6,159 +6,49 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
before_action :ensure_can_request_suggestions
before_action :rate_limiter_performed!, except: %i[prompts]
include SecureUploadEndpointHelpers
def prompts
render json:
ActiveModel::ArraySerializer.new(
DiscourseAi::AiHelper::LlmPrompt.new.available_prompts,
root: false,
),
status: 200
end
def suggest
input = get_text_param!
raise Discourse::InvalidParameters.new(:text) if params[:text].blank?
prompt = CompletionPrompt.find_by(id: params[:mode])
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
prompt.custom_instruction = params[:custom_prompt]
end
suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
hijack do
render json:
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
DiscourseAi::AiHelper::LlmPrompt.new.generate_and_send_prompt(
prompt,
input,
current_user,
params[:text],
),
status: 200
end
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
def suggest_title
input = get_text_param!
prompt = CompletionPrompt.enabled_by_name("generate_titles")
raise Discourse::InvalidParameters.new(:mode) if !prompt
hijack do
render json:
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
prompt,
input,
current_user,
),
status: 200
end
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
def suggest_category
input = get_text_param!
input_hash = { text: input }
render json:
DiscourseAi::AiHelper::SemanticCategorizer.new(
input_hash,
current_user,
).categories,
status: 200
end
def suggest_tags
input = get_text_param!
input_hash = { text: input }
render json: DiscourseAi::AiHelper::SemanticCategorizer.new(input_hash, current_user).tags,
status: 200
end
def suggest_thumbnails(input)
hijack do
thumbnails = DiscourseAi::AiHelper::Painter.new.commission_thumbnails(input, current_user)
render json: { thumbnails: thumbnails }, status: 200
end
end
def explain
post_id = get_post_param!
term_to_explain = get_text_param!
post = Post.includes(:topic).find_by(id: post_id)
raise Discourse::InvalidParameters.new(:post_id) unless post
Jobs.enqueue(
:stream_post_helper,
post_id: post.id,
user_id: current_user.id,
term_to_explain: term_to_explain,
)
render json: { success: true }, status: 200
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
def caption_image
image_url = params[:image_url]
raise Discourse::InvalidParameters.new(:image_url) if !image_url
image = upload_from_full_url(image_url)
raise Discourse::NotFound if image.blank?
final_image_url = get_caption_url(image, image_url)
hijack do
caption =
DiscourseAi::AiHelper::Assistant.new.generate_image_caption(
final_image_url,
current_user,
)
render json: {
caption:
"#{caption} (#{I18n.t("discourse_ai.ai_helper.image_caption.attribution")})",
},
status: 200
end
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed, Net::HTTPBadResponse
rescue ::DiscourseAi::Inference::OpenAiCompletions::CompletionFailed,
::DiscourseAi::Inference::AnthropicCompletions::CompletionFailed => e
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502
end
private
def get_text_param!
params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? }
end
def get_post_param!
params[:post_id].tap { |t| raise Discourse::InvalidParameters.new(:post_id) if t.blank? }
end
def rate_limiter_performed!
RateLimiter.new(current_user, "ai_assistant", 6, 3.minutes).performed!
end
def ensure_can_request_suggestions
if !current_user.in_any_groups?(SiteSetting.ai_helper_allowed_groups_map)
raise Discourse::InvalidAccess
end
end
user_group_ids = current_user.group_ids
def get_caption_url(image_upload, image_url)
if image_upload.secure?
check_secure_upload_permission(image_upload)
return Discourse.store.url_for(image_upload)
end
allowed =
SiteSetting.ai_helper_allowed_groups_map.any? do |group_id|
user_group_ids.include?(group_id)
end
UrlHelper.absolute(image_url)
raise Discourse::InvalidAccess if !allowed
end
end
end
@@ -8,11 +8,8 @@ module DiscourseAi
SEMANTIC_SEARCH_TYPE = "semantic_search"
def search
query = params[:q].to_s
if query.length < SiteSetting.min_search_term_length
raise Discourse::InvalidParameters.new(:q)
end
query = params[:q]
page = (params[:page] || 1).to_i
grouped_results =
Search::GroupedSearchResults.new(
@@ -22,47 +19,17 @@ module DiscourseAi
use_pg_headlines_for_excerpt: false,
)
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
if !semantic_search.cached_query?(query)
RateLimiter.new(current_user, "semantic-search", 4, 1.minutes).performed!
end
hijack do
semantic_search
.search_for_topics(query)
.each { |topic_post| grouped_results.add(topic_post) }
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
end
end
def quick_search
query = params[:q].to_s
if query.length < SiteSetting.min_search_term_length
raise Discourse::InvalidParameters.new(:q)
end
grouped_results =
Search::GroupedSearchResults.new(
type_filter: SEMANTIC_SEARCH_TYPE,
term: query,
search_context: guardian,
use_pg_headlines_for_excerpt: false,
model =
DiscourseAi::Embeddings::Model.instantiate(
SiteSetting.ai_embeddings_semantic_search_model,
)
semantic_search = DiscourseAi::Embeddings::SemanticSearch.new(guardian)
DiscourseAi::Embeddings::SemanticSearch
.new(guardian, model)
.search_for_topics(query, page)
.each { |topic_post| grouped_results.add(topic_post) }
if !semantic_search.cached_query?(query)
RateLimiter.new(current_user, "semantic-search", 60, 1.minutes).performed!
end
hijack do
semantic_search.quick_search(query).each { |topic_post| grouped_results.add(topic_post) }
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
end
render_serialized(grouped_results, GroupedSearchResultSerializer, result: grouped_results)
end
end
end
@@ -0,0 +1,48 @@
# frozen_string_literal: true
module DiscourseAi
module Summarization
class SummaryController < ::ApplicationController
requires_plugin ::DiscourseAi::PLUGIN_NAME
requires_login
VALID_SINCE_VALUES = [1, 3, 6, 12, 24, 72, 168]
VALID_TARGETS = %w[chat_channel topic]
def show
raise PluginDisabled unless SiteSetting.ai_summarization_enabled
target_type = params[:target_type]
raise Discourse::InvalidParameters.new(:target_type) if !VALID_TARGETS.include?(target_type)
since = nil
if target_type == "chat_channel"
since = params[:since].to_i
raise Discourse::InvalidParameters.new(:since) if !VALID_SINCE_VALUES.include?(since)
target = Chat::Channel.find_by(id: params[:target_id])
raise Discourse::NotFound.new(:chat_channel) if !target
raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(target)
else
target = Topic.find_by(id: params[:target_id])
raise Discourse::NotFound.new(:topic) if !target
raise Discourse::InvalidAccess if !guardian.can_see_topic?(target)
end
RateLimiter.new(
current_user,
"ai_summarization",
6,
SiteSetting.ai_summarization_rate_limit_minutes.minutes,
).performed!
hijack do
summary =
DiscourseAi::Summarization::SummaryGenerator.new(target, current_user).summarize!(since)
render json: { summary: summary }, status: 200
end
end
end
end
end
@@ -1,15 +0,0 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
module SharedAiConversationsHelper
# bump up version when assets change
# long term we may want to change this cause it is hard to remember
# to bump versions, but for now this does the job
VERSION = "1"
def share_asset_url(short_path)
::UrlHelper.local_cdn_url("/plugins/discourse-ai/ai-share/#{short_path}?#{VERSION}")
end
end
end
end
-26
View File
@@ -1,26 +0,0 @@
# frozen_string_literal: true
module ::Jobs
class CreateAiReply < ::Jobs::Base
sidekiq_options retry: false
def execute(args)
return unless bot_user = User.find_by(id: args[:bot_user_id])
return unless post = Post.includes(:topic).find_by(id: args[:post_id])
persona_id = args[:persona_id]
begin
persona = DiscourseAi::AiBot::Personas::Persona.find_by(user: post.user, id: persona_id)
raise DiscourseAi::AiBot::Bot::BOT_NOT_FOUND if persona.nil?
bot = DiscourseAi::AiBot::Bot.as(bot_user, persona: persona.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to(post)
rescue DiscourseAi::AiBot::Bot::BOT_NOT_FOUND
Rails.logger.warn(
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
)
end
end
end
end
-172
View File
@@ -1,172 +0,0 @@
# frozen_string_literal: true
module ::Jobs
class DigestRagUpload < ::Jobs::Base
CHUNK_SIZE = 1024
CHUNK_OVERLAP = 64
MAX_FRAGMENTS = 100_000
# TODO(roman): Add a way to automatically recover from errors, resulting in unindexed uploads.
def execute(args)
return if (upload = Upload.find_by(id: args[:upload_id])).nil?
return if (ai_persona = AiPersona.find_by(id: args[:ai_persona_id])).nil?
truncation = DiscourseAi::Embeddings::Strategies::Truncation.new
vector_rep =
DiscourseAi::Embeddings::VectorRepresentations::Base.current_representation(truncation)
tokenizer = vector_rep.tokenizer
chunk_tokens = ai_persona.rag_chunk_tokens
overlap_tokens = ai_persona.rag_chunk_overlap_tokens
fragment_ids = RagDocumentFragment.where(ai_persona: ai_persona, upload: upload).pluck(:id)
# Check if this is the first time we process this upload.
if fragment_ids.empty?
document = get_uploaded_file(upload)
return if document.nil?
RagDocumentFragment.publish_status(upload, { total: 0, indexed: 0, left: 0 })
fragment_ids = []
idx = 0
ActiveRecord::Base.transaction do
chunk_document(
file: document,
tokenizer: tokenizer,
chunk_tokens: chunk_tokens,
overlap_tokens: overlap_tokens,
) do |chunk, metadata|
fragment_ids << RagDocumentFragment.create!(
ai_persona: ai_persona,
fragment: chunk,
fragment_number: idx + 1,
upload: upload,
metadata: metadata,
).id
idx += 1
if idx > MAX_FRAGMENTS
Rails.logger.warn("Upload #{upload.id} has too many fragments, truncating.")
break
end
end
end
end
fragment_ids.each_slice(50) do |slice|
Jobs.enqueue(:generate_rag_embeddings, fragment_ids: slice)
end
end
private
def chunk_document(file:, tokenizer:, chunk_tokens:, overlap_tokens:)
buffer = +""
current_metadata = nil
done = false
overlap = ""
# generally this will be plenty
read_size = chunk_tokens * 10
while buffer.present? || !done
if buffer.length < read_size
read = file.read(read_size)
done = true if read.nil?
read = Encodings.to_utf8(read) if read
buffer << (read || "")
end
# at this point we unconditionally have 2x CHUNK_SIZE worth of data in the buffer
metadata_regex = /\[\[metadata (.*?)\]\]/m
before_metadata, new_metadata, after_metadata = buffer.split(metadata_regex)
to_chunk = nil
if before_metadata.present?
to_chunk = before_metadata
elsif after_metadata.present?
current_metadata = new_metadata
to_chunk = after_metadata
buffer = buffer.split(metadata_regex, 2).last
overlap = ""
else
current_metadata = new_metadata
buffer = buffer.split(metadata_regex, 2).last
overlap = ""
next
end
chunk, split_char = first_chunk(to_chunk, tokenizer: tokenizer, chunk_tokens: chunk_tokens)
buffer = buffer[chunk.length..-1]
processed_chunk = overlap + chunk
processed_chunk.strip!
processed_chunk.gsub!(/\n[\n]+/, "\n\n")
yield processed_chunk, current_metadata
current_chunk_tokens = tokenizer.encode(chunk)
overlap_token_ids = current_chunk_tokens[-overlap_tokens..-1] || current_chunk_tokens
overlap = ""
while overlap_token_ids.present?
begin
overlap = tokenizer.decode(overlap_token_ids) + split_char
break if overlap.encoding == Encoding::UTF_8
rescue StandardError
# it is possible that we truncated mid char
end
overlap_token_ids.shift
end
# remove first word it is probably truncated
overlap = overlap.split(" ", 2).last
end
end
def first_chunk(text, chunk_tokens:, tokenizer:, splitters: ["\n\n", "\n", ".", ""])
return text, " " if tokenizer.tokenize(text).length <= chunk_tokens
splitters = splitters.find_all { |s| text.include?(s) }.compact
buffer = +""
split_char = nil
splitters.each do |splitter|
split_char = splitter
text
.split(split_char)
.each do |part|
break if tokenizer.tokenize(buffer + split_char + part).length > chunk_tokens
buffer << split_char
buffer << part
end
break if buffer.length > 0
end
[buffer, split_char]
end
def get_uploaded_file(upload)
store = Discourse.store
@file ||=
if store.external?
# Upload#filesize could be approximate.
# add two extra Mbs to make sure that we'll be able to download the upload.
max_filesize = upload.filesize + 2.megabytes
store.download(upload, max_file_size_kb: max_filesize)
else
File.open(store.path_for(upload))
end
end
end
end
@@ -1,21 +0,0 @@
# frozen_string_literal: true
module Jobs
class GenerateChatThreadTitle < ::Jobs::Base
sidekiq_options queue: "low"
def execute(args)
return unless SiteSetting.ai_helper_automatic_chat_thread_title
return if (thread_id = args[:thread_id]).blank?
thread = ::Chat::Thread.find_by_id(thread_id)
return if thread.nil? || thread.title.present?
title = DiscourseAi::AiHelper::ChatThreadTitler.new(thread).suggested_title
return if title.blank?
# TODO use a proper API that will make the new title update live
thread.update!(title: title)
end
end
end

Some files were not shown because too many files have changed in this diff Show More