FEATURE: improve site setting search (#780)

This improves the site setting search so it performs a somewhat
fuzzy match.

Previously it did not handle seperators such as "space" and a
term such as "min_post_length" would not find "min_first_post_length"

A more liberal search algorithm makes it easier to the AI to
navigate settings.

* Minor fix, {{and parameter.enum parameter.enum.length}} is non
obviously broken.


If parameter.enum is a tracked array it will return the object
cause embers and helper implementation.

This corrects an issue where enum keeps on selecting itself by
mistake.
This commit is contained in:
Sam 2024-08-29 16:05:38 +10:00 committed by GitHub
parent 943504049c
commit 41054c4fb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 78 additions and 21 deletions

View File

@ -20,26 +20,25 @@ export default class AiTool extends RestModel {
return this.getProperties(CREATE_ATTRIBUTES); return this.getProperties(CREATE_ATTRIBUTES);
} }
workingCopy() { trackParameters(parameters) {
const attrs = this.getProperties(CREATE_ATTRIBUTES); return new TrackedArray(
parameters?.map((p) => {
attrs.parameters = new TrackedArray(
attrs.parameters?.map((p) => {
const parameter = new TrackedObject(p); const parameter = new TrackedObject(p);
//Backwards-compatibility code. if (parameter.enum && parameter.enum.length) {
// TODO(roman): Remove aug 2024. Leave only else clause.
if (parameter.enum_values) {
parameter.enum = new TrackedArray(parameter.enum_values);
delete parameter.enum_values;
} else {
parameter.enum = new TrackedArray(parameter.enum); parameter.enum = new TrackedArray(parameter.enum);
} else {
parameter.enum = null;
} }
return parameter; return parameter;
}) })
); );
}
workingCopy() {
const attrs = this.getProperties(CREATE_ATTRIBUTES);
attrs.parameters = this.trackParameters(attrs.parameters);
return this.store.createRecord("ai-tool", attrs); return this.store.createRecord("ai-tool", attrs);
} }
} }

View File

@ -25,6 +25,7 @@ export default class AiToolEditor extends Component {
@service dialog; @service dialog;
@service modal; @service modal;
@service toasts; @service toasts;
@service store;
@tracked isSaving = false; @tracked isSaving = false;
@tracked editingModel = null; @tracked editingModel = null;
@ -53,8 +54,9 @@ export default class AiToolEditor extends Component {
@action @action
configurePreset() { configurePreset() {
this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId); this.selectedPreset = this.args.presets.findBy("preset_id", this.presetId);
this.editingModel = this.args.model.workingCopy(); this.editingModel = this.store
this.editingModel.setProperties(this.selectedPreset); .createRecord("ai-tool", this.selectedPreset)
.workingCopy();
this.showDelete = false; this.showDelete = false;
} }

View File

@ -7,7 +7,6 @@ import DButton from "discourse/components/d-button";
import withEventValue from "discourse/helpers/with-event-value"; import withEventValue from "discourse/helpers/with-event-value";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import ComboBox from "select-kit/components/combo-box"; import ComboBox from "select-kit/components/combo-box";
import and from "truth-helpers/helpers/and";
const PARAMETER_TYPES = [ const PARAMETER_TYPES = [
{ name: "string", id: "string" }, { name: "string", id: "string" },
@ -59,6 +58,9 @@ export default class AiToolParameterEditor extends Component {
@action @action
removeEnumValue(parameter, index) { removeEnumValue(parameter, index) {
parameter.enum.splice(index, 1); parameter.enum.splice(index, 1);
if (parameter.enum.length === 0) {
parameter.enum = null;
}
} }
@action @action
@ -94,6 +96,7 @@ export default class AiToolParameterEditor extends Component {
{{on "input" (fn this.toggleRequired parameter)}} {{on "input" (fn this.toggleRequired parameter)}}
checked={{parameter.required}} checked={{parameter.required}}
type="checkbox" type="checkbox"
class="parameter-row__required-toggle"
/> />
{{I18n.t "discourse_ai.tools.parameter_required"}} {{I18n.t "discourse_ai.tools.parameter_required"}}
</label> </label>
@ -101,8 +104,9 @@ export default class AiToolParameterEditor extends Component {
<label> <label>
<input <input
{{on "input" (fn this.toggleEnum parameter)}} {{on "input" (fn this.toggleEnum parameter)}}
checked={{and parameter.enum parameter.enum.length}} checked={{parameter.enum}}
type="checkbox" type="checkbox"
class="parameter-row__enum-toggle"
/> />
{{I18n.t "discourse_ai.tools.parameter_enum"}} {{I18n.t "discourse_ai.tools.parameter_enum"}}
</label> </label>
@ -114,7 +118,7 @@ export default class AiToolParameterEditor extends Component {
/> />
</div> </div>
{{#if (and parameter.enum parameter.enum.length)}} {{#if parameter.enum}}
<div class="parameter-enum-values"> <div class="parameter-enum-values">
{{#each parameter.enum as |enumValue enumIndex|}} {{#each parameter.enum as |enumValue enumIndex|}}
<div class="enum-value-row"> <div class="enum-value-row">

View File

@ -31,20 +31,35 @@ module DiscourseAi
parameters[:query].to_s parameters[:query].to_s
end end
def all_settings
@all_settings ||= SiteSetting.all_settings
end
def all_settings=(settings)
# this is only used for testing
@all_settings = settings
end
def invoke def invoke
@last_num_results = 0 @last_num_results = 0
terms = query.split(",").map(&:strip).map(&:downcase).reject(&:blank?) terms = query.split(",").map(&:strip).map(&:downcase).reject(&:blank?)
terms_regexes =
terms.map do |term|
regex_string = term.split(/[ _\.\|]/).map { |t| Regexp.escape(t) }.join(".*")
Regexp.new(regex_string, Regexp::IGNORECASE)
end
found = found =
SiteSetting.all_settings.filter do |setting| all_settings.filter do |setting|
name = setting[:setting].to_s.downcase name = setting[:setting].to_s.downcase
description = setting[:description].to_s.downcase description = setting[:description].to_s.downcase
plugin = setting[:plugin].to_s.downcase plugin = setting[:plugin].to_s.downcase
search_string = "#{name} #{description} #{plugin}" search_string = "#{name} #{description} #{plugin}"
terms.any? { |term| search_string.include?(term) } terms_regexes.any? { |regex| search_string.match?(regex) }
end end
if found.blank? if found.blank?

View File

@ -5,13 +5,28 @@ RSpec.describe DiscourseAi::AiBot::Tools::SearchSettings do
let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) } let(:bot_user) { DiscourseAi::AiBot::EntryPoint.find_user_from_model(llm_model.name) }
let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") } let(:llm) { DiscourseAi::Completions::Llm.proxy("custom:#{llm_model.id}") }
let(:fake_settings) do
[
{ setting: "default_locale", description: "The default locale for the site", plugin: "core" },
{ setting: "min_post_length", description: "The minimum length of a post", plugin: "core" },
{
setting: "ai_bot_enabled",
description: "Enable or disable the AI bot",
plugin: "discourse-ai",
},
{ setting: "min_first_post_length", description: "First post length", plugin: "core" },
]
end
before do before do
SiteSetting.ai_bot_enabled = true SiteSetting.ai_bot_enabled = true
toggle_enabled_bots(bots: [llm_model]) toggle_enabled_bots(bots: [llm_model])
end end
def search_settings(query) def search_settings(query, mock: true)
described_class.new({ query: query }, bot_user: bot_user, llm: llm) search = described_class.new({ query: query }, bot_user: bot_user, llm: llm)
search.all_settings = fake_settings if mock
search
end end
describe "#process" do describe "#process" do
@ -21,8 +36,20 @@ RSpec.describe DiscourseAi::AiBot::Tools::SearchSettings do
expect(results[:rows]).to eq([]) expect(results[:rows]).to eq([])
end end
it "can find a setting based on fuzzy match" do
results = search_settings("default locale").invoke
expect(results[:rows].length).to eq(1)
expect(results[:rows][0][0]).to eq("default_locale")
results = search_settings("min_post_length").invoke
expect(results[:rows].length).to eq(2)
expect(results[:rows][0][0]).to eq("min_post_length")
expect(results[:rows][1][0]).to eq("min_first_post_length")
end
it "can return more many settings with no descriptions if there are lots of hits" do it "can return more many settings with no descriptions if there are lots of hits" do
results = search_settings("a").invoke results = search_settings("a", mock: false).invoke
expect(results[:rows].length).to be > 30 expect(results[:rows].length).to be > 30
expect(results[:rows][0].length).to eq(1) expect(results[:rows][0].length).to eq(1)

View File

@ -22,6 +22,10 @@ describe "AI Tool Management", type: :system do
select_kit.select_row_by_value("exchange_rate") select_kit.select_row_by_value("exchange_rate")
find(".ai-tool-editor__next").click find(".ai-tool-editor__next").click
expect(page.first(".parameter-row__required-toggle").checked?).to eq(true)
expect(page.first(".parameter-row__enum-toggle").checked?).to eq(false)
find(".ai-tool-editor__test-button").click find(".ai-tool-editor__test-button").click
expect(page).not_to have_button(".ai-tool-editor__delete") expect(page).not_to have_button(".ai-tool-editor__delete")
@ -49,6 +53,12 @@ describe "AI Tool Management", type: :system do
expect(page).to have_content("Tool saved") expect(page).to have_content("Tool saved")
last_tool = AiTool.order("id desc").limit(1).first
visit "/admin/plugins/discourse-ai/ai-tools/#{last_tool.id}"
expect(page.first(".parameter-row__required-toggle").checked?).to eq(true)
expect(page.first(".parameter-row__enum-toggle").checked?).to eq(false)
visit "/admin/plugins/discourse-ai/ai-personas/new" visit "/admin/plugins/discourse-ai/ai-personas/new"
tool_id = AiTool.order("id desc").limit(1).pluck(:id).first tool_id = AiTool.order("id desc").limit(1).pluck(:id).first