Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655c7e9708 | ||
|
|
4fb686a548 | ||
|
|
5026ab52d0 | ||
|
|
823e8ef490 | ||
|
|
f75b13c4fa |
+45
-30
@@ -18,19 +18,20 @@ export default class SemanticSearch extends Component {
|
||||
return siteSettings.ai_embeddings_semantic_search_enabled;
|
||||
}
|
||||
|
||||
@service router;
|
||||
@service appEvents;
|
||||
@service siteSettings;
|
||||
@service searchPreferencesManager;
|
||||
|
||||
@tracked searching = false;
|
||||
@tracked AIResults = [];
|
||||
@tracked showingAIResults = false;
|
||||
@tracked AiResults = [];
|
||||
@tracked showingAiResults = false;
|
||||
initialSearchTerm = this.args.outletArgs.search;
|
||||
|
||||
get disableToggleSwitch() {
|
||||
if (
|
||||
this.searching ||
|
||||
this.AIResults.length === 0 ||
|
||||
this.AiResults.length === 0 ||
|
||||
this.args.outletArgs.sortOrder !== 0
|
||||
) {
|
||||
return true;
|
||||
@@ -39,19 +40,19 @@ export default class SemanticSearch extends Component {
|
||||
|
||||
get searchStateText() {
|
||||
// Search results:
|
||||
if (this.AIResults.length > 0) {
|
||||
if (this.showingAIResults) {
|
||||
if (this.AiResults.length > 0) {
|
||||
if (this.showingAiResults) {
|
||||
return I18n.t(
|
||||
"discourse_ai.embeddings.semantic_search_results.toggle",
|
||||
{
|
||||
count: this.AIResults.length,
|
||||
count: this.AiResults.length,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return I18n.t(
|
||||
"discourse_ai.embeddings.semantic_search_results.toggle_hidden",
|
||||
{
|
||||
count: this.AIResults.length,
|
||||
count: this.AiResults.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -64,14 +65,14 @@ export default class SemanticSearch extends Component {
|
||||
|
||||
// Typing to search:
|
||||
if (
|
||||
this.AIResults.length === 0 &&
|
||||
this.AiResults.length === 0 &&
|
||||
this.searchTerm !== this.initialSearchTerm
|
||||
) {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_results.new");
|
||||
}
|
||||
|
||||
// No results:
|
||||
if (this.AIResults.length === 0) {
|
||||
if (this.AiResults.length === 0) {
|
||||
return I18n.t("discourse_ai.embeddings.semantic_search_results.none");
|
||||
}
|
||||
}
|
||||
@@ -93,19 +94,19 @@ export default class SemanticSearch extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
toggleAIResults() {
|
||||
if (this.showingAIResults) {
|
||||
toggleAiResults() {
|
||||
if (this.showingAiResults) {
|
||||
this.args.outletArgs.addSearchResults([], "topic_id");
|
||||
} else {
|
||||
this.args.outletArgs.addSearchResults(this.AIResults, "topic_id");
|
||||
this.args.outletArgs.addSearchResults(this.AiResults, "topic_id");
|
||||
}
|
||||
this.showingAIResults = !this.showingAIResults;
|
||||
this.showingAiResults = !this.showingAiResults;
|
||||
}
|
||||
|
||||
@action
|
||||
resetAIResults() {
|
||||
this.AIResults = [];
|
||||
this.showingAIResults = false;
|
||||
resetAiResults() {
|
||||
this.AiResults = [];
|
||||
this.showingAiResults = false;
|
||||
this.args.outletArgs.addSearchResults([], "topic_id");
|
||||
}
|
||||
|
||||
@@ -119,19 +120,12 @@ export default class SemanticSearch extends Component {
|
||||
return this.performHyDESearch();
|
||||
}
|
||||
|
||||
withPluginApi("1.15.0", (api) => {
|
||||
api.onAppEvent("full-page-search:trigger-search", () => {
|
||||
if (!this.searching) {
|
||||
this.resetAIResults();
|
||||
return this.performHyDESearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
this.#resetAndSearchOnEvent();
|
||||
}
|
||||
|
||||
performHyDESearch() {
|
||||
this.searching = true;
|
||||
this.resetAIResults();
|
||||
this.resetAiResults();
|
||||
|
||||
ajax("/discourse-ai/embeddings/semantic-search", {
|
||||
data: { q: this.searchTerm },
|
||||
@@ -145,15 +139,37 @@ export default class SemanticSearch extends Component {
|
||||
}
|
||||
|
||||
model.posts.forEach((post) => {
|
||||
post.generatedByAI = true;
|
||||
post.generatedByAi = true;
|
||||
});
|
||||
|
||||
this.AIResults = model.posts;
|
||||
this.AiResults = model.posts;
|
||||
})
|
||||
.finally(() => (this.searching = false));
|
||||
}
|
||||
|
||||
#resetAndSearchOnEvent() {
|
||||
return withPluginApi("1.15.0", (api) => {
|
||||
api.onAppEvent("full-page-search:trigger-search", () => {
|
||||
if (!this.searching) {
|
||||
this.resetAiResults();
|
||||
return this.performHyDESearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
checkQueryParamsAndSearch() {
|
||||
// This check is necessary because handleSearch() isn't called
|
||||
// if query params are present and a new search has appended text.
|
||||
// It ensures AiResults are reset and searched for properly
|
||||
const searchQueryParam = this.router.currentRoute?.queryParams?.q;
|
||||
if (searchQueryParam) {
|
||||
this.#resetAndSearchOnEvent();
|
||||
}
|
||||
}
|
||||
<template>
|
||||
<span {{didInsert this.checkQueryParamsAndSearch}}></span>
|
||||
{{#if this.searchEnabled}}
|
||||
<div class="semantic-search__container search-results" role="region">
|
||||
<div class="semantic-search__results" {{didInsert this.handleSearch}}>
|
||||
@@ -163,10 +179,9 @@ export default class SemanticSearch extends Component {
|
||||
>
|
||||
<DToggleSwitch
|
||||
disabled={{this.disableToggleSwitch}}
|
||||
@state={{this.showingAIResults}}
|
||||
title="AI search results hidden"
|
||||
@state={{this.showingAiResults}}
|
||||
class="semantic-search__results-toggle"
|
||||
{{on "click" this.toggleAIResults}}
|
||||
{{on "click" this.toggleAiResults}}
|
||||
/>
|
||||
|
||||
<div class="semantic-search__searching-text">
|
||||
|
||||
@@ -4,6 +4,6 @@ export default apiInitializer("1.15.0", (api) => {
|
||||
api.modifyClass("component:search-result-entry", {
|
||||
pluginId: "discourse-ai",
|
||||
|
||||
classNameBindings: ["bulkSelectEnabled", "post.generatedByAI:ai-result"],
|
||||
classNameBindings: ["bulkSelectEnabled", "post.generatedByAi:ai-result"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ en:
|
||||
saved: "Persona saved"
|
||||
enabled: "Enabled?"
|
||||
tools: "Enabled tools"
|
||||
forced_tools: "Forced fools"
|
||||
forced_tools: "Forced tools"
|
||||
allowed_groups: "Allowed groups"
|
||||
confirm_delete: "Are you sure you want to delete this persona?"
|
||||
new: "New persona"
|
||||
|
||||
+11
-8
@@ -185,20 +185,23 @@ module DiscourseAi
|
||||
end
|
||||
|
||||
def invoke_tool(tool, llm, cancel, context, &update_blk)
|
||||
update_blk.call("", cancel, build_placeholder(tool.summary, ""))
|
||||
show_placeholder = !context[:skip_tool_details]
|
||||
|
||||
update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder
|
||||
|
||||
result =
|
||||
tool.invoke do |progress|
|
||||
placeholder = build_placeholder(tool.summary, progress)
|
||||
update_blk.call("", cancel, placeholder)
|
||||
if show_placeholder
|
||||
placeholder = build_placeholder(tool.summary, progress)
|
||||
update_blk.call("", cancel, placeholder)
|
||||
end
|
||||
end
|
||||
|
||||
tool_details = build_placeholder(tool.summary, tool.details, custom_raw: tool.custom_raw)
|
||||
|
||||
if context[:skip_tool_details] && tool.custom_raw.present?
|
||||
update_blk.call(tool.custom_raw, cancel, nil, :custom_raw)
|
||||
elsif !context[:skip_tool_details]
|
||||
if show_placeholder
|
||||
tool_details = build_placeholder(tool.summary, tool.details, custom_raw: tool.custom_raw)
|
||||
update_blk.call(tool_details, cancel, nil, :tool_details)
|
||||
elsif tool.custom_raw.present?
|
||||
update_blk.call(tool.custom_raw, cancel, nil, :custom_raw)
|
||||
end
|
||||
|
||||
result
|
||||
|
||||
@@ -452,7 +452,7 @@ module DiscourseAi
|
||||
bot.reply(context) do |partial, cancel, placeholder, type|
|
||||
reply << partial
|
||||
raw = reply.dup
|
||||
raw << "\n\n" << placeholder if placeholder.present? && !context[:skip_tool_details]
|
||||
raw << "\n\n" << placeholder if placeholder.present?
|
||||
|
||||
blk.call(partial) if blk && type != :tool_details
|
||||
|
||||
|
||||
@@ -4,28 +4,50 @@ class DiscourseAi::Completions::AnthropicMessageProcessor
|
||||
class AnthropicToolCall
|
||||
attr_reader :name, :raw_json, :id
|
||||
|
||||
def initialize(name, id)
|
||||
def initialize(name, id, partial_tool_calls: false)
|
||||
@name = name
|
||||
@id = id
|
||||
@raw_json = +""
|
||||
@tool_call = DiscourseAi::Completions::ToolCall.new(id: id, name: name, parameters: {})
|
||||
@streaming_parser =
|
||||
DiscourseAi::Completions::ToolCallProgressTracker.new(self) if partial_tool_calls
|
||||
end
|
||||
|
||||
def append(json)
|
||||
@raw_json << json
|
||||
@streaming_parser << json if @streaming_parser
|
||||
end
|
||||
|
||||
def notify_progress(key, value)
|
||||
@tool_call.partial = true
|
||||
@tool_call.parameters[key.to_sym] = value
|
||||
@has_new_data = true
|
||||
end
|
||||
|
||||
def has_partial?
|
||||
@has_new_data
|
||||
end
|
||||
|
||||
def partial_tool_call
|
||||
@has_new_data = false
|
||||
@tool_call
|
||||
end
|
||||
|
||||
def to_tool_call
|
||||
parameters = JSON.parse(raw_json, symbolize_names: true)
|
||||
DiscourseAi::Completions::ToolCall.new(id: id, name: name, parameters: parameters)
|
||||
@tool_call.partial = false
|
||||
@tool_call.parameters = parameters
|
||||
@tool_call
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :tool_calls, :input_tokens, :output_tokens
|
||||
|
||||
def initialize(streaming_mode:)
|
||||
def initialize(streaming_mode:, partial_tool_calls: false)
|
||||
@streaming_mode = streaming_mode
|
||||
@tool_calls = []
|
||||
@current_tool_call = nil
|
||||
@partial_tool_calls = partial_tool_calls
|
||||
end
|
||||
|
||||
def to_tool_calls
|
||||
@@ -38,11 +60,17 @@ class DiscourseAi::Completions::AnthropicMessageProcessor
|
||||
tool_name = parsed.dig(:content_block, :name)
|
||||
tool_id = parsed.dig(:content_block, :id)
|
||||
result = @current_tool_call.to_tool_call if @current_tool_call
|
||||
@current_tool_call = AnthropicToolCall.new(tool_name, tool_id) if tool_name
|
||||
@current_tool_call =
|
||||
AnthropicToolCall.new(
|
||||
tool_name,
|
||||
tool_id,
|
||||
partial_tool_calls: @partial_tool_calls,
|
||||
) if tool_name
|
||||
elsif parsed[:type] == "content_block_start" || parsed[:type] == "content_block_delta"
|
||||
if @current_tool_call
|
||||
tool_delta = parsed.dig(:delta, :partial_json).to_s
|
||||
@current_tool_call.append(tool_delta)
|
||||
result = @current_tool_call.partial_tool_call if @current_tool_call.has_partial?
|
||||
else
|
||||
result = parsed.dig(:delta, :text).to_s
|
||||
end
|
||||
|
||||
@@ -107,7 +107,10 @@ module DiscourseAi
|
||||
|
||||
def processor
|
||||
@processor ||=
|
||||
DiscourseAi::Completions::AnthropicMessageProcessor.new(streaming_mode: @streaming_mode)
|
||||
DiscourseAi::Completions::AnthropicMessageProcessor.new(
|
||||
streaming_mode: @streaming_mode,
|
||||
partial_tool_calls: partial_tool_calls,
|
||||
)
|
||||
end
|
||||
|
||||
def has_tool?(_response_data)
|
||||
|
||||
@@ -4,6 +4,8 @@ module DiscourseAi
|
||||
module Completions
|
||||
module Endpoints
|
||||
class Base
|
||||
attr_reader :partial_tool_calls
|
||||
|
||||
CompletionFailed = Class.new(StandardError)
|
||||
TIMEOUT = 60
|
||||
|
||||
@@ -58,8 +60,10 @@ module DiscourseAi
|
||||
model_params = {},
|
||||
feature_name: nil,
|
||||
feature_context: nil,
|
||||
partial_tool_calls: false,
|
||||
&blk
|
||||
)
|
||||
@partial_tool_calls = partial_tool_calls
|
||||
model_params = normalize_model_params(model_params)
|
||||
orig_blk = blk
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ module DiscourseAi
|
||||
_user,
|
||||
_model_params,
|
||||
feature_name: nil,
|
||||
feature_context: nil
|
||||
feature_context: nil,
|
||||
partial_tool_calls: false
|
||||
)
|
||||
@dialect = dialect
|
||||
response = responses[completions]
|
||||
|
||||
@@ -120,7 +120,8 @@ module DiscourseAi
|
||||
user,
|
||||
model_params = {},
|
||||
feature_name: nil,
|
||||
feature_context: nil
|
||||
feature_context: nil,
|
||||
partial_tool_calls: false
|
||||
)
|
||||
last_call = { dialect: dialect, user: user, model_params: model_params }
|
||||
self.class.last_call = last_call
|
||||
|
||||
@@ -33,6 +33,7 @@ module DiscourseAi
|
||||
model_params = {},
|
||||
feature_name: nil,
|
||||
feature_context: nil,
|
||||
partial_tool_calls: false,
|
||||
&blk
|
||||
)
|
||||
if dialect.respond_to?(:is_gpt_o?) && dialect.is_gpt_o? && block_given?
|
||||
@@ -103,10 +104,16 @@ module DiscourseAi
|
||||
|
||||
def decode_chunk(chunk)
|
||||
@decoder ||= JsonStreamDecoder.new
|
||||
(@decoder << chunk)
|
||||
.map { |parsed_json| processor.process_streamed_message(parsed_json) }
|
||||
.flatten
|
||||
.compact
|
||||
elements =
|
||||
(@decoder << chunk)
|
||||
.map { |parsed_json| processor.process_streamed_message(parsed_json) }
|
||||
.flatten
|
||||
.compact
|
||||
|
||||
# Remove duplicate partial tool calls
|
||||
# sometimes we stream weird chunks
|
||||
seen_tools = Set.new
|
||||
elements.select { |item| !item.is_a?(ToolCall) || seen_tools.add?(item) }
|
||||
end
|
||||
|
||||
def decode_chunk_finish
|
||||
@@ -120,7 +127,7 @@ module DiscourseAi
|
||||
private
|
||||
|
||||
def processor
|
||||
@processor ||= OpenAiMessageProcessor.new
|
||||
@processor ||= OpenAiMessageProcessor.new(partial_tool_calls: partial_tool_calls)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-15
@@ -164,24 +164,18 @@ module DiscourseAi
|
||||
|
||||
# @param generic_prompt { DiscourseAi::Completions::Prompt } - Our generic prompt object
|
||||
# @param user { User } - User requesting the summary.
|
||||
# @param temperature { Float - Optional } - The temperature to use for the completion.
|
||||
# @param top_p { Float - Optional } - The top_p to use for the completion.
|
||||
# @param max_tokens { Integer - Optional } - The maximum number of tokens to generate.
|
||||
# @param stop_sequences { Array<String> - Optional } - The stop sequences to use for the completion.
|
||||
# @param feature_name { String - Optional } - The feature name to use for the completion.
|
||||
# @param feature_context { Hash - Optional } - The feature context to use for the completion.
|
||||
# @param partial_tool_calls { Boolean - Optional } - If true, the completion will return partial tool calls.
|
||||
#
|
||||
# @param &on_partial_blk { Block - Optional } - The passed block will get called with the LLM partial response alongside a cancel function.
|
||||
#
|
||||
# @returns { String } - Completion result.
|
||||
#
|
||||
# When the model invokes a tool, we'll wait until the endpoint finishes replying and feed you a fully-formed tool,
|
||||
# even if you passed a partial_read_blk block. Invocations are strings that look like this:
|
||||
#
|
||||
# <function_calls>
|
||||
# <invoke>
|
||||
# <tool_name>get_weather</tool_name>
|
||||
# <tool_id>get_weather</tool_id>
|
||||
# <parameters>
|
||||
# <location>Sydney</location>
|
||||
# <unit>c</unit>
|
||||
# </parameters>
|
||||
# </invoke>
|
||||
# </function_calls>
|
||||
# @returns String | ToolCall - Completion result.
|
||||
# if multiple tools or a tool and a message come back, the result will be an array of ToolCall / String objects.
|
||||
#
|
||||
def generate(
|
||||
prompt,
|
||||
@@ -192,6 +186,7 @@ module DiscourseAi
|
||||
user:,
|
||||
feature_name: nil,
|
||||
feature_context: nil,
|
||||
partial_tool_calls: false,
|
||||
&partial_read_blk
|
||||
)
|
||||
self.class.record_prompt(prompt)
|
||||
@@ -226,6 +221,7 @@ module DiscourseAi
|
||||
model_params,
|
||||
feature_name: feature_name,
|
||||
feature_context: feature_context,
|
||||
partial_tool_calls: partial_tool_calls,
|
||||
&partial_read_blk
|
||||
)
|
||||
end
|
||||
|
||||
@@ -3,11 +3,12 @@ module DiscourseAi::Completions
|
||||
class OpenAiMessageProcessor
|
||||
attr_reader :prompt_tokens, :completion_tokens
|
||||
|
||||
def initialize
|
||||
def initialize(partial_tool_calls: false)
|
||||
@tool = nil
|
||||
@tool_arguments = +""
|
||||
@prompt_tokens = nil
|
||||
@completion_tokens = nil
|
||||
@partial_tool_calls = partial_tool_calls
|
||||
end
|
||||
|
||||
def process_message(json)
|
||||
@@ -57,12 +58,16 @@ module DiscourseAi::Completions
|
||||
if id.present? && name.present?
|
||||
@tool_arguments = +""
|
||||
@tool = ToolCall.new(id: id, name: name)
|
||||
@streaming_parser = ToolCallProgressTracker.new(self) if @partial_tool_calls
|
||||
end
|
||||
|
||||
@tool_arguments << arguments.to_s
|
||||
@streaming_parser << arguments.to_s if @streaming_parser && !arguments.to_s.empty?
|
||||
rval = current_tool_progress if !rval
|
||||
elsif finished_tools && @tool
|
||||
parsed_args = JSON.parse(@tool_arguments, symbolize_names: true)
|
||||
@tool.parameters = parsed_args
|
||||
@tool.partial = false
|
||||
rval = @tool
|
||||
@tool = nil
|
||||
elsif !content.to_s.empty?
|
||||
@@ -75,6 +80,23 @@ module DiscourseAi::Completions
|
||||
rval
|
||||
end
|
||||
|
||||
def notify_progress(key, value)
|
||||
if @tool
|
||||
@tool.partial = true
|
||||
@tool.parameters[key.to_sym] = value
|
||||
@has_new_data = true
|
||||
end
|
||||
end
|
||||
|
||||
def current_tool_progress
|
||||
if @has_new_data
|
||||
@has_new_data = false
|
||||
@tool
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def finish
|
||||
rval = []
|
||||
if @tool
|
||||
|
||||
@@ -4,12 +4,14 @@ module DiscourseAi
|
||||
module Completions
|
||||
class ToolCall
|
||||
attr_reader :id, :name, :parameters
|
||||
attr_accessor :partial
|
||||
|
||||
def initialize(id:, name:, parameters: nil)
|
||||
@id = id
|
||||
@name = name
|
||||
self.parameters = parameters if parameters
|
||||
@parameters ||= {}
|
||||
@partial = false
|
||||
end
|
||||
|
||||
def parameters=(parameters)
|
||||
@@ -24,6 +26,12 @@ module DiscourseAi
|
||||
def to_s
|
||||
"#{name} - #{id} (\n#{parameters.map(&:to_s).join("\n")}\n)"
|
||||
end
|
||||
|
||||
def dup
|
||||
call = ToolCall.new(id: id, name: name, parameters: parameters.deep_dup)
|
||||
call.partial = partial
|
||||
call
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Completions
|
||||
class ToolCallProgressTracker
|
||||
attr_reader :current_key, :current_value, :tool_call
|
||||
|
||||
def initialize(tool_call)
|
||||
@tool_call = tool_call
|
||||
@current_key = nil
|
||||
@current_value = nil
|
||||
@parser = DiscourseAi::Completions::JsonStreamingParser.new
|
||||
|
||||
@parser.key do |k|
|
||||
@current_key = k
|
||||
@current_value = nil
|
||||
end
|
||||
|
||||
@parser.value { |v| tool_call.notify_progress(@current_key, v) if @current_key }
|
||||
end
|
||||
|
||||
def <<(json)
|
||||
# llm could send broken json
|
||||
# in that case just deal with it later
|
||||
# don't stream
|
||||
return if @broken
|
||||
|
||||
begin
|
||||
@parser << json
|
||||
rescue DiscourseAi::Completions::ParserError
|
||||
@broken = true
|
||||
return
|
||||
end
|
||||
|
||||
if @parser.state == :start_string && @current_key
|
||||
# this is is worth notifying
|
||||
tool_call.notify_progress(@current_key, @parser.buf)
|
||||
end
|
||||
|
||||
@current_key = nil if @parser.state == :end_value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Sentiment
|
||||
class EmotionFilterOrder
|
||||
def self.register!(plugin)
|
||||
emotions = %w[
|
||||
admiration
|
||||
amusement
|
||||
anger
|
||||
annoyance
|
||||
approval
|
||||
caring
|
||||
confusion
|
||||
curiosity
|
||||
desire
|
||||
disappointment
|
||||
disapproval
|
||||
disgust
|
||||
embarrassment
|
||||
excitement
|
||||
fear
|
||||
gratitude
|
||||
grief
|
||||
joy
|
||||
love
|
||||
nervousness
|
||||
neutral
|
||||
optimism
|
||||
pride
|
||||
realization
|
||||
relief
|
||||
remorse
|
||||
sadness
|
||||
surprise
|
||||
]
|
||||
|
||||
emotions.each do |emotion|
|
||||
filter_order_emotion = ->(scope, order_direction) do
|
||||
emotion_clause = <<~SQL
|
||||
SUM(
|
||||
CASE
|
||||
WHEN (classification_results.classification::jsonb->'#{emotion}')::float > 0.1
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
)::float / COUNT(posts.id)
|
||||
SQL
|
||||
|
||||
# TODO: This is slow, we will need to materialize this in the future
|
||||
with_clause = <<~SQL
|
||||
SELECT
|
||||
topics.id,
|
||||
#{emotion_clause} AS emotion_#{emotion}
|
||||
FROM
|
||||
topics
|
||||
INNER JOIN
|
||||
posts ON posts.topic_id = topics.id
|
||||
INNER JOIN
|
||||
classification_results ON
|
||||
classification_results.target_id = posts.id AND
|
||||
classification_results.target_type = 'Post' AND
|
||||
classification_results.model_used = 'SamLowe/roberta-base-go_emotions'
|
||||
WHERE
|
||||
topics.archetype = 'regular'
|
||||
AND topics.deleted_at IS NULL
|
||||
AND posts.deleted_at IS NULL
|
||||
AND posts.post_type = 1
|
||||
GROUP BY
|
||||
1
|
||||
HAVING
|
||||
#{emotion_clause} > 0.05
|
||||
SQL
|
||||
|
||||
scope
|
||||
.with(topic_emotion: Arel.sql(with_clause))
|
||||
.joins("INNER JOIN topic_emotion ON topic_emotion.id = topics.id")
|
||||
.order("topic_emotion.emotion_#{emotion} #{order_direction}")
|
||||
end
|
||||
plugin.add_filter_custom_filter("order:emotion_#{emotion}", &filter_order_emotion)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,8 @@ module DiscourseAi
|
||||
plugin.on(:post_created, &sentiment_analysis_cb)
|
||||
plugin.on(:post_edited, &sentiment_analysis_cb)
|
||||
|
||||
EmotionFilterOrder.register!(plugin)
|
||||
|
||||
plugin.add_report("overall_sentiment") do |report|
|
||||
report.modes = [:stacked_chart]
|
||||
threshold = 0.6
|
||||
|
||||
@@ -109,9 +109,11 @@ RSpec.describe DiscourseAi::Completions::Endpoints::Anthropic do
|
||||
EndpointMock.with_chunk_array_support do
|
||||
stub_request(:post, url).to_return(status: 200, body: body)
|
||||
|
||||
llm.generate(prompt_with_google_tool, user: Discourse.system_user) do |partial|
|
||||
result << partial
|
||||
end
|
||||
llm.generate(
|
||||
prompt_with_google_tool,
|
||||
user: Discourse.system_user,
|
||||
partial_tool_calls: true,
|
||||
) { |partial| result << partial.dup }
|
||||
end
|
||||
|
||||
tool_call =
|
||||
@@ -124,7 +126,13 @@ RSpec.describe DiscourseAi::Completions::Endpoints::Anthropic do
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).to eq([tool_call])
|
||||
expect(result.last).to eq(tool_call)
|
||||
|
||||
search_queries = result.filter(&:partial).map { |r| r.parameters[:search_query] }
|
||||
categories = result.filter(&:partial).map { |r| r.parameters[:category] }
|
||||
|
||||
expect(categories).to eq([nil, nil, nil, nil, "gene", "general"])
|
||||
expect(search_queries).to eq(["s", "s<a>m", "s<a>m ", "s<a>m sam", "s<a>m sam", "s<a>m sam"])
|
||||
end
|
||||
|
||||
it "can stream a response" do
|
||||
|
||||
@@ -571,7 +571,7 @@ TEXT
|
||||
end
|
||||
end
|
||||
|
||||
it "properly handles spaces in tools payload" do
|
||||
it "properly handles spaces in tools payload and partial tool calls" do
|
||||
raw_data = <<~TEXT.strip
|
||||
data: {"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"func_id","type":"function","function":{"name":"go|ogle","arg|uments":""}}]}}]}
|
||||
|
||||
@@ -609,7 +609,9 @@ TEXT
|
||||
partials = []
|
||||
|
||||
dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: tools))
|
||||
endpoint.perform_completion!(dialect, user) { |partial| partials << partial }
|
||||
endpoint.perform_completion!(dialect, user, partial_tool_calls: true) do |partial|
|
||||
partials << partial.dup
|
||||
end
|
||||
|
||||
tool_call =
|
||||
DiscourseAi::Completions::ToolCall.new(
|
||||
@@ -620,7 +622,10 @@ TEXT
|
||||
},
|
||||
)
|
||||
|
||||
expect(partials).to eq([tool_call])
|
||||
expect(partials.last).to eq(tool_call)
|
||||
|
||||
progress = partials.map { |p| p.parameters[:query] }
|
||||
expect(progress).to eq(["Ad", "Adabas", "Adabas 9.", "Adabas 9.1"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -462,7 +462,6 @@ RSpec.describe DiscourseAi::AiBot::Personas::Persona do
|
||||
expect(crafted_system_prompt).to include("fragment-n14")
|
||||
expect(crafted_system_prompt).to include("fragment-n13")
|
||||
expect(crafted_system_prompt).to include("fragment-n12")
|
||||
|
||||
expect(crafted_system_prompt).not_to include("fragment-n4") # Fragment #11 not included
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe DiscourseAi::Sentiment::EmotionFilterOrder do
|
||||
let(:plugin) { Plugin::Instance.new }
|
||||
let(:model_used) { "SamLowe/roberta-base-go_emotions" }
|
||||
let(:post_1) { Fabricate(:post) }
|
||||
let(:post_2) { Fabricate(:post) }
|
||||
let(:post_3) { Fabricate(:post) }
|
||||
let(:classification_1) do
|
||||
{
|
||||
love: 0.9444406,
|
||||
admiration: 0.013724019,
|
||||
surprise: 0.010188869,
|
||||
excitement: 0.007888741,
|
||||
curiosity: 0.006301749,
|
||||
joy: 0.004060776,
|
||||
confusion: 0.0028238264,
|
||||
approval: 0.0018160914,
|
||||
realization: 0.001174849,
|
||||
neutral: 0.0008561869,
|
||||
amusement: 0.00075853954,
|
||||
disapproval: 0.0006987994,
|
||||
disappointment: 0.0006166883,
|
||||
anger: 0.0006000542,
|
||||
annoyance: 0.0005615011,
|
||||
desire: 0.00046368592,
|
||||
fear: 0.00045117878,
|
||||
sadness: 0.00041727215,
|
||||
gratitude: 0.00041727215,
|
||||
optimism: 0.00037112957,
|
||||
disgust: 0.00035552034,
|
||||
nervousness: 0.00022954118,
|
||||
embarrassment: 0.0002049572,
|
||||
caring: 0.00017737568,
|
||||
remorse: 0.00011407586,
|
||||
grief: 0.0001006716,
|
||||
pride: 0.00009681493,
|
||||
relief: 0.00008919009,
|
||||
}
|
||||
end
|
||||
let(:classification_2) do
|
||||
{
|
||||
love: 0.8444406,
|
||||
admiration: 0.113724019,
|
||||
surprise: 0.010188869,
|
||||
excitement: 0.007888741,
|
||||
curiosity: 0.006301749,
|
||||
joy: 0.004060776,
|
||||
confusion: 0.0028238264,
|
||||
approval: 0.0018160914,
|
||||
realization: 0.001174849,
|
||||
neutral: 0.0008561869,
|
||||
amusement: 0.00075853954,
|
||||
disapproval: 0.0006987994,
|
||||
disappointment: 0.0006166883,
|
||||
anger: 0.0006000542,
|
||||
annoyance: 0.0005615011,
|
||||
desire: 0.00046368592,
|
||||
fear: 0.00045117878,
|
||||
sadness: 0.00041727215,
|
||||
gratitude: 0.00041727215,
|
||||
optimism: 0.00037112957,
|
||||
disgust: 0.00035552034,
|
||||
nervousness: 0.00022954118,
|
||||
embarrassment: 0.0002049572,
|
||||
caring: 0.00017737568,
|
||||
remorse: 0.00011407586,
|
||||
grief: 0.0001006716,
|
||||
pride: 0.00009681493,
|
||||
relief: 0.00008919009,
|
||||
}
|
||||
end
|
||||
let(:classification_3) do
|
||||
{
|
||||
anger: 0.8503682,
|
||||
annoyance: 0.08113059,
|
||||
disgust: 0.020593312,
|
||||
disapproval: 0.013718102,
|
||||
neutral: 0.0074148285,
|
||||
disappointment: 0.005785964,
|
||||
sadness: 0.0028253668,
|
||||
curiosity: 0.0028253668,
|
||||
confusion: 0.0023885092,
|
||||
surprise: 0.001524171,
|
||||
embarrassment: 0.0012784768,
|
||||
love: 0.001177788,
|
||||
admiration: 0.0010892758,
|
||||
realization: 0.001080799,
|
||||
approval: 0.00102328,
|
||||
fear: 0.00097261387,
|
||||
amusement: 0.0007724123,
|
||||
excitement: 0.00059921003,
|
||||
gratitude: 0.00055852515,
|
||||
joy: 0.00054986606,
|
||||
optimism: 0.00050458545,
|
||||
desire: 0.00046849172,
|
||||
caring: 0.00037205798,
|
||||
remorse: 0.00028415458,
|
||||
grief: 0.00025973833,
|
||||
nervousness: 0.00024305031,
|
||||
pride: 0.00011661681,
|
||||
relief: 0.00007470753,
|
||||
}
|
||||
end
|
||||
let!(:classification_result_1) do
|
||||
Fabricate(
|
||||
:sentiment_classification,
|
||||
target: post_1,
|
||||
model_used: model_used,
|
||||
classification: classification_1,
|
||||
)
|
||||
end
|
||||
let!(:classification_result_2) do
|
||||
Fabricate(
|
||||
:sentiment_classification,
|
||||
target: post_2,
|
||||
model_used: model_used,
|
||||
classification: classification_2,
|
||||
)
|
||||
end
|
||||
let!(:classification_result_3) do
|
||||
Fabricate(
|
||||
:sentiment_classification,
|
||||
target: post_3,
|
||||
model_used: model_used,
|
||||
classification: classification_3,
|
||||
)
|
||||
end
|
||||
|
||||
before { described_class.register!(plugin) }
|
||||
|
||||
it "registers emotion filters" do
|
||||
emotions = %w[
|
||||
disappointment
|
||||
sadness
|
||||
annoyance
|
||||
neutral
|
||||
disapproval
|
||||
realization
|
||||
nervousness
|
||||
approval
|
||||
joy
|
||||
anger
|
||||
embarrassment
|
||||
caring
|
||||
remorse
|
||||
disgust
|
||||
grief
|
||||
confusion
|
||||
relief
|
||||
desire
|
||||
admiration
|
||||
optimism
|
||||
fear
|
||||
love
|
||||
excitement
|
||||
curiosity
|
||||
amusement
|
||||
surprise
|
||||
gratitude
|
||||
pride
|
||||
]
|
||||
|
||||
filters = DiscoursePluginRegistry.custom_filter_mappings.reduce(Hash.new, :merge)
|
||||
|
||||
emotions.each { |emotion| expect(filters).to include("order:emotion_#{emotion}") }
|
||||
end
|
||||
|
||||
it "filters topics by emotion" do
|
||||
emotion = "joy"
|
||||
scope = Topic.all
|
||||
order_direction = "desc"
|
||||
|
||||
filter =
|
||||
DiscoursePluginRegistry
|
||||
.custom_filter_mappings
|
||||
.find { _1.keys.include? "order:emotion_#{emotion}" }
|
||||
.values
|
||||
.first
|
||||
result = filter.call(scope, order_direction)
|
||||
|
||||
expect(result.to_sql).to include("classification_results")
|
||||
expect(result.to_sql).to include(
|
||||
"classification_results.model_used = 'SamLowe/roberta-base-go_emotions'",
|
||||
)
|
||||
expect(result.to_sql).to include("ORDER BY topic_emotion.emotion_joy desc")
|
||||
end
|
||||
|
||||
it "sorts emotion in ascending order" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("order:emotion_love-asc")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(post_2.topic.id, post_1.topic.id)
|
||||
end
|
||||
it "sorts emotion in default descending order" do
|
||||
expect(
|
||||
TopicsFilter
|
||||
.new(guardian: Guardian.new)
|
||||
.filter_from_query_string("order:emotion_love")
|
||||
.pluck(:id),
|
||||
).to contain_exactly(post_1.topic.id, post_2.topic.id)
|
||||
end
|
||||
end
|
||||
@@ -253,6 +253,9 @@ RSpec.describe DiscourseAi::AiHelper::AssistantController do
|
||||
after { SiteSetting.provider = @original_provider }
|
||||
|
||||
it "returns a 403 error if the user cannot access the secure upload" do
|
||||
# hosted-site plugin edge case, it enables embeddings
|
||||
SiteSetting.ai_embeddings_enabled = false
|
||||
|
||||
create_post(
|
||||
title: "Secure upload post",
|
||||
raw: "This is a new post <img src=\"#{upload.url}\" />",
|
||||
|
||||
Reference in New Issue
Block a user