mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 00:53:37 +00:00
Export the preselected tools and data sources of a chat template
This commit is contained in:
parent
6a2a8c4e9d
commit
b3fe7e518c
@ -369,15 +369,24 @@ public record ChatTemplate(
|
||||
{
|
||||
issue = string.Empty;
|
||||
var fileAttachmentsLua = this.BuildFileAttachmentsLua(fileAttachmentPaths);
|
||||
|
||||
//
|
||||
// Both of these may be absent entirely, because saying nothing about tools or data sources
|
||||
// is a statement of its own. They therefore bring their own line break and indentation
|
||||
// instead of sitting on a line of the template:
|
||||
//
|
||||
var toolIdsLua = this.BuildToolIdsLua();
|
||||
var dataSourceOptionsLua = this.BuildDataSourceOptionsLua();
|
||||
|
||||
luaCode = $$"""
|
||||
CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = {
|
||||
{{this.BuildDataSourceIdNote()}}CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = {
|
||||
["Id"] = "{{LuaTools.EscapeLuaString(exportId)}}",
|
||||
["Name"] = {{LuaTools.ToLuaStringLiteral(this.Name)}},
|
||||
["SystemPrompt"] = {{LuaTools.ToLuaStringLiteral(this.SystemPrompt)}},
|
||||
["PredefinedUserPrompt"] = {{LuaTools.ToLuaStringLiteral(this.PredefinedUserPrompt)}},
|
||||
["AllowProfileUsage"] = {{this.AllowProfileUsage.ToString().ToLowerInvariant()}},
|
||||
["FileAttachments"] = {{fileAttachmentsLua}},
|
||||
["ExampleConversation"] = {{exampleConversationLua}},
|
||||
["ExampleConversation"] = {{exampleConversationLua}},{{toolIdsLua}}{{dataSourceOptionsLua}}
|
||||
}
|
||||
""";
|
||||
return true;
|
||||
@ -487,6 +496,84 @@ public record ChatTemplate(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty set is written out as an empty table rather than being left out: the two say
|
||||
/// different things, and dropping the line would turn "no tools at all" into "whatever the
|
||||
/// chat default is" on the machine which reads this back.
|
||||
/// </remarks>
|
||||
private string BuildToolIdsLua()
|
||||
{
|
||||
if (this.ToolIds is null)
|
||||
return string.Empty;
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine();
|
||||
if (this.ToolIds.Count == 0)
|
||||
{
|
||||
builder.Append(""" ["ToolIds"] = {},""");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
builder.AppendLine(""" ["ToolIds"] = {""");
|
||||
|
||||
//
|
||||
// A set has no order of its own, so exporting the same template twice would otherwise
|
||||
// produce two different files. Sorting keeps the plugin diffs readable:
|
||||
//
|
||||
foreach (var toolId in this.ToolIds.Order(StringComparer.Ordinal))
|
||||
builder.AppendLine($" {LuaTools.ToLuaStringLiteral(toolId)},");
|
||||
|
||||
builder.Append(" },");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private string BuildDataSourceOptionsLua()
|
||||
{
|
||||
if (this.DataSourceOptions is not { } options)
|
||||
return string.Empty;
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(""" ["DataSourceOptions"] = {""");
|
||||
builder.AppendLine($""" ["DisableDataSources"] = {options.DisableDataSources.ToString().ToLowerInvariant()},""");
|
||||
builder.AppendLine($""" ["AutomaticDataSourceSelection"] = {options.AutomaticDataSourceSelection.ToString().ToLowerInvariant()},""");
|
||||
builder.AppendLine($""" ["AutomaticValidation"] = {options.AutomaticValidation.ToString().ToLowerInvariant()},""");
|
||||
|
||||
if (options.PreselectedDataSourceIds.Count == 0)
|
||||
builder.AppendLine(""" ["PreselectedDataSourceIds"] = {},""");
|
||||
else
|
||||
{
|
||||
builder.AppendLine(""" ["PreselectedDataSourceIds"] = {""");
|
||||
foreach (var dataSourceId in options.PreselectedDataSourceIds)
|
||||
builder.AppendLine($" {LuaTools.ToLuaStringLiteral(dataSourceId)},");
|
||||
|
||||
builder.AppendLine(" },");
|
||||
}
|
||||
|
||||
builder.Append(" },");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The template itself gets a fresh ID on export, but the data source IDs must not: they point
|
||||
/// at the sources of the organization and only work when both sides agree on them. Nobody can
|
||||
/// see that from the exported code alone, hence this note.
|
||||
/// </remarks>
|
||||
private string BuildDataSourceIdNote()
|
||||
{
|
||||
if (this.DataSourceOptions is not { PreselectedDataSourceIds.Count: > 0 })
|
||||
return string.Empty;
|
||||
|
||||
// The empty line before the closing delimiter is what ends the last comment line. Without
|
||||
// it, the assignment would continue that comment and the whole export would be one comment:
|
||||
return """
|
||||
-- The data source IDs below are the ones of the machine this was exported from.
|
||||
-- Please check them against your CONFIG["DATA_SOURCES"]: an ID which resolves to
|
||||
-- nothing is ignored, and a chat with this template then starts without that source.
|
||||
|
||||
""";
|
||||
}
|
||||
|
||||
private string BuildFileAttachmentsLua(IReadOnlyList<string>? fileAttachmentPaths)
|
||||
{
|
||||
var paths = fileAttachmentPaths ?? this.FileAttachments.Select(attachment => attachment.FilePath).ToList();
|
||||
|
||||
239
app/Tests/Settings/ChatTemplateConfigurationTests.cs
Normal file
239
app/Tests/Settings/ChatTemplateConfigurationTests.cs
Normal file
@ -0,0 +1,239 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
using Lua.Standard;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the tools and data sources a chat template carries, across the two surfaces which have
|
||||
/// to agree on them: the Lua a configuration plugin states, and the Lua the app exports.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The interesting part is not that a value survives, but that the difference between "this
|
||||
/// template says nothing" and "this template says none" survives. Both end up as an empty
|
||||
/// selection in the chat on a fresh installation, so a mistake here stays invisible until somebody
|
||||
/// sets a default tool for their chats -- and then quietly hands out a tool the template ruled out.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChatTemplateConfigurationTests
|
||||
{
|
||||
private static readonly Guid PLUGIN_ID = new("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
[Test]
|
||||
public async Task WhatTheAppExportsIsWhatAConfigurationPluginCanReadBack()
|
||||
{
|
||||
var written = NewTemplate() with
|
||||
{
|
||||
ToolIds = ["read_web_page", "web_search"],
|
||||
DataSourceOptions = new()
|
||||
{
|
||||
DisableDataSources = false,
|
||||
AutomaticDataSourceSelection = false,
|
||||
AutomaticValidation = true,
|
||||
PreselectedDataSourceIds = ["11111111-1111-1111-1111-111111111111"],
|
||||
},
|
||||
};
|
||||
|
||||
var read = await ExportAndReadBackAsync(written);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(read.ToolIds, Is.EquivalentTo(written.ToolIds!));
|
||||
Assert.That(read.DataSourceOptions, Is.Not.Null);
|
||||
Assert.That(read.DataSourceOptions!.DisableDataSources, Is.False);
|
||||
Assert.That(read.DataSourceOptions.AutomaticDataSourceSelection, Is.False);
|
||||
Assert.That(read.DataSourceOptions.AutomaticValidation, Is.True);
|
||||
Assert.That(read.DataSourceOptions.PreselectedDataSourceIds, Is.EqualTo(written.DataSourceOptions!.PreselectedDataSourceIds));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnAgenticSelectionSurvivesTheExportAsWell()
|
||||
{
|
||||
var written = NewTemplate() with
|
||||
{
|
||||
DataSourceOptions = new()
|
||||
{
|
||||
DisableDataSources = false,
|
||||
AutomaticDataSourceSelection = true,
|
||||
AutomaticValidation = true,
|
||||
PreselectedDataSourceIds = [],
|
||||
},
|
||||
};
|
||||
|
||||
var read = await ExportAndReadBackAsync(written);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(read.DataSourceOptions, Is.Not.Null);
|
||||
Assert.That(read.DataSourceOptions!.AutomaticDataSourceSelection, Is.True, "Letting an agent pick the sources is the one thing only a chat template can state, so it must not be lost on the way through Lua.");
|
||||
Assert.That(read.DataSourceOptions.PreselectedDataSourceIds, Is.Empty);
|
||||
Assert.That(read.ToolIds, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ATemplateWhichSaysNothingExportsNeitherTable()
|
||||
{
|
||||
var written = NewTemplate();
|
||||
Assert.That(written.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(luaCode, Does.Not.Contain("ToolIds"));
|
||||
Assert.That(luaCode, Does.Not.Contain("DataSourceOptions"));
|
||||
});
|
||||
|
||||
var read = await ParseAsync(luaCode);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(read.ToolIds, Is.Null, "A template without a tool selection must stay without one, so the chat keeps using its own default.");
|
||||
Assert.That(read.DataSourceOptions, Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ATemplateWhichRulesOutEveryToolStaysThatWay()
|
||||
{
|
||||
var written = NewTemplate() with { ToolIds = [] };
|
||||
var read = await ExportAndReadBackAsync(written);
|
||||
|
||||
Assert.That(read.ToolIds, Is.Not.Null, "An empty selection is the statement that this template wants no tools. Reading it back as null would hand out the chat default instead.");
|
||||
Assert.That(read.ToolIds, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NamingTheDataSourceOptionsAtAllSwitchesDataSourcesOn()
|
||||
{
|
||||
var read = await ParseAsync("""
|
||||
CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = {
|
||||
["Id"] = "33333333-3333-3333-3333-333333333333",
|
||||
["Name"] = "Intranet Research",
|
||||
["SystemPrompt"] = "You are a research assistant.",
|
||||
["DataSourceOptions"] = {
|
||||
["PreselectedDataSourceIds"] = {
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
},
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.That(read.DataSourceOptions, Is.Not.Null);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(read.DataSourceOptions!.DisableDataSources, Is.False, "Writing this table is already the statement that the template wants data sources, so an omitted switch must not turn them off again.");
|
||||
Assert.That(read.DataSourceOptions.AutomaticDataSourceSelection, Is.False);
|
||||
Assert.That(read.DataSourceOptions.AutomaticValidation, Is.False);
|
||||
Assert.That(read.DataSourceOptions.PreselectedDataSourceIds, Is.EqualTo(new[] { "11111111-1111-1111-1111-111111111111" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnUnusableEntryIsSkippedAndTheRestOfTheListSurvives()
|
||||
{
|
||||
var read = await ParseAsync("""
|
||||
CONFIG["CHAT_TEMPLATES"][#CONFIG["CHAT_TEMPLATES"]+1] = {
|
||||
["Id"] = "33333333-3333-3333-3333-333333333333",
|
||||
["Name"] = "Intranet Research",
|
||||
["SystemPrompt"] = "You are a research assistant.",
|
||||
["ToolIds"] = {
|
||||
"web_search",
|
||||
"",
|
||||
{},
|
||||
"read_web_page",
|
||||
},
|
||||
["DataSourceOptions"] = {
|
||||
["PreselectedDataSourceIds"] = {
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
" ",
|
||||
},
|
||||
},
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(read.ToolIds, Is.EquivalentTo(new[] { "web_search", "read_web_page" }));
|
||||
Assert.That(read.DataSourceOptions!.PreselectedDataSourceIds, Is.EqualTo(new[] { "11111111-1111-1111-1111-111111111111" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExportedDataSourceIdsComeWithTheNoteThatTheyAreLocalOnes()
|
||||
{
|
||||
var withSources = NewTemplate() with
|
||||
{
|
||||
DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = ["11111111-1111-1111-1111-111111111111"] },
|
||||
};
|
||||
|
||||
var agenticOnly = NewTemplate() with
|
||||
{
|
||||
DataSourceOptions = new() { DisableDataSources = false, AutomaticDataSourceSelection = true },
|
||||
};
|
||||
|
||||
Assert.That(withSources.TryExportAsConfigurationSection(out var withSourcesLua, out var issue), Is.True, issue);
|
||||
Assert.That(agenticOnly.TryExportAsConfigurationSection(out var agenticOnlyLua, out issue), Is.True, issue);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(withSourcesLua, Does.StartWith("--"), "Whoever pastes this into a plugin cannot see from the IDs alone that they belong to another machine.");
|
||||
Assert.That(agenticOnlyLua, Does.Not.StartWith("--"), "Without IDs there is nothing to check, so the note would only be noise.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A template with the parts every export needs, and nothing said about tools or data sources.
|
||||
/// </summary>
|
||||
private static ChatTemplate NewTemplate() => new()
|
||||
{
|
||||
Num = 1,
|
||||
Id = "33333333-3333-3333-3333-333333333333",
|
||||
Name = "Intranet Research",
|
||||
SystemPrompt = "You are a research assistant.",
|
||||
PredefinedUserPrompt = string.Empty,
|
||||
ExampleConversation = [],
|
||||
FileAttachments = [],
|
||||
AllowProfileUsage = true,
|
||||
};
|
||||
|
||||
private static async Task<ChatTemplate> ExportAndReadBackAsync(ChatTemplate template)
|
||||
{
|
||||
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
|
||||
return await ParseAsync(luaCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a chat template the way a configuration plugin states it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Through a real Lua state rather than a table put together in C#, so that the exported code
|
||||
/// has to be valid Lua before anything else is checked.
|
||||
/// </remarks>
|
||||
/// <param name="luaCode">The lines a plugin would contain, including the assignment itself.</param>
|
||||
/// <returns>The chat template read from it.</returns>
|
||||
private static async Task<ChatTemplate> ParseAsync(string luaCode)
|
||||
{
|
||||
var state = LuaState.Create();
|
||||
state.OpenBasicLibrary();
|
||||
state.OpenTableLibrary();
|
||||
|
||||
await state.DoStringAsync($$"""
|
||||
CONFIG = {}
|
||||
CONFIG["CHAT_TEMPLATES"] = {}
|
||||
{{luaCode}}
|
||||
""");
|
||||
|
||||
if (!state.Environment["CONFIG"].TryRead<LuaTable>(out var configTable) ||
|
||||
!configTable["CHAT_TEMPLATES"].TryRead<LuaTable>(out var templatesTable) ||
|
||||
!templatesTable[1].TryRead<LuaTable>(out var templateTable))
|
||||
throw new InvalidOperationException("The code of this test did not produce a chat template table.");
|
||||
|
||||
if (!ChatTemplate.TryParseChatTemplateTable(1, templateTable, PLUGIN_ID, string.Empty, out var parsed) || parsed is not ChatTemplate chatTemplate)
|
||||
throw new InvalidOperationException("The chat template of this test could not be read.");
|
||||
|
||||
return chatTemplate;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user