Added prompt overrides for testing the BeyondFacts Lab (#1181)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-10 08:57:36 +01:00
1 parent 9f2280bed7
commit 6d0052ecdf
21 files changed
+1035 -13

No files matched your search

@@ -128,6 +128,8 @@
<MudGrid Spacing="3">
<MudItem xs="12" lg="8">
<div @key="@this.activeStep" class="px-6">
@* It checks the test folder anew for every step, as the key of the step recreates it: *@
<BflPromptOverrideNotice Class="mt-2 mb-2"/>
<MudText Typo="Typo.h5" Class="mt-2">@this.activeStep.Title</MudText>
<MudText Typo="Typo.body2" Class="mb-4">@this.activeStep.Description</MudText>
@@ -73,6 +73,11 @@ public sealed partial class BeyondFactsLabStore(ILogger<BeyondFactsLabStore> log
throw new InvalidOperationException("The AI Studio data directory is not initialized."),
ROOT_DIRECTORY_NAME);
/// <summary>
/// Gets the folder whose files replace the prompts of the lab while the person allows it, see BflPromptOverrides.
/// </summary>
public string PromptOverridesDirectory => Path.Combine(this.RootDirectory, BflPromptOverrides.DIRECTORY_NAME);
/// <summary>
/// Writes a text file so that readers see either the old or the new content, never a part of it.
/// </summary>
@@ -58,6 +58,12 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
/// </summary>
private readonly Lock runningLock = new();
/// <summary>
/// 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.
/// </summary>
private string? PromptOverridesDirectory => BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory);
/// <summary>
/// Happens whenever a chat changed: a message, a chunk of an answer, or the end of a request.
/// </summary>
@@ -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
/// <exception cref="FormatException">A prompt has a placeholder without a value.</exception>
public async Task<string> ComposeSystemPromptAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => (await this.ComposeRequestAsync(location, provider)).SystemPrompt;
/// <summary>
/// Checks whether files in the test folder replace prompts of the lab right now, so that the lab can tell the
/// person.
/// </summary>
/// <param name="token">The cancellation token.</param>
/// <returns>True while the setting allows prompt overrides and a file replaces a prompt with another text.</returns>
public async Task<bool> UsesPromptOverridesAsync(CancellationToken token = default) => this.PromptOverridesDirectory is { } directory && (await BflPromptOverrides.FindAsync(directory, token)).Count > 0;
/// <summary>
/// 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);
}
@@ -1,4 +1,5 @@
@inherits MSGComponentBase
<BflPromptOverrideNotice Class="mb-3"/>
<ProviderSelection @bind-ProviderSettings="@this.providerSettings" ValidateProvider="@this.ValidateProvider"/>
<BflChat Location="@BflOnboardingThreadLocation.INSTANCE" Provider="@this.providerSettings" ProviderIssue="@this.ValidateProvider(this.providerSettings)" ShowHeading="@false"/>
@@ -1,3 +1,5 @@
using System.Collections.Frozen;
using AIStudio.Settings.DataModel;
namespace AIStudio.Assistants.BeyondFactsLab;
@@ -8,6 +10,7 @@ namespace AIStudio.Assistants.BeyondFactsLab;
/// <remarks>
/// 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.
/// </remarks>
public static class BflPromptLibrary
{
@@ -42,6 +45,30 @@ public static class BflPromptLibrary
/// </summary>
private const string ADDRESS_INFORMAL_PATH = FOLDER + "/Prompts/snippets/address-informal.md";
/// <summary>
/// The placeholders BflPromptComposer fills in the core.
/// </summary>
private static readonly FrozenSet<string> CORE_PLACEHOLDERS = FrozenSet.Create(StringComparer.Ordinal,
BflPromptTemplate.RESPONSE_LANGUAGE,
BflPromptTemplate.ADDRESS_FORM,
BflPromptTemplate.KNOWLEDGE,
BflPromptTemplate.STEP,
BflPromptTemplate.MATERIALS,
BflPromptTemplate.DETAILS,
BflPromptTemplate.MISSING_DETAILS);
/// <summary>
/// The placeholders BflPromptComposer fills in the prompt of the onboarding.
/// </summary>
private static readonly FrozenSet<string> ONBOARDING_PLACEHOLDERS = FrozenSet.Create(StringComparer.Ordinal,
BflPromptTemplate.RESPONSE_LANGUAGE,
BflPromptTemplate.ADDRESS_FORM);
/// <summary>
/// The placeholders of every other file: none, because BflPromptComposer inserts these files as values.
/// </summary>
private static readonly FrozenSet<string> NO_PLACEHOLDERS = FrozenSet<string>.Empty;
/// <summary>
/// Gets the prompt of a step, named like the folder of the step.
/// </summary>
@@ -66,39 +93,83 @@ public static class BflPromptLibrary
..BflStep.All.Select(StepPath)
];
/// <summary>
/// Gets a file of the lab's prompts relative to the folder of the lab, e.g., "Prompts/core.md".
/// </summary>
/// <param name="path">The file, one of the paths of this class.</param>
/// <exception cref="ArgumentException">The file lies outside the folder of the lab.</exception>
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));
/// <summary>
/// Gets the placeholders a file of the lab's prompts may contain.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="path">The file, one of the paths of this class.</param>
public static IReadOnlySet<string> PlaceholdersOf(string path) => path switch
{
CORE_PATH => CORE_PLACEHOLDERS,
ONBOARDING_PATH => ONBOARDING_PLACEHOLDERS,
_ => NO_PLACEHOLDERS
};
/// <summary>
/// Reads the texts of the system prompt of a step.
/// </summary>
/// <param name="step">The step.</param>
/// <param name="addressForm">How the model addresses the person.</param>
/// <param name="overrideDirectory">The folder whose files replace the prompts, or null while the person does not allow it.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The texts, which BflPromptComposer puts together.</returns>
/// <exception cref="FileNotFoundException">The app comes without one of the files.</exception>
public static async Task<BflPromptParts> 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));
/// <exception cref="BflPromptOverrideException">A file which replaces a prompt contains a placeholder which the lab does not fill in it.</exception>
public static async Task<BflPromptParts> 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));
/// <summary>
/// Reads the texts of the system prompt of the conversation about whether the lab fits a task.
/// </summary>
/// <param name="addressForm">How the model addresses the person.</param>
/// <param name="overrideDirectory">The folder whose files replace the prompts, or null while the person does not allow it.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The texts, which BflPromptComposer puts together.</returns>
/// <exception cref="FileNotFoundException">The app comes without one of the files.</exception>
public static async Task<BflOnboardingPromptParts> LoadOnboardingPartsAsync(AddressForm addressForm, CancellationToken token = default) => new(
await ReadAsync(ONBOARDING_PATH, token),
await ReadAsync(AddressPath(addressForm), token));
/// <exception cref="BflPromptOverrideException">A file which replaces a prompt contains a placeholder which the lab does not fill in it.</exception>
public static async Task<BflOnboardingPromptParts> LoadOnboardingPartsAsync(AddressForm addressForm, string? overrideDirectory = null, CancellationToken token = default) => new(
await ReadAsync(ONBOARDING_PATH, overrideDirectory, token),
await ReadAsync(AddressPath(addressForm), overrideDirectory, token));
/// <summary>
/// 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.
/// </summary>
/// <param name="path">The file, one of the paths of this class.</param>
/// <param name="overrideDirectory">The folder whose files replace the prompts, or null while the person does not allow it.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The text of the file.</returns>
/// <exception cref="FileNotFoundException">The app comes without the file, or the file is empty.</exception>
/// <exception cref="BflPromptOverrideException">The file which replaces the prompt contains a placeholder which the lab does not fill in it.</exception>
public static async Task<string> 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);
}
/// <summary>
/// Reads a file of the lab's prompts as it comes with the app.
/// </summary>
/// <param name="path">The file, one of the paths of this class.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The text of the file.</returns>
/// <exception cref="FileNotFoundException">The app comes without the file, or the file is empty.</exception>
public static async Task<string> ReadAsync(string path, CancellationToken token = default)
public static async Task<string> ReadBuiltInAsync(string path, CancellationToken token = default)
{
var text = await AppResources.ReadTextAsync(path, token);
if (string.IsNullOrWhiteSpace(text))
@@ -0,0 +1,24 @@
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// Tells that a file in the folder for prompt overrides contains a placeholder which the lab does not fill in it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="fileName">The file, relative to the folder for prompt overrides, with forward slashes.</param>
/// <param name="placeholder">The name of the placeholder, without braces.</param>
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.")
{
/// <summary>
/// Gets the file, relative to the folder for prompt overrides, with forward slashes.
/// </summary>
public string FileName { get; } = fileName;
/// <summary>
/// Gets the name of the placeholder, without braces.
/// </summary>
public string Placeholder { get; } = placeholder;
}
@@ -0,0 +1,8 @@
@inherits MSGComponentBase
@if (this.usesPromptOverrides)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="true" Class="@this.Class">
@TA("The lab currently uses experimental instructions from its test folder. You can switch them off in the options of the lab.")
</MudAlert>
}
@@ -0,0 +1,72 @@
using AIStudio.Components;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public partial class BflPromptOverrideNotice : MSGComponentBase
{
[Inject]
private BflConversationService ConversationService { get; init; } = null!;
[Inject]
private ILogger<BflPromptOverrideNotice> Logger { get; init; } = null!;
/// <summary>
/// Gets or sets the CSS classes of the notice, e.g., for its margins.
/// </summary>
[Parameter]
public string Class { get; set; } = string.Empty;
/// <summary>
/// Whether files in the test folder replace prompts of the lab.
/// </summary>
private bool usesPromptOverrides;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [Event.CONFIGURATION_CHANGED]);
await base.OnInitializedAsync();
await this.CheckAsync();
}
/// <inheritdoc />
protected override async Task ProcessIncomingMessage<T>(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);
}
/// <summary>
/// Checks whether files in the test folder replace prompts of the lab.
/// </summary>
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;
}
}
}
@@ -0,0 +1,157 @@
using System.Text;
using AIStudio.Settings.DataModel;
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class BflPromptOverrides
{
/// <summary>
/// The name of the folder below the folder of the lab.
/// </summary>
public const string DIRECTORY_NAME = "promptOverrides";
/// <summary>
/// Gets the folder whose files replace the prompts of the lab, or null while the setting does not allow them.
/// </summary>
/// <param name="settings">The settings of the lab, which an organization may lock.</param>
/// <param name="directory">The folder for prompt overrides.</param>
public static string? ActiveDirectory(DataBeyondFactsLab settings, string directory) => settings.AllowPromptOverrides ? directory : null;
/// <summary>
/// Reads the file which replaces a prompt of the lab, and checks its placeholders.
/// </summary>
/// <param name="directory">The folder for prompt overrides.</param>
/// <param name="path">The prompt, one of BflPromptLibrary.AllPaths.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The text of the file, or null when no file replaces the prompt.</returns>
/// <exception cref="BflPromptOverrideException">The file contains a placeholder which the lab does not fill in it.</exception>
public static async Task<string?> 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;
}
/// <summary>
/// Finds the prompts of the lab which a file in the folder replaces with another text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="directory">The folder for prompt overrides.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The prompts, as paths of BflPromptLibrary.AllPaths.</returns>
public static async Task<IReadOnlyList<string>> FindAsync(string directory, CancellationToken token = default)
{
var replaced = new List<string>();
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;
}
/// <summary>
/// Copies the prompts of this version into the folder, so that changes start from them. A file which exists stays
/// as it is.
/// </summary>
/// <param name="directory">The folder for prompt overrides; it is created when it is missing.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>How many files the folder got.</returns>
public static async Task<int> 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;
}
/// <summary>
/// Gets the file which replaces a prompt of the lab.
/// </summary>
/// <param name="directory">The folder for prompt overrides.</param>
/// <param name="path">The prompt, one of BflPromptLibrary.AllPaths.</param>
public static string FilePath(string directory, string path) => Path.Join([directory, ..BflPromptLibrary.RelativePath(path).Split('/')]);
/// <summary>
/// Reads the file which replaces a prompt of the lab, without checking it.
/// </summary>
/// <param name="directory">The folder for prompt overrides.</param>
/// <param name="path">The prompt, one of BflPromptLibrary.AllPaths.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The text of the file, or null when there is no such file or it is empty.</returns>
private static async Task<string?> 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;
}
/// <summary>
/// Brings a prompt into the form in which two copies compare, whatever system wrote them.
/// </summary>
/// <param name="text">The prompt.</param>
private static string Normalize(string text) => text.ReplaceLineEndings("\n").Trim();
}
@@ -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.");
});
/// <summary>
/// Finds the placeholders of a template, as Fill reads them.
/// </summary>
/// <param name="template">The template.</param>
/// <returns>The name of each placeholder, once each, in the order they first appear.</returns>
public static IReadOnlyList<string> NamesIn(string template) => [.. PlaceholderPattern().Matches(template).Select(match => match.Groups["name"].Value).Distinct(StringComparer.Ordinal)];
[GeneratedRegex(@"\{\{\s*(?<name>[^{}]*?)\s*\}\}")]
private static partial Regex PlaceholderPattern();
}