FEATURE: persona/tool import and export (#1450)

Introduces import/export feature for tools and personas.

Uploads are omitted for now, and will be added in a future PR 

*   **Backend:**
    *   Adds `import` and `export` actions to `Admin::AiPersonasController` and `Admin::AiToolsController`.
    *   Introduces `DiscourseAi::PersonaExporter` and `DiscourseAi::PersonaImporter` services to manage JSON serialization and deserialization.
    *   The export format for a persona embeds its associated custom tools. To ensure portability, `AiTool` references are serialized using their `tool_name` rather than their internal database `id`.
    *   The import logic detects conflicts by name. A `force=true` parameter can be passed to overwrite existing records.

*   **Frontend:**
    *   `AiPersonaListEditor` and `AiToolListEditor` components now include an "Import" button that handles file selection and POSTs the JSON data to the respective `import` endpoint.
    *   `AiPersonaEditorForm` and `AiToolEditorForm` components feature an "Export" button that triggers a download of the serialized record.
    *   Handles import conflicts (HTTP `409` for tools, `422` for personas) by showing a `dialog.confirm` prompt to allow the user to force an overwrite.

*   **Testing:**
    *   Adds comprehensive request specs for the new controller actions (`#import`, `#export`).
    *   Includes unit specs for the `PersonaExporter` and `PersonaImporter` services.
* Persona import and export implemented
This commit is contained in:
Sam
2025-06-24 12:41:10 +10:00
committed by GitHub
parent eea96d6df9
commit 9f2a4094f5
15 changed files with 1081 additions and 17 deletions
@@ -5,7 +5,7 @@ module DiscourseAi
class AiPersonasController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_persona, only: %i[edit update destroy create_user]
before_action :find_ai_persona, only: %i[edit update destroy create_user export]
def index
ai_personas =
@@ -105,6 +105,43 @@ module DiscourseAi
end
end
def export
persona = AiPersona.find(params[:id])
exporter = DiscourseAi::PersonaExporter.new(persona: persona)
response.headers[
"Content-Disposition"
] = "attachment; filename=\"#{persona.name.parameterize}.json\""
render json: exporter.export
end
def import
name = params.dig(:persona, :name)
existing_persona = AiPersona.find_by(name: name)
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
begin
importer = DiscourseAi::PersonaImporter.new(json: params.to_unsafe_h)
if existing_persona && force_update
initial_attributes = existing_persona.attributes.dup
persona = importer.import!(overwrite: true)
log_ai_persona_update(persona, initial_attributes)
render json: LocalizedAiPersonaSerializer.new(persona, root: false)
else
persona = importer.import!
log_ai_persona_creation(persona)
render json: LocalizedAiPersonaSerializer.new(persona, root: false), status: :created
end
rescue DiscourseAi::PersonaImporter::ImportError => e
render_json_error e.message, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error("AI Persona import failed: #{e.message}")
render_json_error "Import failed: #{e.message}", status: :unprocessable_entity
end
end
def stream_reply
persona =
AiPersona.find_by(name: params[:persona_name]) ||
@@ -5,7 +5,7 @@ module DiscourseAi
class AiToolsController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_tool, only: %i[test edit update destroy]
before_action :find_ai_tool, only: %i[test edit update destroy export]
def index
ai_tools = AiTool.all
@@ -32,6 +32,45 @@ module DiscourseAi
end
end
def export
response.headers[
"Content-Disposition"
] = "attachment; filename=\"#{@ai_tool.tool_name}.json\""
render_serialized(@ai_tool, AiCustomToolSerializer)
end
def import
existing_tool = AiTool.find_by(tool_name: ai_tool_params[:tool_name])
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
if existing_tool && !force_update
return(
render_json_error "Tool with tool_name '#{ai_tool_params[:tool_name]}' already exists. Use force=true to overwrite.",
status: :conflict
)
end
if existing_tool && force_update
initial_attributes = existing_tool.attributes.dup
if existing_tool.update(ai_tool_params)
log_ai_tool_update(existing_tool, initial_attributes)
render_serialized(existing_tool, AiCustomToolSerializer)
else
render_json_error existing_tool
end
else
ai_tool = AiTool.new(ai_tool_params)
ai_tool.created_by_id = current_user.id
if ai_tool.save
log_ai_tool_creation(ai_tool)
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
else
render_json_error ai_tool
end
end
end
def update
initial_attributes = @ai_tool.attributes.dup
@@ -0,0 +1,80 @@
# frozen_string_literal: true
module DiscourseAi
class PersonaExporter
def initialize(persona:)
raise ArgumentError, "Invalid persona provided" if !persona.is_a?(AiPersona)
@persona = persona
end
def export
serialized_custom_tools = serialize_tools(@persona)
serialize_persona(@persona, serialized_custom_tools)
end
private
def serialize_tools(ai_persona)
custom_tool_ids =
(ai_persona.tools || []).filter_map do |tool_config|
# A tool config is an array like: ["custom-ID", {options}, force_flag]
if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
tool_config[0].split("-", 2).last.to_i
end
end
return [] if custom_tool_ids.empty?
tools = AiTool.where(id: custom_tool_ids)
tools.map do |tool|
{
identifier: tool.tool_name, # Use tool_name for portability
name: tool.name,
description: tool.description,
tool_name: tool.tool_name,
parameters: tool.parameters,
summary: tool.summary,
script: tool.script,
}
end
end
def serialize_persona(ai_persona, serialized_custom_tools)
export_data = {
meta: {
version: "1.0",
exported_at: Time.zone.now.iso8601,
},
persona: {
name: ai_persona.name,
description: ai_persona.description,
system_prompt: ai_persona.system_prompt,
examples: ai_persona.examples,
temperature: ai_persona.temperature,
top_p: ai_persona.top_p,
response_format: ai_persona.response_format,
tools: transform_tools_for_export(ai_persona.tools, serialized_custom_tools),
},
custom_tools: serialized_custom_tools,
}
JSON.pretty_generate(export_data)
end
def transform_tools_for_export(tools_config, _serialized_custom_tools)
return [] if tools_config.blank?
tools_config.map do |tool_config|
unless tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
next tool_config
end
tool_id = tool_config[0].split("-", 2).last.to_i
tool = AiTool.find_by(id: tool_id)
next tool_config unless tool
["custom-#{tool.tool_name}", tool_config[1], tool_config[2]]
end
end
end
end
@@ -0,0 +1,149 @@
# frozen_string_literal: true
module DiscourseAi
class PersonaImporter
class ImportError < StandardError
attr_reader :conflicts
def initialize(message, conflicts = {})
super(message)
@conflicts = conflicts
end
end
def initialize(json:)
@data =
case json
when String
JSON.parse(json)
when Hash
json
else
raise ArgumentError, "Invalid JSON payload"
end
validate_payload!
end
def import!(overwrite: false)
ActiveRecord::Base.transaction do
check_conflicts! unless overwrite
tool_name_to_id = import_custom_tools(@data["custom_tools"] || [], overwrite: overwrite)
persona_data = @data["persona"]
existing_persona = AiPersona.find_by(name: persona_data["name"])
attrs = {
description: persona_data["description"],
system_prompt: persona_data["system_prompt"],
examples: persona_data["examples"],
temperature: persona_data["temperature"],
top_p: persona_data["top_p"],
response_format: persona_data["response_format"],
tools: transform_tools_for_import(persona_data["tools"], tool_name_to_id),
}
if existing_persona && overwrite
existing_persona.update!(**attrs)
existing_persona
else
attrs[:name] = persona_data["name"]
AiPersona.create!(**attrs)
end
end
end
private
def validate_payload!
unless @data.is_a?(Hash) && @data["persona"].is_a?(Hash)
raise ArgumentError, "Invalid persona export data"
end
end
def check_conflicts!
conflicts = {}
persona_name = @data["persona"]["name"]
conflicts[:persona] = persona_name if AiPersona.exists?(name: persona_name)
if @data["custom_tools"].present?
existing_tools = []
@data["custom_tools"].each do |tool_data|
tool_name = tool_data["tool_name"] || tool_data["identifier"]
existing_tools << tool_name if AiTool.exists?(tool_name: tool_name)
end
conflicts[:custom_tools] = existing_tools if existing_tools.any?
end
if conflicts.any?
message = build_conflict_message(conflicts)
raise ImportError.new(message, conflicts)
end
end
def build_conflict_message(conflicts)
messages = []
if conflicts[:persona]
messages << I18n.t("discourse_ai.errors.persona_already_exists", name: conflicts[:persona])
end
if conflicts[:custom_tools] && conflicts[:custom_tools].any?
tools_list = conflicts[:custom_tools].join(", ")
error =
I18n.t(
"discourse_ai.errors.custom_tool_exists",
names: tools_list,
count: conflicts[:custom_tools].size,
)
messages << error
end
messages.join("\n")
end
def import_custom_tools(custom_tools, overwrite:)
return {} if custom_tools.blank?
custom_tools.each_with_object({}) do |tool_data, map|
tool_name = tool_data["tool_name"] || tool_data["identifier"]
if overwrite
tool = AiTool.find_or_initialize_by(tool_name: tool_name)
else
tool = AiTool.new(tool_name: tool_name)
end
tool.tap do |t|
t.name = tool_data["name"]
t.description = tool_data["description"]
t.parameters = tool_data["parameters"]
t.script = tool_data["script"]
t.summary = tool_data["summary"]
t.created_by ||= Discourse.system_user
t.save!
end
map[tool.tool_name] = tool.id
end
end
def transform_tools_for_import(tools_config, tool_name_to_id)
return [] if tools_config.blank?
tools_config.map do |tool_config|
if tool_config.is_a?(Array) && tool_config[0].to_s.start_with?("custom-")
tool_name = tool_config[0].split("-", 2).last
tool_id = tool_name_to_id[tool_name] || AiTool.find_by(tool_name: tool_name)&.id
raise ArgumentError, "Custom tool '#{tool_name}' not found" unless tool_id
["custom-#{tool_id}", tool_config[1], tool_config[2]]
else
tool_config
end
end
end
end
end
@@ -11,6 +11,7 @@ import BackButton from "discourse/components/back-button";
import Form from "discourse/components/form";
import Avatar from "discourse/helpers/bound-avatar-template";
import { popupAjaxError } from "discourse/lib/ajax-error";
import getURL from "discourse/lib/get-url";
import Group from "discourse/models/group";
import { i18n } from "discourse-i18n";
import AdminUser from "admin/models/admin-user";
@@ -292,6 +293,12 @@ export default class PersonaEditor extends Component {
this.args.personas.setObjects(sorted);
}
@action
exportPersona() {
const exportUrl = `/admin/plugins/discourse-ai/ai-personas/${this.args.model.id}/export.json`;
window.location.href = getURL(exportUrl);
}
<template>
<BackButton
@route="adminPlugins.show.discourse-ai-personas"
@@ -713,6 +720,11 @@ export default class PersonaEditor extends Component {
<form.Submit />
{{#unless (or @model.isNew @model.system)}}
<form.Button
@label="discourse_ai.ai_persona.export"
@action={{this.exportPersona}}
class="ai-persona-editor__export"
/>
<form.Button
@action={{this.delete}}
@label="discourse_ai.ai_persona.delete"
@@ -14,10 +14,12 @@ import FilterInput from "discourse/components/filter-input";
import avatar from "discourse/helpers/avatar";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import DMenu from "float-kit/components/d-menu";
import AiPersona from "../admin/models/ai-persona";
import AiPersonaEditor from "./ai-persona-editor";
const LAYOUT_BUTTONS = [
@@ -37,6 +39,7 @@ export default class AiPersonaListEditor extends Component {
@service adminPluginNavManager;
@service keyValueStore;
@service capabilities;
@service dialog;
@tracked filterValue = "";
@tracked featureFilter = "all";
@@ -159,6 +162,77 @@ export default class AiPersonaListEditor extends Component {
this.dMenu.close();
}
@action
importPersona() {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.style.display = "none";
fileInput.onchange = (event) => this.handleFileSelect(event);
document.body.appendChild(fileInput);
fileInput.click();
document.body.removeChild(fileInput);
}
@action
handleFileSelect(event) {
const file = event.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result);
this.uploadPersona(json);
} catch {
this.dialog.alert(
i18n("discourse_ai.ai_persona.import_error_not_json")
);
}
};
reader.readAsText(file);
}
uploadPersona(personaData, force = false) {
let url = `/admin/plugins/discourse-ai/ai-personas/import.json`;
const payload = personaData;
if (force) {
payload.force = true;
}
return ajax(url, {
type: "POST",
data: JSON.stringify(payload),
contentType: "application/json",
})
.then((result) => {
let persona = AiPersona.create(result);
let existingPersona = this.args.personas.findBy("id", persona.id);
if (existingPersona) {
this.args.personas.removeObject(existingPersona);
}
this.args.personas.insertAt(0, persona);
})
.catch((error) => {
if (error.jqXHR?.status === 422) {
this.dialog.confirm({
message:
i18n("discourse_ai.ai_persona.import_error_conflict", {
name: personaData.persona.name,
}) +
"\n" +
error.jqXHR.responseJSON?.errors?.join("\n"),
confirmButtonLabel: "discourse_ai.ai_persona.overwrite",
didConfirm: () => this.uploadPersona(personaData, true),
});
} else {
popupAjaxError(error);
}
});
}
<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-personas"
@@ -176,6 +250,12 @@ export default class AiPersonaListEditor extends Component {
@learnMoreUrl="https://meta.discourse.org/t/ai-bot-personas/306099"
>
<:actions as |actions|>
<actions.Default
@label="discourse_ai.ai_persona.import"
@action={{this.importPersona}}
@icon="upload"
class="ai-persona-list-editor__import-button"
/>
<actions.Primary
@label="discourse_ai.ai_persona.new"
@route="adminPlugins.show.discourse-ai-personas.new"
@@ -6,6 +6,7 @@ import { service } from "@ember/service";
import { and, gt } from "truth-helpers";
import Form from "discourse/components/form";
import { popupAjaxError } from "discourse/lib/ajax-error";
import getURL from "discourse/lib/get-url";
import { i18n } from "discourse-i18n";
import AiToolTestModal from "./modal/ai-tool-test-modal";
import RagOptionsFk from "./rag-options-fk";
@@ -151,6 +152,12 @@ export default class AiToolEditorForm extends Component {
: i18n("discourse_ai.rag.uploads.description");
}
@action
exportTool() {
const exportUrl = `/admin/plugins/discourse-ai/ai-tools/${this.args.model.id}/export.json`;
window.location.href = getURL(exportUrl);
}
<template>
<Form
@onSubmit={{this.save}}
@@ -386,7 +393,11 @@ export default class AiToolEditorForm extends Component {
@action={{this.openTestModal}}
class="ai-tool-editor__test-button"
/>
<form.Button
@label="discourse_ai.tools.export"
@action={{this.exportTool}}
class="ai-tool-editor__export"
/>
<form.Button
@label="discourse_ai.tools.delete"
@icon="trash-can"
@@ -8,13 +8,17 @@ import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import DropdownMenu from "discourse/components/dropdown-menu";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import DMenu from "float-kit/components/d-menu";
import AiTool from "../admin/models/ai-tool";
export default class AiToolListEditor extends Component {
@service adminPluginNavManager;
@service router;
@service dialog;
get lastIndexOfPresets() {
return this.args.tools.resultSetMeta.presets.length - 1;
@@ -32,6 +36,75 @@ export default class AiToolListEditor extends Component {
);
}
@action
importTool() {
// Create a hidden file input and click it
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.style.display = "none";
fileInput.onchange = (event) => this.handleFileSelect(event);
document.body.appendChild(fileInput);
fileInput.click();
document.body.removeChild(fileInput);
}
@action
handleFileSelect(event) {
const file = event.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result);
this.uploadTool(json.ai_tool);
} catch {
this.dialog.alert(i18n("discourse_ai.tools.import_error_not_json"));
}
};
reader.readAsText(file);
}
uploadTool(toolData, force = false) {
let url = `/admin/plugins/discourse-ai/ai-tools/import.json`;
const payload = {
ai_tool: toolData,
};
if (force) {
payload.force = true;
}
return ajax(url, {
type: "POST",
data: JSON.stringify(payload),
contentType: "application/json",
})
.then((result) => {
let tool = AiTool.create(result.ai_tool);
let existingTool = this.args.tools.findBy("id", tool.id);
if (existingTool) {
this.args.tools.removeObject(existingTool);
}
this.args.tools.insertAt(0, tool);
})
.catch((error) => {
if (error.jqXHR?.status === 409) {
this.dialog.confirm({
message: i18n("discourse_ai.tools.import_error_conflict", {
name: toolData.tool_name,
}),
confirmButtonLabel: "discourse_ai.tools.overwrite",
didConfirm: () => this.uploadTool(toolData, true),
});
} else {
popupAjaxError(error);
}
});
}
<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-tools"
@@ -44,6 +117,12 @@ export default class AiToolListEditor extends Component {
@descriptionLabel={{i18n "discourse_ai.tools.subheader_description"}}
>
<:actions>
<DButton
@translatedLabel={{i18n "discourse_ai.tools.import"}}
@icon="upload"
class="btn btn-small ai-tool-list-editor__import-button"
@action={{this.importTool}}
/>
<DMenu
@triggerClass="btn-primary btn-small ai-tool-list-editor__new-button"
@label={{i18n "discourse_ai.tools.new"}}
+8
View File
@@ -328,6 +328,10 @@ en:
back: "Back"
name: "Name"
edit: "Edit"
export: "Export"
import: "Import"
import_error_conflict: "Conflict detected importing %{name}, would you like to update the existing persona?"
overwrite: "Overwrite"
description: "Description"
no_llm_selected: "No language model selected"
use_parent_llm: "Use personas language model"
@@ -445,6 +449,10 @@ en:
tools:
back: "Back"
short_title: "Tools"
export: "Export"
import: "Import"
import_error_conflict: "Tool already exists, would you like to update it?"
overwrite: "Overwrite"
no_tools: "You have not created any tools yet"
name: "Name"
name_help: "Name will show up in the Discourse UI and is the short identifier you will use to find the tool in various settings, it should be distinct (it is required)"
+4
View File
@@ -621,6 +621,10 @@ en:
no_default_llm: The persona must have a default_llm defined.
user_not_allowed: The user is not allowed to participate in the topic.
prompt_message_length: The message %{idx} is over the 1000 character limit.
persona_already_exists: Persona with the name %{name} already exists.
custom_tool_exists:
one: "Custom tool with the name %{names} already exists."
other: "Custom tools with the names %{names} already exists."
dashboard:
problem:
ai_llm_status: "The LLM model: %{model_name} is encountering issues. Please check the <a href='%{url}'>model's configuration page</a>."
+4
View File
@@ -85,8 +85,12 @@ Discourse::Application.routes.draw do
)
post "/ai-tools/:id/test", to: "discourse_ai/admin/ai_tools#test"
get "/ai-tools/:id/export", to: "discourse_ai/admin/ai_tools#export", format: :json
post "/ai-tools/import", to: "discourse_ai/admin/ai_tools#import"
post "/ai-personas/:id/create-user", to: "discourse_ai/admin/ai_personas#create_user"
get "/ai-personas/:id/export", to: "discourse_ai/admin/ai_personas#export", format: :json
post "/ai-personas/import", to: "discourse_ai/admin/ai_personas#import"
put "/ai-personas/:id/files/remove", to: "discourse_ai/admin/ai_personas#remove_file"
get "/ai-personas/:id/files/status", to: "discourse_ai/admin/ai_personas#indexing_status_check"
File diff suppressed because it is too large Load Diff
@@ -46,6 +46,107 @@ RSpec.describe DiscourseAi::Admin::AiToolsController do
end
end
describe "GET #export" do
it "returns the ai_tool as JSON attachment" do
get "/admin/plugins/discourse-ai/ai-tools/#{ai_tool.id}/export.json"
expect(response).to be_successful
expect(response.headers["Content-Disposition"]).to eq(
"attachment; filename=\"#{ai_tool.tool_name}.json\"",
)
expect(response.parsed_body["ai_tool"]["name"]).to eq(ai_tool.name)
expect(response.parsed_body["ai_tool"]["tool_name"]).to eq(ai_tool.tool_name)
expect(response.parsed_body["ai_tool"]["description"]).to eq(ai_tool.description)
expect(response.parsed_body["ai_tool"]["parameters"]).to eq(ai_tool.parameters)
end
it "returns 404 for non-existent ai_tool" do
get "/admin/plugins/discourse-ai/ai-tools/99999/export.json"
expect(response).to have_http_status(:not_found)
end
end
describe "POST #import" do
let(:import_attributes) do
{
name: "Imported Tool",
tool_name: "imported_tool",
description: "An imported test tool",
parameters: [{ name: "query", type: "string", description: "perform a search" }],
script: "function invoke(params) { return params; }",
summary: "Imported tool summary",
}
end
it "imports a new AI tool successfully" do
expect {
post "/admin/plugins/discourse-ai/ai-tools/import.json",
params: { ai_tool: import_attributes }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
}
}.to change(AiTool, :count).by(1)
expect(response).to have_http_status(:created)
expect(response.parsed_body["ai_tool"]["name"]).to eq("Imported Tool")
expect(response.parsed_body["ai_tool"]["tool_name"]).to eq("imported_tool")
end
it "returns conflict error when tool with same tool_name exists without force" do
_existing_tool =
AiTool.create!(
name: "Existing Tool",
tool_name: "imported_tool",
description: "Existing tool",
script: "function invoke(params) { return 'existing'; }",
summary: "Existing summary",
created_by_id: admin.id,
)
expect {
post "/admin/plugins/discourse-ai/ai-tools/import.json",
params: { ai_tool: import_attributes }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
}
}.not_to change(AiTool, :count)
expect(response).to have_http_status(:conflict)
expect(response.parsed_body["errors"]).to include(
"Tool with tool_name 'imported_tool' already exists. Use force=true to overwrite.",
)
end
it "force updates existing tool when force=true" do
existing_tool =
AiTool.create!(
name: "Existing Tool",
tool_name: "imported_tool",
description: "Existing tool",
script: "function invoke(params) { return 'existing'; }",
summary: "Existing summary",
created_by_id: admin.id,
)
expect {
post "/admin/plugins/discourse-ai/ai-tools/import.json?force=true",
params: { ai_tool: import_attributes }.to_json,
headers: {
"CONTENT_TYPE" => "application/json",
}
}.not_to change(AiTool, :count)
expect(response).to have_http_status(:ok)
expect(response.parsed_body["ai_tool"]["name"]).to eq("Imported Tool")
expect(response.parsed_body["ai_tool"]["description"]).to eq("An imported test tool")
existing_tool.reload
expect(existing_tool.name).to eq("Imported Tool")
expect(existing_tool.description).to eq("An imported test tool")
end
end
describe "POST #create" do
let(:valid_attributes) do
{
@@ -0,0 +1,41 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::PersonaExporter do
describe "#export" do
subject(:export_json) { JSON.parse(exporter.export) }
context "when exporting a persona with a custom tool" do
fab!(:ai_tool) { Fabricate(:ai_tool, name: "Giphy Searcher", tool_name: "giphy_search") }
fab!(:ai_persona) { Fabricate(:ai_persona, tools: [["custom-#{ai_tool.id}", nil, false]]) }
let(:exporter) { described_class.new(persona: ai_persona) }
it "returns JSON containing the persona and its custom tool" do
expect(export_json["persona"]["name"]).to eq(ai_persona.name)
expect(export_json["persona"]["tools"].first.first).to eq("custom-#{ai_tool.tool_name}")
custom_tool = export_json["custom_tools"].first
expect(custom_tool["identifier"]).to eq(ai_tool.tool_name)
expect(custom_tool["name"]).to eq(ai_tool.name)
end
end
context "when the persona has no custom tools" do
fab!(:ai_persona) { Fabricate(:ai_persona, tools: []) }
let(:exporter) { described_class.new(persona: ai_persona) }
it "returns JSON with an empty custom_tools array" do
expect(export_json["custom_tools"]).to eq([])
end
end
context "when the persona does not exist" do
it "raises an error if initialized with a non persona" do
expect { described_class.new(persona: nil) }.to raise_error(
ArgumentError,
"Invalid persona provided",
)
end
end
end
end
@@ -0,0 +1,156 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::PersonaImporter do
describe "#import!" do
context "when importing a persona with a custom tool" do
fab!(:ai_tool) { Fabricate(:ai_tool, name: "Giphy Searcher", tool_name: "giphy_search") }
fab!(:ai_persona) { Fabricate(:ai_persona, tools: [["custom-#{ai_tool.id}", nil, false]]) }
let!(:export_json) { DiscourseAi::PersonaExporter.new(persona: ai_persona).export }
it "creates the persona and its custom tool" do
ai_persona.destroy
ai_tool.destroy
importer = described_class.new(json: export_json)
persona = importer.import!
expect(persona).to be_persisted
expect(persona.tools.first.first).to start_with("custom-")
tool_id = persona.tools.first.first.split("-", 2).last.to_i
imported_tool = AiTool.find(tool_id)
expect(imported_tool.tool_name).to eq("giphy_search")
expect(imported_tool.name).to eq("Giphy Searcher")
end
end
context "when conflicts exist" do
fab!(:existing_tool) { Fabricate(:ai_tool, name: "Web Browser", tool_name: "browse_web") }
fab!(:another_tool) { Fabricate(:ai_tool, name: "Calculator", tool_name: "calculator") }
fab!(:ai_persona) do
Fabricate(
:ai_persona,
name: "Test Persona",
tools: [
["custom-#{existing_tool.id}", nil, false],
["custom-#{another_tool.id}", nil, false],
],
)
end
let(:export_json) { DiscourseAi::PersonaExporter.new(persona: ai_persona).export }
context "when persona already exists" do
it "raises ImportError with persona conflict details" do
importer = described_class.new(json: export_json)
expect { importer.import! }.to raise_error(
DiscourseAi::PersonaImporter::ImportError,
) do |error|
expect(error.conflicts).to eq(
persona: "Test Persona",
custom_tools: %w[browse_web calculator],
)
end
end
end
context "when custom tools already exist" do
before { ai_persona.destroy }
it "raises ImportError with custom tools conflict details" do
importer = described_class.new(json: export_json)
expect { importer.import! }.to raise_error(
DiscourseAi::PersonaImporter::ImportError,
) do |error|
expect(error.conflicts).to eq(custom_tools: %w[browse_web calculator])
end
end
end
context "when both persona and custom tools exist" do
it "raises ImportError with all conflicts" do
importer = described_class.new(json: export_json)
expect { importer.import! }.to raise_error(
DiscourseAi::PersonaImporter::ImportError,
) do |error|
expect(error.conflicts).to eq(
persona: "Test Persona",
custom_tools: %w[browse_web calculator],
)
end
end
end
end
context "with overwrite: true" do
fab!(:existing_tool) { Fabricate(:ai_tool, name: "Old Browser", tool_name: "browse_web") }
fab!(:existing_persona) do
Fabricate(
:ai_persona,
name: "Test Persona",
description: "Old description",
system_prompt: "Old prompt",
tools: [],
)
end
fab!(:new_tool) { Fabricate(:ai_tool, name: "New Tool", tool_name: "new_tool") }
let(:export_persona) do
Fabricate.build(
:ai_persona,
name: "Test Persona",
description: "New description",
system_prompt: "New prompt",
tools: [
["custom-#{existing_tool.id}", nil, false],
["custom-#{new_tool.id}", nil, false],
],
)
end
let(:export_json) { DiscourseAi::PersonaExporter.new(persona: export_persona).export }
before { export_persona.destroy }
it "overwrites existing persona" do
importer = described_class.new(json: export_json)
persona = importer.import!(overwrite: true)
expect(persona.id).to eq(existing_persona.id)
expect(persona.description).to eq("New description")
expect(persona.system_prompt).to eq("New prompt")
expect(persona.tools.length).to eq(2)
end
it "overwrites existing custom tools" do
existing_tool.update!(name: "Old Browser", description: "Old description")
importer = described_class.new(json: export_json)
expect { importer.import!(overwrite: true) }.not_to change { AiTool.count }
existing_tool.reload
expect(existing_tool.name).to eq("Old Browser") # Name from export_json
end
end
context "with invalid payload" do
it "raises an error for invalid JSON structure" do
expect { described_class.new(json: "{}").import! }.to raise_error(
ArgumentError,
"Invalid persona export data",
)
end
it "raises an error for missing persona data" do
expect { described_class.new(json: { "custom_tools" => [] }).import! }.to raise_error(
ArgumentError,
"Invalid persona export data",
)
end
end
end
end