Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a3a50d86 |
@@ -0,0 +1,5 @@
|
||||
< 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
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("@discourse/lint-configs/eslint");
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Discourse Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
/gems
|
||||
/auto_generated
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("@discourse/lint-configs/prettier");
|
||||
@@ -0,0 +1,4 @@
|
||||
inherit_gem:
|
||||
rubocop-discourse: stree-compat.yml
|
||||
RSpec/NamedSubject:
|
||||
Enabled: false
|
||||
@@ -0,0 +1,2 @@
|
||||
--print-width=100
|
||||
--plugins=plugin/trailing_comma,plugin/disable_auto_ternary
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require("@discourse/lint-configs/template-lint");
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
source "https://rubygems.org"
|
||||
|
||||
group :development do
|
||||
gem "rubocop-discourse"
|
||||
gem "syntax_tree"
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
ast (2.4.2)
|
||||
json (2.6.3)
|
||||
language_server-protocol (3.17.0.3)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.4)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
prettier_print (1.2.1)
|
||||
racc (1.7.2)
|
||||
rainbow (3.1.1)
|
||||
regexp_parser (2.8.2)
|
||||
rexml (3.2.6)
|
||||
rubocop (1.57.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.2.2.4)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.28.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.30.0)
|
||||
parser (>= 3.2.1.0)
|
||||
rubocop-capybara (2.19.0)
|
||||
rubocop (~> 1.41)
|
||||
rubocop-discourse (3.4.0)
|
||||
rubocop (>= 1.1.0)
|
||||
rubocop-rspec (>= 2.0.0)
|
||||
rubocop-factory_bot (2.24.0)
|
||||
rubocop (~> 1.33)
|
||||
rubocop-rspec (2.25.0)
|
||||
rubocop (~> 1.40)
|
||||
rubocop-capybara (~> 2.17)
|
||||
rubocop-factory_bot (~> 2.22)
|
||||
ruby-progressbar (1.13.0)
|
||||
syntax_tree (6.2.0)
|
||||
prettier_print (>= 1.2.0)
|
||||
unicode-display_width (2.5.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
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.21
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 Civilized Discourse Construction Kit
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,3 +1,5 @@
|
||||
# Discourse AI is now shipped with Discourse core
|
||||
# **Discourse AI** Plugin
|
||||
|
||||
https://github.com/discourse/discourse/tree/main/plugins/discourse-ai
|
||||
**Plugin Summary**
|
||||
|
||||
For more information, please see: https://meta.discourse.org/t/discourse-ai/259214?u=falco
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiPersonasController < ::Admin::AdminController
|
||||
before_action :find_ai_persona, only: %i[show update destroy]
|
||||
|
||||
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
|
||||
commands =
|
||||
DiscourseAi::AiBot::Personas::Persona.all_available_commands.map do |command|
|
||||
{ id: command.to_s.split("::").last, name: command.name.humanize.titleize }
|
||||
end
|
||||
render json: { ai_personas: ai_personas, meta: { commands: commands } }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: LocalizedAiPersonaSerializer.new(@ai_persona)
|
||||
end
|
||||
|
||||
def create
|
||||
ai_persona = AiPersona.new(ai_persona_params)
|
||||
if ai_persona.save
|
||||
render json: { ai_persona: ai_persona }, status: :created
|
||||
else
|
||||
render_json_error ai_persona
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @ai_persona.update(ai_persona_params)
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def find_ai_persona
|
||||
@ai_persona = AiPersona.find(params[:id])
|
||||
end
|
||||
|
||||
def ai_persona_params
|
||||
params.require(:ai_persona).permit(
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system_prompt,
|
||||
:enabled,
|
||||
:priority,
|
||||
allowed_group_ids: [],
|
||||
commands: [],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class DashboardController < ::Admin::StaffController
|
||||
requires_plugin DiscourseAi::PLUGIN_NAME
|
||||
|
||||
def sentiment
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module AiBot
|
||||
class BotController < ::ApplicationController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
|
||||
def stop_streaming_response
|
||||
post = Post.find(params[:post_id])
|
||||
guardian.ensure_can_see!(post)
|
||||
|
||||
Discourse.redis.del("gpt_cancel:#{post.id}")
|
||||
|
||||
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
|
||||
@@ -0,0 +1,138 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module AiHelper
|
||||
class AssistantController < ::ApplicationController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
requires_login
|
||||
before_action :ensure_can_request_suggestions
|
||||
before_action :rate_limiter_performed!, except: %i[prompts]
|
||||
|
||||
def prompts
|
||||
render json:
|
||||
ActiveModel::ArraySerializer.new(
|
||||
DiscourseAi::AiHelper::Assistant.new.available_prompts,
|
||||
root: false,
|
||||
),
|
||||
status: 200
|
||||
end
|
||||
|
||||
def suggest
|
||||
input = get_text_param!
|
||||
|
||||
prompt = CompletionPrompt.find_by(id: params[:mode])
|
||||
|
||||
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled?
|
||||
if prompt.prompt_type == "custom_prompt" && params[:custom_prompt].blank?
|
||||
raise Discourse::InvalidParameters.new(:custom_prompt)
|
||||
end
|
||||
|
||||
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 => e
|
||||
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 => e
|
||||
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 = get_text_param!
|
||||
|
||||
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
|
||||
|
||||
render json:
|
||||
DiscourseAi::AiHelper::TopicHelper.new(current_user).explain(
|
||||
term_to_explain,
|
||||
post,
|
||||
),
|
||||
status: 200
|
||||
rescue DiscourseAi::Completions::Endpoints::Base::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
|
||||
user_group_ids = current_user.group_ids
|
||||
|
||||
allowed =
|
||||
SiteSetting.ai_helper_allowed_groups_map.any? do |group_id|
|
||||
user_group_ids.include?(group_id)
|
||||
end
|
||||
|
||||
raise Discourse::InvalidAccess if !allowed
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Embeddings
|
||||
class EmbeddingsController < ::ApplicationController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
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
|
||||
|
||||
grouped_results =
|
||||
Search::GroupedSearchResults.new(
|
||||
type_filter: SEMANTIC_SEARCH_TYPE,
|
||||
term: query,
|
||||
search_context: guardian,
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiApiAuditLog < ActiveRecord::Base
|
||||
module Provider
|
||||
OpenAI = 1
|
||||
Anthropic = 2
|
||||
HuggingFaceTextGeneration = 3
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ai_api_audit_logs
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# provider_id :integer not null
|
||||
# user_id :integer
|
||||
# topic_id :integer
|
||||
# post_id :integer
|
||||
# request_tokens :integer
|
||||
# response_tokens :integer
|
||||
# raw_request_payload :string
|
||||
# raw_response_payload :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
@@ -0,0 +1,179 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiPersona < ActiveRecord::Base
|
||||
# places a hard limit, so per site we cache a maximum of 500 classes
|
||||
MAX_PERSONAS_PER_SITE = 500
|
||||
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
|
||||
validates :description, presence: true, length: { maximum: 2000 }
|
||||
validates :system_prompt, presence: true, length: { maximum: 10_000_000 }
|
||||
validate :system_persona_unchangeable, on: :update, if: :system
|
||||
|
||||
before_destroy :ensure_not_system
|
||||
|
||||
class MultisiteHash
|
||||
def initialize(id)
|
||||
@hash = Hash.new { |h, k| h[k] = {} }
|
||||
@id = id
|
||||
|
||||
MessageBus.subscribe(channel_name) { |message| @hash[message.data] = {} }
|
||||
end
|
||||
|
||||
def channel_name
|
||||
"/multisite-hash-#{@id}"
|
||||
end
|
||||
|
||||
def current_db
|
||||
RailsMultisite::ConnectionManagement.current_db
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@hash.dig(current_db, key)
|
||||
end
|
||||
|
||||
def []=(key, val)
|
||||
@hash[current_db][key] = val
|
||||
end
|
||||
|
||||
def flush!
|
||||
@hash[current_db] = {}
|
||||
MessageBus.publish(channel_name, current_db)
|
||||
end
|
||||
end
|
||||
|
||||
def self.persona_cache
|
||||
@persona_cache ||= MultisiteHash.new("persona_cache")
|
||||
end
|
||||
|
||||
scope :ordered, -> { order("priority DESC, lower(name) ASC") }
|
||||
|
||||
def self.all_personas
|
||||
persona_cache[:value] ||= AiPersona
|
||||
.ordered
|
||||
.where(enabled: true)
|
||||
.all
|
||||
.limit(MAX_PERSONAS_PER_SITE)
|
||||
.map(&:class_instance)
|
||||
end
|
||||
|
||||
after_commit :bump_cache
|
||||
|
||||
def bump_cache
|
||||
self.class.persona_cache.flush!
|
||||
end
|
||||
|
||||
def class_instance
|
||||
allowed_group_ids = self.allowed_group_ids
|
||||
id = self.id
|
||||
system = self.system
|
||||
|
||||
persona_class = DiscourseAi::AiBot::Personas.system_personas_by_id[self.id]
|
||||
if persona_class
|
||||
persona_class.define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :id do
|
||||
id
|
||||
end
|
||||
|
||||
persona_class.define_singleton_method :system do
|
||||
system
|
||||
end
|
||||
|
||||
return persona_class
|
||||
end
|
||||
|
||||
name = self.name
|
||||
description = self.description
|
||||
ai_persona_id = self.id
|
||||
commands =
|
||||
self.commands.filter_map do |inner_name|
|
||||
begin
|
||||
("DiscourseAi::AiBot::Commands::#{inner_name}").constantize
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
Class.new(DiscourseAi::AiBot::Personas::Persona) do
|
||||
define_singleton_method :id do
|
||||
id
|
||||
end
|
||||
|
||||
define_singleton_method :name do
|
||||
name
|
||||
end
|
||||
|
||||
define_singleton_method :description do
|
||||
description
|
||||
end
|
||||
|
||||
define_singleton_method :system do
|
||||
system
|
||||
end
|
||||
|
||||
define_singleton_method :allowed_group_ids do
|
||||
allowed_group_ids
|
||||
end
|
||||
|
||||
define_singleton_method :to_s do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_singleton_method :inspect do
|
||||
"#<DiscourseAi::AiBot::Personas::Persona::Custom @name=#{self.name} @allowed_group_ids=#{self.allowed_group_ids.join(",")}>"
|
||||
end
|
||||
|
||||
define_method :initialize do |*args, **kwargs|
|
||||
@ai_persona = AiPersona.find_by(id: ai_persona_id)
|
||||
super(*args, **kwargs)
|
||||
end
|
||||
|
||||
define_method :commands do
|
||||
commands
|
||||
end
|
||||
|
||||
define_method :system_prompt do
|
||||
@ai_persona&.system_prompt || "You are a helpful bot."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_persona_unchangeable
|
||||
if system_prompt_changed? || commands_changed? || name_changed? || description_changed?
|
||||
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_edit_system_persona"))
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_not_system
|
||||
if system
|
||||
errors.add(:base, I18n.t("discourse_ai.ai_bot.personas.cannot_delete_system_persona"))
|
||||
throw :abort
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ai_personas
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# description :string(2000) not null
|
||||
# commands :string default([]), not null, is an Array
|
||||
# system_prompt :string(10000000) not null
|
||||
# allowed_group_ids :integer default([]), not null, is an Array
|
||||
# created_by_id :integer
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# system :boolean default(FALSE), not null
|
||||
# priority :integer default(0), not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_ai_personas_on_name (name) UNIQUE
|
||||
#
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ClassificationResult < ActiveRecord::Base
|
||||
belongs_to :target, polymorphic: true
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: classification_results
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# model_used :string
|
||||
# classification_type :string
|
||||
# target_id :integer
|
||||
# target_type :string
|
||||
# classification :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# unique_classification_target_per_type (target_id,target_type,model_used) UNIQUE
|
||||
#
|
||||
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CompletionPrompt < ActiveRecord::Base
|
||||
# TODO(roman): Remove may 2024.
|
||||
self.ignored_columns = ["provider"]
|
||||
|
||||
TRANSLATE = -301
|
||||
GENERATE_TITLES = -302
|
||||
PROOFREAD = -303
|
||||
MARKDOWN_TABLE = -304
|
||||
CUSTOM_PROMPT = -305
|
||||
EXPLAIN = -306
|
||||
|
||||
enum :prompt_type, { text: 0, list: 1, diff: 2 }
|
||||
|
||||
validates :messages, length: { maximum: 20 }
|
||||
validate :each_message_length
|
||||
|
||||
def self.enabled_by_name(name)
|
||||
where(enabled: true).find_by(name: name)
|
||||
end
|
||||
|
||||
def messages_with_input(input)
|
||||
return unless input
|
||||
|
||||
messages_hash.merge(input: "<input>#{input}</input")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def messages_hash
|
||||
@messages_hash ||= messages.symbolize_keys!
|
||||
end
|
||||
|
||||
def each_message_length
|
||||
messages.each_with_index do |msg, idx|
|
||||
next if msg["content"].length <= 1000
|
||||
|
||||
errors.add(:messages, I18n.t("errors.prompt_message_length", idx: idx + 1))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: completion_prompts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string not null
|
||||
# translated_name :string
|
||||
# prompt_type :integer default("text"), not null
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# messages :jsonb
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_completion_prompts_on_name (name)
|
||||
#
|
||||
@@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ModelAccuracy < ActiveRecord::Base
|
||||
def self.adjust_model_accuracy(new_status, reviewable)
|
||||
return unless %i[approved rejected].include?(new_status)
|
||||
return unless [ReviewableAiPost, ReviewableAiChatMessage].include?(reviewable.class)
|
||||
|
||||
verdicts = reviewable.payload.to_h["verdicts"] || {}
|
||||
|
||||
verdicts.each do |model_name, verdict|
|
||||
accuracy_model = find_by(model: model_name)
|
||||
|
||||
attribute =
|
||||
if verdict
|
||||
new_status == :approved ? :flags_agreed : :flags_disagreed
|
||||
else
|
||||
new_status == :rejected ? :flags_agreed : :flags_disagreed
|
||||
end
|
||||
|
||||
accuracy_model.increment!(attribute)
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_accuracy
|
||||
return 0 if total_flags.zero?
|
||||
|
||||
(flags_agreed * 100) / total_flags
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def total_flags
|
||||
flags_agreed + flags_disagreed
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: model_accuracies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# model :string not null
|
||||
# classification_type :string not null
|
||||
# flags_agreed :integer default(0), not null
|
||||
# flags_disagreed :integer default(0), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_model_accuracies_on_model (model) UNIQUE
|
||||
#
|
||||
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PostCustomPrompt < ActiveRecord::Base
|
||||
belongs_to :post
|
||||
end
|
||||
|
||||
class ::Post
|
||||
has_one :post_custom_prompt, dependent: :destroy
|
||||
end
|
||||
@@ -0,0 +1,186 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_dependency "reviewable"
|
||||
|
||||
class ReviewableAiChatMessage < Reviewable
|
||||
def self.action_aliases
|
||||
{
|
||||
agree_and_keep_hidden: :agree_and_delete,
|
||||
agree_and_silence: :agree_and_delete,
|
||||
agree_and_suspend: :agree_and_delete,
|
||||
delete_and_agree: :agree_and_delete,
|
||||
}
|
||||
end
|
||||
|
||||
def self.score_to_silence_user
|
||||
sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6)
|
||||
end
|
||||
|
||||
def chat_message
|
||||
@chat_message ||= (target || Chat::Message.with_deleted.find_by(id: target_id))
|
||||
end
|
||||
|
||||
def chat_message_creator
|
||||
@chat_message_creator ||= chat_message.user
|
||||
end
|
||||
|
||||
def flagged_by_user_ids
|
||||
@flagged_by_user_ids ||= reviewable_scores.map(&:user_id)
|
||||
end
|
||||
|
||||
def post
|
||||
nil
|
||||
end
|
||||
|
||||
def build_actions(actions, guardian, args)
|
||||
return unless pending?
|
||||
return if chat_message.blank?
|
||||
|
||||
agree =
|
||||
actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title")
|
||||
|
||||
if chat_message.deleted_at?
|
||||
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree)
|
||||
build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree)
|
||||
build_action(actions, :disagree_and_restore, icon: "thumbs-down")
|
||||
else
|
||||
build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree)
|
||||
build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree)
|
||||
build_action(actions, :disagree, icon: "thumbs-down")
|
||||
end
|
||||
|
||||
if guardian.can_suspend?(chat_message_creator)
|
||||
build_action(
|
||||
actions,
|
||||
:agree_and_suspend,
|
||||
icon: "ban",
|
||||
bundle: agree,
|
||||
client_action: "suspend",
|
||||
)
|
||||
build_action(
|
||||
actions,
|
||||
:agree_and_silence,
|
||||
icon: "microphone-slash",
|
||||
bundle: agree,
|
||||
client_action: "silence",
|
||||
)
|
||||
end
|
||||
|
||||
build_action(actions, :ignore, icon: "external-link-alt")
|
||||
|
||||
build_action(actions, :delete_and_agree, icon: "far-trash-alt") unless chat_message.deleted_at?
|
||||
end
|
||||
|
||||
def perform_agree_and_keep_message(performed_by, args)
|
||||
agree
|
||||
end
|
||||
|
||||
def perform_agree_and_restore(performed_by, args)
|
||||
agree { chat_message.recover! }
|
||||
end
|
||||
|
||||
def perform_agree_and_delete(performed_by, args)
|
||||
agree { chat_message.trash!(performed_by) }
|
||||
end
|
||||
|
||||
def perform_disagree_and_restore(performed_by, args)
|
||||
disagree { chat_message.recover! }
|
||||
end
|
||||
|
||||
def perform_disagree(performed_by, args)
|
||||
disagree
|
||||
end
|
||||
|
||||
def perform_ignore(performed_by, args)
|
||||
ignore
|
||||
end
|
||||
|
||||
def perform_delete_and_ignore(performed_by, args)
|
||||
ignore { chat_message.trash!(performed_by) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agree
|
||||
yield if block_given?
|
||||
create_result(:success, :approved) do |result|
|
||||
result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids }
|
||||
result.recalculate_score = true
|
||||
end
|
||||
end
|
||||
|
||||
def disagree
|
||||
yield if block_given?
|
||||
|
||||
UserSilencer.unsilence(chat_message_creator)
|
||||
|
||||
create_result(:success, :rejected) do |result|
|
||||
result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids }
|
||||
result.recalculate_score = true
|
||||
end
|
||||
end
|
||||
|
||||
def ignore
|
||||
yield if block_given?
|
||||
create_result(:success, :ignored) do |result|
|
||||
result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids }
|
||||
end
|
||||
end
|
||||
|
||||
def build_action(
|
||||
actions,
|
||||
id,
|
||||
icon:,
|
||||
button_class: nil,
|
||||
bundle: nil,
|
||||
client_action: nil,
|
||||
confirm: false
|
||||
)
|
||||
actions.add(id, bundle: bundle) do |action|
|
||||
prefix = "reviewables.actions.#{id}"
|
||||
action.icon = icon
|
||||
action.button_class = button_class
|
||||
action.label = "chat.#{prefix}.title"
|
||||
action.description = "chat.#{prefix}.description"
|
||||
action.client_action = client_action
|
||||
action.confirm_message = "#{prefix}.confirm" if confirm
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: reviewables
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# type :string not null
|
||||
# status :integer default("pending"), not null
|
||||
# created_by_id :integer not null
|
||||
# reviewable_by_moderator :boolean default(FALSE), not null
|
||||
# reviewable_by_group_id :integer
|
||||
# category_id :integer
|
||||
# topic_id :integer
|
||||
# score :float default(0.0), not null
|
||||
# potential_spam :boolean default(FALSE), not null
|
||||
# target_id :integer
|
||||
# target_type :string
|
||||
# target_created_by_id :integer
|
||||
# payload :json
|
||||
# version :integer default(0), not null
|
||||
# latest_score :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# force_review :boolean default(FALSE), not null
|
||||
# reject_reason :text
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_reviewables_score_desc_created_at_desc (score,created_at)
|
||||
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
|
||||
# index_reviewables_on_status_and_created_at (status,created_at)
|
||||
# index_reviewables_on_status_and_score (status,score)
|
||||
# index_reviewables_on_status_and_type (status,type)
|
||||
# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text)
|
||||
# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id)
|
||||
# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
|
||||
#
|
||||
@@ -0,0 +1,244 @@
|
||||
# frozen_string_literal:true
|
||||
|
||||
require_dependency "reviewable"
|
||||
|
||||
class ReviewableAiPost < Reviewable
|
||||
# Penalties are handled by the modal after the action is performed
|
||||
def self.action_aliases
|
||||
{
|
||||
agree_and_keep_hidden: :agree_and_keep,
|
||||
agree_and_silence: :agree_and_keep,
|
||||
agree_and_suspend: :agree_and_keep,
|
||||
disagree_and_restore: :disagree,
|
||||
}
|
||||
end
|
||||
|
||||
def build_actions(actions, guardian, args)
|
||||
return actions if !pending? || post.blank?
|
||||
|
||||
agree =
|
||||
actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title")
|
||||
|
||||
if !post.user_deleted? && !post.hidden?
|
||||
build_action(actions, :agree_and_hide, icon: "far-eye-slash", bundle: agree)
|
||||
end
|
||||
|
||||
if post.hidden?
|
||||
build_action(actions, :agree_and_keep_hidden, icon: "thumbs-up", bundle: agree)
|
||||
else
|
||||
build_action(actions, :agree_and_keep, icon: "thumbs-up", bundle: agree)
|
||||
end
|
||||
|
||||
if guardian.can_suspend?(target_created_by)
|
||||
build_action(
|
||||
actions,
|
||||
:agree_and_suspend,
|
||||
icon: "ban",
|
||||
bundle: agree,
|
||||
client_action: "suspend",
|
||||
)
|
||||
build_action(
|
||||
actions,
|
||||
:agree_and_silence,
|
||||
icon: "microphone-slash",
|
||||
bundle: agree,
|
||||
client_action: "silence",
|
||||
)
|
||||
end
|
||||
|
||||
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) if post.user_deleted?
|
||||
|
||||
if post.hidden?
|
||||
build_action(actions, :disagree_and_restore, icon: "thumbs-down")
|
||||
else
|
||||
build_action(actions, :disagree, icon: "thumbs-down")
|
||||
end
|
||||
|
||||
if guardian.can_delete_post_or_topic?(post)
|
||||
delete =
|
||||
actions.add_bundle(
|
||||
"#{id}-delete",
|
||||
icon: "far-trash-alt",
|
||||
label: "reviewables.actions.delete.title",
|
||||
)
|
||||
build_action(actions, :delete_and_ignore, icon: "external-link-alt", bundle: delete)
|
||||
if post.reply_count > 0
|
||||
build_action(
|
||||
actions,
|
||||
:delete_and_ignore_replies,
|
||||
icon: "external-link-alt",
|
||||
confirm: true,
|
||||
bundle: delete,
|
||||
)
|
||||
end
|
||||
build_action(actions, :delete_and_agree, icon: "thumbs-up", bundle: delete)
|
||||
if post.reply_count > 0
|
||||
build_action(
|
||||
actions,
|
||||
:delete_and_agree_replies,
|
||||
icon: "external-link-alt",
|
||||
bundle: delete,
|
||||
confirm: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
delete_user_actions(actions) if guardian.can_delete_user?(target_created_by)
|
||||
|
||||
build_action(actions, :ignore, icon: "external-link-alt")
|
||||
end
|
||||
|
||||
def perform_agree_and_hide(performed_by, args)
|
||||
post.hide!(reviewable_scores.first.reviewable_score_type)
|
||||
|
||||
agree
|
||||
end
|
||||
|
||||
def perform_agree_and_keep(_performed_by, _args)
|
||||
agree
|
||||
end
|
||||
|
||||
def perform_agree_and_restore(performed_by, args)
|
||||
destroyer(performed_by).recover
|
||||
agree
|
||||
end
|
||||
|
||||
def perform_disagree(performed_by, args)
|
||||
# Undo hide/silence if applicable
|
||||
post.unhide! if post.hidden?
|
||||
|
||||
create_result(:success, :rejected) do |result|
|
||||
result.update_flag_stats = { status: :disagreed, user_ids: [created_by_id] }
|
||||
end
|
||||
end
|
||||
|
||||
def perform_ignore(performed_by, args)
|
||||
create_result(:success, :ignored) do |result|
|
||||
result.update_flag_stats = { status: :ignored, user_ids: [created_by_id] }
|
||||
end
|
||||
end
|
||||
|
||||
def perform_delete_and_ignore(performed_by, args)
|
||||
destroyer(performed_by).destroy
|
||||
|
||||
perform_ignore(performed_by, args)
|
||||
end
|
||||
|
||||
def perform_delete_and_agree(performed_by, args)
|
||||
destroyer(performed_by).destroy
|
||||
|
||||
agree
|
||||
end
|
||||
|
||||
def perform_delete_and_ignore_replies(performed_by, args)
|
||||
PostDestroyer.delete_with_replies(performed_by, post, self)
|
||||
|
||||
perform_ignore(performed_by, args)
|
||||
end
|
||||
|
||||
def perform_delete_and_agree_replies(performed_by, args)
|
||||
PostDestroyer.delete_with_replies(performed_by, post, self)
|
||||
|
||||
agree
|
||||
end
|
||||
|
||||
def perform_delete_user(performed_by, args)
|
||||
UserDestroyer.new(performed_by).destroy(post.user, delete_opts)
|
||||
|
||||
agree
|
||||
end
|
||||
|
||||
def perform_delete_user_block(performed_by, args)
|
||||
delete_options = delete_opts
|
||||
|
||||
delete_options.merge!(block_email: true, block_ip: true) if Rails.env.production?
|
||||
|
||||
UserDestroyer.new(performed_by).destroy(post.user, delete_options)
|
||||
|
||||
agree
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def post
|
||||
@post ||= (target || Post.with_deleted.find_by(id: target_id))
|
||||
end
|
||||
|
||||
def destroyer(performed_by)
|
||||
PostDestroyer.new(performed_by, post, reviewable: self)
|
||||
end
|
||||
|
||||
def agree
|
||||
create_result(:success, :approved) do |result|
|
||||
result.update_flag_stats = { status: :agreed, user_ids: [created_by_id] }
|
||||
result.recalculate_score = true
|
||||
end
|
||||
end
|
||||
|
||||
def delete_opts
|
||||
{
|
||||
delete_posts: true,
|
||||
prepare_for_destroy: true,
|
||||
block_urls: true,
|
||||
delete_as_spammer: true,
|
||||
context: "review",
|
||||
}
|
||||
end
|
||||
|
||||
def build_action(
|
||||
actions,
|
||||
id,
|
||||
icon:,
|
||||
button_class: nil,
|
||||
bundle: nil,
|
||||
client_action: nil,
|
||||
confirm: false
|
||||
)
|
||||
actions.add(id, bundle: bundle) do |action|
|
||||
prefix = "reviewables.actions.#{id}"
|
||||
action.icon = icon
|
||||
action.button_class = button_class
|
||||
action.label = "#{prefix}.title"
|
||||
action.description = "#{prefix}.description"
|
||||
action.client_action = client_action
|
||||
action.confirm_message = "#{prefix}.confirm" if confirm
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: reviewables
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# type :string not null
|
||||
# status :integer default("pending"), not null
|
||||
# created_by_id :integer not null
|
||||
# reviewable_by_moderator :boolean default(FALSE), not null
|
||||
# reviewable_by_group_id :integer
|
||||
# category_id :integer
|
||||
# topic_id :integer
|
||||
# score :float default(0.0), not null
|
||||
# potential_spam :boolean default(FALSE), not null
|
||||
# target_id :integer
|
||||
# target_type :string
|
||||
# target_created_by_id :integer
|
||||
# payload :json
|
||||
# version :integer default(0), not null
|
||||
# latest_score :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# force_review :boolean default(FALSE), not null
|
||||
# reject_reason :text
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_reviewables_score_desc_created_at_desc (score,created_at)
|
||||
# index_reviewables_on_reviewable_by_group_id (reviewable_by_group_id)
|
||||
# index_reviewables_on_status_and_created_at (status,created_at)
|
||||
# index_reviewables_on_status_and_score (status,score)
|
||||
# index_reviewables_on_status_and_type (status,type)
|
||||
# index_reviewables_on_target_id_where_post_type_eq_post (target_id) WHERE ((target_type)::text = 'Post'::text)
|
||||
# index_reviewables_on_topic_id_and_status_and_created_by_id (topic_id,status,created_by_id)
|
||||
# index_reviewables_on_type_and_target_id (type,target_id) UNIQUE
|
||||
#
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AiChatChannelSerializer < ApplicationSerializer
|
||||
attributes :id, :chatable, :chatable_type, :chatable_url, :slug
|
||||
|
||||
def title
|
||||
# Display all participants for a DM.
|
||||
# For category channels, the argument is ignored.
|
||||
object.title(nil)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LocalizedAiPersonaSerializer < ApplicationSerializer
|
||||
root "ai_persona"
|
||||
|
||||
attributes :id,
|
||||
:name,
|
||||
:description,
|
||||
:enabled,
|
||||
:system,
|
||||
:priority,
|
||||
:commands,
|
||||
:system_prompt,
|
||||
:allowed_group_ids
|
||||
|
||||
def name
|
||||
object.class_instance.name
|
||||
end
|
||||
|
||||
def description
|
||||
object.class_instance.description
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_dependency "reviewable_serializer"
|
||||
|
||||
class ReviewableAiChatMessageSerializer < ReviewableSerializer
|
||||
payload_attributes :accuracies, :message_cooked
|
||||
target_attributes :cooked
|
||||
attributes :target_id
|
||||
|
||||
has_one :chat_channel, serializer: AiChatChannelSerializer, root: false, embed: :objects
|
||||
|
||||
def chat_channel
|
||||
object.chat_message.chat_channel
|
||||
end
|
||||
|
||||
def target_id
|
||||
object.target&.id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_dependency "reviewable_flagged_post_serializer"
|
||||
|
||||
class ReviewableAiPostSerializer < ReviewableFlaggedPostSerializer
|
||||
payload_attributes :accuracies
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user