Compare commits

..
Author SHA1 Message Date
Keegan George 3ce8d943a7 DEV: First version
showing message bus error, but it kinda works? wip on a fix.
2024-11-12 20:42:19 -08:00
25 changed files with 148 additions and 1227 deletions
@@ -18,20 +18,19 @@ 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;
@@ -40,19 +39,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,
}
);
}
@@ -65,14 +64,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");
}
}
@@ -94,19 +93,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");
}
@@ -120,12 +119,19 @@ export default class SemanticSearch extends Component {
return this.performHyDESearch();
}
this.#resetAndSearchOnEvent();
withPluginApi("1.15.0", (api) => {
api.onAppEvent("full-page-search:trigger-search", () => {
if (!this.searching) {
this.resetAIResults();
return this.performHyDESearch();
}
});
});
}
performHyDESearch() {
this.searching = true;
this.resetAiResults();
this.resetAIResults();
ajax("/discourse-ai/embeddings/semantic-search", {
data: { q: this.searchTerm },
@@ -139,37 +145,15 @@ 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}}>
@@ -179,9 +163,10 @@ export default class SemanticSearch extends Component {
>
<DToggleSwitch
disabled={{this.disableToggleSwitch}}
@state={{this.showingAiResults}}
@state={{this.showingAIResults}}
title="AI search results hidden"
class="semantic-search__results-toggle"
{{on "click" this.toggleAiResults}}
{{on "click" this.toggleAIResults}}
/>
<div class="semantic-search__searching-text">
@@ -19,8 +19,7 @@ import I18n from "discourse-i18n";
import DMenu from "float-kit/components/d-menu";
import DTooltip from "float-kit/components/d-tooltip";
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
const STREAMED_TEXT_SPEED = 15;
import smoothStreamText from "../../modifiers/smooth-stream-text";
export default class AiSummaryBox extends Component {
@service siteSettings;
@@ -36,10 +35,7 @@ export default class AiSummaryBox extends Component {
@tracked canRegenerate = false;
@tracked loading = false;
@tracked isStreaming = false;
@tracked streamedText = "";
@tracked currentIndex = 0;
typingTimer = null;
streamedTextLength = 0;
@tracked haltAnimation = false;
get outdatedSummaryWarningText() {
let outdatedText = I18n.t("summary.outdated");
@@ -55,8 +51,6 @@ export default class AiSummaryBox extends Component {
}
resetSummary() {
this.streamedText = "";
this.currentIndex = 0;
this.text = "";
this.summarizedOn = null;
this.summarizedBy = null;
@@ -145,26 +139,6 @@ export default class AiSummaryBox extends Component {
});
}
typeCharacter() {
if (this.streamedTextLength < this.text.length) {
this.streamedText += this.text.charAt(this.streamedTextLength);
this.streamedTextLength++;
this.typingTimer = later(this, this.typeCharacter, STREAMED_TEXT_SPEED);
} else {
this.typingTimer = null;
}
}
onTextUpdate() {
// Reset only if there’s a new summary to process
if (this.typingTimer) {
cancel(this.typingTimer);
}
this.typeCharacter();
}
@bind
async _updateSummary(update) {
const topicSummary = {
@@ -173,13 +147,13 @@ export default class AiSummaryBox extends Component {
...update.ai_topic_summary,
};
const newText = topicSummary.raw || "";
this.text = newText;
this.loading = false;
if (update.done) {
this.text = newText;
this.streamedText = newText;
this.displayedTextLength = newText.length;
this.isStreaming = false;
this.haltAnimation = true;
this.summarizedOn = shortDateNoYear(
moment(topicSummary.updated_at, "YYYY-MM-DD HH:mm:ss Z")
);
@@ -187,16 +161,6 @@ export default class AiSummaryBox extends Component {
this.newPostsSinceSummary = topicSummary.new_posts_since_summary;
this.outdated = topicSummary.outdated;
this.canRegenerate = topicSummary.outdated && topicSummary.can_regenerate;
// Clear pending animations
if (this.typingTimer) {
cancel(this.typingTimer);
this.typingTimer = null;
}
} else if (newText.length > this.text.length) {
this.text = newText;
this.isStreaming = true;
this.onTextUpdate();
}
}
@@ -260,8 +224,13 @@ export default class AiSummaryBox extends Component {
{{#if this.loading}}
<AiSummarySkeleton />
{{else}}
<div class="generated-summary cooked">
<CookText @rawText={{this.streamedText}} />
<div
class="generated-summary"
{{smoothStreamText this.text this.haltAnimation}}
>
{{#if this.haltAnimation}}
<CookText @rawText={{this.text}} />
{{/if}}
</div>
{{#if this.summarizedOn}}
<div class="summarized-on">
@@ -0,0 +1,60 @@
import { cancel, later } from "@ember/runloop";
import { htmlSafe } from "@ember/template";
import Modifier from "ember-modifier";
import { cook } from "discourse/lib/text";
const STREAMED_TEXT_SPEED = 15;
export default class SmoothStreamTextModifier extends Modifier {
typingTimer = null;
displayedText = "";
modify(element, [text, haltAnimation]) {
if (haltAnimation) {
return;
}
this._startTypingAnimation(element, text);
}
async _startTypingAnimation(element, text) {
if (this.typingTimer) {
cancel(this.typingTimer);
}
if (this.displayedText.length === 0) {
element.innerHTML = "";
}
this._typeCharacter(element, text);
}
async _typeCharacter(element, text) {
if (this.displayedText.length < text.length) {
this.displayedText += text.charAt(this.displayedText.length);
try {
const cookedText = await cook(this.displayedText);
element.classList.add("cooked");
element.innerHTML = htmlSafe(cookedText);
} catch (error) {
console.error("Error cooking text during typing: ", error);
}
this.typingTimer = later(
this,
this._typeCharacter,
element,
text,
STREAMED_TEXT_SPEED
);
} else {
this.typingTimer = null;
}
}
willRemove() {
if (this.typingTimer) {
cancel(this.typingTimer);
}
}
}
@@ -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"],
});
});
+1 -1
View File
@@ -165,7 +165,7 @@ en:
saved: "Persona saved"
enabled: "Enabled?"
tools: "Enabled tools"
forced_tools: "Forced tools"
forced_tools: "Forced fools"
allowed_groups: "Allowed groups"
confirm_delete: "Are you sure you want to delete this persona?"
new: "New persona"
+8 -11
View File
@@ -185,23 +185,20 @@ module DiscourseAi
end
def invoke_tool(tool, llm, cancel, context, &update_blk)
show_placeholder = !context[:skip_tool_details]
update_blk.call("", cancel, build_placeholder(tool.summary, "")) if show_placeholder
update_blk.call("", cancel, build_placeholder(tool.summary, ""))
result =
tool.invoke do |progress|
if show_placeholder
placeholder = build_placeholder(tool.summary, progress)
update_blk.call("", cancel, placeholder)
end
placeholder = build_placeholder(tool.summary, progress)
update_blk.call("", cancel, placeholder)
end
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?
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]
update_blk.call(tool_details, cancel, nil, :tool_details)
end
result
+1 -1
View File
@@ -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?
raw << "\n\n" << placeholder if placeholder.present? && !context[:skip_tool_details]
blk.call(partial) if blk && type != :tool_details
+4 -32
View File
@@ -4,50 +4,28 @@ class DiscourseAi::Completions::AnthropicMessageProcessor
class AnthropicToolCall
attr_reader :name, :raw_json, :id
def initialize(name, id, partial_tool_calls: false)
def initialize(name, id)
@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)
@tool_call.partial = false
@tool_call.parameters = parameters
@tool_call
DiscourseAi::Completions::ToolCall.new(id: id, name: name, parameters: parameters)
end
end
attr_reader :tool_calls, :input_tokens, :output_tokens
def initialize(streaming_mode:, partial_tool_calls: false)
def initialize(streaming_mode:)
@streaming_mode = streaming_mode
@tool_calls = []
@current_tool_call = nil
@partial_tool_calls = partial_tool_calls
end
def to_tool_calls
@@ -60,17 +38,11 @@ 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,
partial_tool_calls: @partial_tool_calls,
) if tool_name
@current_tool_call = AnthropicToolCall.new(tool_name, tool_id) 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
+1 -4
View File
@@ -107,10 +107,7 @@ module DiscourseAi
def processor
@processor ||=
DiscourseAi::Completions::AnthropicMessageProcessor.new(
streaming_mode: @streaming_mode,
partial_tool_calls: partial_tool_calls,
)
DiscourseAi::Completions::AnthropicMessageProcessor.new(streaming_mode: @streaming_mode)
end
def has_tool?(_response_data)
-4
View File
@@ -4,8 +4,6 @@ module DiscourseAi
module Completions
module Endpoints
class Base
attr_reader :partial_tool_calls
CompletionFailed = Class.new(StandardError)
TIMEOUT = 60
@@ -60,10 +58,8 @@ 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
+1 -2
View File
@@ -28,8 +28,7 @@ module DiscourseAi
_user,
_model_params,
feature_name: nil,
feature_context: nil,
partial_tool_calls: false
feature_context: nil
)
@dialect = dialect
response = responses[completions]
+1 -2
View File
@@ -120,8 +120,7 @@ module DiscourseAi
user,
model_params = {},
feature_name: nil,
feature_context: nil,
partial_tool_calls: false
feature_context: nil
)
last_call = { dialect: dialect, user: user, model_params: model_params }
self.class.last_call = last_call
+5 -12
View File
@@ -33,7 +33,6 @@ 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?
@@ -104,16 +103,10 @@ module DiscourseAi
def decode_chunk(chunk)
@decoder ||= JsonStreamDecoder.new
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) }
(@decoder << chunk)
.map { |parsed_json| processor.process_streamed_message(parsed_json) }
.flatten
.compact
end
def decode_chunk_finish
@@ -127,7 +120,7 @@ module DiscourseAi
private
def processor
@processor ||= OpenAiMessageProcessor.new(partial_tool_calls: partial_tool_calls)
@processor ||= OpenAiMessageProcessor.new
end
end
end
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -164,18 +164,24 @@ 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 | ToolCall - Completion result.
# if multiple tools or a tool and a message come back, the result will be an array of ToolCall / String objects.
# @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>
#
def generate(
prompt,
@@ -186,7 +192,6 @@ module DiscourseAi
user:,
feature_name: nil,
feature_context: nil,
partial_tool_calls: false,
&partial_read_blk
)
self.class.record_prompt(prompt)
@@ -221,7 +226,6 @@ module DiscourseAi
model_params,
feature_name: feature_name,
feature_context: feature_context,
partial_tool_calls: partial_tool_calls,
&partial_read_blk
)
end
+1 -23
View File
@@ -3,12 +3,11 @@ module DiscourseAi::Completions
class OpenAiMessageProcessor
attr_reader :prompt_tokens, :completion_tokens
def initialize(partial_tool_calls: false)
def initialize
@tool = nil
@tool_arguments = +""
@prompt_tokens = nil
@completion_tokens = nil
@partial_tool_calls = partial_tool_calls
end
def process_message(json)
@@ -58,16 +57,12 @@ 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?
@@ -80,23 +75,6 @@ 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
-8
View File
@@ -4,14 +4,12 @@ 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)
@@ -26,12 +24,6 @@ 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
@@ -1,44 +0,0 @@
# 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
-85
View File
@@ -1,85 +0,0 @@
# 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
-2
View File
@@ -14,8 +14,6 @@ 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,11 +109,9 @@ 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,
partial_tool_calls: true,
) { |partial| result << partial.dup }
llm.generate(prompt_with_google_tool, user: Discourse.system_user) do |partial|
result << partial
end
end
tool_call =
@@ -126,13 +124,7 @@ RSpec.describe DiscourseAi::Completions::Endpoints::Anthropic do
},
)
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"])
expect(result).to eq([tool_call])
end
it "can stream a response" do
@@ -571,7 +571,7 @@ TEXT
end
end
it "properly handles spaces in tools payload and partial tool calls" do
it "properly handles spaces in tools payload" 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,9 +609,7 @@ TEXT
partials = []
dialect = compliance.dialect(prompt: compliance.generic_prompt(tools: tools))
endpoint.perform_completion!(dialect, user, partial_tool_calls: true) do |partial|
partials << partial.dup
end
endpoint.perform_completion!(dialect, user) { |partial| partials << partial }
tool_call =
DiscourseAi::Completions::ToolCall.new(
@@ -622,10 +620,7 @@ TEXT
},
)
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"])
expect(partials).to eq([tool_call])
end
end
end
@@ -462,6 +462,7 @@ 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
@@ -1,207 +0,0 @@
# 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,9 +253,6 @@ 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}\" />",