From 6d0052ecdf3a11df927f9a4a20729cb1165e26e8 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Tue, 10 Nov 2026 08:57:36 +0100 Subject: [PATCH] Added prompt overrides for testing the BeyondFacts Lab (#1181) --- .../BeyondFactsLabAssistant.razor | 2 + .../BeyondFactsLab/BeyondFactsLabStore.cs | 5 + .../BeyondFactsLab/BflConversationService.cs | 26 +- .../BeyondFactsLab/BflOnboarding.razor | 1 + .../BeyondFactsLab/BflPromptLibrary.cs | 91 ++++++- .../BflPromptOverrideException.cs | 24 ++ .../BflPromptOverrideNotice.razor | 8 + .../BflPromptOverrideNotice.razor.cs | 72 ++++++ .../BeyondFactsLab/BflPromptOverrides.cs | 157 +++++++++++ .../BeyondFactsLab/BflPromptTemplate.cs | 7 + .../Assistants/I18N/allTexts.lua | 63 +++++ .../SettingsDialogBeyondFactsLab.razor | 21 ++ .../SettingsDialogBeyondFactsLab.razor.cs | 85 ++++++ .../Plugins/configuration/plugin.lua | 7 + .../plugin.lua | 63 +++++ .../plugin.lua | 63 +++++ .../Settings/DataModel/DataBeyondFactsLab.cs | 9 + .../Tools/PluginSystem/PluginConfiguration.cs | 1 + .../BeyondFactsLab/BflPromptLibraryTests.cs | 26 ++ .../BflPromptOverrideSettingTests.cs | 74 ++++++ .../BeyondFactsLab/BflPromptOverridesTests.cs | 243 ++++++++++++++++++ 21 files changed, 1035 insertions(+), 13 deletions(-) create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideException.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrides.cs create mode 100644 app/Tests/BeyondFactsLab/BflPromptOverrideSettingTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor index 6d2060c7c..5c687dc73 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor @@ -128,6 +128,8 @@
+ @* It checks the test folder anew for every step, as the key of the step recreates it: *@ + @this.activeStep.Title @this.activeStep.Description diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs index 333dc5797..683655dd2 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs @@ -73,6 +73,11 @@ public sealed partial class BeyondFactsLabStore(ILogger log throw new InvalidOperationException("The AI Studio data directory is not initialized."), ROOT_DIRECTORY_NAME); + /// + /// Gets the folder whose files replace the prompts of the lab while the person allows it, see BflPromptOverrides. + /// + public string PromptOverridesDirectory => Path.Combine(this.RootDirectory, BflPromptOverrides.DIRECTORY_NAME); + /// /// Writes a text file so that readers see either the old or the new content, never a part of it. /// diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs index 4c6f58719..8578900fd 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs @@ -58,6 +58,12 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa /// private readonly Lock runningLock = new(); + /// + /// Gets the folder whose files replace the prompts of the lab, or null while the person does not allow it. The + /// setting counts anew for every request, so that switching it on or off shows in the next answer. + /// + private string? PromptOverridesDirectory => BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory); + /// /// Happens whenever a chat changed: a message, a chunk of an answer, or the end of a request. /// @@ -144,6 +150,12 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa { requestMaterial = await this.ComposeRequestAsync(location, provider); } + catch (BflPromptOverrideException exception) + { + logger.LogWarning(exception, "A prompt override of the BeyondFacts Lab has a placeholder which the lab does not fill."); + await messageBus.SendError(new(Icons.Material.Filled.ErrorOutline, string.Format(TA("The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again."),exception.FileName, exception.Placeholder))); + return; + } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException) { logger.LogError(exception, "The BeyondFacts Lab could not put together the system prompt of the chat '{Location}'.", location.Key); @@ -285,6 +297,14 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa /// A prompt has a placeholder without a value. public async Task ComposeSystemPromptAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => (await this.ComposeRequestAsync(location, provider)).SystemPrompt; + /// + /// Checks whether files in the test folder replace prompts of the lab right now, so that the lab can tell the + /// person. + /// + /// The cancellation token. + /// True while the setting allows prompt overrides and a file replaces a prompt with another text. + public async Task UsesPromptOverridesAsync(CancellationToken token = default) => this.PromptOverridesDirectory is { } directory && (await BflPromptOverrides.FindAsync(directory, token)).Count > 0; + /// /// Puts together what a request of a chat carries beside the conversation: the system prompt, and the images of /// the material the model may see. @@ -302,13 +322,13 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa { case BflStepThreadLocation stepLocation: var forms = await store.LoadFormsAsync(stepLocation.ProjectId); - var parts = await BflPromptLibrary.LoadPartsAsync(stepLocation.Step, addressForm); + var parts = await BflPromptLibrary.LoadPartsAsync(stepLocation.Step, addressForm, this.PromptOverridesDirectory); var shares = await this.ShareMaterialsAsync(stepLocation, parts, forms, language, provider); var materials = await this.ReadMaterialsAsync(stepLocation.ProjectId, shares); return new(BflPromptComposer.Compose(parts, stepLocation.Step, forms, materials, language), this.ImagesOf(stepLocation.ProjectId, shares)); case BflOnboardingThreadLocation: - return new(BflPromptComposer.ComposeOnboarding(await BflPromptLibrary.LoadOnboardingPartsAsync(addressForm), language), []); + return new(BflPromptComposer.ComposeOnboarding(await BflPromptLibrary.LoadOnboardingPartsAsync(addressForm, this.PromptOverridesDirectory), language), []); default: throw new ArgumentOutOfRangeException(nameof(location), location, "The lab has no prompt for this chat."); @@ -406,7 +426,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa { var language = (await settingsManager.GetActiveLanguagePlugin()).LangName; var forms = await store.LoadFormsAsync(location.ProjectId); - var parts = await BflPromptLibrary.LoadPartsAsync(location.Step, Tools.PluginSystem.I18N.I.AddressForm); + var parts = await BflPromptLibrary.LoadPartsAsync(location.Step, Tools.PluginSystem.I18N.I.AddressForm, this.PromptOverridesDirectory); return await this.ShareMaterialsAsync(location, parts, forms, language, provider); } diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflOnboarding.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflOnboarding.razor index 1602ccdc2..1323b6845 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflOnboarding.razor +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflOnboarding.razor @@ -1,4 +1,5 @@ @inherits MSGComponentBase + \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptLibrary.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptLibrary.cs index e5e858878..07ca1f28d 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptLibrary.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptLibrary.cs @@ -1,3 +1,5 @@ +using System.Collections.Frozen; + using AIStudio.Settings.DataModel; namespace AIStudio.Assistants.BeyondFactsLab; @@ -8,6 +10,7 @@ namespace AIStudio.Assistants.BeyondFactsLab; /// /// The files are German Markdown below Assistants/BeyondFactsLab, embedded into the app. A development build reads /// them from the disk, so that a changed prompt shows in the next request without building again; see AppResources. +/// While the person allows it, files in the folder for prompt overrides replace them, see BflPromptOverrides. /// public static class BflPromptLibrary { @@ -42,6 +45,30 @@ public static class BflPromptLibrary /// private const string ADDRESS_INFORMAL_PATH = FOLDER + "/Prompts/snippets/address-informal.md"; + /// + /// The placeholders BflPromptComposer fills in the core. + /// + private static readonly FrozenSet CORE_PLACEHOLDERS = FrozenSet.Create(StringComparer.Ordinal, + BflPromptTemplate.RESPONSE_LANGUAGE, + BflPromptTemplate.ADDRESS_FORM, + BflPromptTemplate.KNOWLEDGE, + BflPromptTemplate.STEP, + BflPromptTemplate.MATERIALS, + BflPromptTemplate.DETAILS, + BflPromptTemplate.MISSING_DETAILS); + + /// + /// The placeholders BflPromptComposer fills in the prompt of the onboarding. + /// + private static readonly FrozenSet ONBOARDING_PLACEHOLDERS = FrozenSet.Create(StringComparer.Ordinal, + BflPromptTemplate.RESPONSE_LANGUAGE, + BflPromptTemplate.ADDRESS_FORM); + + /// + /// The placeholders of every other file: none, because BflPromptComposer inserts these files as values. + /// + private static readonly FrozenSet NO_PLACEHOLDERS = FrozenSet.Empty; + /// /// Gets the prompt of a step, named like the folder of the step. /// @@ -66,39 +93,83 @@ public static class BflPromptLibrary ..BflStep.All.Select(StepPath) ]; + /// + /// Gets a file of the lab's prompts relative to the folder of the lab, e.g., "Prompts/core.md". + /// + /// The file, one of the paths of this class. + /// The file lies outside the folder of the lab. + public static string RelativePath(string path) => path.StartsWith(FOLDER + "/", StringComparison.Ordinal) ? path[(FOLDER.Length + 1)..] : throw new ArgumentException("The file lies outside the folder of the BeyondFacts Lab.", nameof(path)); + + /// + /// Gets the placeholders a file of the lab's prompts may contain. + /// + /// + /// BflPromptComposer fills placeholders in the core and in the prompt of the onboarding only. It inserts every + /// other file as a value, whose placeholders it leaves as they are, so that they would reach the model as text. + /// + /// The file, one of the paths of this class. + public static IReadOnlySet PlaceholdersOf(string path) => path switch + { + CORE_PATH => CORE_PLACEHOLDERS, + ONBOARDING_PATH => ONBOARDING_PLACEHOLDERS, + _ => NO_PLACEHOLDERS + }; + /// /// Reads the texts of the system prompt of a step. /// /// The step. /// How the model addresses the person. + /// The folder whose files replace the prompts, or null while the person does not allow it. /// The cancellation token. /// The texts, which BflPromptComposer puts together. /// The app comes without one of the files. - public static async Task LoadPartsAsync(BflStep step, AddressForm addressForm, CancellationToken token = default) => new( - await ReadAsync(CORE_PATH, token), - await ReadAsync(KNOWLEDGE_PATH, token), - await ReadAsync(StepPath(step), token), - await ReadAsync(AddressPath(addressForm), token)); + /// A file which replaces a prompt contains a placeholder which the lab does not fill in it. + public static async Task LoadPartsAsync(BflStep step, AddressForm addressForm, string? overrideDirectory = null, CancellationToken token = default) => new( + await ReadAsync(CORE_PATH, overrideDirectory, token), + await ReadAsync(KNOWLEDGE_PATH, overrideDirectory, token), + await ReadAsync(StepPath(step), overrideDirectory, token), + await ReadAsync(AddressPath(addressForm), overrideDirectory, token)); /// /// Reads the texts of the system prompt of the conversation about whether the lab fits a task. /// /// How the model addresses the person. + /// The folder whose files replace the prompts, or null while the person does not allow it. /// The cancellation token. /// The texts, which BflPromptComposer puts together. /// The app comes without one of the files. - public static async Task LoadOnboardingPartsAsync(AddressForm addressForm, CancellationToken token = default) => new( - await ReadAsync(ONBOARDING_PATH, token), - await ReadAsync(AddressPath(addressForm), token)); + /// A file which replaces a prompt contains a placeholder which the lab does not fill in it. + public static async Task LoadOnboardingPartsAsync(AddressForm addressForm, string? overrideDirectory = null, CancellationToken token = default) => new( + await ReadAsync(ONBOARDING_PATH, overrideDirectory, token), + await ReadAsync(AddressPath(addressForm), overrideDirectory, token)); /// - /// Reads a file of the lab's prompts. + /// Reads a file of the lab's prompts: the file which replaces it, if there is one, or else the file which comes + /// with the app. + /// + /// The file, one of the paths of this class. + /// The folder whose files replace the prompts, or null while the person does not allow it. + /// The cancellation token. + /// The text of the file. + /// The app comes without the file, or the file is empty. + /// The file which replaces the prompt contains a placeholder which the lab does not fill in it. + public static async Task ReadAsync(string path, string? overrideDirectory = null, CancellationToken token = default) + { + if (overrideDirectory is not null && await BflPromptOverrides.TryReadAsync(overrideDirectory, path, token) is { } replacement) + return replacement; + + return await ReadBuiltInAsync(path, token); + } + + /// + /// Reads a file of the lab's prompts as it comes with the app. /// /// The file, one of the paths of this class. /// The cancellation token. /// The text of the file. /// The app comes without the file, or the file is empty. - public static async Task ReadAsync(string path, CancellationToken token = default) + public static async Task ReadBuiltInAsync(string path, CancellationToken token = default) { var text = await AppResources.ReadTextAsync(path, token); if (string.IsNullOrWhiteSpace(text)) diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideException.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideException.cs new file mode 100644 index 000000000..c60a79889 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideException.cs @@ -0,0 +1,24 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Tells that a file in the folder for prompt overrides contains a placeholder which the lab does not fill in it. +/// +/// +/// The lab fills placeholders in the core and in the prompt of the onboarding only, and only those it knows; in any +/// other file, a placeholder would reach the model as text. Whoever tests a changed prompt needs to learn which file +/// and which placeholder it is, so the message names both. +/// +/// The file, relative to the folder for prompt overrides, with forward slashes. +/// The name of the placeholder, without braces. +public sealed class BflPromptOverrideException(string fileName, string placeholder) : FormatException($"The file '{fileName}' in the folder for prompt overrides contains the placeholder '{placeholder}', which the lab does not fill in this file.") +{ + /// + /// Gets the file, relative to the folder for prompt overrides, with forward slashes. + /// + public string FileName { get; } = fileName; + + /// + /// Gets the name of the placeholder, without braces. + /// + public string Placeholder { get; } = placeholder; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor new file mode 100644 index 000000000..28b70df94 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor @@ -0,0 +1,8 @@ +@inherits MSGComponentBase + +@if (this.usesPromptOverrides) +{ + + @TA("The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab.") + +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor.cs new file mode 100644 index 000000000..34b41a2da --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrideNotice.razor.cs @@ -0,0 +1,72 @@ +using AIStudio.Components; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Tells the person that files in the test folder replace prompts of the lab, so that nobody mistakes the answers of +/// experimental prompts for those of the lab. +/// +/// +/// It checks the folder when it appears, e.g., for every step the person opens, and whenever a setting changes. It +/// shows nothing while the setting does not allow prompt overrides, or while every file in the folder matches the +/// prompt it replaces. +/// +public partial class BflPromptOverrideNotice : MSGComponentBase +{ + [Inject] + private BflConversationService ConversationService { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + + /// + /// Gets or sets the CSS classes of the notice, e.g., for its margins. + /// + [Parameter] + public string Class { get; set; } = string.Empty; + + /// + /// Whether files in the test folder replace prompts of the lab. + /// + private bool usesPromptOverrides; + + /// + protected override async Task OnInitializedAsync() + { + this.ApplyFilters([], [Event.CONFIGURATION_CHANGED]); + await base.OnInitializedAsync(); + await this.CheckAsync(); + } + + /// + protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + if (triggeredEvent is Event.CONFIGURATION_CHANGED) + { + // The person may switch the prompt overrides on or off in the options of the lab while the page is open: + await this.CheckAsync(); + this.StateHasChanged(); + } + + await base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); + } + + /// + /// Checks whether files in the test folder replace prompts of the lab. + /// + private async Task CheckAsync() + { + try + { + this.usesPromptOverrides = await this.ConversationService.UsesPromptOverridesAsync(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Sending reports a problem with the prompts; the notice only stays hidden: + this.Logger.LogDebug(exception, "Could not check the test folder of the BeyondFacts Lab."); + this.usesPromptOverrides = false; + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrides.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrides.cs new file mode 100644 index 000000000..292f9621b --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptOverrides.cs @@ -0,0 +1,157 @@ +using System.Text; + +using AIStudio.Settings.DataModel; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Reads the files which replace the prompts of the BeyondFacts Lab, so that changed prompts can be tested without a +/// new version of AI Studio. +/// +/// +/// The folder mirrors the files of the lab below Assistants/BeyondFactsLab, e.g., Prompts/steps/02-knowledge.md, so +/// that every name stays unique. Only the files of BflPromptLibrary.AllPaths count; the lab leaves any other file in +/// the folder alone, and an empty file replaces nothing. The files are instructions of the person, who chose to use +/// them, so they bypass the prompt injection filter like the prompts which come with the app. +/// +public static class BflPromptOverrides +{ + /// + /// The name of the folder below the folder of the lab. + /// + public const string DIRECTORY_NAME = "promptOverrides"; + + /// + /// Gets the folder whose files replace the prompts of the lab, or null while the setting does not allow them. + /// + /// The settings of the lab, which an organization may lock. + /// The folder for prompt overrides. + public static string? ActiveDirectory(DataBeyondFactsLab settings, string directory) => settings.AllowPromptOverrides ? directory : null; + + /// + /// Reads the file which replaces a prompt of the lab, and checks its placeholders. + /// + /// The folder for prompt overrides. + /// The prompt, one of BflPromptLibrary.AllPaths. + /// The cancellation token. + /// The text of the file, or null when no file replaces the prompt. + /// The file contains a placeholder which the lab does not fill in it. + public static async Task TryReadAsync(string directory, string path, CancellationToken token = default) + { + var text = await TryReadFileAsync(directory, path, token); + if (text is null) + return null; + + var allowed = BflPromptLibrary.PlaceholdersOf(path); + foreach (var placeholder in BflPromptTemplate.NamesIn(text)) + { + if (!allowed.Contains(placeholder)) + throw new BflPromptOverrideException(BflPromptLibrary.RelativePath(path), placeholder); + } + + return text; + } + + /// + /// Finds the prompts of the lab which a file in the folder replaces with another text. + /// + /// + /// A copy of a prompt which matches the prompt of this version, apart from line breaks and the space around the + /// text, changes nothing and does not count. A copy of an older version does. + /// + /// The folder for prompt overrides. + /// The cancellation token. + /// The prompts, as paths of BflPromptLibrary.AllPaths. + public static async Task> FindAsync(string directory, CancellationToken token = default) + { + var replaced = new List(); + foreach (var path in BflPromptLibrary.AllPaths) + { + string? text; + try + { + text = await TryReadFileAsync(directory, path, token); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // The lab will try to read the file, too, so it replaces the prompt as far as the person can tell: + replaced.Add(path); + continue; + } + + if (text is not null && Normalize(text) != Normalize(await BflPromptLibrary.ReadBuiltInAsync(path, token))) + replaced.Add(path); + } + + return replaced; + } + + /// + /// Copies the prompts of this version into the folder, so that changes start from them. A file which exists stays + /// as it is. + /// + /// The folder for prompt overrides; it is created when it is missing. + /// The cancellation token. + /// How many files the folder got. + public static async Task CopyBuiltInAsync(string directory, CancellationToken token = default) + { + var copied = 0; + foreach (var path in BflPromptLibrary.AllPaths) + { + var target = FilePath(directory, path); + if (File.Exists(target)) + continue; + + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + var temporary = $"{target}.{Guid.NewGuid():N}.tmp"; + await File.WriteAllTextAsync(temporary, await BflPromptLibrary.ReadBuiltInAsync(path, token), new UTF8Encoding(false), token); + try + { + // Moving fails when the file appeared meanwhile, so the person's file is never overwritten: + File.Move(temporary, target, false); + copied++; + } + catch (IOException) when (File.Exists(target)) + { + File.Delete(temporary); + } + } + + return copied; + } + + /// + /// Gets the file which replaces a prompt of the lab. + /// + /// The folder for prompt overrides. + /// The prompt, one of BflPromptLibrary.AllPaths. + public static string FilePath(string directory, string path) => Path.Join([directory, ..BflPromptLibrary.RelativePath(path).Split('/')]); + + /// + /// Reads the file which replaces a prompt of the lab, without checking it. + /// + /// The folder for prompt overrides. + /// The prompt, one of BflPromptLibrary.AllPaths. + /// The cancellation token. + /// The text of the file, or null when there is no such file or it is empty. + private static async Task TryReadFileAsync(string directory, string path, CancellationToken token) + { + string text; + try + { + text = await File.ReadAllTextAsync(FilePath(directory, path), Encoding.UTF8, token); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return null; + } + + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + /// + /// Brings a prompt into the form in which two copies compare, whatever system wrote them. + /// + /// The prompt. + private static string Normalize(string text) => text.ReplaceLineEndings("\n").Trim(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs index d385b611d..dc6df353f 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs @@ -60,6 +60,13 @@ public static partial class BflPromptTemplate return values.TryGetValue(name, out var value) ? value : throw new FormatException($"The prompt has the placeholder '{name}', which has no value."); }); + /// + /// Finds the placeholders of a template, as Fill reads them. + /// + /// The template. + /// The name of each placeholder, once each, in the order they first appear. + public static IReadOnlyList NamesIn(string template) => [.. PlaceholderPattern().Matches(template).Select(match => match.Groups["name"].Value).Distinct(StringComparer.Ordinal)]; + [GeneratedRegex(@"\{\{\s*(?[^{}]*?)\s*\}\}")] private static partial Regex PlaceholderPattern(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index f7dfded47..b26179225 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -970,6 +970,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCHAT::T991103295"] = " -- This conversation fills most of the context window of the model. Soon, you will have to start it over. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCHAT::T991103295::INFORMAL"] = "This conversation fills most of the context window of the model. Soon, you will have to start it over." +-- The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1150375027"] = "The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again." + +-- The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1150375027::INFORMAL"] = "The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again." + -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1809312323"] = "Please select a provider." @@ -1459,6 +1465,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLONBOARDING::T180931232 -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLONBOARDING::T1809312323::INFORMAL"] = "Please select a provider." +-- The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLPROMPTOVERRIDENOTICE::T1719028521"] = "The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab." + +-- The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLPROMPTOVERRIDENOTICE::T1719028521::INFORMAL"] = "The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab." + -- Test situation UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1049923473"] = "Test situation" @@ -10405,9 +10417,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T82 -- Restore default UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Restore default" +-- Hide Expert Settings +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1108876344"] = "Hide Expert Settings" + +-- Could not open the test folder. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1196482976"] = "Could not open the test folder." + +-- The test folder holds every prompt already. AI Studio changed no file. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1367912036"] = "The test folder holds every prompt already. AI Studio changed no file." + +-- Could not open the test folder: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1478589470"] = "Could not open the test folder: {0}" + +-- These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T15706444"] = "These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance." + +-- These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T15706444::INFORMAL"] = "These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance." + +-- The lab uses its own prompts +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1931375136"] = "The lab uses its own prompts" + -- Assistant: BeyondFacts Lab Options UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T19824556"] = "Assistant: BeyondFacts Lab Options" +-- Files copied into the test folder: {0}. Files which were there already stay as they are. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2064303845"] = "Files copied into the test folder: {0}. Files which were there already stay as they are." + +-- A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2174086011"] = "A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step." + +-- A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2174086011::INFORMAL"] = "A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step." + -- Form of address UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2183481736"] = "Form of address" @@ -10417,9 +10459,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T257 -- Select a minimum confidence level UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2579793544::INFORMAL"] = "Select a minimum confidence level" +-- Use prompts from the test folder? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2718589875"] = "Use prompts from the test folder?" + +-- Copy the built-in prompts into the test folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2787777151"] = "Copy the built-in prompts into the test folder" + +-- Files in the test folder replace the prompts of the lab +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T320994561"] = "Files in the test folder replace the prompts of the lab" + +-- Show Expert Settings +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3361153305"] = "Show Expert Settings" + -- Close UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3448155331"] = "Close" +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3461425987"] = "Unknown error" + +-- Could not copy the prompts into the test folder. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3478020269"] = "Could not copy the prompts into the test folder." + -- Whether the lab addresses you formally or informally. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3883838251"] = "Whether the lab addresses you formally or informally." @@ -10432,6 +10492,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T400 -- Your preparations usually contain unpublished research. Therefore, the lab accepts only providers with at least medium confidence. You can raise this minimum, e.g., to use only self-hosted models. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T4002746484::INFORMAL"] = "Your preparations usually contain unpublished research. Therefore, the lab accepts only providers with at least medium confidence. You can raise this minimum, e.g., to use only self-hosted models." +-- Open test folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T567935877"] = "Open test folder" + -- Files are sent as they were first read UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T1320692831"] = "Files are sent as they were first read" diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor index b2a6a3bbd..a865661d3 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor @@ -17,6 +17,27 @@ + + @* Only whoever develops the prompts further needs these settings, so they stay folded away. *@ + + @(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings")) + + + + + @TA("These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance.") + + + + + @T("Open test folder") + + + @T("Copy the built-in prompts into the test folder") + + + + @T("Close") diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs index f5391feab..3826af93c 100644 --- a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs @@ -1,5 +1,10 @@ +using AIStudio.Assistants.BeyondFactsLab; using AIStudio.Provider; +using AIStudio.Settings; using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Rust; + +using Microsoft.AspNetCore.Components; namespace AIStudio.Dialogs.Settings; @@ -8,12 +13,39 @@ namespace AIStudio.Dialogs.Settings; /// public partial class SettingsDialogBeyondFactsLab : SettingsDialogBase { + [Inject] + private BeyondFactsLabStore Store { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + /// /// Whether the language of the app tells a formal address from an informal one, so that the form of address /// is a choice at all. /// private bool offersFormOfAddress; + /// + /// Whether the settings for the further development of the lab are unfolded. + /// + private bool showExpertSettings; + + /// + /// Whether the prompts are being copied into the test folder right now. + /// + private bool isCopyingPrompts; + + /// + /// Gets whether an organization manages the prompt overrides. + /// + private static bool IsPromptOverridesLocked => ManagedConfiguration.TryGet(x => x.BeyondFactsLab, x => x.AllowPromptOverrides, out var meta) && meta.IsLocked; + + /// + /// Gets whether an organization switched the prompt overrides off for good, so that the test folder serves no + /// purpose. + /// + private bool ArePromptOverridesLockedOff => IsPromptOverridesLocked && !this.SettingsManager.ConfigurationData.BeyondFactsLab.AllowPromptOverrides; + /// protected override async Task OnInitializedAsync() { @@ -29,4 +61,57 @@ public partial class SettingsDialogBeyondFactsLab : SettingsDialogBase /// offer it either. Showing the floor instead tells what really counts. /// private ConfidenceLevel SelectedMinimumConfidence() => Tools.Components.BEYOND_FACTS_LAB_ASSISTANT.MinimumConfidence(this.SettingsManager); + + private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings; + + /// + /// Opens the test folder in the file manager, and creates it first when it is missing. + /// + private async Task OpenTestFolderAsync() + { + OpenPathResponse response; + try + { + var directory = this.Store.PromptOverridesDirectory; + Directory.CreateDirectory(directory); + response = await this.RustService.TryOpenPathInRuntimeFileManager(directory); + } + catch (Exception exception) + { + this.Logger.LogWarning(exception, "Could not open the test folder of the BeyondFacts Lab."); + await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the test folder."))); + return; + } + + if (response.Success) + return; + + var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue; + await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the test folder: {0}"), issue))); + } + + /// + /// Copies the prompts of this version into the test folder, so that changes start from them. A file which exists + /// stays as it is. + /// + private async Task CopyPromptsAsync() + { + this.isCopyingPrompts = true; + try + { + var copied = await BflPromptOverrides.CopyBuiltInAsync(this.Store.PromptOverridesDirectory); + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.ContentCopy, copied is 0 + ? T("The test folder holds every prompt already. AI Studio changed no file.") + : string.Format(T("Files copied into the test folder: {0}. Files which were there already stay as they are."), copied))); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + this.Logger.LogWarning(exception, "Could not copy the prompts of the BeyondFacts Lab into the test folder."); + await this.MessageBus.SendError(new(Icons.Material.Filled.ContentCopy, T("Could not copy the prompts into the test folder."))); + } + finally + { + this.isCopyingPrompts = false; + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index c6a88aea4..2595f3c27 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -1002,6 +1002,13 @@ CONFIG["SETTINGS"] = {} -- of the lab do not show it. -- Allowed values are: FORMAL, INFORMAL -- CONFIG["SETTINGS"]["DataBeyondFactsLab.AddressForm"] = "FORMAL" +-- +-- Configure whether files in the folder for prompt overrides replace the prompts which come with the lab. +-- The option serves the further development of the prompts, e.g., by the research team behind the lab. +-- Preparing an appearance does not need it. Such files count as instructions of the users themselves, +-- and the lab shows a banner while one of them replaces a prompt. Set it to false and lock it to keep +-- users with the prompts which come with AI Studio. +-- CONFIG["SETTINGS"]["DataBeyondFactsLab.AllowPromptOverrides"] = false -- Configure defaults for the Assistant Builder. -- Preselection must be enabled for the remaining Assistant Builder settings to take effect. diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index b8690cf2c..53689e9aa 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -972,6 +972,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCHAT::T991103295"] = " -- This conversation fills most of the context window of the model. Soon, you will have to start it over. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCHAT::T991103295::INFORMAL"] = "Dieses Gespräch füllt den größten Teil des Kontextfensters des Modells. Bald musst du es neu beginnen." +-- The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1150375027"] = "Die Datei „{0}“ im Testordner des BeyondFacts Labs enthält den Platzhalter „{1}“, den das Lab in dieser Datei nicht ausfüllt. Bitte korrigieren Sie die Datei und versuchen Sie es erneut." + +-- The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1150375027::INFORMAL"] = "Die Datei „{0}“ im Testordner des BeyondFacts Labs enthält den Platzhalter „{1}“, den das Lab in dieser Datei nicht ausfüllt. Bitte korrigiere die Datei und versuche es erneut." + -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1809312323"] = "Bitte wählen Sie einen Anbieter aus." @@ -1461,6 +1467,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLONBOARDING::T180931232 -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLONBOARDING::T1809312323::INFORMAL"] = "Bitte wähle einen Anbieter aus." +-- The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLPROMPTOVERRIDENOTICE::T1719028521"] = "Das Lab verwendet derzeit experimentelle Anweisungen aus seinem Testordner. Sie können sie in den Optionen des Labs deaktivieren." + +-- The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLPROMPTOVERRIDENOTICE::T1719028521::INFORMAL"] = "Das Lab verwendet derzeit experimentelle Anweisungen aus seinem Testordner. Du kannst sie in den Optionen des Labs deaktivieren." + -- Test situation UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1049923473"] = "Testsituation" @@ -10407,9 +10419,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T82 -- Restore default UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Standard wiederherstellen" +-- Hide Expert Settings +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1108876344"] = "Experten-Einstellungen ausblenden" + +-- Could not open the test folder. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1196482976"] = "Der Testordner konnte nicht geöffnet werden." + +-- The test folder holds every prompt already. AI Studio changed no file. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1367912036"] = "Der Testordner enthält bereits alle Prompts. AI Studio hat keine Datei geändert." + +-- Could not open the test folder: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1478589470"] = "Der Testordner konnte nicht geöffnet werden: {0}" + +-- These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T15706444"] = "Diese Einstellungen dienen der Weiterentwicklung des Labs, z. B. durch das Forschungsteam hinter dem Lab. Zur Vorbereitung eines Auftritts brauchen Sie sie nicht." + +-- These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T15706444::INFORMAL"] = "Diese Einstellungen dienen der Weiterentwicklung des Labs, z. B. durch das Forschungsteam hinter dem Lab. Zur Vorbereitung eines Auftritts brauchst du sie nicht." + +-- The lab uses its own prompts +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1931375136"] = "Das Lab verwendet seine eigenen Prompts" + -- Assistant: BeyondFacts Lab Options UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T19824556"] = "Assistent: Optionen für das BeyondFacts Lab" +-- Files copied into the test folder: {0}. Files which were there already stay as they are. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2064303845"] = "In den Testordner kopierte Dateien: {0}. Dateien, die bereits dort vorhanden waren, bleiben unverändert." + +-- A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2174086011"] = "Eine Datei im Testordner ersetzt den gleichnamigen Prompt, z. B. „Prompts/core.md“. Das Lab verwendet alle anderen Prompts aus AI Studio. Solange eine Datei einen Prompt ersetzt, zeigt das Lab über jedem Schritt einen Hinweis an." + +-- A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2174086011::INFORMAL"] = "Eine Datei im Testordner ersetzt den gleichnamigen Prompt, z. B. „Prompts/core.md“. Das Lab verwendet alle anderen Prompts aus AI Studio. Solange eine Datei einen Prompt ersetzt, zeigt das Lab über jedem Schritt einen Hinweis an." + -- Form of address UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2183481736"] = "Anrede" @@ -10419,9 +10461,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T257 -- Select a minimum confidence level UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2579793544::INFORMAL"] = "Wähle ein minimales Vertrauensniveau aus" +-- Use prompts from the test folder? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2718589875"] = "Prompts aus dem Testordner verwenden?" + +-- Copy the built-in prompts into the test folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2787777151"] = "Mitgelieferte Prompts in den Testordner kopieren" + +-- Files in the test folder replace the prompts of the lab +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T320994561"] = "Dateien im Testordner ersetzen die Prompts des Labs" + +-- Show Expert Settings +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3361153305"] = "Experten-Einstellungen anzeigen" + -- Close UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3448155331"] = "Schließen" +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3461425987"] = "Unbekannter Fehler" + +-- Could not copy the prompts into the test folder. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3478020269"] = "Die Prompts konnten nicht in den Testordner kopiert werden." + -- Whether the lab addresses you formally or informally. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3883838251"] = "Ob das Lab Sie siezt oder duzt." @@ -10434,6 +10494,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T400 -- Your preparations usually contain unpublished research. Therefore, the lab accepts only providers with at least medium confidence. You can raise this minimum, e.g., to use only self-hosted models. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T4002746484::INFORMAL"] = "Deine Vorbereitungen enthalten meist unveröffentlichte Forschung. Daher akzeptiert das Lab nur Anbieter mit mindestens dem Vertrauensniveau „Mittel“. Du kannst diese Untergrenze anheben, etwa um nur selbst gehostete Modelle zu verwenden." +-- Open test folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T567935877"] = "Testordner öffnen" + -- Files are sent as they were first read UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T1320692831"] = "Dateien werden so gesendet, wie sie zuerst eingelesen wurden" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 5596a7dd3..3bc8c0f35 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -972,6 +972,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCHAT::T991103295"] = " -- This conversation fills most of the context window of the model. Soon, you will have to start it over. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCHAT::T991103295::INFORMAL"] = "This conversation fills most of the context window of the model. Soon, you will have to start it over." +-- The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1150375027"] = "The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again." + +-- The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1150375027::INFORMAL"] = "The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again." + -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLCONVERSATIONSERVICE::T1809312323"] = "Please select a provider." @@ -1461,6 +1467,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLONBOARDING::T180931232 -- Please select a provider. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLONBOARDING::T1809312323::INFORMAL"] = "Please select a provider." +-- The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLPROMPTOVERRIDENOTICE::T1719028521"] = "The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab." + +-- The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLPROMPTOVERRIDENOTICE::T1719028521::INFORMAL"] = "The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab." + -- Test situation UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1049923473"] = "Test situation" @@ -10407,9 +10419,39 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T82 -- Restore default UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Restore default" +-- Hide Expert Settings +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1108876344"] = "Hide Expert Settings" + +-- Could not open the test folder. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1196482976"] = "Could not open the test folder." + +-- The test folder holds every prompt already. AI Studio changed no file. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1367912036"] = "The test folder holds every prompt already. AI Studio changed no file." + +-- Could not open the test folder: {0} +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1478589470"] = "Could not open the test folder: {0}" + +-- These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T15706444"] = "These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance." + +-- These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T15706444::INFORMAL"] = "These settings serve the further development of the lab, e.g., by the research team behind it. You do not need them to prepare an appearance." + +-- The lab uses its own prompts +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1931375136"] = "The lab uses its own prompts" + -- Assistant: BeyondFacts Lab Options UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T19824556"] = "Assistant: BeyondFacts Lab Options" +-- Files copied into the test folder: {0}. Files which were there already stay as they are. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2064303845"] = "Files copied into the test folder: {0}. Files which were there already stay as they are." + +-- A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2174086011"] = "A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step." + +-- A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2174086011::INFORMAL"] = "A file in the test folder replaces the prompt of the same name, e.g., 'Prompts/core.md'. The lab takes every other prompt from AI Studio. While a file replaces a prompt, the lab shows a notice above every step." + -- Form of address UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2183481736"] = "Form of address" @@ -10419,9 +10461,27 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T257 -- Select a minimum confidence level UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2579793544::INFORMAL"] = "Select a minimum confidence level" +-- Use prompts from the test folder? +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2718589875"] = "Use prompts from the test folder?" + +-- Copy the built-in prompts into the test folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2787777151"] = "Copy the built-in prompts into the test folder" + +-- Files in the test folder replace the prompts of the lab +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T320994561"] = "Files in the test folder replace the prompts of the lab" + +-- Show Expert Settings +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3361153305"] = "Show Expert Settings" + -- Close UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3448155331"] = "Close" +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3461425987"] = "Unknown error" + +-- Could not copy the prompts into the test folder. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3478020269"] = "Could not copy the prompts into the test folder." + -- Whether the lab addresses you formally or informally. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3883838251"] = "Whether the lab addresses you formally or informally." @@ -10434,6 +10494,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T400 -- Your preparations usually contain unpublished research. Therefore, the lab accepts only providers with at least medium confidence. You can raise this minimum, e.g., to use only self-hosted models. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T4002746484::INFORMAL"] = "Your preparations usually contain unpublished research. Therefore, the lab accepts only providers with at least medium confidence. You can raise this minimum, e.g., to use only self-hosted models." +-- Open test folder +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T567935877"] = "Open test folder" + -- Files are sent as they were first read UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T1320692831"] = "Files are sent as they were first read" diff --git a/app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs b/app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs index be03dadaf..fc0d3767f 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs @@ -45,6 +45,15 @@ public sealed class DataBeyondFactsLab(Expression /// public AddressForm AddressForm { get; set; } = ManagedConfiguration.Register(configSelection, value => value.AddressForm, AddressForm.FORMAL); + /// + /// Gets or sets whether files in the folder for prompt overrides replace the prompts which come with the lab. + /// + /// + /// The option serves the further development of the prompts, e.g., by the research team behind the lab, not the + /// preparation of an appearance. An organization may switch it off and lock it. + /// + public bool AllowPromptOverrides { get; set; } = ManagedConfiguration.Register(configSelection, value => value.AllowPromptOverrides, false); + /// /// Gets or sets the hash of the notes the person confirmed, see BflNotice; empty while they confirmed none. /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 7fbde6747..3e6bc61a1 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -520,6 +520,7 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.AddressForm, this.Id, settingsTable, dryRun); + ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.AllowPromptOverrides, this.Id, settingsTable, dryRun); // Config: Assistant Builder defaults? ManagedConfiguration.TryProcessConfiguration(x => x.AssistantBuilder, x => x.PreselectOptions, this.Id, settingsTable, dryRun); diff --git a/app/Tests/BeyondFactsLab/BflPromptLibraryTests.cs b/app/Tests/BeyondFactsLab/BflPromptLibraryTests.cs index 143fa5932..2c693f1f9 100644 --- a/app/Tests/BeyondFactsLab/BflPromptLibraryTests.cs +++ b/app/Tests/BeyondFactsLab/BflPromptLibraryTests.cs @@ -55,6 +55,32 @@ public sealed class BflPromptLibraryTests Assert.That(BflPromptLibrary.AddressPath(AddressForm.INFORMAL), Is.Not.EqualTo(BflPromptLibrary.AddressPath(AddressForm.FORMAL))); } + [Test] + public async Task EveryPromptUsesOnlyThePlaceholdersTheLabFillsInIt() + { + foreach (var path in BflPromptLibrary.AllPaths) + Assert.That(BflPromptTemplate.NamesIn(await BflPromptLibrary.ReadBuiltInAsync(path)), Is.SubsetOf(BflPromptLibrary.PlaceholdersOf(path)), path); + } + + [Test] + public void TheComposerFillsEveryPlaceholderAPromptMayContain() + { + var core = string.Join("\n", BflPromptLibrary.PlaceholdersOf(BflPromptLibrary.CORE_PATH).Select(name => $"{{{{{name}}}}}")); + var onboarding = string.Join("\n", BflPromptLibrary.PlaceholdersOf(BflPromptLibrary.ONBOARDING_PATH).Select(name => $"{{{{{name}}}}}")); + + using (Assert.EnterMultipleScope()) + { + Assert.That(BflPromptComposer.Compose(new(core, "Leitfaden", "Schritt", "Anrede"), BflStep.FRAMEWORK, new Dictionary(), [], "Deutsch"), Does.Not.Contain("{{")); + Assert.That(BflPromptComposer.ComposeOnboarding(new(onboarding, "Anrede"), "Deutsch"), Does.Not.Contain("{{")); + } + } + + [Test] + public void EveryPromptLiesInTheFolderOfTheLab() + { + Assert.That(BflPromptLibrary.AllPaths.Select(BflPromptLibrary.RelativePath), Is.Unique.And.All.Not.StartWith("/")); + } + [Test] public async Task AMissingPromptIsAnError() { diff --git a/app/Tests/BeyondFactsLab/BflPromptOverrideSettingTests.cs b/app/Tests/BeyondFactsLab/BflPromptOverrideSettingTests.cs new file mode 100644 index 000000000..f67085797 --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflPromptOverrideSettingTests.cs @@ -0,0 +1,74 @@ +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Settings; + +using Lua; +using Lua.Standard; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks that an organization can keep the prompts of the BeyondFacts Lab as they come with AI Studio. +/// +/// +/// The settings are reached through Program.SERVICE_PROVIDER, as in the app, because a managed setting writes its +/// value and its lock there. +/// +[TestFixture] +public sealed class BflPromptOverrideSettingTests +{ + private static readonly Guid PLUGIN_ID = Guid.Parse("4f0c2b7e-8d31-4a96-b5e2-7c1d9a3e6f08"); + + private SettingsManager settingsManager = null!; + private ServiceProvider serviceProvider = null!; + private IServiceProvider previousServiceProvider = null!; + + [SetUp] + public void CreateSettings() + { + // Creating the settings data registers every managed setting: + this.settingsManager = new SettingsManager(NullLogger.Instance, null!); + + this.previousServiceProvider = Program.SERVICE_PROVIDER; + this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider(); + Program.SERVICE_PROVIDER = this.serviceProvider; + } + + [TearDown] + public void RestoreApplicationState() + { + // The managed state of a setting outlives this fixture, so we release what the plugin took over: + ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet()); + + Program.SERVICE_PROVIDER = this.previousServiceProvider; + this.serviceProvider.Dispose(); + } + + [Test] + public async Task AnOrganizationCanSwitchOffThePromptOverridesForGood() + { + this.settingsManager.ConfigurationData.BeyondFactsLab.AllowPromptOverrides = true; + + var state = LuaState.Create(); + state.OpenBasicLibrary(); + await state.DoStringAsync(""" + SETTINGS = {} + SETTINGS["DataBeyondFactsLab.AllowPromptOverrides"] = false + """); + + if (!state.Environment["SETTINGS"].TryRead(out var settingsTable)) + throw new InvalidOperationException("The settings of this test are not a Lua table."); + + var processed = ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.AllowPromptOverrides, PLUGIN_ID, settingsTable, false); + ManagedConfiguration.TryGet(x => x.BeyondFactsLab, x => x.AllowPromptOverrides, out var configMeta); + + using (Assert.EnterMultipleScope()) + { + Assert.That(processed, Is.True); + Assert.That(configMeta.IsLocked, Is.True); + Assert.That(BflPromptOverrides.ActiveDirectory(this.settingsManager.ConfigurationData.BeyondFactsLab, "prompts"), Is.Null); + } + } +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs b/app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs new file mode 100644 index 000000000..ff2c7747b --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflPromptOverridesTests.cs @@ -0,0 +1,243 @@ +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks how files in the folder for prompt overrides replace the prompts of the BeyondFacts Lab. +/// +/// +/// The folder serves whoever develops the prompts further. A file there replaces the prompt of the same name only; +/// what the folder lacks, or what it holds besides, changes nothing. +/// +[TestFixture] +public sealed class BflPromptOverridesTests +{ + private string directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + this.directory = Path.Combine(Path.GetTempPath(), $"ai-studio-beyondfacts-lab-prompts-{Guid.NewGuid():N}"); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(this.directory)) + Directory.Delete(this.directory, true); + } + + [Test] + public void TheFolderMirrorsTheFilesOfTheLab() + { + var path = BflPromptOverrides.FilePath(this.directory, BflPromptLibrary.StepPath(BflStep.KNOWLEDGE)); + + Assert.That(path, Is.EqualTo(Path.Combine(this.directory, "Prompts", "steps", $"{BflStep.KNOWLEDGE.FolderName}.md"))); + } + + [Test] + public void EveryPromptHasAFileOfItsOwnInTheFolder() + { + var files = BflPromptLibrary.AllPaths.Select(path => BflPromptOverrides.FilePath(this.directory, path)).ToList(); + + Assert.That(files, Is.Unique); + } + + [Test] + public async Task AFileInTheFolderReplacesThePromptOfTheSameNameOnly() + { + var replaced = BflPromptLibrary.StepPath(BflStep.GOALS_AND_ROLE); + await this.WriteAsync(replaced, "Ein neuer Prompt für die Ziele."); + + var parts = await BflPromptLibrary.LoadPartsAsync(BflStep.GOALS_AND_ROLE, AddressForm.FORMAL, this.directory); + var otherStep = await BflPromptLibrary.ReadAsync(BflPromptLibrary.StepPath(BflStep.VALUES_AND_PERSPECTIVES), this.directory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(parts.Step, Is.EqualTo("Ein neuer Prompt für die Ziele.")); + Assert.That(parts.Core, Is.EqualTo(await BflPromptLibrary.ReadBuiltInAsync(BflPromptLibrary.CORE_PATH))); + Assert.That(otherStep, Is.EqualTo(await BflPromptLibrary.ReadBuiltInAsync(BflPromptLibrary.StepPath(BflStep.VALUES_AND_PERSPECTIVES)))); + Assert.That(await BflPromptOverrides.FindAsync(this.directory), Is.EqualTo(new[] { replaced })); + } + } + + [Test] + public async Task WithoutTheSettingTheFilesReplaceNothing() + { + await this.WriteAsync(BflPromptLibrary.CORE_PATH, "Ein neuer Kern."); + var settings = new DataBeyondFactsLab { AllowPromptOverrides = false }; + + var activeDirectory = BflPromptOverrides.ActiveDirectory(settings, this.directory); + var core = await BflPromptLibrary.ReadAsync(BflPromptLibrary.CORE_PATH, activeDirectory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(activeDirectory, Is.Null); + Assert.That(core, Is.EqualTo(await BflPromptLibrary.ReadBuiltInAsync(BflPromptLibrary.CORE_PATH))); + Assert.That(BflPromptOverrides.ActiveDirectory(new DataBeyondFactsLab { AllowPromptOverrides = true }, this.directory), Is.EqualTo(this.directory)); + } + } + + [Test] + public async Task AMissingFolderReplacesNothing() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(await BflPromptLibrary.ReadAsync(BflPromptLibrary.ONBOARDING_PATH, this.directory), Is.EqualTo(await BflPromptLibrary.ReadBuiltInAsync(BflPromptLibrary.ONBOARDING_PATH))); + Assert.That(await BflPromptOverrides.FindAsync(this.directory), Is.Empty); + } + } + + [Test] + public async Task AnEmptyFileReplacesNothing() + { + await this.WriteAsync(BflPromptLibrary.ONBOARDING_PATH, " \n"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await BflPromptLibrary.ReadAsync(BflPromptLibrary.ONBOARDING_PATH, this.directory), Is.EqualTo(await BflPromptLibrary.ReadBuiltInAsync(BflPromptLibrary.ONBOARDING_PATH))); + Assert.That(await BflPromptOverrides.FindAsync(this.directory), Is.Empty); + } + } + + [Test] + public async Task AnUnknownFileChangesNothing() + { + Directory.CreateDirectory(Path.Combine(this.directory, "Prompts", "steps")); + await File.WriteAllTextAsync(Path.Combine(this.directory, "Prompts", "steps", "99-unknown.md"), "Ein Schritt, den es nicht gibt."); + await File.WriteAllTextAsync(Path.Combine(this.directory, "core.md"), "Ein Kern am falschen Ort."); + await File.WriteAllTextAsync(Path.Combine(this.directory, "notes.txt"), "Notizen von {{NIEMANDEM}}."); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await BflPromptLibrary.ReadAsync(BflPromptLibrary.CORE_PATH, this.directory), Is.EqualTo(await BflPromptLibrary.ReadBuiltInAsync(BflPromptLibrary.CORE_PATH))); + Assert.That(await BflPromptOverrides.FindAsync(this.directory), Is.Empty); + } + } + + [Test] + public async Task AFileWithAPlaceholderTheLabDoesNotFillThereIsAnError() + { + await this.WriteAsync(BflPromptLibrary.StepPath(BflStep.STRATEGY), "Beachte {{DETAILS}}."); + await this.WriteAsync(BflPromptLibrary.CORE_PATH, "{{STEP}} {{UNKNOWN}}"); + await this.WriteAsync(BflPromptLibrary.ONBOARDING_PATH, "{{RESPONSE_LANGUAGE}} {{KNOWLEDGE}}"); + + var inStep = await Assert.ThrowsAsync(async () => await BflPromptLibrary.ReadAsync(BflPromptLibrary.StepPath(BflStep.STRATEGY), this.directory)); + var inCore = await Assert.ThrowsAsync(async () => await BflPromptLibrary.LoadPartsAsync(BflStep.FRAMEWORK, AddressForm.FORMAL, this.directory)); + var inOnboarding = await Assert.ThrowsAsync(async () => await BflPromptLibrary.LoadOnboardingPartsAsync(AddressForm.FORMAL, this.directory)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(inStep!.FileName, Is.EqualTo($"Prompts/steps/{BflStep.STRATEGY.FolderName}.md")); + Assert.That(inStep.Placeholder, Is.EqualTo(BflPromptTemplate.DETAILS)); + Assert.That(inCore!.FileName, Is.EqualTo("Prompts/core.md")); + Assert.That(inCore.Placeholder, Is.EqualTo("UNKNOWN")); + Assert.That(inOnboarding!.FileName, Is.EqualTo("Prompts/onboarding.md")); + Assert.That(inOnboarding.Placeholder, Is.EqualTo(BflPromptTemplate.KNOWLEDGE)); + } + } + + [Test] + public async Task AFileWithThePlaceholdersOfItsPromptGivesAPrompt() + { + await this.WriteAsync(BflPromptLibrary.CORE_PATH, "Ein neuer Kern.\n{{RESPONSE_LANGUAGE}}\n{{ADDRESS_FORM}}\n{{KNOWLEDGE}}\n{{STEP}}\n{{MATERIALS}}\n{{DETAILS}}\n{{MISSING_DETAILS}}"); + await this.WriteAsync(BflPromptLibrary.ONBOARDING_PATH, "Ein neues Onboarding.\n{{RESPONSE_LANGUAGE}}\n{{ADDRESS_FORM}}"); + + var prompt = BflPromptComposer.Compose(await BflPromptLibrary.LoadPartsAsync(BflStep.TEST_SITUATION, AddressForm.FORMAL, this.directory), BflStep.TEST_SITUATION, new Dictionary(), [], "Deutsch"); + var onboarding = BflPromptComposer.ComposeOnboarding(await BflPromptLibrary.LoadOnboardingPartsAsync(AddressForm.FORMAL, this.directory), "Deutsch"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(prompt, Does.StartWith("Ein neuer Kern.")); + Assert.That(prompt, Does.Not.Contain("{{")); + Assert.That(onboarding, Does.StartWith("Ein neues Onboarding.")); + Assert.That(onboarding, Does.Not.Contain("{{")); + } + } + + [Test] + public async Task CopyingGivesTheFolderEveryPromptOfThisVersion() + { + var copied = await BflPromptOverrides.CopyBuiltInAsync(this.directory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(copied, Is.EqualTo(BflPromptLibrary.AllPaths.Count)); + foreach (var path in BflPromptLibrary.AllPaths) + Assert.That(await File.ReadAllTextAsync(BflPromptOverrides.FilePath(this.directory, path)), Is.EqualTo(await BflPromptLibrary.ReadBuiltInAsync(path)), path); + + Assert.That(await BflPromptOverrides.FindAsync(this.directory), Is.Empty, "A copy which nobody changed replaces nothing."); + Assert.That(Directory.EnumerateFiles(this.directory, "*.tmp", SearchOption.AllDirectories), Is.Empty); + } + } + + [Test] + public async Task CopyingKeepsEveryFileWhichExists() + { + await this.WriteAsync(BflPromptLibrary.CORE_PATH, "Ein neuer Kern."); + await this.WriteAsync(BflPromptLibrary.StepPath(BflStep.RESULTS), string.Empty); + + var copied = await BflPromptOverrides.CopyBuiltInAsync(this.directory); + var copiedAgain = await BflPromptOverrides.CopyBuiltInAsync(this.directory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(copied, Is.EqualTo(BflPromptLibrary.AllPaths.Count - 2)); + Assert.That(copiedAgain, Is.Zero); + Assert.That(await File.ReadAllTextAsync(BflPromptOverrides.FilePath(this.directory, BflPromptLibrary.CORE_PATH)), Is.EqualTo("Ein neuer Kern.")); + Assert.That(await File.ReadAllTextAsync(BflPromptOverrides.FilePath(this.directory, BflPromptLibrary.StepPath(BflStep.RESULTS))), Is.Empty, "Even an empty file is the person's own."); + } + } + + [Test] + public async Task TheLabTellsAboutReplacedPromptsOnlyWhileTheSettingAllowsThem() + { + var settingsManager = new SettingsManager(NullLogger.Instance, null!); + var store = new BeyondFactsLabStore(NullLogger.Instance, new BflStorageOptions { DataDirectory = this.directory }); + var service = new BflConversationService(store, settingsManager, null!, null!, NullLogger.Instance); + await BflPromptOverrides.CopyBuiltInAsync(store.PromptOverridesDirectory); + + settingsManager.ConfigurationData.BeyondFactsLab.AllowPromptOverrides = true; + var withCopies = await service.UsesPromptOverridesAsync(); + + await File.WriteAllTextAsync(BflPromptOverrides.FilePath(store.PromptOverridesDirectory, BflPromptLibrary.CORE_PATH), "Ein neuer Kern."); + var withChange = await service.UsesPromptOverridesAsync(); + + settingsManager.ConfigurationData.BeyondFactsLab.AllowPromptOverrides = false; + var switchedOff = await service.UsesPromptOverridesAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(store.PromptOverridesDirectory, Is.EqualTo(Path.Combine(this.directory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME, BflPromptOverrides.DIRECTORY_NAME))); + Assert.That(withCopies, Is.False, "Copies which nobody changed replace nothing."); + Assert.That(withChange, Is.True); + Assert.That(switchedOff, Is.False); + } + } + + [Test] + public async Task ACopyWithOtherLineBreaksReplacesNothing() + { + var path = BflPromptLibrary.StepPath(BflStep.FRAMEWORK); + var builtIn = await BflPromptLibrary.ReadBuiltInAsync(path); + await this.WriteAsync(path, builtIn.ReplaceLineEndings("\r\n") + "\r\n"); + + Assert.That(await BflPromptOverrides.FindAsync(this.directory), Is.Empty); + } + + /// + /// Writes the file which replaces a prompt. + /// + /// The prompt, one of BflPromptLibrary.AllPaths. + /// The text of the file. + private async Task WriteAsync(string path, string text) + { + var file = BflPromptOverrides.FilePath(this.directory, path); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + await File.WriteAllTextAsync(file, text); + } +} \ No newline at end of file