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(); }