mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 20:47:22 +00:00
Added importing of configurations exported from AI Studio (#1007)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
f8c0232cfd
commit
22065783c2
49 files changed
+3257
-263
No files matched your search
@@ -0,0 +1,609 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tests.Tools.ToolCalling;
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using AuthMethod = AIStudio.Tools.ERIClient.DataModel.AuthMethod;
|
||||
using Host = AIStudio.Provider.SelfHosted.Host;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that an imported configuration snippet becomes a configuration object of the user's own,
|
||||
/// which keeps what the export wrote and leaves out what does not exist on this computer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The exports encrypt their secrets with an encryption secret of the test. PluginFactory holds it
|
||||
/// for the whole process, as the app does, so this fixture must not run in parallel with others.
|
||||
/// Nothing here asserts on the wording of a hint or an issue, see ConfigurationSnippetParserTests.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
|
||||
{
|
||||
private const string API_KEY = "sk-imported-key";
|
||||
|
||||
private const string TOKEN = "eri-imported-token";
|
||||
|
||||
private static readonly PluginConfigurationObjectType[] IMPORTABLE_TYPES =
|
||||
[
|
||||
PluginConfigurationObjectType.LLM_PROVIDER,
|
||||
PluginConfigurationObjectType.EMBEDDING_PROVIDER,
|
||||
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER,
|
||||
PluginConfigurationObjectType.PROFILE,
|
||||
PluginConfigurationObjectType.CHAT_TEMPLATE,
|
||||
PluginConfigurationObjectType.DATA_SOURCE,
|
||||
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY,
|
||||
];
|
||||
|
||||
private string temporaryDirectory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void PrepareEncryptionAndFiles()
|
||||
{
|
||||
PluginFactory.InitializeEnterpriseEncryption(EnterpriseEncryption.GenerateSecret());
|
||||
this.temporaryDirectory = Directory.CreateTempSubdirectory("snippet-import-").FullName;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RemoveEncryptionAndFiles()
|
||||
{
|
||||
RemoveEncryptionSecret();
|
||||
Directory.Delete(this.temporaryDirectory, recursive: true);
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(IMPORTABLE_TYPES))]
|
||||
public void EveryExportBecomesAnObjectOfTheUsersOwn(PluginConfigurationObjectType type)
|
||||
{
|
||||
var (original, snippet) = Export(type);
|
||||
var imported = Import(snippet, type).ConfigurationObject;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Guid.TryParse(imported.Id, out _), Is.True, "Every configuration object needs a GUID as its ID.");
|
||||
Assert.That(imported.Id, Is.Not.EqualTo(original.Id).And.Not.EqualTo(ExportedId(snippet, type)), "Importing the same snippet twice must not create two objects with one ID.");
|
||||
Assert.That(imported.Num, Is.Zero, "The number is given when the object is stored.");
|
||||
Assert.That(imported.IsEnterpriseConfiguration, Is.False);
|
||||
Assert.That(imported.EnterpriseConfigurationPluginId, Is.EqualTo(Guid.Empty));
|
||||
Assert.That(imported.Name, Is.EqualTo(original.Name));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryTypeOfConfigurationObjectCanBeImported() => Assert.That(IMPORTABLE_TYPES, Is.EquivalentTo(PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.Type)), "A new type of configuration object needs a decision on how an import makes it local, and its export here.");
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.PROFILE)]
|
||||
[TestCase(PluginConfigurationObjectType.DATA_SOURCE)]
|
||||
public void AnImportKeepsEveryValueButTheIdentity(PluginConfigurationObjectType type)
|
||||
{
|
||||
var (original, snippet) = Export(type);
|
||||
var imported = Import(snippet, type).ConfigurationObject;
|
||||
|
||||
Assert.That(WithIdentityOf(imported, original), Is.EqualTo(original));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChatTemplateKeepsItsPromptsConversationAndPreselections()
|
||||
{
|
||||
var (original, snippet) = Export(PluginConfigurationObjectType.CHAT_TEMPLATE);
|
||||
var template = (ChatTemplate)original;
|
||||
var imported = (ChatTemplate)Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE).ConfigurationObject;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(imported.SystemPrompt, Is.EqualTo(template.SystemPrompt));
|
||||
Assert.That(imported.PredefinedUserPrompt, Is.EqualTo(template.PredefinedUserPrompt));
|
||||
Assert.That(imported.AllowProfileUsage, Is.EqualTo(template.AllowProfileUsage));
|
||||
Assert.That(imported.ExampleConversation.Select(Message), Is.EqualTo(template.ExampleConversation.Select(Message)));
|
||||
Assert.That(imported.ToolIds, Is.EquivalentTo(template.ToolIds!));
|
||||
Assert.That(imported.DataSourceOptions!.AutomaticDataSourceSelection, Is.EqualTo(template.DataSourceOptions!.AutomaticDataSourceSelection));
|
||||
Assert.That(imported.DataSourceOptions.AutomaticValidation, Is.EqualTo(template.DataSourceOptions.AutomaticValidation));
|
||||
Assert.That(imported.DataSourceOptions.PreselectedDataSourceIds, Is.EqualTo(template.DataSourceOptions.PreselectedDataSourceIds));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APolicyKeepsItsRulesButNotItsProtection()
|
||||
{
|
||||
var (original, snippet) = Export(PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY);
|
||||
var policy = (DataDocumentAnalysisPolicy)original;
|
||||
var imported = (DataDocumentAnalysisPolicy)Import(snippet, PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY).ConfigurationObject;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(imported.IsProtected, Is.False, "A configuration plugin protects its policies; the user's own policy is the user's to edit.");
|
||||
Assert.That(imported.PolicyDescription, Is.EqualTo(policy.PolicyDescription));
|
||||
Assert.That(imported.AnalysisRules, Is.EqualTo(policy.AnalysisRules));
|
||||
Assert.That(imported.OutputRules, Is.EqualTo(policy.OutputRules));
|
||||
Assert.That(imported.MinimumProviderConfidence, Is.EqualTo(policy.MinimumProviderConfidence));
|
||||
Assert.That(imported.AllowedToolIds, Is.EquivalentTo(policy.AllowedToolIds));
|
||||
Assert.That(imported.PreselectedProvider, Is.EqualTo(policy.PreselectedProvider));
|
||||
Assert.That(imported.PreselectedProfile, Is.EqualTo(policy.PreselectedProfile));
|
||||
Assert.That(imported.HidePolicyDefinition, Is.EqualTo(policy.HidePolicyDefinition));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASnippetWhichIsNoValidConfigurationIsRefused()
|
||||
{
|
||||
const string PROFILE_WITHOUT_ACTIONS = """CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { ["Id"] = "6b8e1a2c-3d4f-4a5b-9c6d-7e8f9a0b1c2d", ["Name"] = "Writer", ["NeedToKnow"] = "I write." }""";
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ConfigurationSnippetImport.TryImport(PROFILE_WITHOUT_ACTIONS, PluginConfigurationObjectType.PROFILE, out var result, out var issue), Is.False);
|
||||
Assert.That(result, Is.Null);
|
||||
Assert.That(issue, Is.Not.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASnippetWhichCannotBeReadIsRefused()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ConfigurationSnippetImport.TryImport("CONFIG[\"PROFILES\"] = os.getenv(\"HOME\")", PluginConfigurationObjectType.PROFILE, out _, out var issue), Is.False);
|
||||
Assert.That(issue, Is.Not.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
|
||||
public void TheAPIKeyOfAProviderIsDecrypted(PluginConfigurationObjectType type)
|
||||
{
|
||||
var result = Import(ExportProvider(type, Encrypt(API_KEY)).Snippet, type);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Secret, Is.EqualTo(API_KEY));
|
||||
Assert.That(result.Hints, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
|
||||
public void AnAPIKeyOfAnotherOrganizationLeavesAHint(PluginConfigurationObjectType type)
|
||||
{
|
||||
var otherOrganization = new EnterpriseEncryption(NullLogger<EnterpriseEncryption>.Instance, EnterpriseEncryption.GenerateSecret());
|
||||
Assert.That(otherOrganization.TryEncrypt(API_KEY, out var foreignApiKey), Is.True);
|
||||
|
||||
AssertAPIKeyLeftOut(Import(ExportProvider(type, foreignApiKey).Snippet, type));
|
||||
}
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
|
||||
public void AnAPIKeyWithoutAnEncryptionSecretLeavesAHint(PluginConfigurationObjectType type)
|
||||
{
|
||||
var snippet = ExportProvider(type, Encrypt(API_KEY)).Snippet;
|
||||
RemoveEncryptionSecret();
|
||||
|
||||
AssertAPIKeyLeftOut(Import(snippet, type));
|
||||
}
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER)]
|
||||
public void AnUnencryptedAPIKeyIsLeftOut(PluginConfigurationObjectType type) => AssertAPIKeyLeftOut(Import(ExportProvider(type, API_KEY).Snippet, type));
|
||||
|
||||
[Test]
|
||||
public void TheTokenOfADataSourceIsDecrypted() => Assert.That(Import(ExportDataSource(AuthMethod.TOKEN).Snippet, PluginConfigurationObjectType.DATA_SOURCE).Secret, Is.EqualTo(TOKEN));
|
||||
|
||||
[Test]
|
||||
public void ADataSourceWithATokenNeedsTheEncryptionSecret()
|
||||
{
|
||||
var snippet = ExportDataSource(AuthMethod.TOKEN).Snippet;
|
||||
RemoveEncryptionSecret();
|
||||
|
||||
AssertRefused(snippet, PluginConfigurationObjectType.DATA_SOURCE);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADataSourceWithATokenOfAnotherOrganizationIsRefused()
|
||||
{
|
||||
var snippet = ExportDataSource(AuthMethod.TOKEN).Snippet;
|
||||
PluginFactory.InitializeEnterpriseEncryption(EnterpriseEncryption.GenerateSecret());
|
||||
|
||||
AssertRefused(snippet, PluginConfigurationObjectType.DATA_SOURCE);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADataSourceWithoutSignInNeedsNoEncryptionSecret()
|
||||
{
|
||||
var snippet = ExportDataSource(AuthMethod.NONE).Snippet;
|
||||
RemoveEncryptionSecret();
|
||||
|
||||
Assert.That(Import(snippet, PluginConfigurationObjectType.DATA_SOURCE).Secret, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AKerberosDataSourceIsRefused() => AssertRefused(ExportDataSource(AuthMethod.KERBEROS).Snippet, PluginConfigurationObjectType.DATA_SOURCE);
|
||||
|
||||
[TestCase("jane.doe", "jane.doe")]
|
||||
[TestCase("", "")]
|
||||
public void ASharedUsernameIsKeptButNotItsPlaceholder(string username, string expectedUsername)
|
||||
{
|
||||
var (_, snippet) = ExportDataSource(AuthMethod.USERNAME_PASSWORD, username, DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD);
|
||||
var result = Import(snippet, PluginConfigurationObjectType.DATA_SOURCE);
|
||||
var dataSource = (DataSourceERI_V1)result.ConfigurationObject;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(dataSource.Username, Is.EqualTo(expectedUsername));
|
||||
Assert.That(dataSource.UsernamePasswordMode, Is.EqualTo(DataSourceERIUsernamePasswordMode.USER_MANAGED), "Only an organization manages the sign-in for its users.");
|
||||
Assert.That(result.Secret, Is.EqualTo(TOKEN));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachmentsWhichAreNotOnThisComputerAreToBeRelinked()
|
||||
{
|
||||
var existing = this.CreateFile("report.pdf");
|
||||
var missing = Path.Combine(this.temporaryDirectory, "missing.pdf");
|
||||
const string RELATIVE = "attachments/notes.txt";
|
||||
const string WINDOWS_SHARE = @"\\fileserver\share\minutes.docx";
|
||||
const string UNIX_SHARE = "//fileserver/share/minutes.docx";
|
||||
|
||||
var result = Import(ExportChatTemplate([existing, missing, RELATIVE, WINDOWS_SHARE, UNIX_SHARE]), PluginConfigurationObjectType.CHAT_TEMPLATE);
|
||||
var template = (ChatTemplate)result.ConfigurationObject;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.FileAttachments.Select(attachment => attachment.FilePath), Is.EqualTo(new[] { existing }));
|
||||
Assert.That(result.AttachmentsToRelink, Is.EqualTo(new[] { missing, RELATIVE, WINDOWS_SHARE, UNIX_SHARE }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachmentsOnThisComputerAreNamedInAHint()
|
||||
{
|
||||
var existing = this.CreateFile("id_rsa");
|
||||
var result = Import(ExportChatTemplate([existing]), PluginConfigurationObjectType.CHAT_TEMPLATE);
|
||||
|
||||
Assert.That(result.Hints, Has.Exactly(1).Contains(existing), "A snippet can name any file of this computer, so the user has to see which ones every chat would carry.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChatTemplateWithoutAttachmentsLeavesNoHint()
|
||||
{
|
||||
var result = Import(ExportChatTemplate([]), PluginConfigurationObjectType.CHAT_TEMPLATE);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Hints, Is.Empty);
|
||||
Assert.That(result.AttachmentsToRelink, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
|
||||
public void ATokenizerOnThisComputerIsKept(PluginConfigurationObjectType type)
|
||||
{
|
||||
var tokenizer = this.CreateFile("tokenizer.json");
|
||||
var result = Import(ExportProvider(type, null, tokenizer).Snippet, type);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.EqualTo(tokenizer));
|
||||
Assert.That(result.Hints, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.LLM_PROVIDER)]
|
||||
[TestCase(PluginConfigurationObjectType.EMBEDDING_PROVIDER)]
|
||||
public void ATokenizerWhichIsNotOnThisComputerIsLeftOut(PluginConfigurationObjectType type)
|
||||
{
|
||||
var tokenizer = Path.Combine(this.temporaryDirectory, "missing-tokenizer.json");
|
||||
var result = Import(ExportProvider(type, null, tokenizer).Snippet, type);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(TokenizerPathOf(result.ConfigurationObject), Is.Empty, "Saving the provider would fail on a tokenizer which cannot be copied.");
|
||||
Assert.That(result.Hints, Has.Exactly(1).Contains(tokenizer));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MissingReferencesOfAPolicyAreNamed()
|
||||
{
|
||||
var missingProfileId = Guid.NewGuid().ToString();
|
||||
var snippet = ExportPolicy(preselectedProvider: "missing-provider", preselectedProfile: missingProfileId, allowedToolIds: [TOOL_ID, "missing_tool"]);
|
||||
var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, this.CreateRegistry(new TestTool(Definition())));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Hints, Has.Count.EqualTo(3), "One sentence for each kind of reference.");
|
||||
Assert.That(result.Hints, Has.Exactly(1).Contains("missing-provider"));
|
||||
Assert.That(result.Hints, Has.Exactly(1).Contains(missingProfileId));
|
||||
Assert.That(result.Hints, Has.Exactly(1).Contains("missing_tool"));
|
||||
Assert.That(result.Hints, Has.None.Contains(TOOL_ID), "The tool exists.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MissingReferencesOfAChatTemplateAreNamed()
|
||||
{
|
||||
var missingDataSourceId = Guid.NewGuid().ToString();
|
||||
var snippet = ExportChatTemplate([], toolIds: [TOOL_ID, "missing_tool"], dataSourceIds: [missingDataSourceId]);
|
||||
var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(snippet, PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition())));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Hints, Has.Count.EqualTo(2), "One sentence for each kind of reference.");
|
||||
Assert.That(result.Hints, Has.Exactly(1).Contains(missingDataSourceId));
|
||||
Assert.That(result.Hints, Has.Exactly(1).Contains("missing_tool"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReferencesWhichExistLeaveNoHint()
|
||||
{
|
||||
var provider = ExportProvider(PluginConfigurationObjectType.LLM_PROVIDER, null).Original;
|
||||
var profile = Export(PluginConfigurationObjectType.PROFILE).Original;
|
||||
var dataSource = ExportDataSource(AuthMethod.NONE).Original;
|
||||
this.SettingsManager.ConfigurationData.Providers.Add((AIStudio.Settings.Provider)provider);
|
||||
this.SettingsManager.ConfigurationData.Profiles.Add((Profile)profile);
|
||||
this.SettingsManager.ConfigurationData.DataSources.Add((DataSourceERI_V1)dataSource);
|
||||
var registry = this.CreateRegistry(new TestTool(Definition()));
|
||||
|
||||
var policy = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportPolicy(provider.Id, profile.Id, [TOOL_ID]), PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, registry);
|
||||
var template = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], [TOOL_ID], [dataSource.Id]), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, registry);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(policy.Hints, Is.Empty);
|
||||
Assert.That(template.Hints, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AToolOfACollectionIsSelectedAsItsCollection()
|
||||
{
|
||||
const string COLLECTION_ID = "test_collection";
|
||||
var registry = this.CreateRegistry([new TestCollection(COLLECTION_ID, ConfidenceLevel.NONE, TOOL_ID)], new TestTool(Definition()));
|
||||
|
||||
var policy = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportPolicy(allowedToolIds: [TOOL_ID]), PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY), this.SettingsManager, registry);
|
||||
var template = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], [TOOL_ID]), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, registry);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(((DataDocumentAnalysisPolicy)policy.ConfigurationObject).AllowedToolIds, Is.EquivalentTo(new[] { COLLECTION_ID }));
|
||||
Assert.That(((ChatTemplate)template.ConfigurationObject).ToolIds, Is.EquivalentTo(new[] { COLLECTION_ID }));
|
||||
Assert.That(policy.Hints, Is.Empty);
|
||||
Assert.That(template.Hints, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChatTemplateWhichSaysNothingAboutToolsKeepsSayingNothing()
|
||||
{
|
||||
var result = await ConfigurationSnippetImport.ResolveReferencesAsync(Import(ExportChatTemplate([], toolIds: null), PluginConfigurationObjectType.CHAT_TEMPLATE), this.SettingsManager, this.CreateRegistry(new TestTool(Definition())));
|
||||
|
||||
Assert.That(((ChatTemplate)result.ConfigurationObject).ToolIds, Is.Null, "No tools at all would be a statement the template never made.");
|
||||
}
|
||||
|
||||
private static ConfigurationSnippetImportResult Import(string snippet, PluginConfigurationObjectType type)
|
||||
{
|
||||
Assert.That(ConfigurationSnippetImport.TryImport(snippet, type, out var result, out var issue), Is.True, issue);
|
||||
return result!;
|
||||
}
|
||||
|
||||
private static void AssertRefused(string snippet, PluginConfigurationObjectType type)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ConfigurationSnippetImport.TryImport(snippet, type, out var result, out var issue), Is.False);
|
||||
Assert.That(result, Is.Null);
|
||||
Assert.That(issue, Is.Not.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertAPIKeyLeftOut(ConfigurationSnippetImportResult result)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.Secret, Is.Empty);
|
||||
Assert.That(result.Hints, Has.Count.EqualTo(1), "The provider is imported, but the user has to enter a key.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leaves this computer without the encryption secret of an organization, as most computers are.
|
||||
/// </summary>
|
||||
private static void RemoveEncryptionSecret() => PluginFactory.InitializeEnterpriseEncryption(encryptionSecret: null);
|
||||
|
||||
private static string Encrypt(string secret)
|
||||
{
|
||||
Assert.That(PluginFactory.EnterpriseEncryption!.TryEncrypt(secret, out var encryptedSecret), Is.True);
|
||||
return encryptedSecret;
|
||||
}
|
||||
|
||||
private static string ExportedId(string snippet, PluginConfigurationObjectType type)
|
||||
{
|
||||
Assert.That(ConfigurationSnippetParser.TryParse(snippet, type, out var table, out var issue), Is.True, issue);
|
||||
return table["Id"].Read<string>();
|
||||
}
|
||||
|
||||
private static object WithIdentityOf(IConfigurationObject imported, IConfigurationObject original) => imported switch
|
||||
{
|
||||
ConfigurationBaseObject configObject => configObject with { Id = original.Id, Num = original.Num },
|
||||
DataSourceERI_V1 dataSource => dataSource with { Id = original.Id, Num = original.Num },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(imported), imported, "This test cannot compare this type."),
|
||||
};
|
||||
|
||||
private static string TokenizerPathOf(IConfigurationObject configObject) => configObject switch
|
||||
{
|
||||
AIStudio.Settings.Provider provider => provider.TokenizerPath,
|
||||
EmbeddingProvider provider => provider.TokenizerPath,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(configObject), configObject, "This type has no tokenizer."),
|
||||
};
|
||||
|
||||
private static (ChatRole Role, string Text) Message(ContentBlock block) => (block.Role, (block.Content as ContentText)?.Text ?? string.Empty);
|
||||
|
||||
private string CreateFile(string fileName)
|
||||
{
|
||||
var path = Path.Combine(this.temporaryDirectory, fileName);
|
||||
File.WriteAllText(path, "{}");
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the app exports for one item of each type, along with the item.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The items are made here rather than in a test case source: NUnit reads such a source before
|
||||
/// TestHost runs, and the provider types need its logger factory. Texts span lines with line
|
||||
/// feeds only, as Lua turns every other line break of a long string into one.
|
||||
/// </remarks>
|
||||
private static (IConfigurationObject Original, string Snippet) Export(PluginConfigurationObjectType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case PluginConfigurationObjectType.LLM_PROVIDER or PluginConfigurationObjectType.EMBEDDING_PROVIDER or PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER:
|
||||
return ExportProvider(type, null);
|
||||
|
||||
case PluginConfigurationObjectType.PROFILE:
|
||||
var profile = new Profile(1, Guid.NewGuid().ToString(), "Technical writer", "I write manuals.\nMy readers are engineers.", "Keep the answers short.");
|
||||
return (profile, profile.ExportAsConfigurationSection());
|
||||
|
||||
case PluginConfigurationObjectType.CHAT_TEMPLATE:
|
||||
var template = ChatTemplateWith([], ["web_search"], [Guid.NewGuid().ToString()]);
|
||||
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
|
||||
return (template, luaCode);
|
||||
|
||||
case PluginConfigurationObjectType.DATA_SOURCE:
|
||||
return ExportDataSource(AuthMethod.TOKEN);
|
||||
|
||||
case PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY:
|
||||
var policy = PolicyWith(Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), ["web_search", "read_web_page"]);
|
||||
return (policy, policy.ExportAsConfigurationSection());
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, "This test knows no export for this type.");
|
||||
}
|
||||
}
|
||||
|
||||
private static (IConfigurationObject Original, string Snippet) ExportProvider(PluginConfigurationObjectType type, string? encryptedApiKey, string tokenizerPath = "")
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case PluginConfigurationObjectType.LLM_PROVIDER:
|
||||
var provider = new AIStudio.Settings.Provider(
|
||||
1,
|
||||
Guid.NewGuid().ToString(),
|
||||
"Ollama on the workstation",
|
||||
LLMProviders.SELF_HOSTED,
|
||||
new Model("llama-3.3-70b", "Llama 3.3"),
|
||||
IsSelfHosted: true,
|
||||
Hostname: "http://localhost:11434",
|
||||
Host: Host.OLLAMA,
|
||||
AdditionalJsonApiParameters: "\"temperature\": 0.5",
|
||||
TokenizerPath: tokenizerPath,
|
||||
CapabilityOverrides: new ProviderCapabilityOverrides { FunctionCalling = true, ContextWindowTokens = 32_768 });
|
||||
return (provider, provider.ExportAsConfigurationSection(encryptedApiKey));
|
||||
|
||||
case PluginConfigurationObjectType.EMBEDDING_PROVIDER:
|
||||
var embeddingProvider = new EmbeddingProvider(
|
||||
1,
|
||||
Guid.NewGuid().ToString(),
|
||||
"Embeddings on the workstation",
|
||||
LLMProviders.SELF_HOSTED,
|
||||
new Model("nomic-embed-text", "Nomic"),
|
||||
IsSelfHosted: true,
|
||||
Hostname: "http://localhost:11434",
|
||||
Host: Host.OLLAMA,
|
||||
TokenizerPath: tokenizerPath,
|
||||
EmbeddingBatchSize: 16,
|
||||
TokenLimit: 512);
|
||||
return (embeddingProvider, embeddingProvider.ExportAsConfigurationSection(encryptedApiKey));
|
||||
|
||||
case PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER:
|
||||
var transcriptionProvider = new TranscriptionProvider(
|
||||
1,
|
||||
Guid.NewGuid().ToString(),
|
||||
"Whisper on the workstation",
|
||||
LLMProviders.SELF_HOSTED,
|
||||
new Model("whisper-large-v3", "Whisper"),
|
||||
IsSelfHosted: true,
|
||||
Hostname: "http://localhost:8080",
|
||||
Host: Host.WHISPER_CPP);
|
||||
return (transcriptionProvider, transcriptionProvider.ExportAsConfigurationSection(encryptedApiKey));
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), type, "This is no type of provider.");
|
||||
}
|
||||
}
|
||||
|
||||
private static (IConfigurationObject Original, string Snippet) ExportDataSource(AuthMethod authMethod, string username = "", DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED)
|
||||
{
|
||||
var dataSource = new DataSourceERI_V1
|
||||
{
|
||||
Num = 1,
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = "Contracts",
|
||||
Type = DataSourceType.ERI_V1,
|
||||
Hostname = "https://eri.example.org",
|
||||
Port = 8443,
|
||||
AuthMethod = authMethod,
|
||||
Username = username,
|
||||
SecurityPolicy = DataSourceSecurity.SELF_HOSTED,
|
||||
SelectedRetrievalId = "semantic-search",
|
||||
MaxMatches = 25,
|
||||
};
|
||||
|
||||
var encryptedSecret = authMethod is AuthMethod.TOKEN or AuthMethod.USERNAME_PASSWORD ? Encrypt(TOKEN) : null;
|
||||
return (dataSource, dataSource.ExportAsConfigurationSection(encryptedSecret, usernamePasswordMode));
|
||||
}
|
||||
|
||||
private static string ExportChatTemplate(IReadOnlyList<string> attachmentPaths, IReadOnlyList<string>? toolIds = null, IReadOnlyList<string>? dataSourceIds = null)
|
||||
{
|
||||
var template = ChatTemplateWith(attachmentPaths, toolIds, dataSourceIds);
|
||||
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
|
||||
return luaCode;
|
||||
}
|
||||
|
||||
private static ChatTemplate ChatTemplateWith(IReadOnlyList<string> attachmentPaths, IReadOnlyList<string>? toolIds, IReadOnlyList<string>? dataSourceIds) => new()
|
||||
{
|
||||
Num = 1,
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = "Contract review",
|
||||
SystemPrompt = "You review contracts.\nYou quote every clause you mention.",
|
||||
PredefinedUserPrompt = "Please review: ",
|
||||
AllowProfileUsage = true,
|
||||
FileAttachments = attachmentPaths.Select(path => new FileAttachment(FileAttachmentType.DOCUMENT, Path.GetFileName(path), path, 0)).ToList(),
|
||||
ExampleConversation =
|
||||
[
|
||||
new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.USER, Content = new ContentText { Text = "Is clause 4 fair?" } },
|
||||
new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.AI, Content = new ContentText { Text = "Clause 4 says:\n> The tenant pays." } },
|
||||
],
|
||||
ToolIds = toolIds?.ToHashSet(StringComparer.Ordinal),
|
||||
DataSourceOptions = dataSourceIds is null ? null : new() { DisableDataSources = false, AutomaticValidation = true, PreselectedDataSourceIds = [..dataSourceIds] },
|
||||
};
|
||||
|
||||
private static string ExportPolicy(string preselectedProvider = "", string preselectedProfile = "", IReadOnlyList<string>? allowedToolIds = null) => PolicyWith(preselectedProvider, preselectedProfile, allowedToolIds ?? []).ExportAsConfigurationSection();
|
||||
|
||||
private static DataDocumentAnalysisPolicy PolicyWith(string preselectedProvider, string preselectedProfile, IReadOnlyList<string> allowedToolIds) => new()
|
||||
{
|
||||
Num = 1,
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
PolicyName = "Contract check",
|
||||
PolicyDescription = "Checks contracts for the clauses our legal department cares about.",
|
||||
AnalysisRules = "Find every clause on liability.\nFind every clause on termination.",
|
||||
OutputRules = "- Answer in a table.\n- Quote the clause.",
|
||||
MinimumProviderConfidence = ConfidenceLevel.MEDIUM,
|
||||
AllowedToolIds = allowedToolIds.ToHashSet(StringComparer.Ordinal),
|
||||
PreselectedProvider = preselectedProvider,
|
||||
PreselectedProfile = preselectedProfile,
|
||||
HidePolicyDefinition = true,
|
||||
IsProtected = true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
using System.Globalization;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
using Lua;
|
||||
using Lua.Standard;
|
||||
|
||||
using AuthMethod = AIStudio.Tools.ERIClient.DataModel.AuthMethod;
|
||||
using Host = AIStudio.Provider.SelfHosted.Host;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PluginSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the snippet parser reads what the app exports, reads it the way Lua does, and
|
||||
/// refuses whatever is not plain data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The parser stands between a text somebody pasted and the settings of the app, so accepting a
|
||||
/// snippet is not enough: every snippet it accepts is also run through a real Lua state, and both
|
||||
/// tables are compared value by value. A parser which read a single string differently than Lua
|
||||
/// would hand the import something the configuration plugin of the same snippet never contains.<br/><br/>
|
||||
/// Nothing here asserts on the wording of an issue: I18N is process-wide state without a reset, so
|
||||
/// an Init somewhere else would decide whether these pass.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ConfigurationSnippetParserTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Quotes, backslashes, and apostrophes, which a quoted Lua string has to escape.
|
||||
/// </summary>
|
||||
private const string QUOTED_TEXT = "He said \"hi\" to C:\\Users\\me, and 'bye'.";
|
||||
|
||||
/// <summary>
|
||||
/// Every kind of line break, an indentation, and the closing brackets of long strings.
|
||||
/// </summary>
|
||||
private const string MULTILINE_TEXT = "First line\r\nSecond line\rThird line\n\tIndented, with ]] and ]==] and ]===] inside.";
|
||||
|
||||
private const string WINDOWS_PATH = @"C:\Users\me\AppData\Roaming\MindWork AI Studio\tokenizer.json";
|
||||
|
||||
/// <summary>
|
||||
/// Every type of configuration object which configuration plugins provide, and thus can be exported.
|
||||
/// </summary>
|
||||
private static readonly PluginConfigurationObjectType[] EXPORTABLE_TYPES =
|
||||
[
|
||||
PluginConfigurationObjectType.LLM_PROVIDER,
|
||||
PluginConfigurationObjectType.EMBEDDING_PROVIDER,
|
||||
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER,
|
||||
PluginConfigurationObjectType.PROFILE,
|
||||
PluginConfigurationObjectType.CHAT_TEMPLATE,
|
||||
PluginConfigurationObjectType.DATA_SOURCE,
|
||||
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY,
|
||||
];
|
||||
|
||||
[TestCaseSource(nameof(EXPORTABLE_TYPES))]
|
||||
public async Task EveryExportIsReadAsLuaReadsIt(PluginConfigurationObjectType type)
|
||||
{
|
||||
var snippet = Export(type);
|
||||
|
||||
Assert.That(ConfigurationSnippetParser.TryParse(snippet, type, out var parsed, out var issue), Is.True, issue);
|
||||
AssertSameTable(await ReadWithLuaAsync(type, snippet), parsed, type.ToString());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryTypeOfConfigurationObjectHasAnExport() => Assert.That(EXPORTABLE_TYPES, Is.EquivalentTo(PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.Type)), "A new type of configuration object needs its export in the round trip above.");
|
||||
|
||||
private static IEnumerable<TestCaseData> PairsOfDifferentTypes() =>
|
||||
from snippetType in EXPORTABLE_TYPES
|
||||
from expectedType in EXPORTABLE_TYPES
|
||||
where snippetType != expectedType
|
||||
select new TestCaseData(snippetType, expectedType);
|
||||
|
||||
[TestCaseSource(nameof(PairsOfDifferentTypes))]
|
||||
public void ASnippetOfAnotherTypeIsRefused(PluginConfigurationObjectType snippetType, PluginConfigurationObjectType expectedType)
|
||||
{
|
||||
var section = PluginConfigurationObjectDescriptors.Get(snippetType).LuaTableName;
|
||||
AssertRefused($$"""CONFIG["{{section}}"][#CONFIG["{{section}}"]+1] = { ["a"] = 1 }""", expectedType);
|
||||
}
|
||||
|
||||
[Test]
|
||||
// ReSharper disable RedundantArgumentDefaultValue
|
||||
public void ASnippetOfAnUnknownSectionIsRefused() => AssertRefused("""CONFIG["SETTINGS"][#CONFIG["SETTINGS"]+1] = { ["a"] = 1 }""", PluginConfigurationObjectType.PROFILE);
|
||||
// ReSharper restore RedundantArgumentDefaultValue
|
||||
|
||||
[TestCase("""["a"] = "q\"b\\c\n\r\t\a\b\f\v'" """, TestName = "Escapes in a double-quoted string")]
|
||||
[TestCase("""["a"] = 'it\'s "fine"' """, TestName = "Escapes in a single-quoted string")]
|
||||
[TestCase("""["a"] = [==[ends with ]] and ]=] but not yet]==]""", TestName = "A long string with a level")]
|
||||
[TestCase("[\"a\"] = [[one\r\ntwo\rthree\n\rfour\n\nfive]]", TestName = "Line breaks of every kind in a long string")]
|
||||
[TestCase("[\"a\"] = [[\n indented]]", TestName = "An indented long string starting on its own line")]
|
||||
[TestCase("[\"a\"] = [[\r\n\r\nafter an empty line]]", TestName = "A long string starting with two line breaks")]
|
||||
[TestCase("""["a"] = "Grüße – 日本", ["Ünïcode"] = true""", TestName = "Text beyond ASCII")]
|
||||
[TestCase("""["a"] = 5, ["b"] = -5, ["c"] = 0.25, ["d"] = -.5, ["e"] = 5., ["f"] = 65535""", TestName = "Numbers")]
|
||||
[TestCase("""["a"] = true; ["b"] = false, ["c"] = { "x", 'y', [[z]], { ["d"] = 1 }, },""", TestName = "Lists, nested tables, and both separators")]
|
||||
[TestCase("""["a"] = {}, ["b"] = { {} }""", TestName = "Empty tables")]
|
||||
[TestCase("-- before\n[\"a\"] = -- after the equals sign\n 1, --[ not a block comment\n-- [[ neither\n[\"b\"] = 2 -- last\n", TestName = "Line comments everywhere")]
|
||||
public async Task AHandwrittenSnippetIsReadAsLuaReadsIt(string fields) => await AssertReadAsLuaReadsItAsync(Snippet(fields));
|
||||
|
||||
[Test]
|
||||
public async Task CommentsBeforeTheAssignmentAreAllowed() => await AssertReadAsLuaReadsItAsync("-- Exported from the laptop of a colleague.\r\n-- Check the IDs below.\r\n\r\n" + Snippet("""["a"] = 1"""));
|
||||
|
||||
[Test]
|
||||
public async Task ThirtyNestedTablesAreStillRead() => await AssertReadAsLuaReadsItAsync(Snippet($"""["a"] = {string.Concat(Enumerable.Repeat("{ ", 30))}1{string.Concat(Enumerable.Repeat(" }", 30))}"""));
|
||||
|
||||
[TestCase("""["a"] = print("x")""", TestName = "A function call")]
|
||||
[TestCase("""["a"] = function() return 1 end""", TestName = "A function")]
|
||||
[TestCase("""["a"] = "x" .. "y" """, TestName = "A concatenation")]
|
||||
[TestCase("""["a"] = 1 + 1""", TestName = "Arithmetic")]
|
||||
[TestCase("""["a"] = CONFIG""", TestName = "A variable")]
|
||||
[TestCase("""["a"] = os.getenv("HOME")""", TestName = "A library call")]
|
||||
[TestCase("""["a"] = nil""", TestName = "nil as a field")]
|
||||
[TestCase("""["a"] = { "x", nil, "y" }""", TestName = "nil in a list")]
|
||||
[TestCase("[\"a\"] = 1, --[[ [\"b\"] = 2, ]] [\"c\"] = 3\n", TestName = "A block comment followed by data on the same line")]
|
||||
[TestCase("--[==[\n[\"a\"] = 1,\n--]==]\n[\"b\"] = 2\n", TestName = "A block comment over several lines")]
|
||||
[TestCase("""["a"] = 1, ["a"] = 2""", TestName = "A field twice")]
|
||||
[TestCase("""a = 1""", TestName = "A name as the key")]
|
||||
[TestCase("""[1] = "x" """, TestName = "A number as the key")]
|
||||
[TestCase("""["a"] = "\x41" """, TestName = "An escape Lua knows, but exports never write")]
|
||||
[TestCase("""["a"] = "unterminated""", TestName = "An unterminated string")]
|
||||
[TestCase("""["a"] = [==[unterminated]=]""", TestName = "An unterminated long string")]
|
||||
[TestCase("""["a"] = 0x10""", TestName = "A hexadecimal number")]
|
||||
public void CodeAndConstructsBeyondPlainDataAreRefused(string fields) => AssertRefused(Snippet(fields));
|
||||
|
||||
[Test]
|
||||
public void ASecondStatementIsRefused() => AssertRefused(Snippet("""["a"] = 1""") + "\nos.exit()");
|
||||
|
||||
[Test]
|
||||
public void ASecondAssignmentIsRefused() => AssertRefused(Snippet("""["a"] = 1""") + "\n" + Snippet("""["b"] = 2"""));
|
||||
|
||||
[Test]
|
||||
public void AnAssignmentWhichCountsAnotherSectionIsRefused() => AssertRefused("""CONFIG["PROFILES"][#CONFIG["CHAT_TEMPLATES"]+1] = { ["a"] = 1 }""");
|
||||
|
||||
[Test]
|
||||
public void AnAssignmentToAFixedIndexIsRefused() => AssertRefused("""CONFIG["PROFILES"][1] = { ["a"] = 1 }""");
|
||||
|
||||
[Test]
|
||||
public void ATooLargeSnippetIsRefused() => AssertRefused(Snippet($"""["a"] = "{new string('x', 1_000_000)}" """));
|
||||
|
||||
[Test]
|
||||
public void TooManyNestedTablesAreRefused() => AssertRefused(Snippet($"""["a"] = {string.Concat(Enumerable.Repeat("{ ", 40))}1{string.Concat(Enumerable.Repeat(" }", 40))}"""));
|
||||
|
||||
[TestCase("")]
|
||||
[TestCase(" \r\n ")]
|
||||
public void AnEmptySnippetIsRefused(string snippet) => AssertRefused(snippet);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a profile snippet with the parser and with Lua, and compares both.
|
||||
/// </summary>
|
||||
private static async Task AssertReadAsLuaReadsItAsync(string snippet)
|
||||
{
|
||||
Assert.That(ConfigurationSnippetParser.TryParse(snippet, PluginConfigurationObjectType.PROFILE, out var parsed, out var issue), Is.True, issue);
|
||||
AssertSameTable(await ReadWithLuaAsync(PluginConfigurationObjectType.PROFILE, snippet), parsed, "PROFILES");
|
||||
}
|
||||
|
||||
private static void AssertRefused(string snippet, PluginConfigurationObjectType type = PluginConfigurationObjectType.PROFILE)
|
||||
{
|
||||
var accepted = ConfigurationSnippetParser.TryParse(snippet, type, out _, out var issue);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(accepted, Is.False);
|
||||
Assert.That(issue, Is.Not.Empty, "A refused snippet needs a reason the user can read.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A profile snippet with these fields.
|
||||
/// </summary>
|
||||
private static string Snippet(string fields) => $$"""CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = { {{fields}} }""";
|
||||
|
||||
/// <summary>
|
||||
/// What the app exports for one item of each type, with values which the export has to escape.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The items are made here rather than in a test case source: NUnit reads such a source before
|
||||
/// TestHost runs, and the provider types need its logger factory.
|
||||
/// </remarks>
|
||||
private static string Export(PluginConfigurationObjectType type) => type switch
|
||||
{
|
||||
PluginConfigurationObjectType.LLM_PROVIDER => new AIStudio.Settings.Provider(
|
||||
1,
|
||||
Guid.NewGuid().ToString(),
|
||||
QUOTED_TEXT,
|
||||
LLMProviders.SELF_HOSTED,
|
||||
new Model("llama-3.3-70b", QUOTED_TEXT),
|
||||
IsSelfHosted: true,
|
||||
Hostname: "http://localhost:11434",
|
||||
Host: Host.OLLAMA,
|
||||
AdditionalJsonApiParameters: "\"temperature\": 0.5, \"top_p\": 0.9",
|
||||
TokenizerPath: WINDOWS_PATH,
|
||||
CapabilityOverrides: new ProviderCapabilityOverrides { FunctionCalling = true, ContextWindowTokens = 32_768, MaxImagesPerMessage = 0 })
|
||||
.ExportAsConfigurationSection("ENC:v1:c2VjcmV0"),
|
||||
|
||||
PluginConfigurationObjectType.EMBEDDING_PROVIDER => new EmbeddingProvider(
|
||||
1,
|
||||
Guid.NewGuid().ToString(),
|
||||
QUOTED_TEXT,
|
||||
LLMProviders.SELF_HOSTED,
|
||||
new Model("nomic-embed-text", null),
|
||||
IsSelfHosted: true,
|
||||
Host: Host.OLLAMA,
|
||||
TokenizerPath: WINDOWS_PATH,
|
||||
EmbeddingBatchSize: 16,
|
||||
TokenLimit: 512)
|
||||
.ExportAsConfigurationSection("ENC:v1:c2VjcmV0"),
|
||||
|
||||
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => new TranscriptionProvider(
|
||||
1,
|
||||
Guid.NewGuid().ToString(),
|
||||
QUOTED_TEXT,
|
||||
LLMProviders.SELF_HOSTED,
|
||||
new Model("whisper-large-v3", "Whisper"),
|
||||
IsSelfHosted: true,
|
||||
Host: Host.WHISPER_CPP)
|
||||
.ExportAsConfigurationSection(),
|
||||
|
||||
PluginConfigurationObjectType.PROFILE => new Profile(1, Guid.NewGuid().ToString(), QUOTED_TEXT, MULTILINE_TEXT, "Short actions.").ExportAsConfigurationSection(),
|
||||
|
||||
PluginConfigurationObjectType.CHAT_TEMPLATE => ExportChatTemplate(),
|
||||
|
||||
PluginConfigurationObjectType.DATA_SOURCE => new DataSourceERI_V1
|
||||
{
|
||||
Num = 1,
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = QUOTED_TEXT,
|
||||
Type = DataSourceType.ERI_V1,
|
||||
Hostname = "https://eri.example.org",
|
||||
Port = 8443,
|
||||
AuthMethod = AuthMethod.USERNAME_PASSWORD,
|
||||
Username = "jane.doe",
|
||||
SecurityPolicy = DataSourceSecurity.SELF_HOSTED,
|
||||
SelectedRetrievalId = "semantic-search",
|
||||
MaxMatches = 25,
|
||||
}.ExportAsConfigurationSection("ENC:v1:c2VjcmV0", DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD),
|
||||
|
||||
PluginConfigurationObjectType.DOCUMENT_ANALYSIS_POLICY => new DataDocumentAnalysisPolicy
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
PolicyName = QUOTED_TEXT,
|
||||
PolicyDescription = "Checks contracts for the clauses our legal department cares about.",
|
||||
AnalysisRules = MULTILINE_TEXT,
|
||||
OutputRules = "- Answer in a table.\r\n- Quote the clause.",
|
||||
MinimumProviderConfidence = ConfidenceLevel.MEDIUM,
|
||||
AllowedToolIds = ["web_search", "read_web_page"],
|
||||
PreselectedProvider = Guid.NewGuid().ToString(),
|
||||
HidePolicyDefinition = true,
|
||||
}.ExportAsConfigurationSection(),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "This test knows no export for this type."),
|
||||
};
|
||||
|
||||
private static string ExportChatTemplate()
|
||||
{
|
||||
var template = new ChatTemplate
|
||||
{
|
||||
Num = 1,
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Name = QUOTED_TEXT,
|
||||
SystemPrompt = MULTILINE_TEXT,
|
||||
PredefinedUserPrompt = "Please summarize: ",
|
||||
AllowProfileUsage = true,
|
||||
FileAttachments = [new FileAttachment(FileAttachmentType.DOCUMENT, "report.pdf", WINDOWS_PATH, 1024)],
|
||||
ExampleConversation =
|
||||
[
|
||||
new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.USER, Content = new ContentText { Text = QUOTED_TEXT } },
|
||||
new ContentBlock { ContentType = ContentType.TEXT, Role = ChatRole.AI, Content = new ContentText { Text = MULTILINE_TEXT } },
|
||||
],
|
||||
ToolIds = ["web_search"],
|
||||
|
||||
// Preselected data sources make the export start with a comment:
|
||||
DataSourceOptions = new() { DisableDataSources = false, PreselectedDataSourceIds = [Guid.NewGuid().ToString()] },
|
||||
};
|
||||
|
||||
Assert.That(template.TryExportAsConfigurationSection(out var luaCode, out var issue), Is.True, issue);
|
||||
return luaCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a snippet the way a configuration plugin containing it would be read.
|
||||
/// </summary>
|
||||
private static async Task<LuaTable> ReadWithLuaAsync(PluginConfigurationObjectType type, string snippet)
|
||||
{
|
||||
var section = PluginConfigurationObjectDescriptors.Get(type).LuaTableName;
|
||||
var state = LuaState.Create();
|
||||
state.OpenBasicLibrary();
|
||||
state.OpenTableLibrary();
|
||||
|
||||
await state.DoStringAsync($$"""
|
||||
CONFIG = {}
|
||||
CONFIG["{{section}}"] = {}
|
||||
{{snippet}}
|
||||
""");
|
||||
|
||||
if (!state.Environment["CONFIG"].TryRead<LuaTable>(out var configTable) ||
|
||||
!configTable[section].TryRead<LuaTable>(out var sectionTable) ||
|
||||
!sectionTable[1].TryRead<LuaTable>(out var itemTable))
|
||||
throw new InvalidOperationException("The snippet of this test did not add a table to its section.");
|
||||
|
||||
return itemTable;
|
||||
}
|
||||
|
||||
private static void AssertSameTable(LuaTable expected, LuaTable actual, string path)
|
||||
{
|
||||
var expectedFields = Fields(expected);
|
||||
var actualFields = Fields(actual);
|
||||
Assert.That(actualFields.Keys, Is.EquivalentTo(expectedFields.Keys), path);
|
||||
foreach (var (key, expectedValue) in expectedFields)
|
||||
AssertSameValue(expectedValue, actualFields[key], $"{path}[{key}]");
|
||||
}
|
||||
|
||||
private static void AssertSameValue(LuaValue expected, LuaValue actual, string path)
|
||||
{
|
||||
Assert.That(actual.Type, Is.EqualTo(expected.Type), path);
|
||||
switch (expected.Type)
|
||||
{
|
||||
case LuaValueType.String:
|
||||
Assert.That(actual.Read<string>(), Is.EqualTo(expected.Read<string>()), path);
|
||||
break;
|
||||
|
||||
case LuaValueType.Number:
|
||||
Assert.That(actual.Read<double>(), Is.EqualTo(expected.Read<double>()), path);
|
||||
break;
|
||||
|
||||
case LuaValueType.Boolean:
|
||||
Assert.That(actual.Read<bool>(), Is.EqualTo(expected.Read<bool>()), path);
|
||||
break;
|
||||
|
||||
case LuaValueType.Table:
|
||||
AssertSameTable(expected.Read<LuaTable>(), actual.Read<LuaTable>(), path);
|
||||
break;
|
||||
|
||||
default:
|
||||
Assert.Fail($"{path} holds a {expected.Type}, which no snippet of this test contains.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, LuaValue> Fields(LuaTable table)
|
||||
{
|
||||
var fields = new Dictionary<string, LuaValue>(StringComparer.Ordinal);
|
||||
var key = LuaValue.Nil;
|
||||
while (table.TryGetNext(key, out var pair))
|
||||
{
|
||||
key = pair.Key;
|
||||
var name = pair.Key.Type switch
|
||||
{
|
||||
LuaValueType.String => $"\"{pair.Key.Read<string>()}\"",
|
||||
LuaValueType.Number => pair.Key.Read<double>().ToString(CultureInfo.InvariantCulture),
|
||||
_ => throw new InvalidOperationException($"A key of the type {pair.Key.Type} cannot appear in a snippet."),
|
||||
};
|
||||
|
||||
fields.Add(name, pair.Value);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,37 @@ public sealed class PluginConfigurationObjectDescriptorsTests
|
||||
Assert.That(describedTypes, Is.Unique, "A type described twice would be read and cleaned up twice.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryDescriptorReadsATableOfItsOwn()
|
||||
{
|
||||
var tableNames = PluginConfigurationObjectDescriptors.ALL.Select(descriptor => descriptor.LuaTableName).ToList();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(tableNames, Has.None.Empty);
|
||||
Assert.That(tableNames, Is.Unique, "Two types reading the same table would each take the objects of the other.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryDescriptorIsFoundByItsTypeAndByItsTable()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var descriptor in PluginConfigurationObjectDescriptors.ALL)
|
||||
{
|
||||
Assert.That(PluginConfigurationObjectDescriptors.Get(descriptor.Type), Is.SameAs(descriptor), descriptor.Type.ToString());
|
||||
Assert.That(PluginConfigurationObjectDescriptors.TryGet(descriptor.LuaTableName, out var found), Is.True, descriptor.LuaTableName);
|
||||
Assert.That(found, Is.SameAs(descriptor), descriptor.LuaTableName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(PluginConfigurationObjectType.NONE)]
|
||||
[TestCase(PluginConfigurationObjectType.UNKNOWN)]
|
||||
public void NoneAndUnknownHaveNoDescriptor(PluginConfigurationObjectType type) => Assert.That(() => PluginConfigurationObjectDescriptors.Get(type), Throws.TypeOf<ArgumentOutOfRangeException>());
|
||||
|
||||
[Test]
|
||||
public void AnUnknownTableHasNoDescriptor() => Assert.That(PluginConfigurationObjectDescriptors.TryGet("SETTINGS", out _), Is.False);
|
||||
}
|
||||
Reference in new issue
Block a user