Compare commits

..
Author SHA1 Message Date
Tobias Eigen 655c7e9708 fixed "fools" to "tools" typo in client.en.yml 2024-11-18 10:59:20 -08:00
Rafael dos Santos Silva 4fb686a548 FIX: Move emotion /filter logic into a CTE to keep cardinality sane (#915) 2024-11-14 17:16:48 -03:00
Rafael dos Santos Silva 5026ab52d0 FEATURE: Order by emotion on /filter (#913) 2024-11-14 12:45:40 -03:00
Sam 823e8ef490 FEATURE: partial tool call support for OpenAI and Anthropic (#908)
Implement streaming tool call implementation for Anthropic and Open AI.

When calling:

llm.generate(..., partial_tool_calls: true) do ...
Partials may contain ToolCall instances with partial: true, These tool calls are partially populated with json partially parsed.

So for example when performing a search you may get:

ToolCall(..., {search: "hello" })
ToolCall(..., {search: "hello world" })

The library used to parse json is:

https://github.com/dgraham/json-stream

We use a fork cause we need access to the internal buffer.

This prepares internals to perform partial tool calls, but does not implement it yet.
2024-11-14 06:58:24 +11:00
Keegan George f75b13c4fa FIX: results not being reset when appending to query param (#912)
This PR fixes an issue where the AI search results were not being reset when you append your search to an existing query param (typically when you've come from quick search). This is because `handleSearch()` doesn't get called in this situation. So here we explicitly check for query params, trigger a reset and search for those occasions.
2024-11-13 07:19:34 -08:00
25 changed files with 1227 additions and 148 deletions
@@ -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">
@@ -19,7 +19,8 @@ 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";
import smoothStreamText from "../../modifiers/smooth-stream-text";
const STREAMED_TEXT_SPEED = 15;
export default class AiSummaryBox extends Component {
@service siteSettings;
@@ -35,7 +36,10 @@ export default class AiSummaryBox extends Component {
@tracked canRegenerate = false;
@tracked loading = false;
@tracked isStreaming = false;
@tracked haltAnimation = false;
@tracked streamedText = "";
@tracked currentIndex = 0;
typingTimer = null;
streamedTextLength = 0;
get outdatedSummaryWarningText() {
let outdatedText = I18n.t("summary.outdated");
@@ -51,6 +55,8 @@ export default class AiSummaryBox extends Component {
}
resetSummary() {
this.streamedText = "";
this.currentIndex = 0;
this.text = "";
this.summarizedOn = null;
this.summarizedBy = null;
@@ -139,6 +145,26 @@ 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 = {
@@ -147,13 +173,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")
);
@@ -161,6 +187,16 @@ 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();
}
}
@@ -224,13 +260,8 @@ export default class AiSummaryBox extends Component {
{{#if this.loading}}
<AiSummarySkeleton />
{{else}}
<div
class="generated-summary"
{{smoothStreamText this.text this.haltAnimation}}
>
{{#if this.haltAnimation}}
<CookText @rawText={{this.text}} />
{{/if}}
<div class="generated-summary cooked">
<CookText @rawText={{this.streamedText}} />
</div>
{{#if this.summarizedOn}}
<div class="summarized-on">
@@ -1,60 +0,0 @@
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 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
View File
@@ -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
+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? && !context[:skip_tool_details]
raw << "\n\n" << placeholder if placeholder.present?
blk.call(partial) if blk && type != :tool_details
+32 -4
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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]
+2 -1
View File
@@ -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
+12 -5
View File
@@ -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
View File
@@ -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
+23 -1
View File
@@ -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
+8
View File
@@ -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
+85
View File
@@ -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
+2
View File
@@ -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}\" />",