From b6c3fbb5db9ae19816fc7daec056a26c11c65c04 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 8 Nov 2026 19:49:46 +0100 Subject: [PATCH] Added the BeyondFacts Lab as a prototype (#1175) --- app/AGENTS.md | 6 + app/Build/Commands/CollectI18NKeysCommand.cs | 113 +---- .../BeyondFactsLabAssistant.razor | 127 +++++ .../BeyondFactsLabAssistant.razor.Projects.cs | 271 ++++++++++ .../BeyondFactsLabAssistant.razor.Steps.cs | 115 +++++ .../BeyondFactsLabAssistant.razor.cs | 94 ++++ .../BeyondFactsLab/BeyondFactsLabJson.cs | 31 ++ .../BeyondFactsLabJsonContext.cs | 14 + .../BeyondFactsLabStore.Forms.cs | 134 +++++ .../BeyondFactsLabStore.Projects.cs | 435 ++++++++++++++++ .../BeyondFactsLab/BeyondFactsLabStore.cs | 189 +++++++ .../Assistants/BeyondFactsLab/BflAccess.cs | 27 + .../BeyondFactsLab/BflAddressForm.cs | 17 + .../BflAddressFormExtensions.cs | 21 + .../BeyondFactsLab/BflFieldCatalog.cs | 124 +++++ .../BeyondFactsLab/BflFieldDefinition.cs | 40 ++ .../Assistants/BeyondFactsLab/BflFieldKind.cs | 32 ++ .../BeyondFactsLab/BflFieldKindExtensions.cs | 31 ++ .../BeyondFactsLab/BflFieldOption.cs | 7 + .../BeyondFactsLab/BflFieldTexts.cs | 112 +++++ .../BeyondFactsLab/BflMissingDetails.cs | 27 + .../Assistants/BeyondFactsLab/BflNameRole.cs | 17 + .../Assistants/BeyondFactsLab/BflNotice.cs | 50 ++ .../BeyondFactsLab/BflNoticeDialog.razor | 11 + .../BeyondFactsLab/BflNoticeDialog.razor.cs | 22 + .../Assistants/BeyondFactsLab/BflProject.cs | 57 +++ .../BeyondFactsLab/BflProjectEntry.cs | 23 + .../BeyondFactsLab/BflProjectLoadStatus.cs | 22 + .../BeyondFactsLab/BflProjectSelection.cs | 29 ++ .../Assistants/BeyondFactsLab/BflStep.cs | 45 ++ .../BeyondFactsLab/BflStepExtensions.cs | 77 +++ .../BeyondFactsLab/BflStepForm.razor | 97 ++++ .../BeyondFactsLab/BflStepForm.razor.cs | 75 +++ .../BeyondFactsLab/BflStepFormData.cs | 69 +++ .../BeyondFactsLab/BflStorageOptions.cs | 12 + .../Assistants/I18N/AssistantI18N.razor | 12 +- .../Assistants/I18N/AssistantI18N.razor.cs | 23 +- .../Assistants/I18N/allTexts.lua | 363 ++++++++++++++ .../AssistantPromptOptimizer.razor.cs | 24 +- .../Components/DataFolders.razor.cs | 1 + .../Components/MudStepperWithoutActions.razor | 2 +- .../MudStepperWithoutActions.razor.cs | 11 + .../SettingsDialogBeyondFactsLab.razor | 21 + .../SettingsDialogBeyondFactsLab.razor.cs | 18 + app/MindWork AI Studio/Pages/Assistants.razor | 1 + .../Plugins/configuration/plugin.lua | 21 +- .../plugin.lua | 363 ++++++++++++++ .../plugin.lua | 363 ++++++++++++++ app/MindWork AI Studio/Program.cs | 2 + app/MindWork AI Studio/Routes.razor.cs | 1 + .../Settings/ConfigurableAssistant.cs | 1 + .../ConfigurationSelectDataFactory.cs | 22 + .../Settings/DataModel/Data.cs | 5 + .../Settings/DataModel/DataBeyondFactsLab.cs | 56 +++ .../Settings/DataModel/PreviewFeatures.cs | 1 + .../DataModel/PreviewFeaturesExtensions.cs | 1 + .../DataModel/PreviewVisibilityExtensions.cs | 1 + app/MindWork AI Studio/Tools/AppResources.cs | 64 +++ .../Tools/AssistantVisibilityExtensions.cs | 1 + app/MindWork AI Studio/Tools/Components.cs | 1 + .../Tools/ComponentsExtensions.cs | 25 +- .../Tools/DataBackup/DataBackupContents.cs | 1 + .../Tools/DataBackup/DataBackupEntry.cs | 1 + .../Tools/DataBackup/DataBackupEntryKind.cs | 5 + .../Tools/PluginSystem/ILangExtensions.cs | 2 +- .../Tools/PluginSystem/PluginConfiguration.cs | 5 + .../PluginSystem/PluginFactory.Internal.cs | 12 +- .../AssistantPluginGenerationService.cs | 28 +- .../wwwroot/changelog/v26.11.2.md | 1 + app/SharedTools/CSharpStringLiteral.cs | 320 ++++++++++++ app/SharedTools/I18NKeyHash.cs | 27 + app/SharedTools/I18NSourceScanner.cs | 95 ++++ app/SharedTools/I18NSourceText.cs | 9 + app/SharedTools/LuaTable.cs | 9 +- .../BeyondFactsLabStoreTests.cs | 469 ++++++++++++++++++ app/Tests/BeyondFactsLab/BflAccessTests.cs | 73 +++ app/Tests/BeyondFactsLab/BflComponentTests.cs | 151 ++++++ .../BeyondFactsLab/BflFieldCatalogTests.cs | 170 +++++++ .../BeyondFactsLab/BflMissingDetailsTests.cs | 94 ++++ app/Tests/BeyondFactsLab/BflNoticeTests.cs | 81 +++ .../BeyondFactsLab/BflPreviewFeatureTests.cs | 68 +++ .../BflProjectSelectionTests.cs | 78 +++ app/Tests/Serialization/JsonTestRoots.cs | 6 + app/Tests/Tools/AppResourcesTests.cs | 47 ++ .../Tools/DataBackup/DataBackupWriterTests.cs | 2 + app/Tests/Tools/LuaTableTests.cs | 88 ++++ .../PluginSystem/CSharpStringLiteralTests.cs | 127 +++++ .../PluginSystem/I18NSourceScannerTests.cs | 179 +++++++ documentation/Build.md | 2 +- runtime/src/data_restore.rs | 18 +- 90 files changed, 5936 insertions(+), 209 deletions(-) create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Projects.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Steps.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJson.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Projects.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAccess.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressForm.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressFormExtensions.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldCatalog.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldDefinition.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKind.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKindExtensions.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldOption.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldTexts.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMissingDetails.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNameRole.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNotice.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProject.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectEntry.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectLoadStatus.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectSelection.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStep.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepExtensions.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs create mode 100644 app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStorageOptions.cs create mode 100644 app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor create mode 100644 app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs create mode 100644 app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs create mode 100644 app/MindWork AI Studio/Tools/AppResources.cs create mode 100644 app/SharedTools/CSharpStringLiteral.cs create mode 100644 app/SharedTools/I18NKeyHash.cs create mode 100644 app/SharedTools/I18NSourceScanner.cs create mode 100644 app/SharedTools/I18NSourceText.cs create mode 100644 app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflAccessTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflComponentTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflFieldCatalogTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflMissingDetailsTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflNoticeTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflPreviewFeatureTests.cs create mode 100644 app/Tests/BeyondFactsLab/BflProjectSelectionTests.cs create mode 100644 app/Tests/Tools/AppResourcesTests.cs create mode 100644 app/Tests/Tools/LuaTableTests.cs create mode 100644 app/Tests/Tools/PluginSystem/CSharpStringLiteralTests.cs create mode 100644 app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs diff --git a/app/AGENTS.md b/app/AGENTS.md index 8a0f60021..5f0aa0f6d 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -72,6 +72,12 @@ The app's texts are localized in two steps, and the developer always does the fi **Moving a `TB()` text into another class gives it a new I18N key**, so its translation is made anew during the next localization run. +**A text with several lines**, such as a notice in Markdown, is one raw string literal right after the +parenthesis: `TB("""`, the indented lines of the text, and `""")` on a line of its own. The collector reads +every literal as the compiler does, with its escape sequences and without the indentation of a raw literal, +and the translations keep the line breaks. A text which is not one literal, such as an interpolated string +or a concatenation, gets no translation; the collector warns about it. + ## Plugin System **Location:** `app/MindWork AI Studio/Plugins/` diff --git a/app/Build/Commands/CollectI18NKeysCommand.cs b/app/Build/Commands/CollectI18NKeysCommand.cs index c2d2155ff..0e2bca951 100644 --- a/app/Build/Commands/CollectI18NKeysCommand.cs +++ b/app/Build/Commands/CollectI18NKeysCommand.cs @@ -6,39 +6,6 @@ namespace Build.Commands; public sealed partial class CollectI18NKeysCommand { - private const string START_TAG1 = """ - T(" - """; - - private const string START_TAG2 = """ - TB(" - """; - - private const string START_TAG3 = """ - T(@" - """; - - private const string END_TAG1 = """ - ") - """; - - private const string END_TAG2 = """ - ", - """; - - private static readonly (string Tag, int Length)[] START_TAGS = - [ - (START_TAG1, START_TAG1.Length), - (START_TAG2, START_TAG2.Length), - (START_TAG3, START_TAG3.Length) - ]; - - private static readonly string[] END_TAGS = - [ - END_TAG1, - END_TAG2 - ]; - /// /// Defines the command "collect-i18n". /// @@ -82,7 +49,7 @@ public sealed partial class CollectI18NKeysCommand continue; var content = await File.ReadAllTextAsync(filePath, Encoding.UTF8); - var matches = this.FindAllTextTags(content, filePath, warnings); + var matches = I18NSourceScanner.FindTexts(content, filePath, warnings); if (matches.Count == 0) continue; @@ -98,9 +65,9 @@ public sealed partial class CollectI18NKeysCommand var langNamespace = $"{ns}.{name}".ToUpperInvariant(); foreach (var match in matches) { - // The key in the format A.B.C.D.T{hash}: - var key = $"UI_TEXT_CONTENT.{langNamespace}.T{match.ToFNV32()}"; - allI18NContent.TryAdd(key, match); + // The key in the format A.B.C.D.T{hash}, with the hash the app computes at runtime: + var key = $"UI_TEXT_CONTENT.{langNamespace}.T{I18NKeyHash.Of(match.Text)}"; + allI18NContent.TryAdd(key, I18NKeyHash.NormalizeLineEndings(match.Text)); } } @@ -181,78 +148,6 @@ public sealed partial class CollectI18NKeysCommand return sb.ToString(); } - private List FindAllTextTags(ReadOnlySpan fileContent, string filePath, List warnings) - { - (int Index, int Len) FindNextStart(ReadOnlySpan content) - { - var bestIndex = -1; - var bestLength = 0; - - foreach (var (tag, length) in START_TAGS) - { - var index = content.IndexOf(tag); - if (index != -1 && (bestIndex == -1 || index < bestIndex)) - { - bestIndex = index; - bestLength = length; - } - } - - return (bestIndex, bestLength); - } - - int FindNextEnd(ReadOnlySpan content) - { - var bestIndex = -1; - foreach (var tag in END_TAGS) - { - var index = content.IndexOf(tag); - if (index != -1 && (bestIndex == -1 || index < bestIndex)) - bestIndex = index; - } - - return bestIndex; - } - - var matches = new List(); - var startIdx = FindNextStart(fileContent); - var content = fileContent; - while (startIdx.Index > -1) - { - // - // In some cases, after the initial " there follow more " characters. - // We need to skip them: - // - content = content[(startIdx.Index + startIdx.Len)..]; - while(content[0] == '"') - content = content[1..]; - - var endIdx = FindNextEnd(content); - if (endIdx == -1) - break; - - var match = content[..endIdx]; - while (match[^1] == '"') - match = match[..^1]; - - var text = match.ToString(); - - // - // We read the raw source text, whereas the app hashes the unescaped string at - // runtime. Thus, any escape sequence makes both hashes differ, so that the text - // never finds its translation. Since we cannot detect this at runtime, we warn - // about it here: - // - if(text.Contains('\\')) - warnings.Add($"- Warning: The text '{text}' in the file '{filePath}' contains an escape sequence. Its key does not match the key the app looks up at runtime, so this text stays untranslated. Please use a raw string literal instead."); - - matches.Add(text); - startIdx = FindNextStart(content); - } - - return matches; - } - private bool IsSupportedSourceFile(string filePath) => filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) || filePath.EndsWith(".razor", StringComparison.OrdinalIgnoreCase); diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor new file mode 100644 index 000000000..3a9a910b7 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor @@ -0,0 +1,127 @@ +@attribute [Route(Routes.ASSISTANT_BEYOND_FACTS_LAB)] +@inherits MSGComponentBase + + + @* The lab does not inherit AssistantBase, so it sets up the inner scrolling itself, the way the + Visual Briefing assistant does. *@ +
+ + + @T("BeyondFacts Lab") + + + + + + + @T("Prepare a challenging appearance as a researcher step by step, e.g., a panel discussion, an interview, or a hearing. Each preparation belongs to one event and has its own steps, chats, and work states.") + + + @T("Your preparations") + @if (this.projects.Count == 0) + { + @T("You have no preparations yet. Create one for the event you want to prepare for.") + } + else + { + + @foreach (var project in this.projects) + { + + + @this.ProjectDisplayName(project) + @this.ProjectCaption(project) + @if (this.projectsWithMissingDetails.Contains(project.ProjectId)) + { + @T("Details missing") + } + @if (!project.IsAvailable) + { + @this.ProjectStatusName(project.Status) + } + + + } + + } + + + @T("New preparation") + + + + + @if (this.selectedProject is { IsAvailable: false } unavailableProject) + { + + + @this.ProjectDisplayName(unavailableProject) + + @this.ProjectRecoveryMessage(unavailableProject.Status) + + @T("AI Studio has left the files of this preparation unchanged. A future update may make it accessible again.") + + @T("Preparation ID"): @unavailableProject.ProjectId.ToString("D") + + + + @T("Delete") + + + + } + else if (this.selectedProject?.Project is { } project) + { + + @project.Name + + @T("Rename") + @T("Delete") + + + + + @T("The event has taken place") + + @if (project.HappenedAtUtc is { } happenedAtUtc) + { + @string.Format(Culture, T("Marked on {0}."), happenedAtUtc.ToLocalTime().ToString("d", Culture)) + } + + + + @foreach (var step in BflStep.All) + { + + @step.Title + @step.Description + + @T("Your details") + @if (this.forms.TryGetValue(step, out var form)) + { + + } + else + { + + @T("AI Studio cannot open your details for this step. Their file may be damaged, or a newer version of AI Studio stored it. AI Studio leaves the file unchanged.") + + } + + } + + + } + else + { + + @T("Create a preparation to begin.") + + } + +
+
\ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Projects.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Projects.cs new file mode 100644 index 000000000..dea20fe03 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Projects.cs @@ -0,0 +1,271 @@ +using System.Globalization; + +using AIStudio.Dialogs; + +using DialogOptions = AIStudio.Dialogs.DialogOptions; + +namespace AIStudio.Assistants.BeyondFactsLab; + +public partial class BeyondFactsLabAssistant +{ + /// + /// The preparations, the one changed last first. + /// + private IReadOnlyList projects = []; + + /// + /// The preparation shown, or null when there is none. + /// + private BflProjectEntry? selectedProject; + + /// + /// Whether a change of the preparations is running. + /// + private bool isBusy; + + /// + /// The IDs of the preparations which lack key details. + /// + private HashSet projectsWithMissingDetails = []; + + /// + /// Gets the culture of the active language plugin, for dates. + /// + private static CultureInfo Culture => Tools.PluginSystem.I18N.I.Culture; + + /// + /// Reads the preparations again and chooses the one to show. + /// + /// The preparation to show, or null to keep the one shown so far. + private async Task ReloadProjectsAsync(Guid? preferredId) + { + this.projects = await this.Store.ListProjectsAsync(); + + var withMissingDetails = new HashSet(); + foreach (var project in this.projects.Where(project => project.IsAvailable)) + { + if (BflMissingDetails.StepsOf(await this.Store.LoadFormsAsync(project.ProjectId)).Count > 0) + withMissingDetails.Add(project.ProjectId); + } + + this.projectsWithMissingDetails = withMissingDetails; + await this.ShowProjectAsync(BflProjectSelection.Choose(this.projects, preferredId ?? this.selectedProject?.ProjectId, this.Store.LastSelectedProjectId)); + } + + /// + /// Shows the preparation the person selected and remembers it for the next time. + /// + /// The ID of the preparation. + private async Task SelectProjectAsync(Guid projectId) + { + if (this.selectedProject?.ProjectId == projectId) + return; + + var project = this.projects.FirstOrDefault(candidate => candidate.ProjectId == projectId); + if (project is null) + return; + + await this.RunStoreActionAsync(async () => + { + await this.ShowProjectAsync(project); + await this.Store.RememberSelectionAsync(projectId); + }); + } + + /// + /// Asks for a name and creates a preparation. + /// + private async Task CreateProjectAsync() + { + var name = await this.AskForNameAsync(T("New preparation"), T("Name the event you want to prepare for, e.g., the title of a panel discussion."), string.Empty, T("Create")); + if (name is null) + return; + + await this.RunStoreActionAsync(async () => + { + var project = await this.Store.CreateAsync(name); + await this.Store.RememberSelectionAsync(project.ProjectId); + await this.ReloadProjectsAsync(project.ProjectId); + }); + } + + /// + /// Asks for a new name and renames the preparation shown. + /// + private async Task RenameProjectAsync() + { + if (this.selectedProject?.Project is not { } project) + return; + + var name = await this.AskForNameAsync(T("Rename preparation"), T("Enter a new name for this preparation."), project.Name, T("Rename")); + if (name is null) + return; + + await this.RunStoreActionAsync(async () => + { + await this.Store.RenameAsync(project.ProjectId, name); + await this.ReloadProjectsAsync(project.ProjectId); + }); + } + + /// + /// Deletes the preparation shown, after the person confirmed it. + /// + private async Task DeleteProjectAsync() + { + if (this.selectedProject is not { } project) + return; + + var parameters = new DialogParameters + { + { + dialog => dialog.Message, + project.IsAvailable + ? string.Format(T("Permanently delete the preparation '{0}' with all of its steps, chats, and work states?"), project.Name) + : T("This preparation cannot be opened. Permanently delete it with all of its files?") + }, + }; + + var reference = await this.DialogService.ShowAsync(T("Delete preparation permanently"), parameters, DialogOptions.FULLSCREEN); + var result = await reference.Result; + if (result is null || result.Canceled) + return; + + await this.RunStoreActionAsync(async () => + { + await this.Store.DeleteAsync(project.ProjectId); + this.selectedProject = null; + await this.ReloadProjectsAsync(null); + }); + } + + /// + /// Marks the event of the preparation shown as having taken place, or takes that back. + /// + /// Whether the event has taken place. + private async Task SetHappenedAsync(bool happened) + { + if (this.selectedProject?.Project is not { } project) + return; + + await this.RunStoreActionAsync(async () => + { + await this.Store.SetHappenedAsync(project.ProjectId, happened ? DateTimeOffset.UtcNow : null); + await this.ReloadProjectsAsync(project.ProjectId); + }); + } + + /// + /// Asks for the name of a preparation. + /// + /// The title of the dialog. + /// What the dialog asks for. + /// The name to start with. + /// The text of the button which confirms. + /// The name entered, or null when the person canceled. + private async Task AskForNameAsync(string title, string message, string currentName, string confirmText) + { + var parameters = new DialogParameters + { + { dialog => dialog.Message, message }, + { dialog => dialog.InputHeaderText, T("Name of the preparation") }, + { dialog => dialog.UserInput, currentName }, + { dialog => dialog.ConfirmText, confirmText }, + { dialog => dialog.ConfirmColor, Color.Primary }, + { dialog => dialog.AllowEmptyInput, false }, + { dialog => dialog.EmptyInputErrorMessage, T("Please enter a name.") }, + }; + + var reference = await this.DialogService.ShowAsync(title, parameters, DialogOptions.FULLSCREEN); + var result = await reference.Result; + if (result is null || result.Canceled || result.Data is not string name || string.IsNullOrWhiteSpace(name)) + return null; + + return name; + } + + /// + /// Runs a change of the preparations, and tells the person when it fails. + /// + /// The change. + private async Task RunStoreActionAsync(Func action) + { + this.isBusy = true; + try + { + await action(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException) + { + this.Logger.LogError(exception, "A change of the preparations of the BeyondFacts Lab failed."); + await this.MessageBus.SendError(new(Icons.Material.Filled.RecordVoiceOver, T("AI Studio could not save the change of your preparations. Please try again."))); + await this.ReloadAfterFailureAsync(); + } + finally + { + this.isBusy = false; + } + } + + /// + /// Reads the preparations again after a change failed, so the page shows what is stored. + /// + private async Task ReloadAfterFailureAsync() + { + try + { + await this.ReloadProjectsAsync(null); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + this.Logger.LogError(exception, "The preparations of the BeyondFacts Lab could not be read."); + } + } + + /// + /// Gets the icon of a preparation in the list. + /// + /// The preparation. + private static string ProjectIcon(BflProjectEntry project) => project switch + { + { IsAvailable: false } => Icons.Material.Filled.WarningAmber, + { Project.HappenedAtUtc: not null } => Icons.Material.Filled.EventAvailable, + _ => Icons.Material.Filled.Event, + }; + + /// + /// Gets the name of a preparation in the list, also when it cannot be read. + /// + /// The preparation. + private string ProjectDisplayName(BflProjectEntry project) => string.IsNullOrWhiteSpace(project.Name) ? T("Unavailable preparation") : project.Name; + + /// + /// Gets the line below the name of a preparation in the list: when it changed, and whether its event has taken place. + /// + /// The preparation. + private string ProjectCaption(BflProjectEntry project) + { + var changed = string.Format(Culture, T("Changed on {0}"), project.ModifiedAtUtc.ToLocalTime().ToString("g", Culture)); + return project.Project?.HappenedAtUtc is null ? changed : $"{changed} · {T("The event has taken place")}"; + } + + /// + /// Gets the short reason why a preparation cannot be opened. + /// + /// The status of the preparation. + private string ProjectStatusName(BflProjectLoadStatus status) => status switch + { + BflProjectLoadStatus.NEWER_VERSION => T("Requires a newer AI Studio version"), + _ => T("Cannot be opened"), + }; + + /// + /// Explains why a preparation cannot be opened. + /// + /// The status of the preparation. + private string ProjectRecoveryMessage(BflProjectLoadStatus status) => status switch + { + BflProjectLoadStatus.NEWER_VERSION => T("A newer version of AI Studio stored this preparation. This version cannot open it. Please update AI Studio."), + _ => T("AI Studio cannot read this preparation. Its files may be damaged."), + }; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Steps.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Steps.cs new file mode 100644 index 000000000..37e4e4548 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.Steps.cs @@ -0,0 +1,115 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +public partial class BeyondFactsLabAssistant +{ + /// + /// The forms of the preparation shown, by their steps. A step missing here has a form this version cannot open. + /// + private Dictionary forms = []; + + /// + /// The step shown. + /// + private BflStep activeStep = BflStep.FRAMEWORK; + + /// + /// Gets the position of the step shown in the stepper, which lists the steps in the order of their values. + /// + private int ActiveStepIndex => (int)this.activeStep; + + /// + /// Shows a preparation with its forms, at the step the person looked at last. + /// + /// The preparation, or null to show none. + private async Task ShowProjectAsync(BflProjectEntry? project) + { + this.selectedProject = project; + if (project?.Project is { } openedProject) + { + this.forms = new(await this.Store.LoadFormsAsync(openedProject.ProjectId)); + this.activeStep = openedProject.CurrentStep; + } + else + { + this.forms = []; + this.activeStep = BflStep.FRAMEWORK; + } + } + + /// + /// Shows the step the person chose in the stepper, and remembers it for the next time. + /// + /// The position of the step in the stepper. + private async Task SelectStepAsync(int index) + { + var steps = BflStep.All; + if (index < 0 || index >= steps.Count || steps[index] == this.activeStep) + return; + + this.activeStep = steps[index]; + if (this.selectedProject?.Project is not { } project) + return; + + try + { + this.ReplaceProject(await this.Store.SetCurrentStepAsync(project.ProjectId, this.activeStep)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + // Only the step to open next time is lost, which is not worth bothering the person with: + this.Logger.LogWarning(exception, "Could not remember the step shown last in the BeyondFacts Lab."); + } + } + + /// + /// Stores the form of a step after the person changed a value. + /// + /// + /// Unlike the other changes, saving leaves the page usable: the fields stay enabled, so that the next field + /// keeps the focus while the previous one is stored. + /// + /// The step. + private async Task SaveFormAsync(BflStep step) + { + if (this.selectedProject?.Project is not { } project || !this.forms.TryGetValue(step, out var form)) + return; + + try + { + this.ReplaceProject(await this.Store.SaveFormAsync(project.ProjectId, step, form)); + if (BflMissingDetails.StepsOf(this.forms).Count > 0) + this.projectsWithMissingDetails.Add(project.ProjectId); + else + this.projectsWithMissingDetails.Remove(project.ProjectId); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + this.Logger.LogError(exception, "Could not save the details of a step in the BeyondFacts Lab."); + await this.MessageBus.SendError(new(Icons.Material.Filled.RecordVoiceOver, T("AI Studio could not save your details. Please try again."))); + } + } + + /// + /// Puts a preparation the store just wrote into the list, without reading the list again. + /// + /// The preparation. + private void ReplaceProject(BflProject project) + { + var entry = BflProjectEntry.FromProject(project); + this.projects = [.. this.projects.Select(candidate => candidate.ProjectId == project.ProjectId ? entry : candidate).OrderByDescending(candidate => candidate.ModifiedAtUtc)]; + if (this.selectedProject?.ProjectId == project.ProjectId) + this.selectedProject = entry; + } + + /// + /// Gets the line below the title of a step in the stepper. + /// + /// The step. + private string StepSecondaryText(BflStep step) + { + if (!this.forms.TryGetValue(step, out var form)) + return T("Cannot be opened"); + + return BflMissingDetails.Of(step, form).Count > 0 ? T("Details missing") : string.Empty; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.cs new file mode 100644 index 000000000..90e1a749e --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabAssistant.razor.cs @@ -0,0 +1,94 @@ +using AIStudio.Components; +using AIStudio.Dialogs.Settings; + +using Microsoft.AspNetCore.Components; + +using DialogOptions = AIStudio.Dialogs.DialogOptions; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// The page of the BeyondFacts Lab, which guides researchers through the preparation of a challenging appearance. +/// +/// +/// The lab keeps several preparations, each with steps of their own, so it does not inherit AssistantBase, +/// which knows one input and one result. Like the Visual Briefing assistant, it checks by itself whether it +/// may be opened. +/// +public partial class BeyondFactsLabAssistant : MSGComponentBase +{ + [Inject] + private IDialogService DialogService { get; init; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; init; } = null!; + + [Inject] + private BeyondFactsLabStore Store { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + + /// + /// Whether the person may open the lab, so that the page did not send them away. + /// + private bool mayOpen; + + /// + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + if (!BflAccess.CanOpen(this.SettingsManager, T("BeyondFacts Lab"))) + { + this.NavigationManager.NavigateTo(Routes.ASSISTANTS); + return; + } + + this.ApplyFilters([], [Event.CONFIGURATION_CHANGED]); + this.mayOpen = true; + await this.RunStoreActionAsync(() => this.ReloadProjectsAsync(null)); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + if (firstRender && this.mayOpen) + await this.ShowNoticeIfNeededAsync(); + } + + /// + /// Shows the notes of the lab, unless the person confirmed them as they are now. + /// + private async Task ShowNoticeIfNeededAsync() + { + var notice = BflNotice.Text; + var currentHash = BflNotice.ComputeHash(notice); + if (BflNotice.IsAccepted(this.SettingsManager.ConfigurationData.BeyondFactsLab.AcceptedNoticeHash, currentHash)) + return; + + var parameters = new DialogParameters { { dialog => dialog.Notice, notice } }; + var reference = await this.DialogService.ShowAsync(T("BeyondFacts Lab"), parameters, DialogOptions.BLOCKING_FULLSCREEN); + var result = await reference.Result; + if (result is null || result.Canceled) + return; + + this.SettingsManager.ConfigurationData.BeyondFactsLab.AcceptedNoticeHash = currentHash; + await this.SettingsManager.StoreSettings(); + } + + /// + protected override async Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + // An organization may hide the lab, or the person may switch its preview feature off, while the page is open: + if (triggeredEvent is Event.CONFIGURATION_CHANGED && !BflAccess.CanOpen(this.SettingsManager)) + { + this.NavigationManager.NavigateTo(Routes.ASSISTANTS); + return; + } + + await base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); + } + + private async Task OpenSettingsDialogAsync() => await this.DialogService.ShowAsync(null, new DialogParameters(), DialogOptions.FULLSCREEN); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJson.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJson.cs new file mode 100644 index 000000000..e085814f3 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJson.cs @@ -0,0 +1,31 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// The JSON options of the files of the BeyondFacts Lab. +/// +internal static class BeyondFactsLabJson +{ + /// + /// The options with which the lab writes and reads its files. + /// + /// + /// The files stay readable for people who look into the data folder: indented, enums by name, and umlauts + /// as they are rather than as escapes. The relaxed encoder is fine here, because nothing embeds these files + /// into HTML. A field this version does not know makes a file unreadable rather than letting a save drop + /// it; such a preparation is listed as one of a newer version or as unavailable, and nothing writes it. + /// + internal static readonly JsonSerializerOptions PERSISTENCE = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = { new JsonStringEnumConverter() }, + TypeInfoResolver = BeyondFactsLabJsonContext.Default, + }.AsReadOnly(); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs new file mode 100644 index 000000000..ec13b1096 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// The contracts with which System.Text.Json reads and writes the files of the BeyondFacts Lab, built at compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(BflProject))] +[JsonSerializable(typeof(BflStepFormData))] + +// The preparation selected last: +[JsonSerializable(typeof(Guid?))] +internal sealed partial class BeyondFactsLabJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs new file mode 100644 index 000000000..d3e99d732 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs @@ -0,0 +1,134 @@ +using System.Text.Json; + +namespace AIStudio.Assistants.BeyondFactsLab; + +public sealed partial class BeyondFactsLabStore +{ + /// + /// The folder of a preparation which holds a folder for each step. + /// + private const string STEPS_DIRECTORY_NAME = "steps"; + + /// + /// The file of a step which holds its form "Your details". + /// + private const string FORM_FILE_NAME = "form.json"; + + /// + /// Loads the form of a step. + /// + /// The ID of the preparation. + /// The step. + /// The cancellation token. + /// The form, or an empty one when the person entered nothing yet. + /// This version cannot open the form. + public async Task LoadFormAsync(Guid projectId, BflStep step, CancellationToken token = default) + { + await this.InitializeAsync(token); + return await this.LoadFormWithoutInitializeAsync(projectId, step, token); + } + + /// + /// Loads the forms of every step of a preparation, e.g., to find missing details. + /// + /// The ID of the preparation. + /// The cancellation token. + /// The forms this version can open, by their steps. + public async Task> LoadFormsAsync(Guid projectId, CancellationToken token = default) + { + await this.InitializeAsync(token); + var forms = new Dictionary(); + foreach (var step in BflStep.All) + { + try + { + forms[step] = await this.LoadFormWithoutInitializeAsync(projectId, step, token); + } + catch (InvalidDataException exception) + { + logger.LogWarning(exception, "Could not open the form of the step '{Step}' of the preparation '{ProjectId}' of the BeyondFacts Lab.", step, projectId); + } + } + + return forms; + } + + /// + /// Stores the form of a step and counts it as a change of the preparation. + /// + /// The ID of the preparation. + /// The step. + /// The form. + /// The cancellation token. + /// The preparation with its new time of change. + /// The preparation does not exist. + /// This version cannot open the preparation or the stored form, so it does not write them either. + public async Task SaveFormAsync(Guid projectId, BflStep step, BflStepFormData form, CancellationToken token = default) + { + await this.InitializeAsync(token); + var gate = this.GetLock(projectId); + await gate.WaitAsync(token); + try + { + var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token); + + // A form which a newer version stored, or which is damaged, stays as it is: + await this.LoadFormWithoutInitializeAsync(projectId, step, token); + + form.SchemaVersion = BflStepFormData.CURRENT_SCHEMA_VERSION; + await WriteTextAtomicAsync(this.FormPath(projectId, step), JsonSerializer.Serialize(form, JSON_OPTIONS), token); + + project.ModifiedAtUtc = DateTimeOffset.UtcNow; + await this.StoreProjectAsync(project, token); + return project; + } + finally + { + gate.Release(); + } + } + + /// + /// Reads the form of a step without the first start of the store. + /// + /// The ID of the preparation. + /// The step. + /// The cancellation token. + /// The form, or an empty one when there is no file. + /// This version cannot open the form. + private async Task LoadFormWithoutInitializeAsync(Guid projectId, BflStep step, CancellationToken token) + { + var path = this.FormPath(projectId, step); + if (!File.Exists(path)) + return new BflStepFormData(); + + BflStepFormData? form; + try + { + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); + form = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + throw new InvalidDataException("This version of AI Studio cannot open the form.", exception); + } + + if (form is null || form.SchemaVersion is < 1 or > BflStepFormData.CURRENT_SCHEMA_VERSION || IsNull(form.Values)) + throw new InvalidDataException("This version of AI Studio cannot open the form."); + + return form; + } + + /// + /// Gets the file of the form of a step. + /// + /// The ID of the preparation. + /// The step. + private string FormPath(Guid projectId, BflStep step) => Path.Combine(this.ProjectDirectory(projectId), STEPS_DIRECTORY_NAME, step.FolderName, FORM_FILE_NAME); + + /// + /// Checks for null a value which the type system says cannot be null, but which JSON can make null. + /// + /// The value. + private static bool IsNull(T? value) => value is null; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Projects.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Projects.cs new file mode 100644 index 000000000..91cef05e0 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Projects.cs @@ -0,0 +1,435 @@ +using System.Text.Json; + +namespace AIStudio.Assistants.BeyondFactsLab; + +public sealed partial class BeyondFactsLabStore +{ + /// + /// Gets the preparation the person selected last, or null when there is none or it does not exist anymore. + /// + public Guid? LastSelectedProjectId { get; private set; } + + /// + /// Lists every preparation, also those this version cannot open, the one changed last first. + /// + /// The cancellation token. + /// The preparations. + public async Task> ListProjectsAsync(CancellationToken token = default) + { + await this.InitializeAsync(token); + List projects = []; + foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory)) + { + token.ThrowIfCancellationRequested(); + if (!Guid.TryParseExact(Path.GetFileName(directory), "D", out var projectId)) + continue; + + projects.Add(await this.LoadProjectEntryAsync(projectId, directory, token)); + } + + return [.. projects.OrderByDescending(project => project.ModifiedAtUtc)]; + } + + /// + /// Loads a preparation. + /// + /// The ID of the preparation. + /// The cancellation token. + /// The preparation, or null when it does not exist or this version cannot open it. + public async Task LoadAsync(Guid projectId, CancellationToken token = default) + { + await this.InitializeAsync(token); + try + { + return await this.LoadWithoutInitializeAsync(projectId, token); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + this.LogUnavailableProject(projectId, exception); + return null; + } + } + + /// + /// Creates a preparation. + /// + /// The name the person gave it. + /// The cancellation token. + /// The new preparation. + /// The name is empty. + public async Task CreateAsync(string name, CancellationToken token = default) + { + var sanitizedName = RequireName(name); + await this.InitializeAsync(token); + + var projectId = Guid.NewGuid(); + var gate = this.GetLock(projectId); + await gate.WaitAsync(token); + try + { + var directory = this.ProjectDirectory(projectId); + if (Directory.Exists(directory)) + throw new IOException($"A preparation with the ID '{projectId}' exists already."); + + var now = DateTimeOffset.UtcNow; + var project = new BflProject + { + ProjectId = projectId, + Name = sanitizedName, + CreatedAtUtc = now, + ModifiedAtUtc = now, + }; + + await this.StoreProjectAsync(project, token); + return project; + } + finally + { + gate.Release(); + } + } + + /// + /// Renames a preparation. + /// + /// The ID of the preparation. + /// The new name. + /// The cancellation token. + /// The renamed preparation. + /// The name is empty. + public async Task RenameAsync(Guid projectId, string name, CancellationToken token = default) + { + var sanitizedName = RequireName(name); + return await this.MutateProjectAsync(projectId, project => project.Name = sanitizedName, token); + } + + /// + /// Marks the event of a preparation as having taken place, or takes that back. + /// + /// The ID of the preparation. + /// When the event took place, or null when it has not. + /// The cancellation token. + /// The changed preparation. + public async Task SetHappenedAsync(Guid projectId, DateTimeOffset? happenedAtUtc, CancellationToken token = default) + { + return await this.MutateProjectAsync(projectId, project => project.HappenedAtUtc = happenedAtUtc?.ToUniversalTime(), token); + } + + /// + /// Deletes a preparation with everything it holds, also when this version cannot open it. + /// + /// The ID of the preparation. + /// The cancellation token. + public async Task DeleteAsync(Guid projectId, CancellationToken token = default) + { + await this.InitializeAsync(token); + var gate = this.GetLock(projectId); + await gate.WaitAsync(token); + try + { + var directory = this.ProjectDirectory(projectId); + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + finally + { + gate.Release(); + this.ForgetLock(projectId); + } + + await this.ForgetSelectionAsync(projectId, token); + } + + /// + /// Remembers the preparation the person selected, for the next time the lab opens. + /// + /// The ID of the preparation. + /// The cancellation token. + public async Task RememberSelectionAsync(Guid projectId, CancellationToken token = default) + { + await this.InitializeAsync(token); + if (this.LastSelectedProjectId == projectId) + return; + + await this.selectionLock.WaitAsync(token); + try + { + this.LastSelectedProjectId = projectId; + await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(projectId, JSON_OPTIONS), token); + } + finally + { + this.selectionLock.Release(); + } + } + + /// + /// Forgets the selection when it names the given preparation. + /// + /// The ID of the preparation. + /// The cancellation token. + private async Task ForgetSelectionAsync(Guid projectId, CancellationToken token) + { + if (this.LastSelectedProjectId != projectId) + return; + + await this.selectionLock.WaitAsync(token); + try + { + if (this.LastSelectedProjectId != projectId) + return; + + this.LastSelectedProjectId = null; + await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(null, JSON_OPTIONS), token); + } + finally + { + this.selectionLock.Release(); + } + } + + /// + /// Reads the preparation selected last, when it still exists. + /// + /// The cancellation token. + private async Task LoadSelectionAsync(CancellationToken token) + { + var path = this.SelectionPath(); + if (!File.Exists(path)) + return; + + try + { + var serialized = await File.ReadAllTextAsync(path, token); + var selected = JsonSerializer.Deserialize(serialized, JSON_OPTIONS); + this.LastSelectedProjectId = selected is not null && Directory.Exists(this.ProjectDirectory(selected.Value)) ? selected : null; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) + { + logger.LogWarning(exception, "Could not read the preparation of the BeyondFacts Lab selected last."); + this.LastSelectedProjectId = null; + } + } + + /// + /// Loads a preparation for the list, or an entry which says why it cannot be opened. + /// + /// The ID of the preparation. + /// The folder of the preparation. + /// The cancellation token. + private async Task LoadProjectEntryAsync(Guid projectId, string directory, CancellationToken token) + { + var path = this.ProjectPath(projectId); + var fallbackModifiedAtUtc = LastWriteTimeUtc(path, directory); + if (!File.Exists(path)) + return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null); + + string json; + try + { + json = await File.ReadAllTextAsync(path, token); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + this.LogUnavailableProject(projectId, exception); + return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null); + } + + try + { + var project = JsonSerializer.Deserialize(json, JSON_OPTIONS); + if (project is not null && IsValidProject(project, projectId)) + return BflProjectEntry.FromProject(project); + } + catch (JsonException exception) + { + this.LogUnavailableProject(projectId, exception); + } + + return ReadRecoveryEntry(projectId, json, fallbackModifiedAtUtc); + } + + /// + /// Reads what the list can show about a preparation this version cannot open: its name, when it changed, + /// and whether a newer version stored it. + /// + /// The ID of the preparation. + /// The content of its file. + /// When the file changed, for files without a readable date. + private static BflProjectEntry ReadRecoveryEntry(Guid projectId, string json, DateTimeOffset fallbackModifiedAtUtc) + { + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + if (root.ValueKind is not JsonValueKind.Object) + return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null); + + var name = root.TryGetProperty("name", out var nameElement) && nameElement.ValueKind is JsonValueKind.String ? SanitizeName(nameElement.GetString()) : string.Empty; + var modifiedAtUtc = root.TryGetProperty("modifiedAtUtc", out var modifiedElement) && modifiedElement.ValueKind is JsonValueKind.String && modifiedElement.TryGetDateTimeOffset(out var parsedModifiedAtUtc) + ? parsedModifiedAtUtc + : fallbackModifiedAtUtc; + + var isNewer = root.TryGetProperty("schemaVersion", out var versionElement) && versionElement.ValueKind is JsonValueKind.Number && versionElement.TryGetInt32(out var schemaVersion) && schemaVersion > BflProject.CURRENT_SCHEMA_VERSION; + return new(projectId, name, modifiedAtUtc, isNewer ? BflProjectLoadStatus.NEWER_VERSION : BflProjectLoadStatus.UNAVAILABLE, null); + } + catch (JsonException) + { + return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null); + } + } + + /// + /// Gets when the file of a preparation, or else its folder, changed last. + /// + /// The file of the preparation. + /// The folder of the preparation. + private static DateTimeOffset LastWriteTimeUtc(string projectPath, string directory) + { + try + { + var timestamp = File.Exists(projectPath) ? File.GetLastWriteTimeUtc(projectPath) : Directory.GetLastWriteTimeUtc(directory); + return new DateTimeOffset(timestamp); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return DateTimeOffset.UnixEpoch; + } + } + + /// + /// Remembers the step the person looked at last, without counting it as a change of the preparation. + /// + /// The ID of the preparation. + /// The step. + /// The cancellation token. + /// The preparation. + public async Task SetCurrentStepAsync(Guid projectId, BflStep step, CancellationToken token = default) + { + return await this.MutateProjectAsync(projectId, project => project.CurrentStep = step, token, isChange: false); + } + + /// + /// Changes a preparation under its lock and stores it. + /// + /// The ID of the preparation. + /// The change. + /// The cancellation token. + /// Whether the list shows the preparation as changed now. + /// The changed preparation. + /// The preparation does not exist. + /// This version cannot open the preparation, so it does not write it either. + private async Task MutateProjectAsync(Guid projectId, Action mutation, CancellationToken token, bool isChange = true) + { + await this.InitializeAsync(token); + var gate = this.GetLock(projectId); + await gate.WaitAsync(token); + try + { + var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token); + mutation(project); + if (isChange) + project.ModifiedAtUtc = DateTimeOffset.UtcNow; + + await this.StoreProjectAsync(project, token); + return project; + } + finally + { + gate.Release(); + } + } + + /// + /// Reads a preparation which has to exist and has to be one this version can open, without the first start of the store. + /// + /// The ID of the preparation. + /// The cancellation token. + /// The preparation. + /// The preparation does not exist. + /// This version cannot open the preparation. + private async Task LoadRequiredWithoutInitializeAsync(Guid projectId, CancellationToken token) + { + var path = this.ProjectPath(projectId); + if (!File.Exists(path)) + throw new FileNotFoundException("The preparation does not exist.", path); + + BflProject? project; + try + { + project = await this.LoadWithoutInitializeAsync(projectId, token); + } + catch (JsonException exception) + { + throw new InvalidDataException("This version of AI Studio cannot open the preparation.", exception); + } + + return project ?? throw new InvalidDataException("This version of AI Studio cannot open the preparation."); + } + + /// + /// Reads a preparation without the first start of the store. + /// + /// The ID of the preparation. + /// The cancellation token. + /// The preparation, or null when it does not exist or is not valid. + private async Task LoadWithoutInitializeAsync(Guid projectId, CancellationToken token) + { + var path = this.ProjectPath(projectId); + if (!File.Exists(path)) + return null; + + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); + var project = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token); + return project is not null && IsValidProject(project, projectId) ? project : null; + } + + /// + /// Writes a preparation in the form of this version. + /// + /// The preparation. + /// The cancellation token. + private async Task StoreProjectAsync(BflProject project, CancellationToken token) + { + project.SchemaVersion = BflProject.CURRENT_SCHEMA_VERSION; + var json = JsonSerializer.Serialize(project, JSON_OPTIONS); + await WriteTextAtomicAsync(this.ProjectPath(project.ProjectId), json, token); + } + + /// + /// Checks a preparation which was read from a file. + /// + /// The preparation. + /// The ID its folder names. + private static bool IsValidProject(BflProject project, Guid expectedProjectId) => + project.SchemaVersion is >= 1 and <= BflProject.CURRENT_SCHEMA_VERSION && + project.ProjectId != Guid.Empty && + project.ProjectId == expectedProjectId && + !string.IsNullOrWhiteSpace(project.Name) && + Enum.IsDefined(project.CurrentStep); + + /// + /// Cleans a name and refuses one which is empty afterward. + /// + /// The name as entered. + /// The cleaned name. + /// The name is empty. + private static string RequireName(string name) + { + var sanitizedName = SanitizeName(name); + if (sanitizedName.Length == 0) + throw new ArgumentException("A preparation needs a name.", nameof(name)); + + return sanitizedName; + } + + /// + /// Logs why a preparation cannot be opened. + /// + /// The ID of the preparation. + /// What went wrong. + private void LogUnavailableProject(Guid projectId, Exception exception) + { + logger.LogWarning(exception, "Could not open the preparation '{ProjectId}' of the BeyondFacts Lab.", projectId); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs new file mode 100644 index 000000000..a3873e9b0 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.cs @@ -0,0 +1,189 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; + +using AIStudio.Settings; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Stores the preparations of the BeyondFacts Lab as files below the data directory. +/// +/// +/// Each preparation lives in a folder named by its ID. The store keeps no paths: it derives every path from +/// IDs, so a backup works on another computer as well. It writes each file atomically, guards each preparation +/// with a lock of its own, and never deletes anything on its own; only the person deletes a preparation. +/// +/// The logger. +/// Overrides the data directory, for tests. +public sealed partial class BeyondFactsLabStore(ILogger logger, BflStorageOptions? storageOptions = null) +{ + /// + /// The name of the folder below the data directory. + /// + public const string ROOT_DIRECTORY_NAME = "beyondFactsLab"; + + /// + /// The longest name of a preparation the store keeps. + /// + public const int MAX_NAME_LENGTH = 200; + + /// + /// The file of a preparation which holds its name and dates. + /// + private const string PROJECT_FILE_NAME = "project.json"; + + /// + /// The file which holds the preparation selected last. + /// + private const string SELECTION_FILE_NAME = "selection.json"; + + /// + /// The JSON options of all files of the lab. + /// + private static readonly JsonSerializerOptions JSON_OPTIONS = BeyondFactsLabJson.PERSISTENCE; + + /// + /// The lock of each preparation. + /// + private readonly ConcurrentDictionary projectLocks = []; + + /// + /// Serializes the first start of the store. + /// + private readonly SemaphoreSlim initializationLock = new(1, 1); + + /// + /// Serializes writing the selection. + /// + private readonly SemaphoreSlim selectionLock = new(1, 1); + + /// + /// Whether the store cleaned up and read the selection already. + /// + private bool initialized; + + /// + /// Gets the folder of the lab below the data directory. + /// + private string RootDirectory => Path.Combine( + storageOptions?.DataDirectory ?? + SettingsManager.DataDirectory ?? + throw new InvalidOperationException("The AI Studio data directory is not initialized."), + ROOT_DIRECTORY_NAME); + + /// + /// Writes a text file so that readers see either the old or the new content, never a part of it. + /// + /// The file to write. + /// The new content. + /// The cancellation token. + private static async Task WriteTextAtomicAsync(string targetPath, string content, CancellationToken token) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + var temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}"; + try + { + await File.WriteAllTextAsync(temporaryPath, content, new UTF8Encoding(false), token); + await using (var stream = new FileStream(temporaryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None, 4_096, true)) + await stream.FlushAsync(token); + + File.Move(temporaryPath, targetPath, overwrite: true); + } + finally + { + TryDeleteFile(temporaryPath); + } + } + + /// + /// Deletes a file when it exists, without failing. + /// + /// The file to delete. + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // Cleaning up a temporary file is best effort; the next start tries again. + } + } + + /// + /// Removes control characters from a name, trims it, and cuts it to the longest name the store keeps. + /// + /// The name as entered or as read from a file. + /// The cleaned name, empty when nothing remains. + internal static string SanitizeName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return string.Empty; + + var sanitized = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim(); + return sanitized.Length <= MAX_NAME_LENGTH ? sanitized : sanitized[..MAX_NAME_LENGTH].TrimEnd(); + } + + /// + /// Gets the lock of a preparation. + /// + /// The ID of the preparation. + private SemaphoreSlim GetLock(Guid projectId) => this.projectLocks.GetOrAdd(projectId, _ => new(1, 1)); + + /// + /// Drops the lock of a preparation which does not exist anymore. + /// + /// + /// The semaphore is not disposed: another operation might still wait on it. + /// + /// The ID of the preparation. + private void ForgetLock(Guid projectId) => this.projectLocks.TryRemove(projectId, out _); + + /// + /// Gets the folder of a preparation. + /// + /// The ID of the preparation. + private string ProjectDirectory(Guid projectId) => Path.Combine(this.RootDirectory, projectId.ToString("D")); + + /// + /// Gets the file which holds the name and the dates of a preparation. + /// + /// The ID of the preparation. + private string ProjectPath(Guid projectId) => Path.Combine(this.ProjectDirectory(projectId), PROJECT_FILE_NAME); + + /// + /// Gets the file which holds the preparation selected last. + /// + private string SelectionPath() => Path.Combine(this.RootDirectory, SELECTION_FILE_NAME); + + /// + /// Creates the folder of the lab, removes temporary files a crash left behind, and reads the selection. + /// + /// The cancellation token. + private async Task InitializeAsync(CancellationToken token) + { + if (this.initialized) + return; + + await this.initializationLock.WaitAsync(token); + try + { + if (this.initialized) + return; + + Directory.CreateDirectory(this.RootDirectory); + foreach (var temporaryPath in Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories)) + TryDeleteFile(temporaryPath); + + await this.LoadSelectionAsync(token); + this.initialized = true; + } + finally + { + this.initializationLock.Release(); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAccess.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAccess.cs new file mode 100644 index 000000000..be54a6b40 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAccess.cs @@ -0,0 +1,27 @@ +using AIStudio.Settings; + +using ComponentKind = AIStudio.Tools.Components; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Decides whether the BeyondFacts Lab may be opened. +/// +/// +/// The lab is a prototype behind its own preview feature, and an organization can hide it like any other +/// assistant. The tile asks the same question through its required preview feature; the page asks here, +/// because somebody might reach its address without the tile, e.g., from a link or the browser history. +/// +public static class BflAccess +{ + /// + /// Checks whether the BeyondFacts Lab may be opened with the given settings. + /// + /// The settings to check. + /// The name of the lab for the log; without one, nothing gets logged. + /// True, when the preview feature is switched on and no configuration hides the lab. + public static bool CanOpen(SettingsManager settingsManager, string assistantName = "") => settingsManager.IsAssistantVisible( + ComponentKind.BEYOND_FACTS_LAB_ASSISTANT, + assistantName: assistantName, + requiredPreviewFeature: ComponentKind.BEYOND_FACTS_LAB_ASSISTANT.RequiredPreviewFeature); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressForm.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressForm.cs new file mode 100644 index 000000000..f7dcf93b6 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressForm.cs @@ -0,0 +1,17 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// How the BeyondFacts Lab addresses the person in languages which tell a formal address from an informal one, such as German. +/// +public enum BflAddressForm +{ + /// + /// The formal address, such as "Sie" in German. The texts of AI Studio use it as well. + /// + FORMAL, + + /// + /// The informal address, such as "du" in German. + /// + INFORMAL, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressFormExtensions.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressFormExtensions.cs new file mode 100644 index 000000000..ee7bd9ee5 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflAddressFormExtensions.cs @@ -0,0 +1,21 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +public static class BflAddressFormExtensions +{ + private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflAddressFormExtensions).Namespace, nameof(BflAddressFormExtensions)); + + /// The form of address. + extension(BflAddressForm addressForm) + { + /// + /// Gets the name of the form of address, as the settings show it. + /// + public string Name => addressForm switch + { + BflAddressForm.FORMAL => TB("Formal address (Sie in German)"), + BflAddressForm.INFORMAL => TB("Informal address (du in German)"), + + _ => TB("Formal address (Sie in German)"), + }; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldCatalog.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldCatalog.cs new file mode 100644 index 000000000..8ba090c6f --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldCatalog.cs @@ -0,0 +1,124 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Every field of the forms "Your details" in the BeyondFacts Lab. +/// +/// +/// The forms store their values under the IDs of the fields and options, so these IDs never change. A field which +/// is no longer needed keeps its ID reserved. The texts of the fields come from BflFieldTexts. +/// +public static class BflFieldCatalog +{ + // Event details: + public const string FORMAT = "format"; + public const string EVENT_TOPIC = "event_topic"; + public const string AUDIENCE = "audience"; + public const string CONTRIBUTION_TOPIC = "contribution_topic"; + public const string EVENT_DATE = "event_date"; + public const string EVENT_LOCATION = "event_location"; + public const string EVENT_DURATION = "event_duration"; + public const string CONTRIBUTION_DURATION = "contribution_duration"; + public const string OTHER_PARTICIPANTS = "other_participants"; + public const string EVENT_LINK = "event_link"; + + // Knowledge and uncertainty: + public const string CONTRIBUTION_SUMMARY = "contribution_summary"; + public const string CORE_THESES = "core_theses"; + public const string UNCERTAINTIES = "uncertainties"; + public const string MATERIAL_NOTES = "material_notes"; + + // Goals and role: + public const string GOALS = "goals"; + public const string GOAL_DIRECTIONS = "goal_directions"; + + // Values and perspectives: + public const string VALUES_AND_CONCERNS = "values_and_concerns"; + public const string PERSONAL_CONCERN = "personal_concern"; + public const string OTHER_PERSPECTIVES = "other_perspectives"; + + // Strategy: + public const string COMMUNICATION_WORRIES = "communication_worries"; + + // Test situation: + public const string QUESTION_TYPE = "question_type"; + public const string DIFFICULTY = "difficulty"; + public const string TEST_NOTES = "test_notes"; + + // Results and materials: + public const string POSITION_CHANGES = "position_changes"; + + // The options of the directions of a goal: + public const string GOAL_INFORM = "goal_inform"; + public const string GOAL_MODERATE = "goal_moderate"; + public const string GOAL_TAKE_POSITION = "goal_take_position"; + public const string GOAL_RAISE_AWARENESS = "goal_raise_awareness"; + public const string GOAL_SHOW_OPTIONS = "goal_show_options"; + public const string GOAL_INFLUENCE_POLICY = "goal_influence_policy"; + + // The options of the type of questions: + public const string QUESTIONS_MIXED = "questions_mixed"; + public const string QUESTIONS_SCIENTIFIC_CRITICISM = "questions_scientific_criticism"; + public const string QUESTIONS_EMOTIONAL_CRITICISM = "questions_emotional_criticism"; + public const string QUESTIONS_MANIPULATIVE = "questions_manipulative"; + public const string QUESTIONS_POLITICAL_INSTRUMENTALIZATION = "questions_political_instrumentalization"; + + // The options of the difficulty: + public const string DIFFICULTY_MODERATE = "difficulty_moderate"; + public const string DIFFICULTY_DEMANDING = "difficulty_demanding"; + public const string DIFFICULTY_VERY_DEMANDING = "difficulty_very_demanding"; + + /// + /// Every field, step by step, in the order the forms show them. + /// + public static readonly IReadOnlyList FIELDS = + [ + new(FORMAT, BflStep.FRAMEWORK, BflFieldKind.SINGLE_LINE) { IsKeyDetail = true, IsSharedWithResearch = true }, + new(EVENT_TOPIC, BflStep.FRAMEWORK, BflFieldKind.SINGLE_LINE) { IsKeyDetail = true, IsSharedWithResearch = true }, + new(AUDIENCE, BflStep.FRAMEWORK, BflFieldKind.SINGLE_LINE) { IsKeyDetail = true }, + + // The topic of the contribution may be unpublished, so it never reaches the research: + new(CONTRIBUTION_TOPIC, BflStep.FRAMEWORK, BflFieldKind.MULTI_LINE) { IsKeyDetail = true }, + + new(EVENT_DATE, BflStep.FRAMEWORK, BflFieldKind.SINGLE_LINE) { IsSharedWithResearch = true }, + new(EVENT_LOCATION, BflStep.FRAMEWORK, BflFieldKind.SINGLE_LINE) { IsSharedWithResearch = true }, + new(EVENT_DURATION, BflStep.FRAMEWORK, BflFieldKind.SINGLE_LINE), + new(CONTRIBUTION_DURATION, BflStep.FRAMEWORK, BflFieldKind.SINGLE_LINE), + new(OTHER_PARTICIPANTS, BflStep.FRAMEWORK, BflFieldKind.MULTI_LINE) { IsSharedWithResearch = true, NameRole = BflNameRole.PARTICIPANTS }, + new(EVENT_LINK, BflStep.FRAMEWORK, BflFieldKind.LINK) { IsSharedWithResearch = true }, + + new(CONTRIBUTION_SUMMARY, BflStep.KNOWLEDGE, BflFieldKind.MULTI_LINE) { IsKeyDetail = true }, + new(CORE_THESES, BflStep.KNOWLEDGE, BflFieldKind.MULTI_LINE), + new(UNCERTAINTIES, BflStep.KNOWLEDGE, BflFieldKind.MULTI_LINE), + new(MATERIAL_NOTES, BflStep.KNOWLEDGE, BflFieldKind.MULTI_LINE), + + new(GOALS, BflStep.GOALS_AND_ROLE, BflFieldKind.MULTI_LINE), + new(GOAL_DIRECTIONS, BflStep.GOALS_AND_ROLE, BflFieldKind.MULTI_CHOICE) + { + Options = [new(GOAL_INFORM), new(GOAL_MODERATE), new(GOAL_TAKE_POSITION), new(GOAL_RAISE_AWARENESS), new(GOAL_SHOW_OPTIONS), new(GOAL_INFLUENCE_POLICY)], + }, + + new(VALUES_AND_CONCERNS, BflStep.VALUES_AND_PERSPECTIVES, BflFieldKind.MULTI_LINE), + new(PERSONAL_CONCERN, BflStep.VALUES_AND_PERSPECTIVES, BflFieldKind.MULTI_LINE) { IsPersonalDisclosure = true }, + new(OTHER_PERSPECTIVES, BflStep.VALUES_AND_PERSPECTIVES, BflFieldKind.MULTI_LINE), + + new(COMMUNICATION_WORRIES, BflStep.STRATEGY, BflFieldKind.MULTI_LINE), + + new(QUESTION_TYPE, BflStep.TEST_SITUATION, BflFieldKind.CHOICE) + { + Options = [new(QUESTIONS_MIXED), new(QUESTIONS_SCIENTIFIC_CRITICISM), new(QUESTIONS_EMOTIONAL_CRITICISM), new(QUESTIONS_MANIPULATIVE), new(QUESTIONS_POLITICAL_INSTRUMENTALIZATION)], + }, + new(DIFFICULTY, BflStep.TEST_SITUATION, BflFieldKind.CHOICE) + { + Options = [new(DIFFICULTY_MODERATE), new(DIFFICULTY_DEMANDING), new(DIFFICULTY_VERY_DEMANDING)], + }, + new(TEST_NOTES, BflStep.TEST_SITUATION, BflFieldKind.MULTI_LINE), + + new(POSITION_CHANGES, BflStep.RESULTS, BflFieldKind.MULTI_LINE), + ]; + + /// + /// Gets the fields of a step, in the order its form shows them. + /// + /// The step. + public static IReadOnlyList ForStep(BflStep step) => [.. FIELDS.Where(field => field.Step == step)]; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldDefinition.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldDefinition.cs new file mode 100644 index 000000000..f9b5c5818 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldDefinition.cs @@ -0,0 +1,40 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// A field of the form "Your details" of a step in the BeyondFacts Lab. +/// +/// The ID of the field, which a form stores. It never changes. +/// The step whose form shows the field. +/// How the field takes its value. +public sealed record BflFieldDefinition(string Id, BflStep Step, BflFieldKind Kind) +{ + /// + /// Gets whether the step works poorly without the field, so the lab marks it as missing while it is empty. + /// The person may continue anyway; the chat asks for it. + /// + public bool IsKeyDetail { get; init; } + + /// + /// Gets whether the field holds something personal, which exports and sharing leave out unless the person + /// chooses otherwise. + /// + public bool IsPersonalDisclosure { get; init; } + + /// + /// Gets whether the field holds public details about the event, which the research on the web may use. + /// + /// + /// Nothing else of a preparation reaches the research: neither the other fields, nor materials, nor work states. + /// + public bool IsSharedWithResearch { get; init; } + + /// + /// Gets whether the field holds names of people. + /// + public BflNameRole NameRole { get; init; } = BflNameRole.NONE; + + /// + /// Gets the options of a field which offers a choice, in the order to show them. + /// + public IReadOnlyList Options { get; init; } = []; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKind.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKind.cs new file mode 100644 index 000000000..44a20d751 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKind.cs @@ -0,0 +1,32 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// How a field of the BeyondFacts Lab takes its value. +/// +public enum BflFieldKind +{ + /// + /// A short text in one line. + /// + SINGLE_LINE, + + /// + /// A longer text over several lines. + /// + MULTI_LINE, + + /// + /// The address of a web page. + /// + LINK, + + /// + /// One of the options of the field. + /// + CHOICE, + + /// + /// Any number of the options of the field. + /// + MULTI_CHOICE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKindExtensions.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKindExtensions.cs new file mode 100644 index 000000000..7135cf6be --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldKindExtensions.cs @@ -0,0 +1,31 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +public static class BflFieldKindExtensions +{ + /// The kind of field. + extension(BflFieldKind kind) + { + /// + /// Gets the longest value a field of this kind takes, in characters. + /// + /// + /// The values go into the prompts of the chats, so each kind gets a limit that leaves room for the + /// rest of a preparation. + /// + public int MaxLength => kind switch + { + BflFieldKind.SINGLE_LINE => 300, + BflFieldKind.MULTI_LINE => 4_000, + BflFieldKind.LINK => 2_000, + BflFieldKind.CHOICE => 100, + BflFieldKind.MULTI_CHOICE => 1_000, + + _ => 0, + }; + + /// + /// Gets whether a field of this kind offers options to choose from. + /// + public bool HasOptions => kind is BflFieldKind.CHOICE or BflFieldKind.MULTI_CHOICE; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldOption.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldOption.cs new file mode 100644 index 000000000..849ccef17 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldOption.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// An option of a field of the BeyondFacts Lab. +/// +/// The ID of the option, which a form stores. It never changes and is unique across all fields. +public sealed record BflFieldOption(string Id); \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldTexts.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldTexts.cs new file mode 100644 index 000000000..669b44add --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflFieldTexts.cs @@ -0,0 +1,112 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// The texts of the fields and options of the BeyondFacts Lab, in the language of the app. +/// +public static class BflFieldTexts +{ + private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflFieldTexts).Namespace, nameof(BflFieldTexts)); + + /// The field. + extension(BflFieldDefinition definition) + { + /// + /// Gets the label of the field. + /// + public string Label => definition.Id switch + { + BflFieldCatalog.FORMAT => TB("Format of the event"), + BflFieldCatalog.EVENT_TOPIC => TB("Topic of the event"), + BflFieldCatalog.AUDIENCE => TB("Audience"), + BflFieldCatalog.CONTRIBUTION_TOPIC => TB("Topic of your contribution or your specific assignment"), + BflFieldCatalog.EVENT_DATE => TB("Date and time"), + BflFieldCatalog.EVENT_LOCATION => TB("Place"), + BflFieldCatalog.EVENT_DURATION => TB("Total duration of the event"), + BflFieldCatalog.CONTRIBUTION_DURATION => TB("Duration of your contribution"), + BflFieldCatalog.OTHER_PARTICIPANTS => TB("Other participants"), + BflFieldCatalog.EVENT_LINK => TB("Link to the event"), + + BflFieldCatalog.CONTRIBUTION_SUMMARY => TB("Short description of your contribution"), + BflFieldCatalog.CORE_THESES => TB("Core theses and arguments (if any)"), + BflFieldCatalog.UNCERTAINTIES => TB("Uncertainties and controversies in content and science (if known)"), + BflFieldCatalog.MATERIAL_NOTES => TB("Notes on how to use your materials"), + + BflFieldCatalog.GOALS => TB("What do you want to achieve with your contribution?"), + BflFieldCatalog.GOAL_DIRECTIONS => TB("Suggestions"), + + BflFieldCatalog.VALUES_AND_CONCERNS => TB("Values and concerns: What matters most to you about this topic?"), + BflFieldCatalog.PERSONAL_CONCERN => TB("Personal involvement: Where does the topic touch you personally? Which question or criticism could trigger a strong reaction?"), + BflFieldCatalog.OTHER_PERSPECTIVES => TB("Other perspectives: Whose point of view would you like to understand better, or have you hardly considered so far?"), + + BflFieldCatalog.COMMUNICATION_WORRIES => TB("Uncertainties and worries about communicating"), + + BflFieldCatalog.QUESTION_TYPE => TB("Type of questions"), + BflFieldCatalog.DIFFICULTY => TB("Difficulty at the start"), + BflFieldCatalog.TEST_NOTES => TB("Special notes for the test"), + + BflFieldCatalog.POSITION_CHANGES => TB("What have you changed, kept, or left open compared to your initial position, and why?"), + + _ => string.Empty, + }; + + /// + /// Gets the help text below the field, or an empty string when it needs none. + /// + public string Hint => definition.Id switch + { + BflFieldCatalog.FORMAT => TB("E.g., panel, keynote, or interview; online, on site, or hybrid"), + BflFieldCatalog.EVENT_TOPIC => TB("The central question or the title"), + BflFieldCatalog.AUDIENCE => TB("E.g., students, experts, or the general public"), + BflFieldCatalog.CONTRIBUTION_TOPIC => TB("Stays in your preparation and never goes into the research on the web"), + BflFieldCatalog.EVENT_DATE => TB("E.g., December 10, 2 pm"), + BflFieldCatalog.EVENT_LOCATION => TB("E.g., the venue and the city, or online"), + BflFieldCatalog.EVENT_DURATION => TB("E.g., 90 minutes"), + BflFieldCatalog.CONTRIBUTION_DURATION => TB("E.g., 8 minutes"), + BflFieldCatalog.OTHER_PARTICIPANTS => TB("Names and roles, as far as you know them"), + BflFieldCatalog.EVENT_LINK => TB("The address of the event page, beginning with https://"), + + BflFieldCatalog.CONTRIBUTION_SUMMARY => TB("A few sentences are enough"), + BflFieldCatalog.MATERIAL_NOTES => TB("E.g., use only paper X as the basis for the content"), + + BflFieldCatalog.GOALS => TB("In your own words, with the suggestions below, or still open: the chat sharpens your goal and role with you."), + + BflFieldCatalog.PERSONAL_CONCERN => TB("Personal detail: it goes into exports and shared sessions only when you explicitly choose so."), + + BflFieldCatalog.COMMUNICATION_WORRIES => TB("E.g., aggressive moderation, time pressure, or a particular counterpart"), + + BflFieldCatalog.DIFFICULTY => TB("You can raise the difficulty after each question."), + BflFieldCatalog.TEST_NOTES => TB("E.g., take up a confirmed conflict of values or a perspective from the step about values and perspectives"), + + _ => string.Empty, + }; + } + + /// The option. + extension(BflFieldOption option) + { + /// + /// Gets the label of the option. + /// + public string Label => option.Id switch + { + BflFieldCatalog.GOAL_INFORM => TB("Inform"), + BflFieldCatalog.GOAL_MODERATE => TB("Moderate the discussion"), + BflFieldCatalog.GOAL_TAKE_POSITION => TB("Take a position in the debate"), + BflFieldCatalog.GOAL_RAISE_AWARENESS => TB("Raise awareness"), + BflFieldCatalog.GOAL_SHOW_OPTIONS => TB("Show options for action"), + BflFieldCatalog.GOAL_INFLUENCE_POLICY => TB("Influence policy"), + + BflFieldCatalog.QUESTIONS_MIXED => TB("Mixed"), + BflFieldCatalog.QUESTIONS_SCIENTIFIC_CRITICISM => TB("Scientific criticism"), + BflFieldCatalog.QUESTIONS_EMOTIONAL_CRITICISM => TB("Emotionally charged criticism"), + BflFieldCatalog.QUESTIONS_MANIPULATIVE => TB("Manipulative questions and straw men"), + BflFieldCatalog.QUESTIONS_POLITICAL_INSTRUMENTALIZATION => TB("Political instrumentalization"), + + BflFieldCatalog.DIFFICULTY_MODERATE => TB("Moderate"), + BflFieldCatalog.DIFFICULTY_DEMANDING => TB("Demanding"), + BflFieldCatalog.DIFFICULTY_VERY_DEMANDING => TB("Very demanding"), + + _ => string.Empty, + }; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMissingDetails.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMissingDetails.cs new file mode 100644 index 000000000..23d639f52 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflMissingDetails.cs @@ -0,0 +1,27 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Finds the key details a preparation of the BeyondFacts Lab still lacks. +/// +/// +/// Missing details never block anything: the person may continue, and the chat asks for them. +/// +public static class BflMissingDetails +{ + /// + /// Gets the key details of a step which its form leaves empty. + /// + /// The step. + /// The form of the step. + /// The fields which are missing, in the order the form shows them. + public static IReadOnlyList Of(BflStep step, BflStepFormData form) => + [.. BflFieldCatalog.ForStep(step).Where(field => field.IsKeyDetail && string.IsNullOrWhiteSpace(form.Get(field.Id)))]; + + /// + /// Gets the steps whose forms leave key details empty. + /// + /// The forms of a preparation, by their steps; a step without a form counts as empty. + /// The steps with missing details, in their order. + public static IReadOnlyList StepsOf(IReadOnlyDictionary forms) => + [.. BflStep.All.Where(step => Of(step, forms.GetValueOrDefault(step) ?? new BflStepFormData()).Count > 0)]; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNameRole.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNameRole.cs new file mode 100644 index 000000000..30b1e8608 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNameRole.cs @@ -0,0 +1,17 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Whether a field of the BeyondFacts Lab holds names of people, which sharing a session replaces. +/// +public enum BflNameRole +{ + /// + /// The field holds no names on purpose. + /// + NONE, + + /// + /// The field holds the names of other participants of the event. + /// + PARTICIPANTS, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNotice.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNotice.cs new file mode 100644 index 000000000..39d0deb5b --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNotice.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using System.Text; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// The notes which the BeyondFacts Lab shows before the first use, and again whenever their text changes. +/// +/// +/// The notes are a text of the localization, so they come in every language of the app. The settings keep a hash of +/// the notes as the app showed them when the person confirmed them. A new wording, in English or in a translation, +/// therefore shows them again, and so does a switch to another language, whose notes the person has not read yet. +/// +public static class BflNotice +{ + private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflNotice).Namespace, nameof(BflNotice)); + + /// + /// Gets the notes in the language of the app, as Markdown. + /// + public static string Text => TB(""" + ## Welcome! + + The BeyondFacts Lab guides you through an AI-supported process of reflection, which helps you take a position in challenging communication situations and stay able to communicate. + + Data protection depends on the AI provider you choose, its rules for processing and storing data, and your configuration. Enter only data that are suitable and approved for this. + + Please check all results critically. Depending on the AI model, the answers may differ. Hallucinations cannot be ruled out. + + This is a prototype, so we look forward to your feedback. + """); + + /// + /// Computes the hash of the notes as the app shows them. + /// + /// + /// Line endings and the space around the text do not count, so that a build from a checkout with other line endings + /// does not show the notes again. + /// + /// The notes, as the app shows them. + /// The hash, as lowercase hexadecimal digits. + public static string ComputeHash(string notes) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(notes.ReplaceLineEndings("\n").Trim()))); + + /// + /// Gets whether the person confirmed the notes as the app shows them now. + /// + /// The hash the settings keep. + /// The hash of the notes as the app shows them now. + public static bool IsAccepted(string acceptedHash, string currentHash) => string.Equals(acceptedHash, currentHash, StringComparison.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor new file mode 100644 index 000000000..14b05b2df --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor @@ -0,0 +1,11 @@ +@inherits MSGComponentBase + + + + + + + @T("I have read the notes") + + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor.cs new file mode 100644 index 000000000..b390487f7 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflNoticeDialog.razor.cs @@ -0,0 +1,22 @@ +using AIStudio.Components; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Shows the notes of the BeyondFacts Lab, which the person confirms having read. +/// +public partial class BflNoticeDialog : MSGComponentBase +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + /// + /// Gets or sets the notes, as Markdown. + /// + [Parameter] + public string Notice { get; set; } = string.Empty; + + private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProject.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProject.cs new file mode 100644 index 000000000..6f7cc57ba --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProject.cs @@ -0,0 +1,57 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// A preparation in the BeyondFacts Lab: one event, for which a person works through the steps of the lab. +/// +/// +/// The lab stores it as project.json in the folder of the preparation. Everything else the preparation +/// holds lies in files beside it, which the lab finds by the ID of the preparation, never by a stored path. +/// +public sealed class BflProject +{ + /// + /// The version of the stored form this version of AI Studio writes. + /// + /// + /// A preparation of a newer form is listed but neither opened nor written, so that an older version of + /// AI Studio cannot lose what a newer one stored. Raise it with every field a released version does not + /// know yet: that version refuses fields it does not know, and only the higher number tells the person + /// that a newer version stored the preparation. + /// + public const int CURRENT_SCHEMA_VERSION = 1; + + /// + /// Gets or sets the version of the stored form. + /// + public int SchemaVersion { get; set; } = CURRENT_SCHEMA_VERSION; + + /// + /// Gets or sets the ID of the preparation, which is also the name of its folder. + /// + public Guid ProjectId { get; set; } + + /// + /// Gets or sets the name the person gave the preparation. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets when the preparation was created. + /// + public DateTimeOffset CreatedAtUtc { get; set; } + + /// + /// Gets or sets when the preparation was changed last. + /// + public DateTimeOffset ModifiedAtUtc { get; set; } + + /// + /// Gets or sets when the person marked the event as having taken place, or null while it has not. + /// + public DateTimeOffset? HappenedAtUtc { get; set; } + + /// + /// Gets or sets the step the person looked at last, where the lab opens the preparation again. + /// + public BflStep CurrentStep { get; set; } = BflStep.FRAMEWORK; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectEntry.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectEntry.cs new file mode 100644 index 000000000..40250b502 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectEntry.cs @@ -0,0 +1,23 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// A preparation in the list of the BeyondFacts Lab, also when it cannot be opened. +/// +/// The ID of the preparation. +/// The name to show; empty when even the name cannot be read. +/// When the preparation was changed last. +/// Whether the preparation can be opened. +/// The preparation, when it can be opened. +public sealed record BflProjectEntry(Guid ProjectId, string Name, DateTimeOffset ModifiedAtUtc, BflProjectLoadStatus Status, BflProject? Project) +{ + /// + /// Gets whether the preparation can be opened. + /// + public bool IsAvailable => this.Status is BflProjectLoadStatus.AVAILABLE && this.Project is not null; + + /// + /// Creates the entry of a preparation which can be opened. + /// + /// The preparation. + public static BflProjectEntry FromProject(BflProject project) => new(project.ProjectId, project.Name, project.ModifiedAtUtc, BflProjectLoadStatus.AVAILABLE, project); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectLoadStatus.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectLoadStatus.cs new file mode 100644 index 000000000..0bade0c8c --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectLoadStatus.cs @@ -0,0 +1,22 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Whether this version of AI Studio can open a stored preparation of the BeyondFacts Lab. +/// +public enum BflProjectLoadStatus +{ + /// + /// The preparation can be opened. + /// + AVAILABLE, + + /// + /// A newer version of AI Studio stored the preparation. It stays as it is until that version opens it again. + /// + NEWER_VERSION, + + /// + /// The preparation cannot be read, e.g., because its file is damaged. It stays as it is; nothing deletes it on its own. + /// + UNAVAILABLE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectSelection.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectSelection.cs new file mode 100644 index 000000000..d50704c2c --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflProjectSelection.cs @@ -0,0 +1,29 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Decides which preparation the BeyondFacts Lab shows after its list changed. +/// +public static class BflProjectSelection +{ + /// + /// Chooses the preparation to show: the one asked for, else the one selected last, else the one changed last. + /// + /// The preparations, the one changed last first. + /// The preparation asked for, e.g., one just created or the one shown so far. + /// The preparation the person selected last, from an earlier session. + /// The preparation to show, or null when there is none. + public static BflProjectEntry? Choose(IReadOnlyList projects, Guid? preferredId, Guid? lastSelectedId) + { + foreach (var candidateId in new[] { preferredId, lastSelectedId }) + { + if (candidateId is null) + continue; + + var candidate = projects.FirstOrDefault(project => project.ProjectId == candidateId); + if (candidate is not null) + return candidate; + } + + return projects.Count > 0 ? projects[0] : null; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStep.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStep.cs new file mode 100644 index 000000000..2e9e1af01 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStep.cs @@ -0,0 +1,45 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// The steps of a preparation in the BeyondFacts Lab, in the order the lab suggests. +/// +/// +/// The names are stored, e.g., as the step shown last, so they never change. People may jump to any step. +/// +public enum BflStep +{ + /// + /// What the event is about, for whom, and what the person was asked to contribute. + /// + FRAMEWORK, + + /// + /// What the person knows for sure, what is uncertain, and where their expertise ends. + /// + KNOWLEDGE, + + /// + /// What the person wants to achieve, and in which role they speak. + /// + GOALS_AND_ROLE, + + /// + /// What matters to the person, what touches them, and what moves others. + /// + VALUES_AND_PERSPECTIVES, + + /// + /// How the person conveys evidence, trade-offs between values, and open questions. + /// + STRATEGY, + + /// + /// Critical questions and opposing positions, played through. + /// + TEST_SITUATION, + + /// + /// What the person takes away from the preparation, and the materials for the event. + /// + RESULTS, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepExtensions.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepExtensions.cs new file mode 100644 index 000000000..c1956deb9 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepExtensions.cs @@ -0,0 +1,77 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +public static class BflStepExtensions +{ + private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflStepExtensions).Namespace, nameof(BflStepExtensions)); + + /// The step. + extension(BflStep step) + { + /// + /// Gets the title of the step. It names the topic, never a number. + /// + public string Title => step switch + { + BflStep.FRAMEWORK => TB("Event details"), + BflStep.KNOWLEDGE => TB("Knowledge and uncertainty"), + BflStep.GOALS_AND_ROLE => TB("Goals and role"), + BflStep.VALUES_AND_PERSPECTIVES => TB("Values and perspectives"), + BflStep.STRATEGY => TB("Strategy"), + BflStep.TEST_SITUATION => TB("Test situation"), + BflStep.RESULTS => TB("Results and materials"), + + _ => string.Empty, + }; + + /// + /// Gets one sentence about what the step is for. + /// + public string Description => step switch + { + BflStep.FRAMEWORK => TB("What the event is about, for whom, and what you were asked to contribute."), + BflStep.KNOWLEDGE => TB("What you know for sure, what is uncertain, and where your expertise ends."), + BflStep.GOALS_AND_ROLE => TB("What you want to achieve, and in which role you speak."), + BflStep.VALUES_AND_PERSPECTIVES => TB("What matters to you, what touches you, and what moves others."), + BflStep.STRATEGY => TB("How you convey evidence, trade-offs between values, and open questions."), + BflStep.TEST_SITUATION => TB("Play through critical questions and opposing positions."), + BflStep.RESULTS => TB("What you take away from your preparation."), + + _ => string.Empty, + }; + + /// + /// Gets a hint for the form of the step, or an empty string when the step needs none. + /// + public string FormHint => step switch + { + BflStep.VALUES_AND_PERSPECTIVES => TB("You can fill in single fields or start right away in the chat."), + + _ => string.Empty, + }; + + /// + /// Gets the name of the folder in which a preparation keeps the files of the step. + /// + /// + /// The number keeps the folders in the order of the steps for people who look into the data folder. + /// These names are stored on disk, so they never change. + /// + public string FolderName => step switch + { + BflStep.FRAMEWORK => "01-framework", + BflStep.KNOWLEDGE => "02-knowledge", + BflStep.GOALS_AND_ROLE => "03-goals", + BflStep.VALUES_AND_PERSPECTIVES => "04-values", + BflStep.STRATEGY => "05-strategy", + BflStep.TEST_SITUATION => "06-test", + BflStep.RESULTS => "07-results", + + _ => throw new ArgumentOutOfRangeException(nameof(step), step, "The step has no folder."), + }; + + /// + /// Gets every step, in the order the lab suggests. + /// + public static IReadOnlyList All => Enum.GetValues(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor new file mode 100644 index 000000000..544717e78 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor @@ -0,0 +1,97 @@ +@inherits MSGComponentBase + +@if (this.MissingFields.Count > 0) +{ + + @T("Some details are still missing. The chat will ask for them, and you can continue anyway.") +
+ @string.Join(", ", this.MissingFields.Select(field => field.Label)) +
+} + +@if (!string.IsNullOrWhiteSpace(this.Step.FormHint)) +{ + @this.Step.FormHint +} + + + @foreach (var field in this.Fields) + { + switch (field.Kind) + { + case BflFieldKind.SINGLE_LINE: + case BflFieldKind.LINK: + + break; + + case BflFieldKind.MULTI_LINE: + + break; + + case BflFieldKind.CHOICE: + + @foreach (var option in field.Options) + { + @option.Label + } + + break; + + case BflFieldKind.MULTI_CHOICE: +
+ @LabelOf(field) + + @foreach (var option in field.Options) + { + + } + + @if (!string.IsNullOrWhiteSpace(field.Hint)) + { + @field.Hint + } +
+ break; + } + } +
+ +@if (this.HasKeyDetails) +{ + @T("* The chat works best when you fill in these details.") +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor.cs new file mode 100644 index 000000000..43d93d8d1 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepForm.razor.cs @@ -0,0 +1,75 @@ +using AIStudio.Components; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// The form "Your details" of one step of the BeyondFacts Lab. +/// +/// +/// The form changes the values it gets and tells its parent, which stores them. Empty key details are marked but +/// never block anything. +/// +public partial class BflStepForm : MSGComponentBase +{ + /// + /// Gets or sets the step whose fields the form shows. + /// + [Parameter, EditorRequired] + public BflStep Step { get; set; } + + /// + /// Gets or sets the values of the form, which the form changes. + /// + [Parameter, EditorRequired] + public BflStepFormData Form { get; set; } = new(); + + /// + /// Gets or sets whether the fields cannot be changed. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Gets or sets the callback raised after a value changed. + /// + [Parameter] + public EventCallback FormChanged { get; set; } + + private IReadOnlyList Fields => BflFieldCatalog.ForStep(this.Step); + + private IReadOnlyList MissingFields => BflMissingDetails.Of(this.Step, this.Form); + + private bool HasKeyDetails => this.Fields.Any(definition => definition.IsKeyDetail); + + private static string LabelOf(BflFieldDefinition field) => field.IsKeyDetail ? $"{field.Label} *" : field.Label; + + private async Task SetValueAsync(BflFieldDefinition field, string? value) + { + var limited = value is not null && value.Length > field.Kind.MaxLength ? value[..field.Kind.MaxLength] : value; + if (string.Equals(this.Form.Get(field.Id), limited ?? string.Empty, StringComparison.Ordinal)) + return; + + this.Form.Set(field.Id, limited); + await this.FormChanged.InvokeAsync(); + } + + private async Task SetChoicesAsync(BflFieldDefinition field, IReadOnlyCollection? optionIds) + { + this.Form.SetChoices(field, optionIds ?? []); + await this.FormChanged.InvokeAsync(); + } + + private Func? ValidationOf(BflFieldDefinition field) => field.Kind is BflFieldKind.LINK ? this.ValidateLink : null; + + private string? ValidateLink(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + return Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp) + ? null + : T("Please enter an address that begins with https:// or http://."); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs new file mode 100644 index 000000000..38b86ec0c --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs @@ -0,0 +1,69 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// What the person entered into the form "Your details" of one step, stored as form.json in the folder of the step. +/// +/// +/// The values are stored under the IDs of the fields of BflFieldCatalog. A value of a field this version does not +/// know, e.g., one a newer version added, stays as it is and is written back unchanged. +/// +public sealed class BflStepFormData +{ + /// + /// The version of the stored form this version of AI Studio writes. Raise it with every new property. + /// + public const int CURRENT_SCHEMA_VERSION = 1; + + /// + /// The character which separates the options of a field with several choices. + /// + private const char OPTION_SEPARATOR = ','; + + /// + /// Gets or sets the version of the stored form. + /// + public int SchemaVersion { get; set; } = CURRENT_SCHEMA_VERSION; + + /// + /// Gets or sets the values, by the IDs of their fields. + /// + public Dictionary Values { get; set; } = new(StringComparer.Ordinal); + + /// + /// Gets the value of a field, or an empty string when the field has none. + /// + /// The ID of the field. + public string Get(string fieldId) => this.Values.GetValueOrDefault(fieldId, string.Empty); + + /// + /// Sets the value of a field; an empty value removes it. + /// + /// The ID of the field. + /// The value. + public void Set(string fieldId, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + this.Values.Remove(fieldId); + else + this.Values[fieldId] = value; + } + + /// + /// Gets the options chosen in a field with several choices. + /// + /// The ID of the field. + public IReadOnlySet GetChoices(string fieldId) => this.Get(fieldId).Split(OPTION_SEPARATOR, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(StringComparer.Ordinal); + + /// + /// Sets the options chosen in a field with several choices. + /// + /// The field. + /// The IDs of the chosen options. + public void SetChoices(BflFieldDefinition field, IEnumerable optionIds) + { + var chosen = optionIds.ToHashSet(StringComparer.Ordinal); + + // Stored in the order of the options, so that the same choice always gives the same value: + this.Set(field.Id, string.Join(OPTION_SEPARATOR, field.Options.Select(option => option.Id).Where(chosen.Contains))); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStorageOptions.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStorageOptions.cs new file mode 100644 index 000000000..f7bfbf0be --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStorageOptions.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Overrides where the BeyondFacts Lab stores its preparations, for tests. +/// +public sealed class BflStorageOptions +{ + /// + /// Gets or initializes the folder in which the folder beyondFactsLab is created. + /// + public string? DataDirectory { get; init; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor index e94184b3b..ef759a5ef 100644 --- a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor +++ b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor @@ -38,9 +38,7 @@ else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue)) @context.Key - - @context.Value - + @context.Value @@ -68,9 +66,7 @@ else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue)) @context.Key - - @context.Value - + @context.Value @@ -112,9 +108,7 @@ else if (!this.isLoading && string.IsNullOrWhiteSpace(this.loadingIssue)) @context.Key - - @context.Value - + @context.Value diff --git a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs index 055543f75..9e32698a4 100644 --- a/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs +++ b/app/MindWork AI Studio/Assistants/I18N/AssistantI18N.razor.cs @@ -5,14 +5,8 @@ using AIStudio.Dialogs.Settings; using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.PluginSystem; -using Microsoft.Extensions.FileProviders; - using SharedTools; -#if RELEASE -using System.Reflection; -#endif - namespace AIStudio.Assistants.I18N; public partial class AssistantI18N : AssistantBaseCore @@ -42,7 +36,10 @@ public partial class AssistantI18N : AssistantBaseCore daily lives with it. The app offers the regular chat mode for which LLMs have become known. However, AI Studio also offers so-called assistants, where users no longer have to prompt. - + + Some texts are longer notes in Markdown, with headings, paragraphs, or lists. Keep their + Markdown and their line breaks exactly as they are, and translate only the words. + # Target Audience The app is intended for everyone, not just IT specialists or scientists. When translating, make sure the texts are easy for everyone to understand. @@ -250,17 +247,7 @@ public partial class AssistantI18N : AssistantBaseCore // // Read the file `Assistants\I18N\allTexts.lua`: // - #if DEBUG - var filePath = Path.Join(Environment.CurrentDirectory, "Assistants", "I18N"); - var resourceFileProvider = new PhysicalFileProvider(filePath); - #else - var resourceFileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "Assistants/I18N"); - #endif - - var file = resourceFileProvider.GetFileInfo("allTexts.lua"); - await using var fileStream = file.CreateReadStream(); - using var reader = new StreamReader(fileStream); - var newI18NDataLuaCode = await reader.ReadToEndAsync(); + var newI18NDataLuaCode = await AppResources.ReadTextAsync("Assistants/I18N/allTexts.lua"); // // Next, we try to load the text as a language plugin -- without diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 64ac0f4eb..c666df764 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -733,6 +733,336 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGPROMPTSOU -- Use a document analysis policy UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGPROMPTSOURCEEXTENSIONS::T3309547196"] = "Use a document analysis policy" +-- Prepare a challenging appearance as a researcher step by step, e.g., a panel discussion, an interview, or a hearing. Each preparation belongs to one event and has its own steps, chats, and work states. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1218290190"] = "Prepare a challenging appearance as a researcher step by step, e.g., a panel discussion, an interview, or a hearing. Each preparation belongs to one event and has its own steps, chats, and work states." + +-- Changed on {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1279137966"] = "Changed on {0}" + +-- Delete +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1469573738"] = "Delete" + +-- AI Studio cannot read this preparation. Its files may be damaged. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1496519091"] = "AI Studio cannot read this preparation. Its files may be damaged." + +-- AI Studio cannot open your details for this step. Their file may be damaged, or a newer version of AI Studio stored it. AI Studio leaves the file unchanged. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1564342983"] = "AI Studio cannot open your details for this step. Their file may be damaged, or a newer version of AI Studio stored it. AI Studio leaves the file unchanged." + +-- You have no preparations yet. Create one for the event you want to prepare for. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1599306665"] = "You have no preparations yet. Create one for the event you want to prepare for." + +-- Enter a new name for this preparation. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T193031277"] = "Enter a new name for this preparation." + +-- Cannot be opened +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1981873292"] = "Cannot be opened" + +-- Copy preparation ID +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2344012850"] = "Copy preparation ID" + +-- Your details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T25208484"] = "Your details" + +-- Create +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2567824509"] = "Create" + +-- Preparation ID +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2671360307"] = "Preparation ID" + +-- Create a preparation to begin. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2751865753"] = "Create a preparation to begin." + +-- AI Studio has left the files of this preparation unchanged. A future update may make it accessible again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2824385338"] = "AI Studio has left the files of this preparation unchanged. A future update may make it accessible again." + +-- The event has taken place +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2842348392"] = "The event has taken place" + +-- Marked on {0}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3254361846"] = "Marked on {0}." + +-- Rename preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3342203974"] = "Rename preparation" + +-- Rename +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3355849203"] = "Rename" + +-- Delete preparation permanently +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T354252674"] = "Delete preparation permanently" + +-- Unavailable preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3646236084"] = "Unavailable preparation" + +-- This preparation cannot be opened. Permanently delete it with all of its files? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3647862078"] = "This preparation cannot be opened. Permanently delete it with all of its files?" + +-- Name the event you want to prepare for, e.g., the title of a panel discussion. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3679923414"] = "Name the event you want to prepare for, e.g., the title of a panel discussion." + +-- Name of the preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3746973353"] = "Name of the preparation" + +-- Please enter a name. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3818326829"] = "Please enter a name." + +-- AI Studio could not save your details. Please try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3921500806"] = "AI Studio could not save your details. Please try again." + +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3956097526"] = "BeyondFacts Lab" + +-- A newer version of AI Studio stored this preparation. This version cannot open it. Please update AI Studio. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T395932079"] = "A newer version of AI Studio stored this preparation. This version cannot open it. Please update AI Studio." + +-- AI Studio could not save the change of your preparations. Please try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T4073131406"] = "AI Studio could not save the change of your preparations. Please try again." + +-- Requires a newer AI Studio version +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T4087140083"] = "Requires a newer AI Studio version" + +-- Details missing +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T506453229"] = "Details missing" + +-- Permanently delete the preparation '{0}' with all of its steps, chats, and work states? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T772787118"] = "Permanently delete the preparation '{0}' with all of its steps, chats, and work states?" + +-- New preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T851005164"] = "New preparation" + +-- Your preparations +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T972511272"] = "Your preparations" + +-- Informal address (du in German) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLADDRESSFORMEXTENSIONS::T554958468"] = "Informal address (du in German)" + +-- Formal address (Sie in German) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLADDRESSFORMEXTENSIONS::T859201973"] = "Formal address (Sie in German)" + +-- Moderate the discussion +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1131866893"] = "Moderate the discussion" + +-- E.g., take up a confirmed conflict of values or a perspective from the step about values and perspectives +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1134014442"] = "E.g., take up a confirmed conflict of values or a perspective from the step about values and perspectives" + +-- What have you changed, kept, or left open compared to your initial position, and why? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1455147822"] = "What have you changed, kept, or left open compared to your initial position, and why?" + +-- Show options for action +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1601690165"] = "Show options for action" + +-- Manipulative questions and straw men +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1656068515"] = "Manipulative questions and straw men" + +-- Topic of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1766174370"] = "Topic of the event" + +-- Moderate +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T177463328"] = "Moderate" + +-- Other perspectives: Whose point of view would you like to understand better, or have you hardly considered so far? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1885081930"] = "Other perspectives: Whose point of view would you like to understand better, or have you hardly considered so far?" + +-- Stays in your preparation and never goes into the research on the web +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1949096445"] = "Stays in your preparation and never goes into the research on the web" + +-- Very demanding +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1967151608"] = "Very demanding" + +-- Date and time +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2054731205"] = "Date and time" + +-- Notes on how to use your materials +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2123646094"] = "Notes on how to use your materials" + +-- Duration of your contribution +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T223853289"] = "Duration of your contribution" + +-- Special notes for the test +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2242825987"] = "Special notes for the test" + +-- Uncertainties and worries about communicating +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2428368968"] = "Uncertainties and worries about communicating" + +-- Difficulty at the start +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T243923502"] = "Difficulty at the start" + +-- E.g., 90 minutes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2537467295"] = "E.g., 90 minutes" + +-- Place +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2597472412"] = "Place" + +-- E.g., 8 minutes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2697611204"] = "E.g., 8 minutes" + +-- A few sentences are enough +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2765665158"] = "A few sentences are enough" + +-- Other participants +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T277557865"] = "Other participants" + +-- What do you want to achieve with your contribution? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2818219287"] = "What do you want to achieve with your contribution?" + +-- Scientific criticism +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2865565843"] = "Scientific criticism" + +-- The address of the event page, beginning with https:// +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2900883219"] = "The address of the event page, beginning with https://" + +-- Names and roles, as far as you know them +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3020176946"] = "Names and roles, as far as you know them" + +-- Personal detail: it goes into exports and shared sessions only when you explicitly choose so. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3063635296"] = "Personal detail: it goes into exports and shared sessions only when you explicitly choose so." + +-- Political instrumentalization +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3078371152"] = "Political instrumentalization" + +-- Mixed +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3114856344"] = "Mixed" + +-- Personal involvement: Where does the topic touch you personally? Which question or criticism could trigger a strong reaction? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3199962614"] = "Personal involvement: Where does the topic touch you personally? Which question or criticism could trigger a strong reaction?" + +-- Format of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3217479436"] = "Format of the event" + +-- E.g., panel, keynote, or interview; online, on site, or hybrid +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3297023821"] = "E.g., panel, keynote, or interview; online, on site, or hybrid" + +-- Values and concerns: What matters most to you about this topic? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3444467007"] = "Values and concerns: What matters most to you about this topic?" + +-- Audience +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3506118019"] = "Audience" + +-- Suggestions +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3534677498"] = "Suggestions" + +-- Emotionally charged criticism +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3552262211"] = "Emotionally charged criticism" + +-- Link to the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3658981841"] = "Link to the event" + +-- Raise awareness +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3790372874"] = "Raise awareness" + +-- E.g., use only paper X as the basis for the content +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3804983466"] = "E.g., use only paper X as the basis for the content" + +-- Core theses and arguments (if any) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T39824489"] = "Core theses and arguments (if any)" + +-- Total duration of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T4051894943"] = "Total duration of the event" + +-- Influence policy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T4052553234"] = "Influence policy" + +-- You can raise the difficulty after each question. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T519332031"] = "You can raise the difficulty after each question." + +-- E.g., students, experts, or the general public +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T533097975"] = "E.g., students, experts, or the general public" + +-- Short description of your contribution +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T667686615"] = "Short description of your contribution" + +-- Type of questions +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T805059831"] = "Type of questions" + +-- The central question or the title +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T828389221"] = "The central question or the title" + +-- Uncertainties and controversies in content and science (if known) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T82922542"] = "Uncertainties and controversies in content and science (if known)" + +-- Inform +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T85613896"] = "Inform" + +-- E.g., December 10, 2 pm +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T881586300"] = "E.g., December 10, 2 pm" + +-- E.g., the venue and the city, or online +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T916990010"] = "E.g., the venue and the city, or online" + +-- In your own words, with the suggestions below, or still open: the chat sharpens your goal and role with you. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T917276711"] = "In your own words, with the suggestions below, or still open: the chat sharpens your goal and role with you." + +-- Topic of your contribution or your specific assignment +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T932670647"] = "Topic of your contribution or your specific assignment" + +-- E.g., aggressive moderation, time pressure, or a particular counterpart +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T965551209"] = "E.g., aggressive moderation, time pressure, or a particular counterpart" + +-- Take a position in the debate +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T974131333"] = "Take a position in the debate" + +-- Demanding +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T985014450"] = "Demanding" + +-- ## Welcome! The BeyondFacts Lab guides you through an AI-supported process of reflection, which helps you take a position in challenging communication situations and stay able to communicate. Data protection depends on the AI provider you choose, its rules for processing and storing data, and your configuration. Enter only data that are suitable and approved for this. Please check all results critically. Depending on the AI model, the answers may differ. Hallucinations cannot be ruled out. This is a prototype, so we look forward to your feedback. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLNOTICE::T940905480"] = "## Welcome!\n\nThe BeyondFacts Lab guides you through an AI-supported process of reflection, which helps you take a position in challenging communication situations and stay able to communicate.\n\nData protection depends on the AI provider you choose, its rules for processing and storing data, and your configuration. Enter only data that are suitable and approved for this.\n\nPlease check all results critically. Depending on the AI model, the answers may differ. Hallucinations cannot be ruled out.\n\nThis is a prototype, so we look forward to your feedback." + +-- I have read the notes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLNOTICEDIALOG::T4071737336"] = "I have read the notes" + +-- Test situation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1049923473"] = "Test situation" + +-- Knowledge and uncertainty +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1147870258"] = "Knowledge and uncertainty" + +-- How you convey evidence, trade-offs between values, and open questions. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1290723890"] = "How you convey evidence, trade-offs between values, and open questions." + +-- Event details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T135387869"] = "Event details" + +-- What the event is about, for whom, and what you were asked to contribute. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T158292580"] = "What the event is about, for whom, and what you were asked to contribute." + +-- You can fill in single fields or start right away in the chat. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T2524378548"] = "You can fill in single fields or start right away in the chat." + +-- Goals and role +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T2988481290"] = "Goals and role" + +-- What matters to you, what touches you, and what moves others. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3022557767"] = "What matters to you, what touches you, and what moves others." + +-- What you want to achieve, and in which role you speak. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3052319680"] = "What you want to achieve, and in which role you speak." + +-- Strategy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3205650756"] = "Strategy" + +-- Results and materials +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3602322182"] = "Results and materials" + +-- Values and perspectives +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T494574525"] = "Values and perspectives" + +-- What you take away from your preparation. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T517692701"] = "What you take away from your preparation." + +-- Play through critical questions and opposing positions. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T573043138"] = "Play through critical questions and opposing positions." + +-- What you know for sure, what is uncertain, and where your expertise ends. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T762525208"] = "What you know for sure, what is uncertain, and where your expertise ends." + +-- Some details are still missing. The chat will ask for them, and you can continue anyway. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T1474406854"] = "Some details are still missing. The chat will ask for them, and you can continue anyway." + +-- Please enter an address that begins with https:// or http://. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T2837809367"] = "Please enter an address that begins with https:// or http://." + +-- * The chat works best when you fill in these details. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T3011046288"] = "* The chat works best when you fill in these details." + -- Extended bias poster UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BIASDAY::BIASOFTHEDAYASSISTANT::T1241605514"] = "Extended bias poster" @@ -4414,6 +4744,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1259707710"] = "Your projec -- Your temporary chats, along with their transcripts. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1293614512"] = "Your temporary chats, along with their transcripts." +-- Your preparations in the BeyondFacts Lab. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1415474409"] = "Your preparations in the BeyondFacts Lab." + -- What belongs into a backup UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1682175486"] = "What belongs into a backup" @@ -9589,6 +9922,24 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T82 -- Restore default UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Restore default" +-- How the lab addresses you in languages with a formal and an informal address, such as German. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1832497993"] = "How the lab addresses you in languages with a formal and an informal address, such as German." + +-- Assistant: BeyondFacts Lab Options +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T19824556"] = "Assistant: BeyondFacts Lab Options" + +-- Form of address +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2183481736"] = "Form of address" + +-- Select a minimum confidence level +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2579793544"] = "Select a minimum confidence level" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3448155331"] = "Close" + +-- 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"] = "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." + -- Files are sent as they were first read UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T1320692831"] = "Files are sent as they were first read" @@ -11197,12 +11548,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3733831260"] = "Generate your own -- Generate an ERI server to integrate business systems. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3756213118"] = "Generate an ERI server to integrate business systems." +-- Prepare step by step for a challenging appearance as a researcher, e.g., a panel discussion or an interview. +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T386812830"] = "Prepare step by step for a challenging appearance as a researcher, e.g., a panel discussion or an interview." + -- Use an LLM to find an icon for a given context. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3881504200"] = "Use an LLM to find an icon for a given context." -- Job Posting UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Job Posting" +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3956097526"] = "BeyondFacts Lab" + -- Ask a question about a legal document. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Ask a question about a legal document." @@ -12919,6 +13276,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::OUTBOUNDDATARESTRICTIONEXTENSION -- Only services configured in AI Studio UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::OUTBOUNDDATARESTRICTIONEXTENSIONS::T750634166"] = "Only services configured in AI Studio" +-- BeyondFacts Lab: Prepare for challenging conversations about your research, step by step +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1110065980"] = "BeyondFacts Lab: Prepare for challenging conversations about your research, step by step" + -- Visual Briefing Assistant: Turn source material into an interactive briefing UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1217946647"] = "Visual Briefing Assistant: Turn source material into an interactive briefing" @@ -13168,6 +13528,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T348883878"] = "Document -- Translation Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T3887962308"] = "Translation Assistant" +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T3956097526"] = "BeyondFacts Lab" + -- Agenda Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4034795997"] = "Agenda Assistant" diff --git a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs index debf7c934..00cc373bd 100644 --- a/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs +++ b/app/MindWork AI Studio/Assistants/PromptOptimizer/AssistantPromptOptimizer.razor.cs @@ -10,11 +10,6 @@ using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; -#if !DEBUG -using System.Reflection; -using Microsoft.Extensions.FileProviders; -#endif - namespace AIStudio.Assistants.PromptOptimizer; public partial class AssistantPromptOptimizer : AssistantBaseCore @@ -578,24 +573,7 @@ public partial class AssistantPromptOptimizer : AssistantBaseCore ReadPromptingGuidelineAsync() - { -#if DEBUG - var guidelinePath = Path.Join(Environment.CurrentDirectory, "Assistants", "PromptOptimizer", "prompting_guideline.md"); - return File.Exists(guidelinePath) - ? await File.ReadAllTextAsync(guidelinePath) - : string.Empty; -#else - var resourceFileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "Assistants/PromptOptimizer"); - var file = resourceFileProvider.GetFileInfo("prompting_guideline.md"); - if (!file.Exists) - return string.Empty; - - await using var fileStream = file.CreateReadStream(); - using var reader = new StreamReader(fileStream); - return await reader.ReadToEndAsync(); -#endif - } + private static async Task ReadPromptingGuidelineAsync() => await AppResources.ReadTextAsync("Assistants/PromptOptimizer/prompting_guideline.md"); private async Task GetPromptingGuidelineForOptimizationAsync() { diff --git a/app/MindWork AI Studio/Components/DataFolders.razor.cs b/app/MindWork AI Studio/Components/DataFolders.razor.cs index 07a88720f..b84529e26 100644 --- a/app/MindWork AI Studio/Components/DataFolders.razor.cs +++ b/app/MindWork AI Studio/Components/DataFolders.razor.cs @@ -82,6 +82,7 @@ public partial class DataFolders : MSGComponentBase DataBackupEntryKind.PLUGINS => T("Your plugins, such as the assistants you built, apart from the folders listed below."), DataBackupEntryKind.TOKENIZERS => T("The tokenizers you chose for your providers."), DataBackupEntryKind.VISUAL_BRIEFINGS => T("Your projects in the Visual Briefing Assistant."), + DataBackupEntryKind.BEYOND_FACTS_LAB => T("Your preparations in the BeyondFacts Lab."), DataBackupEntryKind.DATA_SOURCE_INDEX => T("The index of your local data sources: which files AI Studio embedded, and their text."), DataBackupEntryKind.VECTOR_STORES => T("The embeddings of your local data sources."), DataBackupEntryKind.MEDIA_TRANSCRIPTS => T("The transcripts of the audio and video files in your local data sources."), diff --git a/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor b/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor index 15a208ccf..2a2968e30 100644 --- a/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor +++ b/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor @@ -3,7 +3,7 @@ CompletedStepColor="Color.Primary" CurrentStepColor="Color.Primary" ErrorStepColor="Color.Error" - NonLinear="@false" + NonLinear="@this.NonLinear" ShowResetButton="@false" OnPreviewInteraction="@this.PreviewInteractionAsync" Class="@this.Classname"> diff --git a/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor.cs b/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor.cs index 43af73634..856eaa58e 100644 --- a/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor.cs +++ b/app/MindWork AI Studio/Components/MudStepperWithoutActions.razor.cs @@ -36,6 +36,17 @@ public partial class MudStepperWithoutActions : ComponentBase [Parameter] public bool ReadOnly { get; set; } + /// + /// Gets or sets whether the user may jump to any step, not only to the next one. + /// + /// + /// Off by default, which keeps the order of a process that runs step by step, such as a build. + /// Switch it on where people work through the steps themselves and may return to an earlier one + /// or look ahead, as in the BeyondFacts Lab. + /// + [Parameter] + public bool NonLinear { get; set; } + /// /// Gets or sets additional CSS classes for the stepper. /// diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor new file mode 100644 index 000000000..11bafb0f6 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor @@ -0,0 +1,21 @@ +@using AIStudio.Settings +@inherits SettingsDialogBase + + + + + + @T("Assistant: BeyondFacts Lab Options") + + + + + + + + + + + @T("Close") + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs new file mode 100644 index 000000000..0b7ccdfa9 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/Settings/SettingsDialogBeyondFactsLab.razor.cs @@ -0,0 +1,18 @@ +using AIStudio.Provider; + +namespace AIStudio.Dialogs.Settings; + +/// +/// Provides the code-behind type for the settings of the BeyondFacts Lab. +/// +public partial class SettingsDialogBeyondFactsLab : SettingsDialogBase +{ + /// + /// Shows the minimum confidence which applies, never one below the floor of the lab. + /// + /// + /// A value below the floor, e.g., from a configuration plugin, has no effect, and the list does not + /// offer it either. Showing the floor instead tells what really counts. + /// + private ConfidenceLevel SelectedMinimumConfidence() => Tools.Components.BEYOND_FACTS_LAB_ASSISTANT.MinimumConfidence(this.SettingsManager); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index 0ff7659d9..f14838102 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -70,6 +70,7 @@ + diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index ac35d4bdb..f3d9ab926 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -914,7 +914,7 @@ CONFIG["SETTINGS"] = {} -- LEGAL_CHECK_ASSISTANT, SYNONYMS_ASSISTANT, MY_TASKS_ASSISTANT, -- JOB_POSTING_ASSISTANT, BIAS_DAY_ASSISTANT, ERI_ASSISTANT, -- DOCUMENT_ANALYSIS_ASSISTANT, BATCH_PROCESSING_ASSISTANT, SLIDE_BUILDER_ASSISTANT, --- VISUAL_BRIEFING_ASSISTANT, I18N_ASSISTANT, +-- VISUAL_BRIEFING_ASSISTANT, BEYOND_FACTS_LAB_ASSISTANT, I18N_ASSISTANT, -- LOG_VIEWER_ASSISTANT -- -- Replaces, does not merge: a configuration with a higher priority replaces this list @@ -982,6 +982,25 @@ CONFIG["SETTINGS"] = {} -- documents. Providers below this level cannot be selected in the assistant. -- CONFIG["SETTINGS"]["DataVisualBriefing.MinimumProviderConfidence"] = "NONE" +-- Configure organization defaults for the BeyondFacts Lab. +-- The lab guides researchers step by step through the preparation of a challenging appearance, +-- such as a panel discussion or an interview. It is a prototype: users see it only when they allow +-- prototypes and enable the preview feature PRE_BEYOND_FACTS_LAB_2026. +-- +-- Configure the provider which new preparations start with. +-- It must be one of the provider IDs defined in CONFIG["LLM_PROVIDERS"]. +-- CONFIG["SETTINGS"]["DataBeyondFactsLab.PreselectedProvider"] = "00000000-0000-0000-0000-000000000000" +-- +-- Configure the minimum provider confidence required by the lab. +-- Allowed values are: NONE, UNTRUSTED, UNKNOWN, VERY_LOW, LOW, MODERATE, MEDIUM, HIGH +-- Unpublished research is the usual material of the lab, so the lab never accepts less than MEDIUM. +-- A lower value has no effect; HIGH allows only self-hosted models in the built-in confidence schemes. +-- CONFIG["SETTINGS"]["DataBeyondFactsLab.MinimumProviderConfidence"] = "MEDIUM" +-- +-- Configure how the lab addresses users in languages with a formal and an informal address, such as German. +-- Allowed values are: FORMAL, INFORMAL +-- CONFIG["SETTINGS"]["DataBeyondFactsLab.AddressForm"] = "FORMAL" + -- Configure defaults for the Assistant Builder. -- Preselection must be enabled for the remaining Assistant Builder settings to take effect. -- CONFIG["SETTINGS"]["DataAssistantBuilder.PreselectOptions"] = true 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 7ba1e5a58..c14b00b81 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 @@ -735,6 +735,336 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGPROMPTSOU -- Use a document analysis policy UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGPROMPTSOURCEEXTENSIONS::T3309547196"] = "Regelwerk für die Dokumentenanalyse verwenden" +-- Prepare a challenging appearance as a researcher step by step, e.g., a panel discussion, an interview, or a hearing. Each preparation belongs to one event and has its own steps, chats, and work states. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1218290190"] = "Bereiten Sie einen herausfordernden Auftritt mit Ihrer Forschung Schritt für Schritt vor, etwa eine Podiumsdiskussion, ein Interview oder eine Anhörung. Jede Vorbereitung gehört zu einer Veranstaltung und hat eigene Schritte, Chats und Arbeitsstände." + +-- Changed on {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1279137966"] = "Geändert am {0}" + +-- Delete +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1469573738"] = "Löschen" + +-- AI Studio cannot read this preparation. Its files may be damaged. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1496519091"] = "AI Studio kann diese Vorbereitung nicht lesen. Die Dateien dieser Vorbereitung sind möglicherweise beschädigt." + +-- AI Studio cannot open your details for this step. Their file may be damaged, or a newer version of AI Studio stored it. AI Studio leaves the file unchanged. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1564342983"] = "AI Studio kann Ihre Angaben zu diesem Schritt nicht öffnen. Die Datei ist möglicherweise beschädigt oder wurde mit einer neueren Version von AI Studio gespeichert. AI Studio lässt die Datei unverändert." + +-- You have no preparations yet. Create one for the event you want to prepare for. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1599306665"] = "Sie haben noch keine Vorbereitung. Erstellen Sie eine für die Veranstaltung, auf die Sie sich vorbereiten möchten." + +-- Enter a new name for this preparation. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T193031277"] = "Geben Sie einen neuen Namen für diese Vorbereitung ein." + +-- Cannot be opened +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1981873292"] = "Kann nicht geöffnet werden" + +-- Copy preparation ID +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2344012850"] = "Vorbereitungs-ID kopieren" + +-- Your details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T25208484"] = "Ihre Angaben" + +-- Create +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2567824509"] = "Erstellen" + +-- Preparation ID +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2671360307"] = "Vorbereitungs-ID" + +-- Create a preparation to begin. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2751865753"] = "Erstellen Sie eine Vorbereitung, um zu beginnen." + +-- AI Studio has left the files of this preparation unchanged. A future update may make it accessible again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2824385338"] = "AI Studio hat die Dateien dieser Vorbereitung unverändert gelassen. Ein zukünftiges Update könnte sie wieder zugänglich machen." + +-- The event has taken place +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2842348392"] = "Die Veranstaltung hat stattgefunden" + +-- Marked on {0}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3254361846"] = "Markiert am {0}." + +-- Rename preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3342203974"] = "Vorbereitung umbenennen" + +-- Rename +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3355849203"] = "Umbenennen" + +-- Delete preparation permanently +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T354252674"] = "Vorbereitung endgültig löschen" + +-- Unavailable preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3646236084"] = "Vorbereitung nicht verfügbar" + +-- This preparation cannot be opened. Permanently delete it with all of its files? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3647862078"] = "Diese Vorbereitung kann nicht geöffnet werden. Möchten Sie sie mit allen Dateien endgültig löschen?" + +-- Name the event you want to prepare for, e.g., the title of a panel discussion. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3679923414"] = "Geben Sie die Veranstaltung an, auf die Sie sich vorbereiten möchten, z. B. den Titel einer Podiumsdiskussion." + +-- Name of the preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3746973353"] = "Name der Vorbereitung" + +-- Please enter a name. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3818326829"] = "Bitte geben Sie einen Namen ein." + +-- AI Studio could not save your details. Please try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3921500806"] = "AI Studio konnte Ihre Angaben nicht speichern. Bitte versuchen Sie es erneut." + +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3956097526"] = "BeyondFacts Lab" + +-- A newer version of AI Studio stored this preparation. This version cannot open it. Please update AI Studio. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T395932079"] = "Eine neuere Version von AI Studio hat diese Vorbereitung gespeichert. Diese Version kann sie nicht öffnen. Bitte aktualisieren Sie AI Studio." + +-- AI Studio could not save the change of your preparations. Please try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T4073131406"] = "AI Studio konnte die Änderung an Ihren Vorbereitungen nicht speichern. Bitte versuchen Sie es erneut." + +-- Requires a newer AI Studio version +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T4087140083"] = "Erfordert eine neuere Version von AI Studio" + +-- Details missing +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T506453229"] = "Angaben fehlen" + +-- Permanently delete the preparation '{0}' with all of its steps, chats, and work states? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T772787118"] = "Die Vorbereitung „{0}“ mit allen Schritten, Chats und Arbeitsständen endgültig löschen?" + +-- New preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T851005164"] = "Neue Vorbereitung" + +-- Your preparations +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T972511272"] = "Ihre Vorbereitungen" + +-- Informal address (du in German) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLADDRESSFORMEXTENSIONS::T554958468"] = "Informelle Anrede (du)" + +-- Formal address (Sie in German) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLADDRESSFORMEXTENSIONS::T859201973"] = "Formelle Anrede (Sie)" + +-- Moderate the discussion +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1131866893"] = "Diskussion moderieren" + +-- E.g., take up a confirmed conflict of values or a perspective from the step about values and perspectives +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1134014442"] = "Z. B. einen bestätigten Wertekonflikt oder eine Perspektive aus dem Schritt „Werte und Perspektiven“ aufgreifen" + +-- What have you changed, kept, or left open compared to your initial position, and why? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1455147822"] = "Was haben Sie gegenüber Ihrer Ausgangsposition verändert, beibehalten oder offengelassen, und weshalb?" + +-- Show options for action +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1601690165"] = "Handlungsoptionen aufzeigen" + +-- Manipulative questions and straw men +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1656068515"] = "Manipulative Fragen und Strohmänner" + +-- Topic of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1766174370"] = "Thema der Veranstaltung" + +-- Moderate +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T177463328"] = "Moderat" + +-- Other perspectives: Whose point of view would you like to understand better, or have you hardly considered so far? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1885081930"] = "Andere Perspektiven: Wessen Sichtweise möchten Sie besser verstehen oder haben Sie bisher wenig berücksichtigt?" + +-- Stays in your preparation and never goes into the research on the web +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1949096445"] = "Bleibt in Ihrer Vorbereitung und geht nie in die Recherche im Web ein" + +-- Very demanding +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1967151608"] = "Sehr anspruchsvoll" + +-- Date and time +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2054731205"] = "Datum und Uhrzeit" + +-- Notes on how to use your materials +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2123646094"] = "Hinweise zur Nutzung Ihrer Materialien" + +-- Duration of your contribution +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T223853289"] = "Dauer Ihres Beitrags" + +-- Special notes for the test +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2242825987"] = "Besondere Hinweise für den Test" + +-- Uncertainties and worries about communicating +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2428368968"] = "Kommunikative Unsicherheiten und Sorgen" + +-- Difficulty at the start +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T243923502"] = "Schwierigkeit zu Beginn" + +-- E.g., 90 minutes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2537467295"] = "Z. B. 90 Minuten" + +-- Place +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2597472412"] = "Ort" + +-- E.g., 8 minutes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2697611204"] = "Z. B. 8 Minuten" + +-- A few sentences are enough +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2765665158"] = "Ein paar Sätze genügen" + +-- Other participants +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T277557865"] = "Weitere Teilnehmende" + +-- What do you want to achieve with your contribution? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2818219287"] = "Was möchten Sie mit Ihrem Beitrag erreichen?" + +-- Scientific criticism +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2865565843"] = "Wissenschaftliche Kritik" + +-- The address of the event page, beginning with https:// +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2900883219"] = "Die Adresse der Veranstaltungsseite, beginnend mit https://" + +-- Names and roles, as far as you know them +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3020176946"] = "Namen und Rollen, soweit bekannt" + +-- Personal detail: it goes into exports and shared sessions only when you explicitly choose so. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3063635296"] = "Persönliche Angabe: Exporte und geteilte Sitzungen enthalten sie nur, wenn Sie das ausdrücklich wählen." + +-- Political instrumentalization +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3078371152"] = "Politische Instrumentalisierung" + +-- Mixed +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3114856344"] = "Gemischt" + +-- Personal involvement: Where does the topic touch you personally? Which question or criticism could trigger a strong reaction? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3199962614"] = "Persönliche Betroffenheit: Wo berührt Sie das Thema persönlich? Welche Frage oder Kritik könnte bei Ihnen eine starke Reaktion auslösen?" + +-- Format of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3217479436"] = "Format der Veranstaltung" + +-- E.g., panel, keynote, or interview; online, on site, or hybrid +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3297023821"] = "Z. B. Podiumsdiskussion, Keynote oder Interview; online, vor Ort oder hybrid" + +-- Values and concerns: What matters most to you about this topic? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3444467007"] = "Werte und Anliegen: Was ist Ihnen bei diesem Thema besonders wichtig?" + +-- Audience +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3506118019"] = "Zielgruppe" + +-- Suggestions +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3534677498"] = "Vorschläge" + +-- Emotionally charged criticism +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3552262211"] = "Emotionalisierte Kritik" + +-- Link to the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3658981841"] = "Link zur Veranstaltung" + +-- Raise awareness +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3790372874"] = "Bewusstsein schaffen" + +-- E.g., use only paper X as the basis for the content +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3804983466"] = "Z. B. nur Paper X als inhaltliche Grundlage verwenden" + +-- Core theses and arguments (if any) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T39824489"] = "Kernthesen und Argumente (falls vorhanden)" + +-- Total duration of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T4051894943"] = "Dauer der Veranstaltung insgesamt" + +-- Influence policy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T4052553234"] = "Politischen Einfluss nehmen" + +-- You can raise the difficulty after each question. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T519332031"] = "Nach jeder Frage können Sie die Schwierigkeit erhöhen." + +-- E.g., students, experts, or the general public +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T533097975"] = "Z. B. Studierende, Fachpublikum oder die breite Öffentlichkeit" + +-- Short description of your contribution +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T667686615"] = "Kurzbeschreibung Ihres Beitrags" + +-- Type of questions +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T805059831"] = "Art der Fragen" + +-- The central question or the title +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T828389221"] = "Die zentrale Fragestellung oder der Titel" + +-- Uncertainties and controversies in content and science (if known) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T82922542"] = "Inhaltliche und wissenschaftliche Unsicherheiten und Kontroversen (falls bekannt)" + +-- Inform +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T85613896"] = "Informieren" + +-- E.g., December 10, 2 pm +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T881586300"] = "Z. B. 10. Dezember, 14 Uhr" + +-- E.g., the venue and the city, or online +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T916990010"] = "Z. B. der Veranstaltungsort und die Stadt oder online" + +-- In your own words, with the suggestions below, or still open: the chat sharpens your goal and role with you. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T917276711"] = "Ob in eigenen Worten, mit den Vorschlägen unten oder noch ganz offen: Im Chat schärfen Sie gemeinsam mit der KI Ihr Ziel und Ihre Rolle." + +-- Topic of your contribution or your specific assignment +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T932670647"] = "Thema Ihres Beitrags oder Ihr konkreter Auftrag" + +-- E.g., aggressive moderation, time pressure, or a particular counterpart +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T965551209"] = "Z. B. aggressive Moderation, Zeitdruck oder ein bestimmtes Gegenüber" + +-- Take a position in the debate +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T974131333"] = "Sich in der Debatte positionieren" + +-- Demanding +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T985014450"] = "Anspruchsvoll" + +-- ## Welcome! The BeyondFacts Lab guides you through an AI-supported process of reflection, which helps you take a position in challenging communication situations and stay able to communicate. Data protection depends on the AI provider you choose, its rules for processing and storing data, and your configuration. Enter only data that are suitable and approved for this. Please check all results critically. Depending on the AI model, the answers may differ. Hallucinations cannot be ruled out. This is a prototype, so we look forward to your feedback. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLNOTICE::T940905480"] = "## Herzlich willkommen!\n\nIm BeyondFacts Lab werden Sie durch einen geführten, KI-unterstützten Reflexionsprozess geleitet, der Ihnen dabei hilft, sich in herausfordernden Kommunikationssituationen zu positionieren und kommunikationsfähig zu bleiben.\n\nDer Datenschutz hängt vom gewählten KI-Anbieter, dessen Verarbeitungs- und Speicherregeln sowie Ihrer Konfiguration ab. Geben Sie nur Daten ein, die dafür geeignet und freigegeben sind.\n\nBitte prüfen Sie alle Ergebnisse kritisch. Die Antworten können, je nach KI-Modell, unterschiedlich ausfallen. Halluzinationen sind nicht ausgeschlossen.\n\nEs handelt sich um einen Prototyp, daher freuen wir uns über Ihr Feedback." + +-- I have read the notes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLNOTICEDIALOG::T4071737336"] = "Ich habe die Hinweise gelesen" + +-- Test situation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1049923473"] = "Testsituation" + +-- Knowledge and uncertainty +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1147870258"] = "Wissen und Unsicherheit" + +-- How you convey evidence, trade-offs between values, and open questions. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1290723890"] = "Wie Sie Evidenz, Wertabwägungen und offene Fragen vermitteln." + +-- Event details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T135387869"] = "Rahmendaten" + +-- What the event is about, for whom, and what you were asked to contribute. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T158292580"] = "Worum es bei der Veranstaltung geht, für wen sie gedacht ist und was Sie beitragen sollen." + +-- You can fill in single fields or start right away in the chat. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T2524378548"] = "Sie können einzelne Felder ausfüllen oder direkt im Chat beginnen." + +-- Goals and role +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T2988481290"] = "Ziele und Rolle" + +-- What matters to you, what touches you, and what moves others. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3022557767"] = "Was Ihnen wichtig ist, was Sie berührt und was andere bewegt." + +-- What you want to achieve, and in which role you speak. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3052319680"] = "Was Sie erreichen möchten und in welcher Rolle Sie sprechen." + +-- Strategy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3205650756"] = "Strategie" + +-- Results and materials +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3602322182"] = "Ergebnisse und Materialien" + +-- Values and perspectives +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T494574525"] = "Werte und Perspektiven" + +-- What you take away from your preparation. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T517692701"] = "Was Sie aus Ihrer Vorbereitung mitnehmen." + +-- Play through critical questions and opposing positions. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T573043138"] = "Kritische Nachfragen und Gegenpositionen durchspielen." + +-- What you know for sure, what is uncertain, and where your expertise ends. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T762525208"] = "Was Sie sicher wissen, was unsicher ist und wo Ihre Expertise endet." + +-- Some details are still missing. The chat will ask for them, and you can continue anyway. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T1474406854"] = "Einige Angaben fehlen noch. Der Chat fragt danach, und Sie können trotzdem weitermachen." + +-- Please enter an address that begins with https:// or http://. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T2837809367"] = "Bitte geben Sie eine Adresse ein, die mit https:// oder http:// beginnt." + +-- * The chat works best when you fill in these details. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T3011046288"] = "* Der Chat funktioniert am besten, wenn Sie diese Angaben ausfüllen." + -- Extended bias poster UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BIASDAY::BIASOFTHEDAYASSISTANT::T1241605514"] = "Erweitertes Poster der Verzerrungen" @@ -4416,6 +4746,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1259707710"] = "Ihre Projek -- Your temporary chats, along with their transcripts. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1293614512"] = "Ihre temporären Chats mit ihren Transkripten." +-- Your preparations in the BeyondFacts Lab. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1415474409"] = "Ihre Vorbereitungen im BeyondFacts Lab." + -- What belongs into a backup UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1682175486"] = "Was in eine Sicherung gehört" @@ -9591,6 +9924,24 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T82 -- Restore default UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Standard wiederherstellen" +-- How the lab addresses you in languages with a formal and an informal address, such as German. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1832497993"] = "Wie das Lab Sie in Sprachen anspricht, die eine formelle und eine informelle Anrede kennen, etwa im Deutschen." + +-- Assistant: BeyondFacts Lab Options +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T19824556"] = "Assistent: Optionen für das BeyondFacts Lab" + +-- Form of address +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2183481736"] = "Anrede" + +-- Select a minimum confidence level +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2579793544"] = "Wählen Sie ein minimales Vertrauensniveau aus" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3448155331"] = "Schließen" + +-- 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"] = "Ihre Vorbereitungen enthalten meist unveröffentlichte Forschung. Daher akzeptiert das Lab nur Anbieter mit mindestens dem Vertrauensniveau „Mittel“. Sie können diese Untergrenze anheben, etwa um nur selbst gehostete Modelle zu verwenden." + -- 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" @@ -11199,12 +11550,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3733831260"] = "Erstellen Sie Ihr -- Generate an ERI server to integrate business systems. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3756213118"] = "Erstellen Sie einen ERI-Server zur Integration von Geschäftssystemen." +-- Prepare step by step for a challenging appearance as a researcher, e.g., a panel discussion or an interview. +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T386812830"] = "Bereiten Sie sich Schritt für Schritt auf einen herausfordernden Auftritt mit Ihrer Forschung vor, etwa eine Podiumsdiskussion oder ein Interview." + -- Use an LLM to find an icon for a given context. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3881504200"] = "Verwenden Sie ein LLM, um ein Icon für einen bestimmten Kontext zu finden." -- Job Posting UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Stellenanzeige" +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3956097526"] = "BeyondFacts Lab" + -- Ask a question about a legal document. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Stellen Sie Fragen zu einem juristischen Dokument." @@ -12921,6 +13278,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::OUTBOUNDDATARESTRICTIONEXTENSION -- Only services configured in AI Studio UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::OUTBOUNDDATARESTRICTIONEXTENSIONS::T750634166"] = "Nur in AI Studio konfigurierte Dienste" +-- BeyondFacts Lab: Prepare for challenging conversations about your research, step by step +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1110065980"] = "BeyondFacts Lab: Sich Schritt für Schritt auf herausfordernde Gespräche über die eigene Forschung vorbereiten" + -- Visual Briefing Assistant: Turn source material into an interactive briefing UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1217946647"] = "Assistent für visuelle Briefings: Quellmaterial in ein interaktives Briefing verwandeln" @@ -13170,6 +13530,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T348883878"] = "Assisten -- Translation Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T3887962308"] = "Übersetzungs-Assistent" +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T3956097526"] = "BeyondFacts Lab" + -- Agenda Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4034795997"] = "Agenda-Assistent" 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 317b3fdb1..506decfd1 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 @@ -735,6 +735,336 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGPROMPTSOU -- Use a document analysis policy UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGPROMPTSOURCEEXTENSIONS::T3309547196"] = "Use a document analysis policy" +-- Prepare a challenging appearance as a researcher step by step, e.g., a panel discussion, an interview, or a hearing. Each preparation belongs to one event and has its own steps, chats, and work states. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1218290190"] = "Prepare a challenging appearance as a researcher step by step, e.g., a panel discussion, an interview, or a hearing. Each preparation belongs to one event and has its own steps, chats, and work states." + +-- Changed on {0} +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1279137966"] = "Changed on {0}" + +-- Delete +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1469573738"] = "Delete" + +-- AI Studio cannot read this preparation. Its files may be damaged. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1496519091"] = "AI Studio cannot read this preparation. Its files may be damaged." + +-- AI Studio cannot open your details for this step. Their file may be damaged, or a newer version of AI Studio stored it. AI Studio leaves the file unchanged. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1564342983"] = "AI Studio cannot open your details for this step. Their file may be damaged, or a newer version of AI Studio stored it. AI Studio leaves the file unchanged." + +-- You have no preparations yet. Create one for the event you want to prepare for. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1599306665"] = "You have no preparations yet. Create one for the event you want to prepare for." + +-- Enter a new name for this preparation. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T193031277"] = "Enter a new name for this preparation." + +-- Cannot be opened +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T1981873292"] = "Cannot be opened" + +-- Copy preparation ID +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2344012850"] = "Copy preparation ID" + +-- Your details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T25208484"] = "Your details" + +-- Create +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2567824509"] = "Create" + +-- Preparation ID +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2671360307"] = "Preparation ID" + +-- Create a preparation to begin. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2751865753"] = "Create a preparation to begin." + +-- AI Studio has left the files of this preparation unchanged. A future update may make it accessible again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2824385338"] = "AI Studio has left the files of this preparation unchanged. A future update may make it accessible again." + +-- The event has taken place +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T2842348392"] = "The event has taken place" + +-- Marked on {0}. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3254361846"] = "Marked on {0}." + +-- Rename preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3342203974"] = "Rename preparation" + +-- Rename +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3355849203"] = "Rename" + +-- Delete preparation permanently +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T354252674"] = "Delete preparation permanently" + +-- Unavailable preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3646236084"] = "Unavailable preparation" + +-- This preparation cannot be opened. Permanently delete it with all of its files? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3647862078"] = "This preparation cannot be opened. Permanently delete it with all of its files?" + +-- Name the event you want to prepare for, e.g., the title of a panel discussion. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3679923414"] = "Name the event you want to prepare for, e.g., the title of a panel discussion." + +-- Name of the preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3746973353"] = "Name of the preparation" + +-- Please enter a name. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3818326829"] = "Please enter a name." + +-- AI Studio could not save your details. Please try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3921500806"] = "AI Studio could not save your details. Please try again." + +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T3956097526"] = "BeyondFacts Lab" + +-- A newer version of AI Studio stored this preparation. This version cannot open it. Please update AI Studio. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T395932079"] = "A newer version of AI Studio stored this preparation. This version cannot open it. Please update AI Studio." + +-- AI Studio could not save the change of your preparations. Please try again. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T4073131406"] = "AI Studio could not save the change of your preparations. Please try again." + +-- Requires a newer AI Studio version +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T4087140083"] = "Requires a newer AI Studio version" + +-- Details missing +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T506453229"] = "Details missing" + +-- Permanently delete the preparation '{0}' with all of its steps, chats, and work states? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T772787118"] = "Permanently delete the preparation '{0}' with all of its steps, chats, and work states?" + +-- New preparation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T851005164"] = "New preparation" + +-- Your preparations +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BEYONDFACTSLABASSISTANT::T972511272"] = "Your preparations" + +-- Informal address (du in German) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLADDRESSFORMEXTENSIONS::T554958468"] = "Informal address (du in German)" + +-- Formal address (Sie in German) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLADDRESSFORMEXTENSIONS::T859201973"] = "Formal address (Sie in German)" + +-- Moderate the discussion +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1131866893"] = "Moderate the discussion" + +-- E.g., take up a confirmed conflict of values or a perspective from the step about values and perspectives +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1134014442"] = "E.g., take up a confirmed conflict of values or a perspective from the step about values and perspectives" + +-- What have you changed, kept, or left open compared to your initial position, and why? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1455147822"] = "What have you changed, kept, or left open compared to your initial position, and why?" + +-- Show options for action +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1601690165"] = "Show options for action" + +-- Manipulative questions and straw men +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1656068515"] = "Manipulative questions and straw men" + +-- Topic of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1766174370"] = "Topic of the event" + +-- Moderate +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T177463328"] = "Moderate" + +-- Other perspectives: Whose point of view would you like to understand better, or have you hardly considered so far? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1885081930"] = "Other perspectives: Whose point of view would you like to understand better, or have you hardly considered so far?" + +-- Stays in your preparation and never goes into the research on the web +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1949096445"] = "Stays in your preparation and never goes into the research on the web" + +-- Very demanding +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T1967151608"] = "Very demanding" + +-- Date and time +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2054731205"] = "Date and time" + +-- Notes on how to use your materials +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2123646094"] = "Notes on how to use your materials" + +-- Duration of your contribution +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T223853289"] = "Duration of your contribution" + +-- Special notes for the test +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2242825987"] = "Special notes for the test" + +-- Uncertainties and worries about communicating +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2428368968"] = "Uncertainties and worries about communicating" + +-- Difficulty at the start +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T243923502"] = "Difficulty at the start" + +-- E.g., 90 minutes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2537467295"] = "E.g., 90 minutes" + +-- Place +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2597472412"] = "Place" + +-- E.g., 8 minutes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2697611204"] = "E.g., 8 minutes" + +-- A few sentences are enough +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2765665158"] = "A few sentences are enough" + +-- Other participants +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T277557865"] = "Other participants" + +-- What do you want to achieve with your contribution? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2818219287"] = "What do you want to achieve with your contribution?" + +-- Scientific criticism +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2865565843"] = "Scientific criticism" + +-- The address of the event page, beginning with https:// +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T2900883219"] = "The address of the event page, beginning with https://" + +-- Names and roles, as far as you know them +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3020176946"] = "Names and roles, as far as you know them" + +-- Personal detail: it goes into exports and shared sessions only when you explicitly choose so. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3063635296"] = "Personal detail: it goes into exports and shared sessions only when you explicitly choose so." + +-- Political instrumentalization +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3078371152"] = "Political instrumentalization" + +-- Mixed +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3114856344"] = "Mixed" + +-- Personal involvement: Where does the topic touch you personally? Which question or criticism could trigger a strong reaction? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3199962614"] = "Personal involvement: Where does the topic touch you personally? Which question or criticism could trigger a strong reaction?" + +-- Format of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3217479436"] = "Format of the event" + +-- E.g., panel, keynote, or interview; online, on site, or hybrid +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3297023821"] = "E.g., panel, keynote, or interview; online, on site, or hybrid" + +-- Values and concerns: What matters most to you about this topic? +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3444467007"] = "Values and concerns: What matters most to you about this topic?" + +-- Audience +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3506118019"] = "Audience" + +-- Suggestions +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3534677498"] = "Suggestions" + +-- Emotionally charged criticism +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3552262211"] = "Emotionally charged criticism" + +-- Link to the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3658981841"] = "Link to the event" + +-- Raise awareness +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3790372874"] = "Raise awareness" + +-- E.g., use only paper X as the basis for the content +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T3804983466"] = "E.g., use only paper X as the basis for the content" + +-- Core theses and arguments (if any) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T39824489"] = "Core theses and arguments (if any)" + +-- Total duration of the event +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T4051894943"] = "Total duration of the event" + +-- Influence policy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T4052553234"] = "Influence policy" + +-- You can raise the difficulty after each question. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T519332031"] = "You can raise the difficulty after each question." + +-- E.g., students, experts, or the general public +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T533097975"] = "E.g., students, experts, or the general public" + +-- Short description of your contribution +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T667686615"] = "Short description of your contribution" + +-- Type of questions +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T805059831"] = "Type of questions" + +-- The central question or the title +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T828389221"] = "The central question or the title" + +-- Uncertainties and controversies in content and science (if known) +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T82922542"] = "Uncertainties and controversies in content and science (if known)" + +-- Inform +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T85613896"] = "Inform" + +-- E.g., December 10, 2 pm +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T881586300"] = "E.g., December 10, 2 pm" + +-- E.g., the venue and the city, or online +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T916990010"] = "E.g., the venue and the city, or online" + +-- In your own words, with the suggestions below, or still open: the chat sharpens your goal and role with you. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T917276711"] = "In your own words, with the suggestions below, or still open: the chat sharpens your goal and role with you." + +-- Topic of your contribution or your specific assignment +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T932670647"] = "Topic of your contribution or your specific assignment" + +-- E.g., aggressive moderation, time pressure, or a particular counterpart +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T965551209"] = "E.g., aggressive moderation, time pressure, or a particular counterpart" + +-- Take a position in the debate +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T974131333"] = "Take a position in the debate" + +-- Demanding +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLFIELDTEXTS::T985014450"] = "Demanding" + +-- ## Welcome! The BeyondFacts Lab guides you through an AI-supported process of reflection, which helps you take a position in challenging communication situations and stay able to communicate. Data protection depends on the AI provider you choose, its rules for processing and storing data, and your configuration. Enter only data that are suitable and approved for this. Please check all results critically. Depending on the AI model, the answers may differ. Hallucinations cannot be ruled out. This is a prototype, so we look forward to your feedback. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLNOTICE::T940905480"] = "## Welcome!\n\nThe BeyondFacts Lab guides you through an AI-supported process of reflection, which helps you take a position in challenging communication situations and stay able to communicate.\n\nData protection depends on the AI provider you choose, its rules for processing and storing data, and your configuration. Enter only data that are suitable and approved for this.\n\nPlease check all results critically. Depending on the AI model, the answers may differ. Hallucinations cannot be ruled out.\n\nThis is a prototype, so we look forward to your feedback." + +-- I have read the notes +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLNOTICEDIALOG::T4071737336"] = "I have read the notes" + +-- Test situation +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1049923473"] = "Test situation" + +-- Knowledge and uncertainty +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1147870258"] = "Knowledge and uncertainty" + +-- How you convey evidence, trade-offs between values, and open questions. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T1290723890"] = "How you convey evidence, trade-offs between values, and open questions." + +-- Event details +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T135387869"] = "Event details" + +-- What the event is about, for whom, and what you were asked to contribute. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T158292580"] = "What the event is about, for whom, and what you were asked to contribute." + +-- You can fill in single fields or start right away in the chat. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T2524378548"] = "You can fill in single fields or start right away in the chat." + +-- Goals and role +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T2988481290"] = "Goals and role" + +-- What matters to you, what touches you, and what moves others. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3022557767"] = "What matters to you, what touches you, and what moves others." + +-- What you want to achieve, and in which role you speak. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3052319680"] = "What you want to achieve, and in which role you speak." + +-- Strategy +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3205650756"] = "Strategy" + +-- Results and materials +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T3602322182"] = "Results and materials" + +-- Values and perspectives +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T494574525"] = "Values and perspectives" + +-- What you take away from your preparation. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T517692701"] = "What you take away from your preparation." + +-- Play through critical questions and opposing positions. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T573043138"] = "Play through critical questions and opposing positions." + +-- What you know for sure, what is uncertain, and where your expertise ends. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPEXTENSIONS::T762525208"] = "What you know for sure, what is uncertain, and where your expertise ends." + +-- Some details are still missing. The chat will ask for them, and you can continue anyway. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T1474406854"] = "Some details are still missing. The chat will ask for them, and you can continue anyway." + +-- Please enter an address that begins with https:// or http://. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T2837809367"] = "Please enter an address that begins with https:// or http://." + +-- * The chat works best when you fill in these details. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BEYONDFACTSLAB::BFLSTEPFORM::T3011046288"] = "* The chat works best when you fill in these details." + -- Extended bias poster UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BIASDAY::BIASOFTHEDAYASSISTANT::T1241605514"] = "Extended bias poster" @@ -4416,6 +4746,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1259707710"] = "Your projec -- Your temporary chats, along with their transcripts. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1293614512"] = "Your temporary chats, along with their transcripts." +-- Your preparations in the BeyondFacts Lab. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1415474409"] = "Your preparations in the BeyondFacts Lab." + -- What belongs into a backup UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATAFOLDERS::T1682175486"] = "What belongs into a backup" @@ -9591,6 +9924,24 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T82 -- Restore default UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Restore default" +-- How the lab addresses you in languages with a formal and an informal address, such as German. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T1832497993"] = "How the lab addresses you in languages with a formal and an informal address, such as German." + +-- Assistant: BeyondFacts Lab Options +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T19824556"] = "Assistant: BeyondFacts Lab Options" + +-- Form of address +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2183481736"] = "Form of address" + +-- Select a minimum confidence level +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T2579793544"] = "Select a minimum confidence level" + +-- Close +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBEYONDFACTSLAB::T3448155331"] = "Close" + +-- 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"] = "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." + -- Files are sent as they were first read UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T1320692831"] = "Files are sent as they were first read" @@ -11199,12 +11550,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3733831260"] = "Generate your own -- Generate an ERI server to integrate business systems. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3756213118"] = "Generate an ERI server to integrate business systems." +-- Prepare step by step for a challenging appearance as a researcher, e.g., a panel discussion or an interview. +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T386812830"] = "Prepare step by step for a challenging appearance as a researcher, e.g., a panel discussion or an interview." + -- Use an LLM to find an icon for a given context. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3881504200"] = "Use an LLM to find an icon for a given context." -- Job Posting UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3930052338"] = "Job Posting" +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3956097526"] = "BeyondFacts Lab" + -- Ask a question about a legal document. UI_TEXT_CONTENT["AISTUDIO::PAGES::ASSISTANTS::T3970214537"] = "Ask a question about a legal document." @@ -12921,6 +13278,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::OUTBOUNDDATARESTRICTIONEXTENSION -- Only services configured in AI Studio UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::OUTBOUNDDATARESTRICTIONEXTENSIONS::T750634166"] = "Only services configured in AI Studio" +-- BeyondFacts Lab: Prepare for challenging conversations about your research, step by step +UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1110065980"] = "BeyondFacts Lab: Prepare for challenging conversations about your research, step by step" + -- Visual Briefing Assistant: Turn source material into an interactive briefing UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1217946647"] = "Visual Briefing Assistant: Turn source material into an interactive briefing" @@ -13170,6 +13530,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T348883878"] = "Document -- Translation Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T3887962308"] = "Translation Assistant" +-- BeyondFacts Lab +UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T3956097526"] = "BeyondFacts Lab" + -- Agenda Assistant UI_TEXT_CONTENT["AISTUDIO::TOOLS::COMPONENTSEXTENSIONS::T4034795997"] = "Agenda Assistant" diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 6d8662de4..c95852ea7 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -1,5 +1,6 @@ using AIStudio.Agents; using AIStudio.Agents.AssistantAudit; +using AIStudio.Assistants.BeyondFactsLab; using AIStudio.Assistants.VisualBriefing; using AIStudio.Settings; using AIStudio.Tools.ToolCallingSystem; @@ -229,6 +230,7 @@ internal sealed class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/app/MindWork AI Studio/Routes.razor.cs b/app/MindWork AI Studio/Routes.razor.cs index 49a343b3b..343870a0b 100644 --- a/app/MindWork AI Studio/Routes.razor.cs +++ b/app/MindWork AI Studio/Routes.razor.cs @@ -27,6 +27,7 @@ public sealed partial class Routes public const string ASSISTANT_SYNONYMS = "/assistant/synonyms"; public const string ASSISTANT_SLIDE_BUILDER = "/assistant/slide-builder"; public const string ASSISTANT_VISUAL_BRIEFING = "/assistant/visual-briefing"; + public const string ASSISTANT_BEYOND_FACTS_LAB = "/assistant/beyondfacts-lab"; public const string ASSISTANT_MY_TASKS = "/assistant/my-tasks"; public const string ASSISTANT_JOB_POSTING = "/assistant/job-posting"; public const string ASSISTANT_BIAS = "/assistant/bias-of-the-day"; diff --git a/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs b/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs index 250fafe4c..c70f320c2 100644 --- a/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs +++ b/app/MindWork AI Studio/Settings/ConfigurableAssistant.cs @@ -29,6 +29,7 @@ public enum ConfigurableAssistant LOG_VIEWER_ASSISTANT, VISUAL_BRIEFING_ASSISTANT, BATCH_PROCESSING_ASSISTANT, + BEYOND_FACTS_LAB_ASSISTANT, // ReSharper disable InconsistentNaming I18N_ASSISTANT, diff --git a/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs b/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs index 0de0745f1..142ad69c6 100644 --- a/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs +++ b/app/MindWork AI Studio/Settings/ConfigurationSelectDataFactory.cs @@ -1,4 +1,5 @@ using AIStudio.Assistants.Agenda; +using AIStudio.Assistants.BeyondFactsLab; using AIStudio.Assistants.Coding; using AIStudio.Assistants.IconFinder; using AIStudio.Assistants.RewriteImprove; @@ -315,6 +316,27 @@ public static class ConfigurationSelectDataFactory } } + /// + /// Gets the minimum confidence levels the BeyondFacts Lab offers: none below its floor. + /// + /// The settings, whose enforced global minimum may lift the floor. + public static IEnumerable> GetBeyondFactsLabConfidenceLevelsData(SettingsManager settingsManager) + { + var minimumLevel = DataBeyondFactsLab.MINIMUM_CONFIDENCE_FLOOR; + if (settingsManager.ConfigurationData.Confidence is { EnforceGlobalMinimumConfidence: true } && settingsManager.ConfigurationData.Confidence.GlobalMinimumConfidence > minimumLevel) + minimumLevel = settingsManager.ConfigurationData.Confidence.GlobalMinimumConfidence; + + foreach (var level in Enum.GetValues()) + if (level >= minimumLevel) + yield return new(level.Name, level); + } + + public static IEnumerable> GetBeyondFactsLabAddressFormData() + { + foreach (var addressForm in Enum.GetValues()) + yield return new(addressForm.Name, addressForm); + } + public static IEnumerable> GetDataSourceConfidenceLevelsData() { foreach (var level in Enum.GetValues()) diff --git a/app/MindWork AI Studio/Settings/DataModel/Data.cs b/app/MindWork AI Studio/Settings/DataModel/Data.cs index 615edf7cd..36b0c969d 100644 --- a/app/MindWork AI Studio/Settings/DataModel/Data.cs +++ b/app/MindWork AI Studio/Settings/DataModel/Data.cs @@ -217,6 +217,11 @@ public sealed class Data /// Gets the managed Visual Briefing Assistant defaults. /// public DataVisualBriefing VisualBriefing { get; set; } = new(x => x.VisualBriefing); + + /// + /// Gets the managed settings of the BeyondFacts Lab. + /// + public DataBeyondFactsLab BeyondFactsLab { get; set; } = new(x => x.BeyondFactsLab); public DataLegalCheck LegalCheck { get; set; } = new(); diff --git a/app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs b/app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs new file mode 100644 index 000000000..3adb9491c --- /dev/null +++ b/app/MindWork AI Studio/Settings/DataModel/DataBeyondFactsLab.cs @@ -0,0 +1,56 @@ +using System.Linq.Expressions; + +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Provider; + +namespace AIStudio.Settings.DataModel; + +/// +/// Stores the settings of the BeyondFacts Lab, which an organization may manage. +/// +/// The managed-configuration selector. +public sealed class DataBeyondFactsLab(Expression>? configSelection = null) +{ + /// + /// The lowest minimum confidence the BeyondFacts Lab accepts, whatever the person or their organization configures. + /// + /// + /// Unpublished research is the usual material of the lab. The built-in confidence schemes give this level + /// to the cloud providers of the region one trusts and to self-hosted models; HIGH alone would leave only + /// self-hosted models. A higher minimum of the person or the organization wins. + /// + public const ConfidenceLevel MINIMUM_CONFIDENCE_FLOOR = ConfidenceLevel.MEDIUM; + + /// + /// Initializes unmanaged settings of the BeyondFacts Lab. + /// + public DataBeyondFactsLab() : this(null) + { + } + + /// + /// Gets or sets the provider which new preparations start with. + /// + public string PreselectedProvider { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedProvider, string.Empty); + + /// + /// Gets or sets the minimum confidence a provider needs for the lab. + /// + /// + /// A value below MINIMUM_CONFIDENCE_FLOOR has no effect, see ComponentsExtensions.MinimumConfidence. + /// + public ConfidenceLevel MinimumProviderConfidence { get; set; } = ManagedConfiguration.Register(configSelection, value => value.MinimumProviderConfidence, MINIMUM_CONFIDENCE_FLOOR); + + /// + /// Gets or sets how the lab addresses the person in languages with a formal and an informal address. + /// + public BflAddressForm AddressForm { get; set; } = ManagedConfiguration.Register(configSelection, value => value.AddressForm, BflAddressForm.FORMAL); + + /// + /// Gets or sets the hash of the notes the person confirmed, see BflNotice; empty while they confirmed none. + /// + /// + /// Only the person can confirm the notes, so an organization cannot manage this setting. + /// + public string AcceptedNoticeHash { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs index a791409ba..a019e31fe 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewFeatures.cs @@ -19,4 +19,5 @@ public enum PreviewFeatures PRE_VISUAL_BRIEFING_ASSISTANT_2026, PRE_MAILBOXES_2026, PRE_MATHEMATICS_2026, + PRE_BEYOND_FACTS_LAB_2026, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs index 38a6a10b5..e4d83a85e 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewFeaturesExtensions.cs @@ -22,6 +22,7 @@ public static class PreviewFeaturesExtensions PreviewFeatures.PRE_VISUAL_BRIEFING_ASSISTANT_2026 => TB("Visual Briefing Assistant: Turn source material into an interactive briefing"), PreviewFeatures.PRE_MAILBOXES_2026 => TB("Mailboxes: Let the AI search and read your e-mails, kept in a local index on your computer"), PreviewFeatures.PRE_MATHEMATICS_2026 => TB("Mathematics: Let the AI calculate exactly and work with formulas, right on your computer"), + PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026 => TB("BeyondFacts Lab: Prepare for challenging conversations about your research, step by step"), _ => TB("Unknown preview feature") }; diff --git a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs index 161e30863..0943316aa 100644 --- a/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs +++ b/app/MindWork AI Studio/Settings/DataModel/PreviewVisibilityExtensions.cs @@ -27,6 +27,7 @@ public static class PreviewVisibilityExtensions { features.Add(PreviewFeatures.PRE_MAILBOXES_2026); features.Add(PreviewFeatures.PRE_MATHEMATICS_2026); + features.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026); } if (visibility >= PreviewVisibility.EXPERIMENTAL) diff --git a/app/MindWork AI Studio/Tools/AppResources.cs b/app/MindWork AI Studio/Tools/AppResources.cs new file mode 100644 index 000000000..ae97149b4 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AppResources.cs @@ -0,0 +1,64 @@ +using System.Text; + +using Microsoft.Extensions.FileProviders; + +#if RELEASE +using System.Reflection; +#endif + +namespace AIStudio.Tools; + +/// +/// Reads files which come with AI Studio, such as prompts, guides, or the built-in plugins. +/// +/// +/// The files lie below the project folder and are listed as EmbeddedResource in the project file. A development +/// build reads them from the disk, so that a change shows without building again; a release reads them from the +/// resources embedded into the app. Paths are relative to the project folder and use forward slashes. +/// +public static class AppResources +{ + /// + /// Gets a file provider for a folder of the app. + /// + /// The folder, relative to the project folder, e.g., "Plugins". + /// The file provider, whose paths are relative to that folder. + public static IFileProvider FolderProvider(string relativeFolder) + { + var folder = Normalize(relativeFolder); +#if DEBUG + return new PhysicalFileProvider(Path.Join(Environment.CurrentDirectory, folder)); +#else + return new ManifestEmbeddedFileProvider(Assembly.GetAssembly(typeof(Program))!, folder); +#endif + } + + /// + /// Reads a text file of the app. + /// + /// The file, relative to the project folder, e.g., "Assistants/PromptOptimizer/prompting_guideline.md". + /// The cancellation token. + /// The text, or an empty string when the app has no such file. + public static async Task ReadTextAsync(string relativePath, CancellationToken token = default) + { + var path = Normalize(relativePath); +#if DEBUG + var filePath = Path.Join(Environment.CurrentDirectory, path); + return File.Exists(filePath) ? await File.ReadAllTextAsync(filePath, Encoding.UTF8, token) : string.Empty; +#else + var file = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(typeof(Program))!).GetFileInfo(path); + if (!file.Exists || file.IsDirectory) + return string.Empty; + + await using var stream = file.CreateReadStream(); + using var reader = new StreamReader(stream, Encoding.UTF8); + return await reader.ReadToEndAsync(token); +#endif + } + + /// + /// Turns a path into the form both kinds of builds understand: forward slashes, without a slash at either end. + /// + /// The path. + private static string Normalize(string relativePath) => relativePath.Replace('\\', '/').Trim('/'); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs b/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs index bcc5b77f6..fd2e8b689 100644 --- a/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs +++ b/app/MindWork AI Studio/Tools/AssistantVisibilityExtensions.cs @@ -72,6 +72,7 @@ public static class AssistantVisibilityExtensions Components.BATCH_PROCESSING_ASSISTANT => ConfigurableAssistant.BATCH_PROCESSING_ASSISTANT, Components.SLIDE_BUILDER_ASSISTANT => ConfigurableAssistant.SLIDE_BUILDER_ASSISTANT, Components.VISUAL_BRIEFING_ASSISTANT => ConfigurableAssistant.VISUAL_BRIEFING_ASSISTANT, + Components.BEYOND_FACTS_LAB_ASSISTANT => ConfigurableAssistant.BEYOND_FACTS_LAB_ASSISTANT, Components.I18N_ASSISTANT => ConfigurableAssistant.I18N_ASSISTANT, Components.LOG_VIEWER_ASSISTANT => ConfigurableAssistant.LOG_VIEWER_ASSISTANT, diff --git a/app/MindWork AI Studio/Tools/Components.cs b/app/MindWork AI Studio/Tools/Components.cs index b15c670c7..c66605b03 100644 --- a/app/MindWork AI Studio/Tools/Components.cs +++ b/app/MindWork AI Studio/Tools/Components.cs @@ -43,4 +43,5 @@ public enum Components LOG_VIEWER_ASSISTANT, VISUAL_BRIEFING_ASSISTANT, BATCH_PROCESSING_ASSISTANT, + BEYOND_FACTS_LAB_ASSISTANT, } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs index 11efdb1bf..980e7ff84 100644 --- a/app/MindWork AI Studio/Tools/ComponentsExtensions.cs +++ b/app/MindWork AI Studio/Tools/ComponentsExtensions.cs @@ -22,6 +22,7 @@ public static class ComponentsExtensions public PreviewFeatures RequiredPreviewFeature => component switch { Components.VISUAL_BRIEFING_ASSISTANT => PreviewFeatures.PRE_VISUAL_BRIEFING_ASSISTANT_2026, + Components.BEYOND_FACTS_LAB_ASSISTANT => PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026, _ => PreviewFeatures.NONE, }; @@ -33,15 +34,16 @@ public static class ComponentsExtensions /// /// Components return false for three different reasons. The chat has no assistant sessions /// at all. Dynamic assistants key their sessions per plugin. The visual briefing assistant keys - /// its sessions per briefing, so it owns one slot per stored briefing rather than one per - /// component. All must be excluded from the single-slot checks, which is why this is a - /// capability and not a component comparison. + /// its sessions per briefing, and the BeyondFacts Lab per step of a preparation, so they own one + /// slot per stored item rather than one per component. All must be excluded from the single-slot + /// checks, which is why this is a capability and not a component comparison. /// /// true when the component owns exactly one session slot. public bool HasSingleSessionSlot => component switch { Components.CHAT or Components.DYNAMIC_ASSISTANT => false, Components.VISUAL_BRIEFING_ASSISTANT => false, + Components.BEYOND_FACTS_LAB_ASSISTANT => false, _ => true, }; @@ -68,7 +70,10 @@ public static class ComponentsExtensions Components.DOCUMENT_ANALYSIS_ASSISTANT => false, Components.BATCH_PROCESSING_ASSISTANT => false, Components.LOG_VIEWER_ASSISTANT => false, - + + // The lab takes in nothing from other places, and its answers stay in its own steps: + Components.BEYOND_FACTS_LAB_ASSISTANT => false, + Components.APP_SETTINGS => false, Components.WRITER => false, @@ -105,7 +110,8 @@ public static class ComponentsExtensions Components.VISUAL_BRIEFING_ASSISTANT => TB("Visual Briefing Assistant"), Components.META_ASSISTANT => TB("Assistant Builder"), Components.LOG_VIEWER_ASSISTANT => TB("Log Viewer Assistant"), - + Components.BEYOND_FACTS_LAB_ASSISTANT => TB("BeyondFacts Lab"), + Components.CHAT => TB("New Chat"), _ => Enum.GetName(component)!, @@ -157,7 +163,10 @@ public static class ComponentsExtensions Components.SLIDE_BUILDER_ASSISTANT => settingsManager.ConfigurationData.SlideBuilder.PreselectOptions ? settingsManager.ConfigurationData.SlideBuilder.MinimumProviderConfidence : default, Components.META_ASSISTANT => settingsManager.ConfigurationData.AssistantBuilder.PreselectOptions ? settingsManager.ConfigurationData.AssistantBuilder.MinimumProviderConfidence : default, Components.VISUAL_BRIEFING_ASSISTANT => settingsManager.ConfigurationData.VisualBriefing.MinimumProviderConfidence, - + + // Unpublished research is the usual material of the lab, so its minimum never drops below the floor: + Components.BEYOND_FACTS_LAB_ASSISTANT => settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence > DataBeyondFactsLab.MINIMUM_CONFIDENCE_FLOOR ? settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence : DataBeyondFactsLab.MINIMUM_CONFIDENCE_FLOOR, + // The minimum confidence for the Document Analysis Assistant is set per policy. // We do this inside the Document Analysis Assistant component: Components.DOCUMENT_ANALYSIS_ASSISTANT => ConfidenceLevel.NONE, @@ -191,6 +200,7 @@ public static class ComponentsExtensions Components.SLIDE_BUILDER_ASSISTANT => settingsManager.ConfigurationData.SlideBuilder.PreselectOptions ? settingsManager.GetProviderById(settingsManager.ConfigurationData.SlideBuilder.PreselectedProvider) : Settings.Provider.NONE, Components.META_ASSISTANT => settingsManager.ConfigurationData.AssistantBuilder.PreselectOptions ? settingsManager.GetProviderById(settingsManager.ConfigurationData.AssistantBuilder.PreselectedProvider) : Settings.Provider.NONE, Components.VISUAL_BRIEFING_ASSISTANT => settingsManager.GetProviderById(settingsManager.ConfigurationData.VisualBriefing.PreselectedProvider), + Components.BEYOND_FACTS_LAB_ASSISTANT => settingsManager.GetProviderById(settingsManager.ConfigurationData.BeyondFactsLab.PreselectedProvider), // The Document Analysis Assistant does not have a preselected provider at the component level. // The provider is selected per policy instead. We do this inside the Document Analysis Assistant component. @@ -229,6 +239,9 @@ public static class ComponentsExtensions // The profile is selected per policy instead. We do this inside the Document Analysis Assistant component: Components.DOCUMENT_ANALYSIS_ASSISTANT => Profile.NO_PROFILE.Id, + // The lab works with what the person enters in each preparation and never with a profile: + Components.BEYOND_FACTS_LAB_ASSISTANT => Profile.NO_PROFILE.Id, + _ => string.Empty, }; diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataBackupContents.cs b/app/MindWork AI Studio/Tools/DataBackup/DataBackupContents.cs index ce08fcdf5..0b114b43e 100644 --- a/app/MindWork AI Studio/Tools/DataBackup/DataBackupContents.cs +++ b/app/MindWork AI Studio/Tools/DataBackup/DataBackupContents.cs @@ -45,6 +45,7 @@ public static class DataBackupContents new(DataBackupLocation.DATA_DIRECTORY, PLUGINS_FOLDER, DataBackupEntryKind.PLUGINS), new(DataBackupLocation.DATA_DIRECTORY, "tokenizers/", DataBackupEntryKind.TOKENIZERS), new(DataBackupLocation.DATA_DIRECTORY, "visualBriefings/", DataBackupEntryKind.VISUAL_BRIEFINGS), + new(DataBackupLocation.DATA_DIRECTORY, "beyondFactsLab/", DataBackupEntryKind.BEYOND_FACTS_LAB), new(DataBackupLocation.DATA_DIRECTORY, "databases/sqlite/", DataBackupEntryKind.DATA_SOURCE_INDEX), new(DataBackupLocation.DATA_DIRECTORY, "databases/vector_database/stores/", DataBackupEntryKind.VECTOR_STORES), new(DataBackupLocation.DATA_DIRECTORY, "mediaTranscripts/", DataBackupEntryKind.MEDIA_TRANSCRIPTS), diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntry.cs b/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntry.cs index 49f8f8f53..05fba0ed1 100644 --- a/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntry.cs +++ b/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntry.cs @@ -18,6 +18,7 @@ public sealed record DataBackupEntry(DataBackupLocation Location, string Relativ or DataBackupEntryKind.PLUGINS or DataBackupEntryKind.TOKENIZERS or DataBackupEntryKind.VISUAL_BRIEFINGS + or DataBackupEntryKind.BEYOND_FACTS_LAB or DataBackupEntryKind.DATA_SOURCE_INDEX or DataBackupEntryKind.VECTOR_STORES or DataBackupEntryKind.MEDIA_TRANSCRIPTS; diff --git a/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntryKind.cs b/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntryKind.cs index b26ad03de..449bba26c 100644 --- a/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntryKind.cs +++ b/app/MindWork AI Studio/Tools/DataBackup/DataBackupEntryKind.cs @@ -41,6 +41,11 @@ public enum DataBackupEntryKind /// VISUAL_BRIEFINGS, + /// + /// The preparations of the BeyondFacts Lab. + /// + BEYOND_FACTS_LAB, + /// /// The SQLite index of the data sources: which files are embedded, and their text chunks. /// diff --git a/app/MindWork AI Studio/Tools/PluginSystem/ILangExtensions.cs b/app/MindWork AI Studio/Tools/PluginSystem/ILangExtensions.cs index 515a1e9fb..c8537dfec 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/ILangExtensions.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/ILangExtensions.cs @@ -19,7 +19,7 @@ public static class ILangExtensions typeName = typeName[..typeName.IndexOf('`')]; var ns = $"{typeNamespace}::{typeName}".ToUpperInvariant().Replace(".", "::"); - var key = $"root::{ns}::T{fallbackEN.ToFNV32()}"; + var key = $"root::{ns}::T{I18NKeyHash.Of(fallbackEN)}"; if(plugin is NoPluginLanguage) return fallbackEN; diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index 6a462a2a2..7fbde6747 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -516,6 +516,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.OptimizeImages, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun); + // Config: BeyondFacts Lab defaults? + 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); + // Config: Assistant Builder defaults? ManagedConfiguration.TryProcessConfiguration(x => x.AssistantBuilder, x => x.PreselectOptions, this.Id, settingsTable, dryRun); ManagedConfiguration.TryProcessConfiguration(x => x.AssistantBuilder, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs index d9b98334d..0b619a2fd 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Internal.cs @@ -1,9 +1,5 @@ using Microsoft.Extensions.FileProviders; -#if RELEASE -using System.Reflection; -#endif - namespace AIStudio.Tools.PluginSystem; public static partial class PluginFactory @@ -48,13 +44,7 @@ public static partial class PluginFactory { try { - #if DEBUG - var basePath = Path.Join(Environment.CurrentDirectory, "Plugins"); - var resourceFileProvider = new PhysicalFileProvider(basePath); - #else - var resourceFileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "Plugins"); - #endif - + var resourceFileProvider = AppResources.FolderProvider("Plugins"); var metaData = plugin.MetaData(); var mainResourcePath = $"{metaData.ResourcePath}/plugin.lua"; var resourceInfo = resourceFileProvider.GetFileInfo(mainResourcePath); diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs index 5372acd25..5e5fb78d3 100644 --- a/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs +++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginGenerationService.cs @@ -1,7 +1,3 @@ -// ReSharper disable RedundantUsingDirective -using System.Reflection; -using Microsoft.Extensions.FileProviders; -// ReSharper restore RedundantUsingDirective using System.Text; using System.Text.Json; using AIStudio.Assistants.Builder; @@ -255,7 +251,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry, foreach (var contextFile in ASSISTANT_CONTEXT_FILES) { - var content = await ReadAppResourceTextAsync(contextFile.RelativePath); + var content = await AppResources.ReadTextAsync(contextFile.RelativePath); if (string.IsNullOrWhiteSpace(content)) { logger.LogError($"The context for \"{contextFile.Title}\" could not be read from the assembly. Path: {contextFile.RelativePath}"); @@ -713,7 +709,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry, private async Task LoadLuaResponseSchemaAsync() { - var responseSchema = await ReadAppResourceTextAsync(LUA_RESPONSE_SCHEMA_PATH); + var responseSchema = await AppResources.ReadTextAsync(LUA_RESPONSE_SCHEMA_PATH); if (!string.IsNullOrWhiteSpace(responseSchema)) return responseSchema.Trim(); @@ -743,26 +739,6 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry, return builder.ToString().Trim(); } - private static async Task ReadAppResourceTextAsync(string relativePath) - { - relativePath = relativePath.Replace('\\', '/'); -#if DEBUG - var filePath = Path.Join(Environment.CurrentDirectory, relativePath); - return File.Exists(filePath) - ? await File.ReadAllTextAsync(filePath) - : string.Empty; -#else - var provider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!); - var file = provider.GetFileInfo(relativePath); - if (!file.Exists) - return string.Empty; - - await using var stream = file.CreateReadStream(); - using var reader = new StreamReader(stream, Encoding.UTF8); - return await reader.ReadToEndAsync(); -#endif - } - private static bool ProviderIsUsable(ProviderSettings provider) => provider != ProviderSettings.NONE && provider.UsedLLMProvider is not LLMProviders.NONE; private static bool IsValidChatLaunchRequest(AssistantBuilderChatLaunchRequest? launch) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md index 7986039af..7ba5230b7 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.11.2.md @@ -1 +1,2 @@ # v26.11.2, build 261 (2026-11-xx xx:xx UTC) +- Added the BeyondFacts Lab as a prototype. It helps researchers prepare step by step for a challenging appearance, such as a panel discussion, an interview, or a hearing. Each event gets a preparation of its own, which leads you through seven steps, from the details of the event to what you take away, and shows you which key details are still missing. Your preparations are part of your data backups. To try it, open the app settings, allow preview features down to prototype, and enable the BeyondFacts Lab. Thanks to Lara Wüster from the Technical University of Munich (TUM) for the concept and the content of the lab. \ No newline at end of file diff --git a/app/SharedTools/CSharpStringLiteral.cs b/app/SharedTools/CSharpStringLiteral.cs new file mode 100644 index 000000000..bc613ace9 --- /dev/null +++ b/app/SharedTools/CSharpStringLiteral.cs @@ -0,0 +1,320 @@ +using System.Globalization; +using System.Text; + +namespace SharedTools; + +/// +/// Reads a C# string literal from source code and gives the string the compiler makes of it. +/// +/// +/// The I18N collector reads the texts of T() and TB() from the source files, and the app looks up their +/// translations by a hash of the string it gets at runtime. Both have to agree on every character, so this reader +/// follows the compiler: escape sequences of regular literals, doubled quotes of verbatim literals, and the removed +/// indentation of multi-line raw literals. Interpolated literals are not read, since their text is known only at +/// runtime. A line break in the source stays as it is; I18NKeyHash makes line endings equal. +/// +public static class CSharpStringLiteral +{ + /// + /// Reads the literal at the start of the source. + /// + /// The source, starting with the first quote of the literal, or with the @ of a verbatim literal. + /// The string the compiler makes of the literal. + /// The number of characters the literal takes in the source. + /// Whether the source starts with a complete literal this reader understands. + public static bool TryRead(ReadOnlySpan source, out string value, out int length) + { + value = string.Empty; + length = 0; + if (source.IsEmpty) + return false; + + if (source[0] == '@') + return TryReadVerbatim(source, out value, out length); + + if (source[0] != '"') + return false; + + var quotes = CountQuotes(source); + if (quotes >= 3) + return TryReadRaw(source, quotes, out value, out length); + + if (quotes == 2) + { + length = 2; + return true; + } + + return TryReadRegular(source, out value, out length); + } + + private static bool TryReadRegular(ReadOnlySpan source, out string value, out int length) + { + value = string.Empty; + length = 0; + + var builder = new StringBuilder(); + for (var index = 1; index < source.Length; index++) + { + var character = source[index]; + switch (character) + { + case '"': + value = builder.ToString(); + length = index + 1; + return true; + + case '\r' or '\n': + return false; + + case '\\': + if (!TryReadEscape(source, ref index, builder)) + return false; + + break; + + default: + builder.Append(character); + break; + } + } + + return false; + } + + /// + /// Reads the escape sequence whose backslash is at the index, and leaves the index at its last character. + /// + private static bool TryReadEscape(ReadOnlySpan source, ref int index, StringBuilder builder) + { + if (++index >= source.Length) + return false; + + switch (source[index]) + { + case '\'': builder.Append('\''); return true; + case '"': builder.Append('"'); return true; + case '\\': builder.Append('\\'); return true; + case '0': builder.Append('\0'); return true; + case 'a': builder.Append('\a'); return true; + case 'b': builder.Append('\b'); return true; + case 'e': builder.Append('\u001b'); return true; + case 'f': builder.Append('\f'); return true; + case 'n': builder.Append('\n'); return true; + case 'r': builder.Append('\r'); return true; + case 't': builder.Append('\t'); return true; + case 'v': builder.Append('\v'); return true; + + case 'u': + if (!TryReadHex(source, index + 1, 4, 4, out var utf16, out var utf16Digits)) + return false; + + builder.Append((char)utf16); + index += utf16Digits; + return true; + + case 'U': + if (!TryReadHex(source, index + 1, 8, 8, out var utf32, out var utf32Digits) || utf32 > 0x10FFFF) + return false; + + builder.Append(char.ConvertFromUtf32(utf32)); + index += utf32Digits; + return true; + + case 'x': + if (!TryReadHex(source, index + 1, 1, 4, out var variable, out var variableDigits)) + return false; + + builder.Append((char)variable); + index += variableDigits; + return true; + + default: + return false; + } + } + + private static bool TryReadHex(ReadOnlySpan source, int start, int minDigits, int maxDigits, out int value, out int digits) + { + value = 0; + digits = 0; + while (digits < maxDigits && start + digits < source.Length && char.IsAsciiHexDigit(source[start + digits])) + digits++; + + return digits >= minDigits && int.TryParse(source.Slice(start, digits), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out value); + } + + private static bool TryReadVerbatim(ReadOnlySpan source, out string value, out int length) + { + value = string.Empty; + length = 0; + if (source.Length < 2 || source[1] != '"') + return false; + + var builder = new StringBuilder(); + for (var index = 2; index < source.Length; index++) + { + if (source[index] != '"') + { + builder.Append(source[index]); + continue; + } + + // Two quotes stand for one: + if (index + 1 < source.Length && source[index + 1] == '"') + { + builder.Append('"'); + index++; + continue; + } + + value = builder.ToString(); + length = index + 1; + return true; + } + + return false; + } + + private static bool TryReadRaw(ReadOnlySpan source, int quotes, out string value, out int length) + { + value = string.Empty; + length = 0; + + // A raw literal spans several lines when nothing but whitespace follows its opening quotes: + var afterOpening = quotes; + while (afterOpening < source.Length && IsWhitespace(source[afterOpening])) + afterOpening++; + + return afterOpening < source.Length && IsLineBreak(source[afterOpening]) + ? TryReadMultiLineRaw(source, quotes, afterOpening, out value, out length) + : TryReadSingleLineRaw(source, quotes, out value, out length); + } + + private static bool TryReadSingleLineRaw(ReadOnlySpan source, int quotes, out string value, out int length) + { + value = string.Empty; + length = 0; + for (var index = quotes; index < source.Length; index++) + { + if (IsLineBreak(source[index])) + return false; + + if (source[index] != '"') + continue; + + var run = CountQuotes(source[index..]); + if (run < quotes) + { + index += run - 1; + continue; + } + + if (run > quotes || index == quotes) + return false; + + value = source[quotes..index].ToString(); + length = index + quotes; + return true; + } + + return false; + } + + private static bool TryReadMultiLineRaw(ReadOnlySpan source, int quotes, int lineBreakAfterOpening, out string value, out int length) + { + value = string.Empty; + length = 0; + + var lines = new List<(int Start, int End, int LineBreakLength)>(); + var lineStart = lineBreakAfterOpening + LineBreakLength(source, lineBreakAfterOpening); + while (lineStart <= source.Length) + { + var lineEnd = lineStart; + while (lineEnd < source.Length && !IsLineBreak(source[lineEnd])) + lineEnd++; + + // Is this the line which closes the literal? + var indentationEnd = lineStart; + while (indentationEnd < lineEnd && IsWhitespace(source[indentationEnd])) + indentationEnd++; + + if (indentationEnd < lineEnd && source[indentationEnd] == '"') + { + var run = CountQuotes(source[indentationEnd..lineEnd]); + if (run >= quotes) + { + if (run > quotes || lines.Count == 0) + return false; + + var indentation = source[lineStart..indentationEnd]; + if (!TryRemoveIndentation(source, lines, indentation, out value)) + return false; + + length = indentationEnd + quotes; + return true; + } + } + + if (lineEnd >= source.Length) + return false; + + var lineBreakLength = LineBreakLength(source, lineEnd); + lines.Add((lineStart, lineEnd, lineBreakLength)); + lineStart = lineEnd + lineBreakLength; + } + + return false; + } + + /// + /// Joins the content lines of a multi-line raw literal without the indentation of its closing line, which every + /// line has to start with. A line of whitespace only may be shorter, and becomes empty then. + /// + private static bool TryRemoveIndentation(ReadOnlySpan source, List<(int Start, int End, int LineBreakLength)> lines, ReadOnlySpan indentation, out string value) + { + value = string.Empty; + var builder = new StringBuilder(); + for (var index = 0; index < lines.Count; index++) + { + var (start, end, lineBreakLength) = lines[index]; + var line = source[start..end]; + if (line.StartsWith(indentation, StringComparison.Ordinal)) + builder.Append(line[indentation.Length..]); + else if (!IsWhitespaceOnly(line) || line.Length > indentation.Length) + return false; + + // The line break after the last content line belongs to the closing line: + if (index < lines.Count - 1) + builder.Append(source.Slice(end, lineBreakLength)); + } + + value = builder.ToString(); + return true; + } + + private static int CountQuotes(ReadOnlySpan source) + { + var count = 0; + while (count < source.Length && source[count] == '"') + count++; + + return count; + } + + private static int LineBreakLength(ReadOnlySpan source, int index) => + source[index] == '\r' && index + 1 < source.Length && source[index + 1] == '\n' ? 2 : 1; + + private static bool IsLineBreak(char character) => character is '\r' or '\n' or '\u0085' or '\u2028' or '\u2029'; + + private static bool IsWhitespace(char character) => character is not ('\r' or '\n' or '\u0085' or '\u2028' or '\u2029') && char.IsWhiteSpace(character); + + private static bool IsWhitespaceOnly(ReadOnlySpan line) + { + foreach (var character in line) + if (!IsWhitespace(character)) + return false; + + return true; + } +} \ No newline at end of file diff --git a/app/SharedTools/I18NKeyHash.cs b/app/SharedTools/I18NKeyHash.cs new file mode 100644 index 000000000..1b86509eb --- /dev/null +++ b/app/SharedTools/I18NKeyHash.cs @@ -0,0 +1,27 @@ +namespace SharedTools; + +/// +/// Computes the hash which turns a text of T() or TB() into its I18N key. +/// +/// +/// The I18N collector and the app both use it: the collector for the text it reads from the source, the app for the +/// string it gets at runtime. A multi-line text holds the line endings of the checkout it was compiled from, so the +/// hash makes them equal first; otherwise, a build from a Windows checkout would find no translation for such a +/// text. Texts without line breaks keep the hash they always had. +/// +public static class I18NKeyHash +{ + /// + /// Computes the hash of a text. + /// + /// The text, as T() or TB() gets it. + /// The 32-bit FNV-1a hash of the text with LF line endings. + public static uint Of(string text) => NormalizeLineEndings(text).ToFNV32(); + + /// + /// Turns CRLF and CR line endings into LF. + /// + /// The text. + /// The text with LF line endings, or the text itself when it holds no CR. + public static string NormalizeLineEndings(string text) => text.Contains('\r') ? text.Replace("\r\n", "\n").Replace('\r', '\n') : text; +} \ No newline at end of file diff --git a/app/SharedTools/I18NSourceScanner.cs b/app/SharedTools/I18NSourceScanner.cs new file mode 100644 index 000000000..547b9866a --- /dev/null +++ b/app/SharedTools/I18NSourceScanner.cs @@ -0,0 +1,95 @@ +namespace SharedTools; + +/// +/// Finds the texts of T() and TB() in a C# or Razor source file. +/// +/// +/// A text counts when its literal follows the opening parenthesis right away and is the whole first argument. +/// Every kind of literal works, also a raw literal over several lines; see CSharpStringLiteral. +/// +public static class I18NSourceScanner +{ + private static readonly string[] START_TAGS = ["TB(", "T("]; + + /// + /// Finds the texts in the content of a source file. + /// + /// The content of the file. + /// The path of the file, for the warnings. + /// Collects a warning for each call whose text cannot be translated. + /// The texts, in the order of the file. + public static IReadOnlyList FindTexts(string content, string filePath, ICollection warnings) + { + var texts = new List(); + var position = 0; + while (TryFindNextCall(content, position, out var literalStart)) + { + position = literalStart; + var rest = content.AsSpan(literalStart); + if (rest.StartsWith("$") || rest.StartsWith("@$")) + { + warnings.Add($"- Warning: An interpolated text in the file '{filePath}' at position {literalStart} cannot be translated, since its text is known only at runtime."); + continue; + } + + if (!rest.StartsWith("\"") && !rest.StartsWith("@\"")) + continue; + + if (!CSharpStringLiteral.TryRead(rest, out var text, out var length)) + { + warnings.Add($"- Warning: The text in the file '{filePath}' at position {literalStart} is no complete string literal."); + continue; + } + + position = literalStart + length; + var next = rest[length..].TrimStart(); + if (next.IsEmpty || next[0] is not (')' or ',')) + { + warnings.Add($"- Warning: The text '{text}' in the file '{filePath}' is not the whole first argument, e.g., because it is concatenated. Its key does not match the key the app looks up at runtime."); + continue; + } + + texts.Add(new I18NSourceText(literalStart, length, text)); + } + + return texts; + } + + /// + /// Finds the next call of T() or TB() at or after the position, i.e., a start tag which no identifier precedes. + /// + private static bool TryFindNextCall(string content, int position, out int argumentStart) + { + argumentStart = -1; + while (position < content.Length) + { + var bestIndex = -1; + var bestLength = 0; + foreach (var tag in START_TAGS) + { + var index = content.IndexOf(tag, position, StringComparison.Ordinal); + if (index >= 0 && (bestIndex < 0 || index < bestIndex)) + { + bestIndex = index; + bestLength = tag.Length; + } + } + + if (bestIndex < 0) + return false; + + if (bestIndex > 0 && IsIdentifierPart(content[bestIndex - 1])) + { + position = bestIndex + 1; + continue; + } + + argumentStart = bestIndex + bestLength; + return true; + } + + return false; + } + + private static bool IsIdentifierPart(char character) => char.IsLetterOrDigit(character) || character == '_'; +} \ No newline at end of file diff --git a/app/SharedTools/I18NSourceText.cs b/app/SharedTools/I18NSourceText.cs new file mode 100644 index 000000000..53fac0d19 --- /dev/null +++ b/app/SharedTools/I18NSourceText.cs @@ -0,0 +1,9 @@ +namespace SharedTools; + +/// +/// A text of T() or TB() which the I18N collector found in a source file. +/// +/// Where its literal starts in the file. +/// How many characters its literal takes in the file. +/// The string the compiler makes of the literal. +public sealed record I18NSourceText(int Offset, int Length, string Text); \ No newline at end of file diff --git a/app/SharedTools/LuaTable.cs b/app/SharedTools/LuaTable.cs index e03eea551..06d4883d5 100644 --- a/app/SharedTools/LuaTable.cs +++ b/app/SharedTools/LuaTable.cs @@ -15,9 +15,14 @@ public static class LuaTable if (cancellationToken.IsCancellationRequested) return sb.ToString(); + // + // A text with several lines, such as a notice in Markdown, keeps its line breaks, which the + // assignment below escapes. Lua ends a comment at the end of its line, though, so the comment + // gets the text on one line: + // var key = kvp.Key; - var value = kvp.Value.Replace("\n", " ").Trim(); - var commentValue = commentContent is null ? value : commentContent.GetValueOrDefault(key, value); + var value = I18NKeyHash.NormalizeLineEndings(kvp.Value).Trim(); + var commentValue = I18NKeyHash.NormalizeLineEndings(commentContent is null ? value : commentContent.GetValueOrDefault(key, value)).Replace("\n", " "); // Remove the "UI_TEXT_CONTENT." prefix from the key: const string UI_TEXT_CONTENT = "UI_TEXT_CONTENT."; diff --git a/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs b/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs new file mode 100644 index 000000000..8ed53d0a0 --- /dev/null +++ b/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs @@ -0,0 +1,469 @@ +using AIStudio.Assistants.BeyondFactsLab; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks how the BeyondFacts Lab stores its preparations on disk. +/// +/// +/// People maintain the list of their preparations themselves: nothing gets deleted on its own, also not a +/// preparation which this version cannot open. +/// +[TestFixture] +public sealed class BeyondFactsLabStoreTests +{ + private string dataDirectory = string.Empty; + + private string RootDirectory => Path.Combine(this.dataDirectory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME); + + [SetUp] + public void CreateDataDirectory() + { + this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-beyondfacts-lab-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this.dataDirectory); + } + + [TearDown] + public void DeleteDataDirectory() + { + if (Directory.Exists(this.dataDirectory)) + Directory.Delete(this.dataDirectory, true); + } + + [Test] + public async Task ACreatedPreparationIsListedAndLoaded() + { + var store = this.CreateStore(); + + var created = await store.CreateAsync(" Panel on climate adaptation "); + var projects = await store.ListProjectsAsync(); + var loaded = await store.LoadAsync(created.ProjectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(created.Name, Is.EqualTo("Panel on climate adaptation")); + Assert.That(created.SchemaVersion, Is.EqualTo(BflProject.CURRENT_SCHEMA_VERSION)); + Assert.That(created.HappenedAtUtc, Is.Null); + Assert.That(projects, Has.Count.EqualTo(1)); + Assert.That(projects[0].IsAvailable, Is.True); + Assert.That(projects[0].Name, Is.EqualTo(created.Name)); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded!.Name, Is.EqualTo(created.Name)); + Assert.That(loaded.CreatedAtUtc, Is.EqualTo(created.CreatedAtUtc)); + Assert.That(File.Exists(this.ProjectPath(created.ProjectId)), Is.True); + } + } + + [Test] + public async Task ANewStoreFindsThePreparationsOfTheLastOne() + { + var created = await this.CreateStore().CreateAsync("Interview"); + + var projects = await this.CreateStore().ListProjectsAsync(); + + Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { created.ProjectId })); + } + + [Test] + public async Task ThePreparationChangedLastComesFirst() + { + var store = this.CreateStore(); + var first = await store.CreateAsync("First"); + var second = await store.CreateAsync("Second"); + + await store.RenameAsync(first.ProjectId, "First, renamed"); + var projects = await store.ListProjectsAsync(); + + Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { first.ProjectId, second.ProjectId })); + } + + [Test] + public async Task RenamingKeepsEverythingElse() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Old name"); + + var renamed = await store.RenameAsync(created.ProjectId, "New name"); + var loaded = await store.LoadAsync(created.ProjectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(renamed.Name, Is.EqualTo("New name")); + Assert.That(loaded!.Name, Is.EqualTo("New name")); + Assert.That(loaded.CreatedAtUtc, Is.EqualTo(created.CreatedAtUtc)); + Assert.That(loaded.ModifiedAtUtc, Is.GreaterThanOrEqualTo(created.ModifiedAtUtc)); + } + } + + [TestCase("")] + [TestCase(" ")] + [TestCase("\a\t")] + public async Task AnEmptyNameIsRefused(string name) + { + var store = this.CreateStore(); + + await Assert.ThrowsAsync(async () => await store.CreateAsync(name)); + } + + [Test] + public async Task ANameLosesControlCharactersAndGetsCut() + { + var store = this.CreateStore(); + + var created = await store.CreateAsync("Talk\0 show" + new string('x', 300)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(created.Name, Does.StartWith("Talk show")); + Assert.That(created.Name, Has.Length.EqualTo(BeyondFactsLabStore.MAX_NAME_LENGTH)); + } + } + + [Test] + public async Task TheEventCanBeMarkedAsHavingTakenPlaceAndBack() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var happenedAt = new DateTimeOffset(2026, 12, 10, 14, 0, 0, TimeSpan.FromHours(1)); + + await store.SetHappenedAsync(created.ProjectId, happenedAt); + var afterMarking = await store.LoadAsync(created.ProjectId); + await store.SetHappenedAsync(created.ProjectId, null); + var afterTakingBack = await store.LoadAsync(created.ProjectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(afterMarking!.HappenedAtUtc, Is.EqualTo(happenedAt)); + Assert.That(afterMarking.HappenedAtUtc!.Value.Offset, Is.EqualTo(TimeSpan.Zero)); + Assert.That(afterTakingBack!.HappenedAtUtc, Is.Null); + } + } + + [Test] + public async Task DeletingRemovesTheFolderAndTheSelection() + { + var store = this.CreateStore(); + var kept = await store.CreateAsync("Kept"); + var deleted = await store.CreateAsync("Deleted"); + await store.RememberSelectionAsync(deleted.ProjectId); + + await store.DeleteAsync(deleted.ProjectId); + var projects = await store.ListProjectsAsync(); + var newStore = this.CreateStore(); + await newStore.ListProjectsAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { kept.ProjectId })); + Assert.That(Directory.Exists(Path.Combine(this.RootDirectory, deleted.ProjectId.ToString("D"))), Is.False); + Assert.That(store.LastSelectedProjectId, Is.Null); + Assert.That(newStore.LastSelectedProjectId, Is.Null); + } + } + + [Test] + public async Task TheSelectionSurvivesANewStore() + { + var store = this.CreateStore(); + await store.CreateAsync("Other"); + var selected = await store.CreateAsync("Selected"); + + await store.RememberSelectionAsync(selected.ProjectId); + var newStore = this.CreateStore(); + await newStore.ListProjectsAsync(); + + Assert.That(newStore.LastSelectedProjectId, Is.EqualTo(selected.ProjectId)); + } + + [Test] + public async Task ASelectionOfAMissingPreparationIsDropped() + { + Directory.CreateDirectory(this.RootDirectory); + await File.WriteAllTextAsync(Path.Combine(this.RootDirectory, "selection.json"), $"\"{Guid.NewGuid():D}\""); + + var store = this.CreateStore(); + await store.ListProjectsAsync(); + + Assert.That(store.LastSelectedProjectId, Is.Null); + } + + [Test] + public async Task NoTemporaryFileStaysBehind() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + await store.RenameAsync(created.ProjectId, "Panel, renamed"); + await store.SetHappenedAsync(created.ProjectId, DateTimeOffset.UtcNow); + await store.RememberSelectionAsync(created.ProjectId); + + Assert.That(Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories), Is.Empty); + } + + [Test] + public async Task ANewStoreRemovesTemporaryFilesACrashLeftBehind() + { + var created = await this.CreateStore().CreateAsync("Panel"); + var leftOver = $"{this.ProjectPath(created.ProjectId)}.tmp-{Guid.NewGuid():N}"; + await File.WriteAllTextAsync(leftOver, "{"); + + await this.CreateStore().ListProjectsAsync(); + + Assert.That(File.Exists(leftOver), Is.False); + } + + [Test] + public async Task ADamagedPreparationIsListedButNeitherOpenedNorChangedNorDeleted() + { + const string DAMAGED = "{ \"name\": \"Damaged panel\", \"projectId\": "; + var projectId = Guid.NewGuid(); + var path = await this.WriteProjectFileAsync(projectId, DAMAGED); + var store = this.CreateStore(); + + var projects = await store.ListProjectsAsync(); + var loaded = await store.LoadAsync(projectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(projects, Has.Count.EqualTo(1)); + Assert.That(projects[0].ProjectId, Is.EqualTo(projectId)); + Assert.That(projects[0].Status, Is.EqualTo(BflProjectLoadStatus.UNAVAILABLE)); + Assert.That(projects[0].IsAvailable, Is.False); + Assert.That(loaded, Is.Null); + await Assert.ThrowsAsync(async () => await store.RenameAsync(projectId, "Repaired")); + Assert.That(await File.ReadAllTextAsync(path), Is.EqualTo(DAMAGED)); + } + } + + [Test] + public async Task APreparationOfANewerVersionIsListedWithItsNameAndNotWritten() + { + var projectId = Guid.NewGuid(); + var json = $$""" + { + "schemaVersion": {{BflProject.CURRENT_SCHEMA_VERSION + 1}}, + "projectId": "{{projectId:D}}", + "name": "Panel of the future", + "createdAtUtc": "2026-11-01T10:00:00+00:00", + "modifiedAtUtc": "2026-11-02T10:00:00+00:00", + "fieldOfANewerVersion": true + } + """; + + var path = await this.WriteProjectFileAsync(projectId, json); + var store = this.CreateStore(); + + var projects = await store.ListProjectsAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(projects[0].Status, Is.EqualTo(BflProjectLoadStatus.NEWER_VERSION)); + Assert.That(projects[0].Name, Is.EqualTo("Panel of the future")); + Assert.That(projects[0].ModifiedAtUtc, Is.EqualTo(new DateTimeOffset(2026, 11, 2, 10, 0, 0, TimeSpan.Zero))); + await Assert.ThrowsAsync(async () => await store.SetHappenedAsync(projectId, DateTimeOffset.UtcNow)); + Assert.That(await File.ReadAllTextAsync(path), Is.EqualTo(json)); + } + } + + [Test] + public async Task APreparationWhoseFileNamesAnotherIdIsNotOpened() + { + var projectId = Guid.NewGuid(); + await this.WriteProjectFileAsync(projectId, $$"""{ "schemaVersion": 1, "projectId": "{{Guid.NewGuid():D}}", "name": "Moved" }"""); + var store = this.CreateStore(); + + var projects = await store.ListProjectsAsync(); + var loaded = await store.LoadAsync(projectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(projects[0].Status, Is.EqualTo(BflProjectLoadStatus.UNAVAILABLE)); + Assert.That(projects[0].Name, Is.EqualTo("Moved")); + Assert.That(loaded, Is.Null); + } + } + + [Test] + public async Task APreparationWhichCannotBeOpenedCanStillBeDeleted() + { + var projectId = Guid.NewGuid(); + await this.WriteProjectFileAsync(projectId, "not JSON at all"); + var store = this.CreateStore(); + + await store.DeleteAsync(projectId); + + Assert.That(await store.ListProjectsAsync(), Is.Empty); + } + + [Test] + public async Task FoldersWhichAreNoPreparationsAreIgnored() + { + Directory.CreateDirectory(Path.Combine(this.RootDirectory, "promptOverrides")); + var created = await this.CreateStore().CreateAsync("Panel"); + + var projects = await this.CreateStore().ListProjectsAsync(); + + Assert.That(projects.Select(project => project.ProjectId), Is.EqualTo(new[] { created.ProjectId })); + } + + [Test] + public async Task UmlautsStayReadableInTheFile() + { + var created = await this.CreateStore().CreateAsync("Podiumsdiskussion über Gewässerschutz"); + + var json = await File.ReadAllTextAsync(this.ProjectPath(created.ProjectId)); + + Assert.That(json, Does.Contain("Podiumsdiskussion über Gewässerschutz")); + } + + [Test] + public async Task AFormIsStoredInTheFolderOfItsStepAndCountsAsAChange() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var form = new BflStepFormData(); + form.Set(BflFieldCatalog.FORMAT, "Panel discussion"); + + var changed = await store.SaveFormAsync(created.ProjectId, BflStep.FRAMEWORK, form); + var loaded = await this.CreateStore().LoadFormAsync(created.ProjectId, BflStep.FRAMEWORK); + + using (Assert.EnterMultipleScope()) + { + Assert.That(File.Exists(Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps", "01-framework", "form.json")), Is.True); + Assert.That(loaded.Get(BflFieldCatalog.FORMAT), Is.EqualTo("Panel discussion")); + Assert.That(changed.ModifiedAtUtc, Is.GreaterThan(created.ModifiedAtUtc)); + Assert.That((await store.LoadAsync(created.ProjectId))!.ModifiedAtUtc, Is.EqualTo(changed.ModifiedAtUtc)); + } + } + + [Test] + public async Task AStepWithoutAFormGetsAnEmptyOne() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + + var form = await store.LoadFormAsync(created.ProjectId, BflStep.STRATEGY); + var forms = await store.LoadFormsAsync(created.ProjectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(form.Values, Is.Empty); + Assert.That(forms.Keys, Is.EquivalentTo(BflStep.All)); + } + } + + [Test] + public async Task AValueOfAFieldThisVersionDoesNotKnowIsKept() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var formPath = await this.WriteFormFileAsync(created.ProjectId, "02-knowledge", """{ "schemaVersion": 1, "values": { "field_of_a_newer_version": "kept" } }"""); + + var form = await store.LoadFormAsync(created.ProjectId, BflStep.KNOWLEDGE); + form.Set(BflFieldCatalog.CORE_THESES, "Thesis"); + await store.SaveFormAsync(created.ProjectId, BflStep.KNOWLEDGE, form); + + var json = await File.ReadAllTextAsync(formPath); + using (Assert.EnterMultipleScope()) + { + Assert.That(json, Does.Contain("field_of_a_newer_version")); + Assert.That(json, Does.Contain("Thesis")); + } + } + + [Test] + public async Task ADamagedFormIsNeitherOpenedNorOverwritten() + { + const string DAMAGED = "{ \"values\": "; + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var formPath = await this.WriteFormFileAsync(created.ProjectId, "01-framework", DAMAGED); + + var forms = await store.LoadFormsAsync(created.ProjectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(forms.ContainsKey(BflStep.FRAMEWORK), Is.False); + Assert.That(forms.ContainsKey(BflStep.KNOWLEDGE), Is.True); + await Assert.ThrowsAsync(async () => await store.LoadFormAsync(created.ProjectId, BflStep.FRAMEWORK)); + await Assert.ThrowsAsync(async () => await store.SaveFormAsync(created.ProjectId, BflStep.FRAMEWORK, new BflStepFormData())); + Assert.That(await File.ReadAllTextAsync(formPath), Is.EqualTo(DAMAGED)); + } + } + + [Test] + public async Task AFormOfANewerVersionIsNotOverwritten() + { + const string NEWER = """{ "schemaVersion": 2, "values": {} }"""; + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var formPath = await this.WriteFormFileAsync(created.ProjectId, "05-strategy", NEWER); + + using (Assert.EnterMultipleScope()) + { + await Assert.ThrowsAsync(async () => await store.SaveFormAsync(created.ProjectId, BflStep.STRATEGY, new BflStepFormData())); + Assert.That(await File.ReadAllTextAsync(formPath), Is.EqualTo(NEWER)); + } + } + + [Test] + public async Task AFormOfAPreparationWhichCannotBeOpenedIsNotWritten() + { + var projectId = Guid.NewGuid(); + await this.WriteProjectFileAsync(projectId, "not JSON at all"); + var store = this.CreateStore(); + + await Assert.ThrowsAsync(async () => await store.SaveFormAsync(projectId, BflStep.FRAMEWORK, new BflStepFormData())); + + Assert.That(Directory.Exists(Path.Combine(this.RootDirectory, projectId.ToString("D"), "steps")), Is.False); + } + + [Test] + public async Task TheStepShownLastIsRememberedWithoutCountingAsAChange() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + + await store.SetCurrentStepAsync(created.ProjectId, BflStep.STRATEGY); + var loaded = await this.CreateStore().LoadAsync(created.ProjectId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(created.CurrentStep, Is.EqualTo(BflStep.FRAMEWORK)); + Assert.That(loaded!.CurrentStep, Is.EqualTo(BflStep.STRATEGY)); + Assert.That(loaded.ModifiedAtUtc, Is.EqualTo(created.ModifiedAtUtc)); + } + } + + [Test] + public async Task APreparationWithAnUnknownStepIsNotOpened() + { + var projectId = Guid.NewGuid(); + await this.WriteProjectFileAsync(projectId, $$"""{ "schemaVersion": 1, "projectId": "{{projectId:D}}", "name": "Panel", "currentStep": 42 }"""); + + var loaded = await this.CreateStore().LoadAsync(projectId); + + Assert.That(loaded, Is.Null); + } + + private BeyondFactsLabStore CreateStore() => new(NullLogger.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory }); + + private async Task WriteFormFileAsync(Guid projectId, string stepFolder, string content) + { + var path = Path.Combine(this.RootDirectory, projectId.ToString("D"), "steps", stepFolder, "form.json"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, content); + return path; + } + + private string ProjectPath(Guid projectId) => Path.Combine(this.RootDirectory, projectId.ToString("D"), "project.json"); + + private async Task WriteProjectFileAsync(Guid projectId, string content) + { + var path = this.ProjectPath(projectId); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, content); + return path; + } +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflAccessTests.cs b/app/Tests/BeyondFactsLab/BflAccessTests.cs new file mode 100644 index 000000000..c3202e1f6 --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflAccessTests.cs @@ -0,0 +1,73 @@ +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Provider; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks who may open the page of the BeyondFacts Lab and what its settings offer. +/// +/// +/// The page asks by itself whether it may be opened, because its address can be reached without the tile. +/// +[TestFixture] +public sealed class BflAccessTests +{ + [Test] + public void ThePageStaysClosedWithoutThePreviewFeature() + { + Assert.That(BflAccess.CanOpen(CreateSettingsManager()), Is.False); + } + + [Test] + public void ThePageOpensWithThePreviewFeature() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026); + + Assert.That(BflAccess.CanOpen(settingsManager), Is.True); + } + + [Test] + public void ThePageStaysClosedWhenAnOrganizationHidesTheLab() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026); + settingsManager.ConfigurationData.App.HiddenAssistants.Add(ConfigurableAssistant.BEYOND_FACTS_LAB_ASSISTANT); + + Assert.That(BflAccess.CanOpen(settingsManager), Is.False); + } + + [Test] + public void TheSettingsOfferNoConfidenceBelowMedium() + { + var levels = ConfigurationSelectDataFactory.GetBeyondFactsLabConfidenceLevelsData(CreateSettingsManager()).Select(data => data.Value); + + Assert.That(levels, Is.EqualTo(new[] { ConfidenceLevel.MEDIUM, ConfidenceLevel.HIGH })); + } + + [Test] + public void AnEnforcedGlobalMinimumLiftsTheChoice() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.Confidence.EnforceGlobalMinimumConfidence = true; + settingsManager.ConfigurationData.Confidence.GlobalMinimumConfidence = ConfidenceLevel.HIGH; + + var levels = ConfigurationSelectDataFactory.GetBeyondFactsLabConfidenceLevelsData(settingsManager).Select(data => data.Value); + + Assert.That(levels, Is.EqualTo(new[] { ConfidenceLevel.HIGH })); + } + + [Test] + public void TheSettingsOfferEveryFormOfAddress() + { + var forms = ConfigurationSelectDataFactory.GetBeyondFactsLabAddressFormData().Select(data => data.Value); + + Assert.That(forms, Is.EquivalentTo(Enum.GetValues())); + } + + private static SettingsManager CreateSettingsManager() => new(NullLogger.Instance, null!); +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflComponentTests.cs b/app/Tests/BeyondFactsLab/BflComponentTests.cs new file mode 100644 index 000000000..67ca4756b --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflComponentTests.cs @@ -0,0 +1,151 @@ +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Provider; +using AIStudio.Settings; +using AIStudio.Settings.DataModel; +using AIStudio.Tools; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks how the BeyondFacts Lab is registered as a component: who may see it, which providers it accepts, +/// and what it shares with the rest of the app. +/// +/// +/// The component is written out in full, AIStudio.Tools.Components: from this namespace, the short name finds +/// the test namespace AIStudio.Tests.Components first. +/// +[TestFixture] +public sealed class BflComponentTests +{ + private const AIStudio.Tools.Components LAB = AIStudio.Tools.Components.BEYOND_FACTS_LAB_ASSISTANT; + + [Test] + public void TheLabBelongsToItsPreviewFeature() + { + Assert.That(LAB.RequiredPreviewFeature, Is.EqualTo(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026), "The tile, the page, and the menus all ask the component for its preview feature."); + } + + [Test] + public void TheLabIsHiddenWithoutItsPreviewFeature() + { + var settingsManager = CreateSettingsManager(); + + Assert.That(settingsManager.IsAssistantVisible(LAB, requiredPreviewFeature: LAB.RequiredPreviewFeature), Is.False); + } + + [Test] + public void TheLabIsVisibleWithItsPreviewFeature() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026); + + Assert.That(settingsManager.IsAssistantVisible(LAB, requiredPreviewFeature: LAB.RequiredPreviewFeature), Is.True); + } + + [Test] + public void AnOrganizationCanHideTheLab() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026); + settingsManager.ConfigurationData.App.HiddenAssistants.Add(ConfigurableAssistant.BEYOND_FACTS_LAB_ASSISTANT); + + Assert.That(settingsManager.IsAssistantVisible(LAB, requiredPreviewFeature: LAB.RequiredPreviewFeature), Is.False, "The list of hidden assistants must reach the lab, too."); + } + + [TestCase(ConfidenceLevel.NONE)] + [TestCase(ConfidenceLevel.LOW)] + [TestCase(ConfidenceLevel.MODERATE)] + [TestCase(ConfidenceLevel.MEDIUM)] + public void TheMinimumConfidenceNeverDropsBelowMedium(ConfidenceLevel configured) + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence = configured; + + Assert.That(LAB.MinimumConfidence(settingsManager), Is.EqualTo(ConfidenceLevel.MEDIUM)); + } + + [Test] + public void AHigherMinimumConfidenceWins() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence = ConfidenceLevel.HIGH; + + Assert.That(LAB.MinimumConfidence(settingsManager), Is.EqualTo(ConfidenceLevel.HIGH)); + } + + /// + /// Checks that the lab takes the cloud providers of the region one trusts and refuses all others. + /// + /// + /// The scheme which trusts Europe gives MEDIUM to Mistral and LOW to OpenAI. MEDIUM is the floor of the + /// lab, so only the first one may see unpublished research. + /// + [Test] + public void OnlyProvidersOfTheTrustedRegionAreAccepted() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.Confidence.ConfidenceScheme = ConfidenceSchemes.TRUST_EUROPE; + var trusted = CreateProvider(LLMProviders.MISTRAL); + var untrusted = CreateProvider(LLMProviders.OPEN_AI); + + using (Assert.EnterMultipleScope()) + { + Assert.That(settingsManager.IsProviderConfident(trusted, LAB), Is.True); + Assert.That(settingsManager.IsProviderConfident(untrusted, LAB), Is.False); + } + } + + [Test] + public void AProhibitedProviderIsRefusedAlthoughTrusted() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.Confidence.ConfidenceScheme = ConfidenceSchemes.TRUST_EUROPE; + settingsManager.ConfigurationData.App.ProhibitedProviders.Add(LLMProviders.MISTRAL); + + Assert.That(settingsManager.IsProviderConfident(CreateProvider(LLMProviders.MISTRAL), LAB), Is.False); + } + + [Test] + public void TheLabNeitherSendsNorReceivesContent() + { + Assert.That(LAB.AllowSendTo, Is.False, "Nothing may enter the lab from other places, and its answers stay in its steps."); + } + + [Test] + public void TheLabKeepsOneSessionPerStep() + { + Assert.That(LAB.HasSingleSessionSlot, Is.False, "A running step must not block the steps of other preparations."); + } + + [Test] + public void TheLabWorksWithoutAProfile() + { + var settingsManager = CreateSettingsManager(); + settingsManager.ConfigurationData.App.PreselectedProfile = Guid.NewGuid().ToString(); + + Assert.That(LAB.GetProfilePreselection(settingsManager), Is.EqualTo(ProfilePreselection.FromStoredValue(Profile.NO_PROFILE.Id)), "The lab works with what the person enters per preparation, never with the app-wide profile."); + } + + [Test] + public void TheLabStartsWithItsPreselectedProvider() + { + var settingsManager = CreateSettingsManager(); + var provider = CreateProvider(LLMProviders.MISTRAL); + settingsManager.ConfigurationData.Providers.Add(provider); + settingsManager.ConfigurationData.BeyondFactsLab.PreselectedProvider = provider.Id; + + Assert.That(LAB.PreselectedProvider(settingsManager).Id, Is.EqualTo(provider.Id)); + } + + [Test] + public void NewSettingsAddressFormally() + { + Assert.That(new DataBeyondFactsLab().AddressForm, Is.EqualTo(BflAddressForm.FORMAL), "The texts of AI Studio address the person formally as well."); + } + + private static SettingsManager CreateSettingsManager() => new(NullLogger.Instance, null!); + + private static AIStudio.Settings.Provider CreateProvider(LLMProviders llmProvider) => new(1, Guid.NewGuid().ToString(), "Test", llmProvider, new("test-model", null)); +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflFieldCatalogTests.cs b/app/Tests/BeyondFactsLab/BflFieldCatalogTests.cs new file mode 100644 index 000000000..861937d18 --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflFieldCatalogTests.cs @@ -0,0 +1,170 @@ +using AIStudio.Assistants.BeyondFactsLab; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks the steps and the fields of the forms "Your details" in the BeyondFacts Lab. +/// +/// +/// The forms store their values under the IDs of the fields and options, and the folders of the steps carry their +/// names, so all of them are pinned here: changing one would lose what people entered before. +/// +[TestFixture] +public sealed class BflFieldCatalogTests +{ + [Test] + public void TheStepsKeepTheirNamesAndOrder() + { + Assert.That(BflStep.All.Select(step => step.ToString()), Is.EqualTo(new[] + { + "FRAMEWORK", "KNOWLEDGE", "GOALS_AND_ROLE", "VALUES_AND_PERSPECTIVES", "STRATEGY", "TEST_SITUATION", "RESULTS", + })); + } + + [Test] + public void TheFoldersOfTheStepsKeepTheirNames() + { + Assert.That(BflStep.All.Select(step => step.FolderName), Is.EqualTo(new[] + { + "01-framework", "02-knowledge", "03-goals", "04-values", "05-strategy", "06-test", "07-results", + })); + } + + [Test] + public void TheFieldsKeepTheirIds() + { + Assert.That(BflFieldCatalog.FIELDS.Select(field => field.Id), Is.EqualTo(new[] + { + "format", "event_topic", "audience", "contribution_topic", "event_date", "event_location", "event_duration", "contribution_duration", "other_participants", "event_link", + "contribution_summary", "core_theses", "uncertainties", "material_notes", + "goals", "goal_directions", + "values_and_concerns", "personal_concern", "other_perspectives", + "communication_worries", + "question_type", "difficulty", "test_notes", + "position_changes", + })); + } + + [Test] + public void TheOptionsKeepTheirIds() + { + Assert.That(BflFieldCatalog.FIELDS.SelectMany(field => field.Options).Select(option => option.Id), Is.EqualTo(new[] + { + "goal_inform", "goal_moderate", "goal_take_position", "goal_raise_awareness", "goal_show_options", "goal_influence_policy", + "questions_mixed", "questions_scientific_criticism", "questions_emotional_criticism", "questions_manipulative", "questions_political_instrumentalization", + "difficulty_moderate", "difficulty_demanding", "difficulty_very_demanding", + })); + } + + [Test] + public void EveryIdIsUnique() + { + var ids = BflFieldCatalog.FIELDS.Select(field => field.Id).Concat(BflFieldCatalog.FIELDS.SelectMany(field => field.Options).Select(option => option.Id)).ToList(); + + Assert.That(ids, Is.Unique); + } + + [Test] + public void TheFieldsOfAStepFollowEachOther() + { + var stepsInOrder = BflFieldCatalog.FIELDS.Select(field => field.Step).Distinct(); + + Assert.That(stepsInOrder, Is.EqualTo(BflStep.All)); + } + + [Test] + public void EveryStepHasAFormWithATitleAndADescription() + { + using (Assert.EnterMultipleScope()) + { + foreach (var step in BflStep.All) + { + Assert.That(BflFieldCatalog.ForStep(step), Is.Not.Empty, $"{step} has no fields."); + Assert.That(step.Title, Is.Not.Empty, $"{step} has no title."); + Assert.That(step.Description, Is.Not.Empty, $"{step} has no description."); + } + } + } + + [Test] + public void OnlyFieldsWithAChoiceHaveOptions() + { + using (Assert.EnterMultipleScope()) + { + foreach (var field in BflFieldCatalog.FIELDS) + { + if (field.Kind.HasOptions) + Assert.That(field.Options, Has.Count.GreaterThanOrEqualTo(2), $"{field.Id} offers too few options."); + else + Assert.That(field.Options, Is.Empty, $"{field.Id} has options, but nothing shows them."); + } + } + } + + [Test] + public void EveryKindHasALimit() + { + Assert.That(Enum.GetValues().Select(kind => kind.MaxLength), Has.All.GreaterThan(0)); + } + + [Test] + public void EveryFieldAndOptionHasALabel() + { + using (Assert.EnterMultipleScope()) + { + foreach (var field in BflFieldCatalog.FIELDS) + { + Assert.That(field.Label, Is.Not.Empty, $"{field.Id} has no label."); + foreach (var option in field.Options) + Assert.That(option.Label, Is.Not.Empty, $"{option.Id} has no label."); + } + } + } + + [Test] + public void EveryFieldAndOptionHasALabelOfItsOwn() + { + // Equal English texts share one key of the localization, so another language could not tell them apart: + var labels = BflFieldCatalog.FIELDS.SelectMany(field => field.Options.Select(option => option.Label).Prepend(field.Label)); + + Assert.That(labels, Is.Unique); + } + + [Test] + public void OnlyPublicDetailsAboutTheEventReachTheResearch() + { + var shared = BflFieldCatalog.FIELDS.Where(field => field.IsSharedWithResearch).Select(field => field.Id); + + Assert.That(shared, Is.EquivalentTo(new[] + { + BflFieldCatalog.FORMAT, BflFieldCatalog.EVENT_TOPIC, BflFieldCatalog.EVENT_DATE, BflFieldCatalog.EVENT_LOCATION, BflFieldCatalog.OTHER_PARTICIPANTS, BflFieldCatalog.EVENT_LINK, + }), "The topic of a contribution may be unpublished, and nothing personal belongs on the web."); + } + + [Test] + public void OnlyThePersonalInvolvementIsPersonal() + { + var personal = BflFieldCatalog.FIELDS.Where(field => field.IsPersonalDisclosure).Select(field => field.Id); + + Assert.That(personal, Is.EquivalentTo(new[] { BflFieldCatalog.PERSONAL_CONCERN })); + } + + [Test] + public void OnlyTheOtherParticipantsHoldNames() + { + var withNames = BflFieldCatalog.FIELDS.Where(field => field.NameRole is not BflNameRole.NONE).Select(field => field.Id); + + Assert.That(withNames, Is.EquivalentTo(new[] { BflFieldCatalog.OTHER_PARTICIPANTS })); + } + + [Test] + public void TheKeyDetailsAreThoseOfTheEventAndTheContribution() + { + var keyDetails = BflFieldCatalog.FIELDS.Where(field => field.IsKeyDetail).Select(field => field.Id); + + Assert.That(keyDetails, Is.EquivalentTo(new[] + { + BflFieldCatalog.FORMAT, BflFieldCatalog.EVENT_TOPIC, BflFieldCatalog.AUDIENCE, BflFieldCatalog.CONTRIBUTION_TOPIC, BflFieldCatalog.CONTRIBUTION_SUMMARY, + })); + } +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflMissingDetailsTests.cs b/app/Tests/BeyondFactsLab/BflMissingDetailsTests.cs new file mode 100644 index 000000000..3931d9f2d --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflMissingDetailsTests.cs @@ -0,0 +1,94 @@ +using AIStudio.Assistants.BeyondFactsLab; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks which details of a preparation the BeyondFacts Lab marks as missing, and how a form keeps its values. +/// +[TestFixture] +public sealed class BflMissingDetailsTests +{ + [Test] + public void ANewPreparationLacksTheDetailsOfTheEventAndTheContribution() + { + var steps = BflMissingDetails.StepsOf(new Dictionary()); + + Assert.That(steps, Is.EqualTo(new[] { BflStep.FRAMEWORK, BflStep.KNOWLEDGE })); + } + + [Test] + public void TheMissingDetailsFollowTheOrderOfTheForm() + { + var form = new BflStepFormData(); + form.Set(BflFieldCatalog.EVENT_TOPIC, "Water protection"); + + var missing = BflMissingDetails.Of(BflStep.FRAMEWORK, form); + + Assert.That(missing.Select(field => field.Id), Is.EqualTo(new[] { BflFieldCatalog.FORMAT, BflFieldCatalog.AUDIENCE, BflFieldCatalog.CONTRIBUTION_TOPIC })); + } + + [Test] + public void BlanksCountAsMissing() + { + var form = new BflStepFormData { Values = { [BflFieldCatalog.CONTRIBUTION_SUMMARY] = " \n " } }; + + Assert.That(BflMissingDetails.Of(BflStep.KNOWLEDGE, form), Has.Count.EqualTo(1)); + } + + [Test] + public void AFilledFormLacksNothing() + { + var framework = new BflStepFormData(); + foreach (var field in BflFieldCatalog.ForStep(BflStep.FRAMEWORK)) + framework.Set(field.Id, "given"); + + var knowledge = new BflStepFormData(); + knowledge.Set(BflFieldCatalog.CONTRIBUTION_SUMMARY, "A few sentences."); + + var steps = BflMissingDetails.StepsOf(new Dictionary { [BflStep.FRAMEWORK] = framework, [BflStep.KNOWLEDGE] = knowledge }); + + Assert.That(steps, Is.Empty); + } + + [Test] + public void AnEmptyValueRemovesTheField() + { + var form = new BflStepFormData(); + form.Set(BflFieldCatalog.GOALS, "Inform the audience"); + + form.Set(BflFieldCatalog.GOALS, " "); + + using (Assert.EnterMultipleScope()) + { + Assert.That(form.Values, Is.Empty); + Assert.That(form.Get(BflFieldCatalog.GOALS), Is.Empty); + } + } + + [Test] + public void ChoicesAreStoredInTheOrderOfTheOptions() + { + var field = BflFieldCatalog.FIELDS.Single(candidate => candidate.Id == BflFieldCatalog.GOAL_DIRECTIONS); + var form = new BflStepFormData(); + + form.SetChoices(field, [BflFieldCatalog.GOAL_INFLUENCE_POLICY, BflFieldCatalog.GOAL_INFORM, "not_an_option"]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(form.Get(field.Id), Is.EqualTo($"{BflFieldCatalog.GOAL_INFORM},{BflFieldCatalog.GOAL_INFLUENCE_POLICY}")); + Assert.That(form.GetChoices(field.Id), Is.EquivalentTo(new[] { BflFieldCatalog.GOAL_INFORM, BflFieldCatalog.GOAL_INFLUENCE_POLICY })); + } + } + + [Test] + public void NoChoiceRemovesTheField() + { + var field = BflFieldCatalog.FIELDS.Single(candidate => candidate.Id == BflFieldCatalog.GOAL_DIRECTIONS); + var form = new BflStepFormData(); + form.SetChoices(field, [BflFieldCatalog.GOAL_INFORM]); + + form.SetChoices(field, []); + + Assert.That(form.Values, Is.Empty); + } +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflNoticeTests.cs b/app/Tests/BeyondFactsLab/BflNoticeTests.cs new file mode 100644 index 000000000..ea2212737 --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflNoticeTests.cs @@ -0,0 +1,81 @@ +using System.Runtime.CompilerServices; + +using AIStudio.Assistants.BeyondFactsLab; + +using SharedTools; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks the notes the BeyondFacts Lab shows before the first use, and when they show again. +/// +[TestFixture] +public sealed class BflNoticeTests +{ + [Test] + public void TheNotesAreMarkdownWithAHeadingAndParagraphs() + { + var paragraphs = BflNotice.Text.ReplaceLineEndings("\n").Split("\n\n"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(paragraphs[0], Is.EqualTo("## Welcome!")); + Assert.That(paragraphs, Has.Length.EqualTo(5)); + Assert.That(paragraphs[^1], Does.Contain("prototype")); + Assert.That(paragraphs, Has.None.StartsWith(" "), "The indentation of the source must not reach the notes."); + } + } + + [Test] + public void TheCollectorFindsTheNotesAsTheAppShowsThem() + { + var source = File.ReadAllText(NoticeSourceFile()); + var warnings = new List(); + + var texts = I18NSourceScanner.FindTexts(source, NoticeSourceFile(), warnings).Select(text => I18NKeyHash.NormalizeLineEndings(text.Text)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(texts, Does.Contain(I18NKeyHash.NormalizeLineEndings(BflNotice.Text))); + Assert.That(warnings, Is.Empty); + } + } + + [Test] + public void AChangeOfTheNotesChangesTheHash() + { + var hash = BflNotice.ComputeHash("## Welcome!\n\nThe notes."); + + using (Assert.EnterMultipleScope()) + { + Assert.That(BflNotice.ComputeHash("## Welcome!\n\nThe changed notes."), Is.Not.EqualTo(hash)); + Assert.That(BflNotice.ComputeHash("## Herzlich willkommen!\n\nDie Hinweise."), Is.Not.EqualTo(hash), "Notes in another language were not read yet."); + } + } + + [Test] + public void LineEndingsAndOuterSpaceDoNotChangeTheHash() + { + var hash = BflNotice.ComputeHash("First line\n\nSecond line"); + + Assert.That(BflNotice.ComputeHash(" First line\r\n\r\nSecond line\r\n"), Is.EqualTo(hash)); + } + + [Test] + public void OnlyTheHashOfTheNotesAsTheyAreNowCountsAsConfirmed() + { + var currentHash = BflNotice.ComputeHash(BflNotice.Text); + + using (Assert.EnterMultipleScope()) + { + Assert.That(currentHash, Has.Length.EqualTo(64)); + Assert.That(BflNotice.IsAccepted(currentHash, currentHash), Is.True); + Assert.That(BflNotice.IsAccepted(currentHash.ToUpperInvariant(), currentHash), Is.True); + Assert.That(BflNotice.IsAccepted(string.Empty, currentHash), Is.False, "Nobody confirmed anything yet."); + Assert.That(BflNotice.IsAccepted(BflNotice.ComputeHash("old"), currentHash), Is.False, "The notes changed since."); + } + } + + private static string NoticeSourceFile([CallerFilePath] string sourceFilePath = "") => + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "MindWork AI Studio", "Assistants", "BeyondFactsLab", "BflNotice.cs")); +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflPreviewFeatureTests.cs b/app/Tests/BeyondFactsLab/BflPreviewFeatureTests.cs new file mode 100644 index 000000000..0a2217cee --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflPreviewFeatureTests.cs @@ -0,0 +1,68 @@ +using AIStudio.Settings; +using AIStudio.Settings.DataModel; + +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks that the BeyondFacts Lab stays a prototype which nobody sees unless they ask for it. +/// +/// +/// The lab grows over a long series of pull requests. Until it is complete, only people who allow +/// prototypes and switch the lab on may find it; raising its level is a decision of its own. +/// +[TestFixture] +public sealed class BflPreviewFeatureTests +{ + [TestCase(PreviewVisibility.PROTOTYPE)] + [TestCase(PreviewVisibility.EXPERIMENTAL)] + public void PrototypesOfferTheLab(PreviewVisibility visibility) + { + Assert.That(visibility.GetPreviewFeatures(), Does.Contain(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026)); + } + + [TestCase(PreviewVisibility.NONE)] + [TestCase(PreviewVisibility.RELEASE_CANDIDATE)] + [TestCase(PreviewVisibility.BETA)] + [TestCase(PreviewVisibility.ALPHA)] + public void LowerLevelsDoNotOfferTheLab(PreviewVisibility visibility) + { + Assert.That(visibility.GetPreviewFeatures(), Does.Not.Contain(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026)); + } + + [Test] + public void LoweringTheLevelLeavesTheLabOut() + { + var enabled = new HashSet { PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026 }; + + Assert.That(PreviewVisibility.BETA.FilterPreviewFeatures(enabled), Is.Empty, "A beta level must not keep a prototype switched on."); + } + + [Test] + public void TheLabIsNotReleased() + { + Assert.That(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.IsReleased, Is.False); + } + + [Test] + public void TheLabCountsOnlyWhenSwitchedOn() + { + var settingsManager = new SettingsManager(NullLogger.Instance, null!); + var wasEnabledBefore = PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.IsEnabled(settingsManager); + + settingsManager.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026); + + using (Assert.EnterMultipleScope()) + { + Assert.That(wasEnabledBefore, Is.False, "New settings must not switch the lab on."); + Assert.That(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.IsEnabled(settingsManager), Is.True); + } + } + + [Test] + public void TheLabHasADescription() + { + Assert.That(PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026.PreviewDescription, Does.StartWith("BeyondFacts Lab"), "The settings list the preview features by this text."); + } +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflProjectSelectionTests.cs b/app/Tests/BeyondFactsLab/BflProjectSelectionTests.cs new file mode 100644 index 000000000..e71d43636 --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflProjectSelectionTests.cs @@ -0,0 +1,78 @@ +using AIStudio.Assistants.BeyondFactsLab; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks which preparation the BeyondFacts Lab shows after its list changed. +/// +[TestFixture] +public sealed class BflProjectSelectionTests +{ + private static readonly BflProjectEntry CHANGED_LAST = Entry("Changed last"); + private static readonly BflProjectEntry SELECTED_LAST = Entry("Selected last"); + private static readonly BflProjectEntry JUST_CREATED = Entry("Just created"); + private static readonly IReadOnlyList PROJECTS = [CHANGED_LAST, SELECTED_LAST, JUST_CREATED]; + + [Test] + public void ThePreparationAskedForComesFirst() + { + var chosen = BflProjectSelection.Choose(PROJECTS, JUST_CREATED.ProjectId, SELECTED_LAST.ProjectId); + + Assert.That(chosen, Is.SameAs(JUST_CREATED)); + } + + [Test] + public void WithoutAWishThePreparationSelectedLastIsShown() + { + var chosen = BflProjectSelection.Choose(PROJECTS, null, SELECTED_LAST.ProjectId); + + Assert.That(chosen, Is.SameAs(SELECTED_LAST)); + } + + [Test] + public void APreparationWhichIsGoneFallsBackToTheOneSelectedLast() + { + var chosen = BflProjectSelection.Choose(PROJECTS, Guid.NewGuid(), SELECTED_LAST.ProjectId); + + Assert.That(chosen, Is.SameAs(SELECTED_LAST)); + } + + [Test] + public void WithoutAnyKnownSelectionThePreparationChangedLastIsShown() + { + var chosen = BflProjectSelection.Choose(PROJECTS, Guid.NewGuid(), Guid.NewGuid()); + + Assert.That(chosen, Is.SameAs(CHANGED_LAST)); + } + + [Test] + public void WithoutPreparationsNothingIsShown() + { + var chosen = BflProjectSelection.Choose([], Guid.NewGuid(), null); + + Assert.That(chosen, Is.Null); + } + + [Test] + public void APreparationWhichCannotBeOpenedCanStillBeShown() + { + var unavailable = new BflProjectEntry(Guid.NewGuid(), string.Empty, DateTimeOffset.UtcNow, BflProjectLoadStatus.UNAVAILABLE, null); + + var chosen = BflProjectSelection.Choose([CHANGED_LAST, unavailable], unavailable.ProjectId, null); + + Assert.That(chosen, Is.SameAs(unavailable)); + } + + private static BflProjectEntry Entry(string name) + { + var project = new BflProject + { + ProjectId = Guid.NewGuid(), + Name = name, + CreatedAtUtc = DateTimeOffset.UtcNow, + ModifiedAtUtc = DateTimeOffset.UtcNow, + }; + + return BflProjectEntry.FromProject(project); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.cs b/app/Tests/Serialization/JsonTestRoots.cs index af7e0167f..228442134 100644 --- a/app/Tests/Serialization/JsonTestRoots.cs +++ b/app/Tests/Serialization/JsonTestRoots.cs @@ -1,5 +1,6 @@ using System.Text.Json; +using AIStudio.Assistants.BeyondFactsLab; using AIStudio.Assistants.SWOTAnalysis; using AIStudio.Chat; using AIStudio.Settings; @@ -59,6 +60,11 @@ public static partial class JsonTestRoots // The text around a passage which the provenance store keeps: yield return Case(new(typeof(IReadOnlyList), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS)); yield return Case(new(typeof(List), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS)); + + // The preparations of the BeyondFacts Lab, their forms, and the preparation selected last: + yield return Case(new(typeof(BflProject), BeyondFactsLabJson.PERSISTENCE)); + yield return Case(new(typeof(BflStepFormData), BeyondFactsLabJson.PERSISTENCE)); + yield return Case(new(typeof(Guid?), BeyondFactsLabJson.PERSISTENCE), "selected preparation"); } private static TestCaseData Case(JsonTestRoot root, string? purpose = null) diff --git a/app/Tests/Tools/AppResourcesTests.cs b/app/Tests/Tools/AppResourcesTests.cs new file mode 100644 index 000000000..cda45f8fd --- /dev/null +++ b/app/Tests/Tools/AppResourcesTests.cs @@ -0,0 +1,47 @@ +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks how AI Studio reads the files which come with it. +/// +/// +/// The tests run a development build, which reads the files from its output folder: the project file copies every +/// embedded file there as well. +/// +[TestFixture] +public sealed class AppResourcesTests +{ + [Test] + public async Task AFileOfTheAppIsRead() + { + var text = await AppResources.ReadTextAsync("Assistants/PromptOptimizer/prompting_guideline.md"); + + Assert.That(text, Is.Not.Empty); + } + + [Test] + public async Task BackslashesAndOuterSlashesAreAccepted() + { + var withSlashes = await AppResources.ReadTextAsync("Assistants/PromptOptimizer/prompting_guideline.md"); + var withBackslashes = await AppResources.ReadTextAsync("/Assistants\\PromptOptimizer\\prompting_guideline.md"); + + Assert.That(withBackslashes, Is.EqualTo(withSlashes)); + } + + [Test] + public async Task AMissingFileGivesAnEmptyText() + { + var text = await AppResources.ReadTextAsync("Assistants/PromptOptimizer/does-not-exist.md"); + + Assert.That(text, Is.Empty); + } + + [Test] + public void AFolderOfTheAppListsItsFiles() + { + var provider = AppResources.FolderProvider("Assistants/PromptOptimizer"); + + Assert.That(provider.GetDirectoryContents(string.Empty).Select(file => file.Name), Does.Contain("prompting_guideline.md")); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/DataBackup/DataBackupWriterTests.cs b/app/Tests/Tools/DataBackup/DataBackupWriterTests.cs index 60ad0658e..cb3008d0d 100644 --- a/app/Tests/Tools/DataBackup/DataBackupWriterTests.cs +++ b/app/Tests/Tools/DataBackup/DataBackupWriterTests.cs @@ -54,6 +54,7 @@ public sealed class DataBackupWriterTests this.WriteDataFile("plugins/.lock", string.Empty); this.WriteDataFile("tokenizers/openai-gpt-6/tokenizer.json", "{}"); this.WriteDataFile("visualBriefings/briefing/manifest.json", "{}"); + this.WriteDataFile("beyondFactsLab/preparation/project.json", "{}"); this.WriteDataFile("media-staging/operation/transcript.md", "temporary"); this.WriteDataFile("databases/sqlite/rag-index.sqlite3", "the index while AI Studio writes to it"); this.WriteDataFile("databases/provenance/provenance.sqlite3", "the provenance store while AI Studio writes to it"); @@ -91,6 +92,7 @@ public sealed class DataBackupWriterTests "data/plugins/my-assistant/.hidden.lua", "data/tokenizers/openai-gpt-6/tokenizer.json", "data/visualBriefings/briefing/manifest.json", + "data/beyondFactsLab/preparation/project.json", "data/databases/provenance/provenance.sqlite3", "data/databases/sqlite/rag-index.sqlite3", "data/databases/vector_database/stores/store_1/segments/segment", diff --git a/app/Tests/Tools/LuaTableTests.cs b/app/Tests/Tools/LuaTableTests.cs new file mode 100644 index 000000000..090b46e7f --- /dev/null +++ b/app/Tests/Tools/LuaTableTests.cs @@ -0,0 +1,88 @@ +using System.Text; + +using Lua; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks the Lua code which the I18N collector and the localization assistant write for the texts of the app. +/// +/// +/// Both write each text as a comment followed by its assignment. A text with several lines, such as a notice in +/// Markdown, has to keep its line breaks, and its comment must not spill into the next line: Lua would read the rest +/// of the text as code there. Each case therefore runs the written code and compares what Lua reads with the text. +/// +[TestFixture] +public sealed class LuaTableTests +{ + private const string KEY = "UI_TEXT_CONTENT.AISTUDIO.TOOLS.NOTICE.T123"; + private const string LUA_KEY = "AISTUDIO::TOOLS::NOTICE::T123"; + + [TestCase("## Welcome!\n\nThe first paragraph.\nThe second line.", "## Welcome!\n\nThe first paragraph.\nThe second line.")] + [TestCase("## Welcome!\r\n\r\nWindows line endings", "## Welcome!\n\nWindows line endings")] + [TestCase("An old Mac line ending\rin between", "An old Mac line ending\nin between")] + [TestCase("Quotes \"like these\",\na backslash \\,\nand -- no comment", "Quotes \"like these\",\na backslash \\,\nand -- no comment")] + public async Task LuaReadsATextWithSeveralLinesWithItsLineBreaks(string text, string expected) + { + var code = Create(new Dictionary { [KEY] = text }); + + Assert.That(await ReadAsync(code, LUA_KEY), Is.EqualTo(expected), code); + } + + [Test] + public void TheCommentOfATextWithSeveralLinesStaysOnOneLine() + { + var code = Create(new Dictionary { [KEY] = "## Welcome!\r\n\r\nThe first paragraph." }); + + Assert.That(code.ReplaceLineEndings("\n").Split('\n'), Does.Contain("-- ## Welcome! The first paragraph."), code); + } + + [Test] + public async Task TheCommentComesFromTheGivenContentAndStaysOnOneLine() + { + var comments = new Dictionary { [KEY] = "## Welcome!\n\nThe first paragraph." }; + var code = Create(new Dictionary { [KEY] = "## Willkommen!\n\nDer erste Absatz." }, comments); + + using (Assert.EnterMultipleScope()) + { + Assert.That(code.ReplaceLineEndings("\n").Split('\n'), Does.Contain("-- ## Welcome! The first paragraph."), code); + Assert.That(await ReadAsync(code, LUA_KEY), Is.EqualTo("## Willkommen!\n\nDer erste Absatz."), code); + } + } + + [Test] + public void TextsOnOneLineAreWrittenAsBefore() + { + var code = Create(new Dictionary + { + [KEY] = "Save \"this\" in C:\\Data ", + ["UI_TEXT_CONTENT.AISTUDIO.TOOLS.NOTICE.T456"] = "Cancel" + }); + + Assert.That(code.ReplaceLineEndings("\n"), Is.EqualTo( + """ + UI_TEXT_CONTENT = {} + + -- Save "this" in C:\Data + UI_TEXT_CONTENT["AISTUDIO::TOOLS::NOTICE::T123"] = "Save \"this\" in C:\\Data" + + -- Cancel + UI_TEXT_CONTENT["AISTUDIO::TOOLS::NOTICE::T456"] = "Cancel" + + """.ReplaceLineEndings("\n"))); + } + + private static string Create(IReadOnlyDictionary texts, IReadOnlyDictionary? comments = null) + { + // The Lua runtime has a type of the same name, which stands for a table in Lua: + var builder = new StringBuilder(); + return SharedTools.LuaTable.Create(ref builder, "UI_TEXT_CONTENT", texts, comments); + } + + private static async Task ReadAsync(string code, string key) + { + using var state = LuaState.Create(); + await state.DoStringAsync(code); + return state.Environment["UI_TEXT_CONTENT"].Read()[key].Read(); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/CSharpStringLiteralTests.cs b/app/Tests/Tools/PluginSystem/CSharpStringLiteralTests.cs new file mode 100644 index 000000000..4d7777543 --- /dev/null +++ b/app/Tests/Tools/PluginSystem/CSharpStringLiteralTests.cs @@ -0,0 +1,127 @@ +using Microsoft.CodeAnalysis.CSharp; + +using SharedTools; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks that the I18N collector reads string literals exactly as the compiler does. +/// +/// +/// The collector files a text under the hash of what it reads, and the app looks it up by the hash of the string the +/// compiler made. Every case therefore compares the reader with Roslyn, the compiler itself. +/// +[TestFixture] +public sealed class CSharpStringLiteralTests +{ + private static readonly string[] LITERALS = + [ + // Regular literals, with every kind of escape sequence: + "\"Plain text\"", + "\"\"", + "\"Quotes \\\" and a backslash \\\\\"", + "\"Line\\nbreak and tab\\t and return\\r\"", + "\"All simple escapes: \\' \\0 \\a \\b \\e \\f \\v\"", + "\"Unicode \\u00FC and \\U0001F600\"", + "\"Hex \\x41, \\x041, \\x0041, and \\xA followed by text\"", + "\"Commas, \\\"quoted\\\", and parentheses (like this)\"", + + // Verbatim literals: + "@\"C:\\Users\\Name\"", + "@\"Doubled \"\"quotes\"\" inside\"", + "@\"Over\nseveral\r\nlines\"", + + // Single-line raw literals: + "\"\"\"Text with \"quotes\" inside\"\"\"", + "\"\"\"By clicking \"Accept GPL and Install,\" you agree\"\"\"", + "\"\"\"\"Text with \"\"\" inside\"\"\"\"", + "\"\"\"{} and \\n stay as they are\"\"\"", + + // Multi-line raw literals: + "\"\"\"\n ## Welcome!\n\n The first paragraph.\n \"\"\"", + "\"\"\"\r\n CRLF line endings\r\n stay as they are\r\n \"\"\"", + "\"\"\"\n Indented\n more indented\n back\n \"\"\"", + "\"\"\"\n Before an empty line\n\n after it\n \"\"\"", + "\"\"\"\n Before a line of spaces\n \n after it\n \"\"\"", + "\"\"\"\n Before a line of more spaces\n \n after it\n \"\"\"", + "\"\"\" \n Spaces after the opening quotes\n \"\"\"", + "\"\"\"\n\tTabs as indentation\n\t\"\"\"", + "\"\"\"\n Quotes \"\" inside, and \"quoted\" text\n \"\"\"", + "\"\"\"\"\n Three quotes \"\"\" inside\n \"\"\"\"", + "\"\"\"\nNo indentation at all\n\"\"\"", + ]; + + private static readonly string[] NO_LITERALS = + [ + "Text without quotes", + "$\"Interpolated {value}\"", + "\"Unterminated", + "\"Line break\n inside a regular literal\"", + "\"Unknown escape \\q\"", + "\"Short unicode \\u12\"", + "@\"Unterminated verbatim", + "\"\"\"Unterminated raw", + "\"\"\"\n Content after the closing line is missing", + "\"\"\"\n Less indented than\n the closing line is not allowed\n \"\"\"", + ]; + + [TestCaseSource(nameof(LITERALS))] + public void TheReaderGetsWhatTheCompilerGets(string literal) + { + var token = SyntaxFactory.ParseToken(literal); + Assume.That(token.ContainsDiagnostics, Is.False, "The case itself is no valid literal."); + + var isRead = CSharpStringLiteral.TryRead($"{literal}) and more code", out var value, out var length); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isRead, Is.True); + Assert.That(value, Is.EqualTo(token.ValueText)); + Assert.That(length, Is.EqualTo(token.Span.Length)); + } + } + + [TestCaseSource(nameof(NO_LITERALS))] + public void SomethingTheCompilerRefusesOrComputesAtRuntimeIsNotRead(string source) + { + Assert.That(CSharpStringLiteral.TryRead(source, out _, out _), Is.False); + } + + [Test] + public void TheCasesWhichAreNoLiteralsAreRefusedByTheCompilerAsWell() + { + using (Assert.EnterMultipleScope()) + { + foreach (var source in NO_LITERALS.Where(source => source.StartsWith('"') || source.StartsWith("@\"", StringComparison.Ordinal))) + { + var token = SyntaxFactory.ParseToken(source); + Assert.That(token.ContainsDiagnostics || token.Span.Length < source.Length, Is.True, $"The compiler accepts: {source}"); + } + } + } + + [Test] + public void TheLiteralEndsWhereTheCompilerEndsIt() + { + const string SOURCE = "\"First\", \"Second\")"; + + var isRead = CSharpStringLiteral.TryRead(SOURCE, out var value, out var length); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isRead, Is.True); + Assert.That(value, Is.EqualTo("First")); + Assert.That(SOURCE[length..], Is.EqualTo(", \"Second\")")); + } + } + + [Test] + public void EveryLiteralIsAValidStringToken() + { + using (Assert.EnterMultipleScope()) + { + foreach (var literal in LITERALS) + Assert.That(SyntaxFactory.ParseToken(literal).Kind(), Is.AnyOf(SyntaxKind.StringLiteralToken, SyntaxKind.SingleLineRawStringLiteralToken, SyntaxKind.MultiLineRawStringLiteralToken), literal); + } + } +} \ No newline at end of file diff --git a/app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs b/app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs new file mode 100644 index 000000000..64f236d35 --- /dev/null +++ b/app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs @@ -0,0 +1,179 @@ +using System.Runtime.CompilerServices; + +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +using SharedTools; + +namespace AIStudio.Tests.Tools.PluginSystem; + +/// +/// Checks how the I18N collector finds the texts of T() and TB(), and which key it files them under. +/// +/// +/// The app looks up a translation by the hash of the string the compiler made of a text. The collector has to file +/// each text under the same hash, or the text stays English in every language without any notice. The checks over +/// the source of the app therefore compare every text the collector finds with what the compiler makes of it. +/// +[TestFixture] +public sealed class I18NSourceScannerTests +{ + [Test] + public void EveryTextOfTheAppIsReadAsTheCompilerReadsIt() + { + var texts = 0; + var mismatches = new List(); + foreach (var file in SourceFiles()) + { + var content = File.ReadAllText(file); + var warnings = new List(); + foreach (var text in I18NSourceScanner.FindTexts(content, file, warnings)) + { + texts++; + var token = SyntaxFactory.ParseToken(content, text.Offset); + if (token.ContainsDiagnostics || token.HasLeadingTrivia || token.Span.Length != text.Length || token.ValueText != text.Text) + mismatches.Add($"{Path.GetRelativePath(AppSourceDirectory(), file)} at {text.Offset}: '{text.Text}' instead of '{token.ValueText}'"); + } + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(texts, Is.GreaterThan(5_000), "The test found too few texts to be looking at the source of the app."); + Assert.That(mismatches, Is.Empty); + } + } + + [Test] + public void EveryCallWithATextIsFound() + { + var missing = new List(); + var calls = 0; + foreach (var file in SourceFiles().Where(file => file.EndsWith(".cs", StringComparison.Ordinal))) + { + var content = File.ReadAllText(file); + if (!content.Contains("T(", StringComparison.Ordinal)) + continue; + + var found = I18NSourceScanner.FindTexts(content, file, []).Select(text => text.Offset).ToHashSet(); + var root = CSharpSyntaxTree.ParseText(content).GetRoot(); + foreach (var invocation in root.DescendantNodes().OfType()) + { + if (CalledName(invocation) is not ("T" or "TB") || invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not LiteralExpressionSyntax literal) + continue; + + // The collector takes only a text which follows the parenthesis right away: + if (literal.SpanStart != invocation.ArgumentList.OpenParenToken.Span.End) + continue; + + calls++; + if (!found.Contains(literal.SpanStart)) + missing.Add($"{Path.GetRelativePath(AppSourceDirectory(), file)}: {literal}"); + } + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(calls, Is.GreaterThan(2_000), "The test found too few calls to be looking at the source of the app."); + Assert.That(missing, Is.Empty); + } + } + + [Test] + public void TheSourceOfTheAppCausesNoWarning() + { + var warnings = new List(); + foreach (var file in SourceFiles()) + I18NSourceScanner.FindTexts(File.ReadAllText(file), Path.GetRelativePath(AppSourceDirectory(), file), warnings); + + Assert.That(warnings, Is.Empty); + } + + [Test] + public void AMultiLineTextIsFoundWithoutItsIndentation() + { + const string SOURCE = """" + public string Notice => TB(""" + ## Welcome! + + The first paragraph. + """); + """"; + + var texts = I18NSourceScanner.FindTexts(SOURCE, "Notice.cs", []); + + Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "## Welcome!\n\nThe first paragraph." })); + } + + [Test] + public void AllKindsOfCallsAreFound() + { + const string SOURCE = """ + T("First"); TB("Second"); this.T("Third", typeof(X).Namespace, nameof(X)); @T(@"Fourth"); T( "Not found"); + """; + + var texts = I18NSourceScanner.FindTexts(SOURCE, "Calls.cs", []); + + Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "First", "Second", "Third", "Fourth" })); + } + + [Test] + public void ACallOfAnotherMethodIsIgnored() + { + const string SOURCE = """GetT("No text"); MyTB("No text either"); T("Text");"""; + + var texts = I18NSourceScanner.FindTexts(SOURCE, "Other.cs", []); + + Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "Text" })); + } + + [TestCase("""T($"Hello {name}");""")] + [TestCase("""T("Hello " + name);""")] + [TestCase("""T("Unterminated);""")] + public void ATextWhichCannotBeTranslatedCausesAWarning(string source) + { + var warnings = new List(); + + var texts = I18NSourceScanner.FindTexts(source, "Warning.cs", warnings); + + using (Assert.EnterMultipleScope()) + { + Assert.That(texts, Is.Empty); + Assert.That(warnings, Has.Count.EqualTo(1)); + } + } + + [Test] + public void LineEndingsDoNotChangeTheKey() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(I18NKeyHash.Of("First line\r\nsecond line"), Is.EqualTo(I18NKeyHash.Of("First line\nsecond line"))); + Assert.That(I18NKeyHash.Of("First line\rsecond line"), Is.EqualTo(I18NKeyHash.Of("First line\nsecond line"))); + } + } + + [TestCase("Save")] + [TestCase("Parameter: ")] + [TestCase("""Invalid JSON: Add the parameters in proper JSON formatting, e.g., "temperature": 0.5.""")] + public void ATextWithoutLineBreaksKeepsItsKey(string text) + { + Assert.That(I18NKeyHash.Of(text), Is.EqualTo(text.ToFNV32())); + } + + private static string? CalledName(InvocationExpressionSyntax invocation) => invocation.Expression switch + { + IdentifierNameSyntax identifier => identifier.Identifier.Text, + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text, + _ => null, + }; + + private static IEnumerable SourceFiles() + { + var appDirectory = AppSourceDirectory(); + return Directory.EnumerateFiles(appDirectory, "*.*", SearchOption.AllDirectories) + .Where(path => path.EndsWith(".cs", StringComparison.Ordinal) || path.EndsWith(".razor", StringComparison.Ordinal)) + .Where(path => Path.GetRelativePath(appDirectory, path).Split(Path.DirectorySeparatorChar)[0] is not ("bin" or "obj" or "wwwroot")); + } + + private static string AppSourceDirectory([CallerFilePath] string sourceFilePath = "") => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "MindWork AI Studio")); +} \ No newline at end of file diff --git a/documentation/Build.md b/documentation/Build.md index 275a1095c..f8ae494bf 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -37,7 +37,7 @@ It runs the .NET tests, the trim smoke test, the Rust tests, Clippy (`cargo clip `dotnet run build` runs the gate first and stops when it does not pass. For the quick loop while you are working on something, use `dotnet run build --skip-verify`, and let the gate run before you open a pull request. The same command runs in our GitHub workflow as the `verify` job, on every pull request which changes more than the documentation — including those without the `run-pipeline` label, because a gate which is closed exactly while nobody is looking is not a gate. Three notes: -- The trim smoke test publishes `app/TrimSmokeTest` trimmed, exactly like the app, and reads and writes the JSON of chats, settings, Visual Briefing projects, backups, and model answers. Debug builds and the tests are not trimmed, so only a published build shows when the trimmer removed code which System.Text.Json needs; AI Studio can then neither load nor save that data. When AI Studio starts to store a new kind of file or to read a new kind of model answer, add its type to `JsonRoots` in `app/TrimSmokeTest`. The smoke test also lets the Mathematics tools compute a few expressions, since AngouriMath is trimmed, too; see `MathematicsChecks` there. +- The trim smoke test publishes `app/TrimSmokeTest` trimmed, exactly like the app, and reads and writes the JSON of chats, settings, Visual Briefing projects, backups, model answers, and every other type AI Studio reads or writes. Debug builds and the tests are not trimmed, so only a published build shows when the trimmer removed code which System.Text.Json needs; AI Studio can then neither load nor save that data. A new kind of file or model answer needs nothing there: `JsonRoots` in `app/TrimSmokeTest` finds every type through the context of the options it is read or written with. The smoke test also lets the Mathematics tools compute a few expressions, since AngouriMath is trimmed, too; see `MathematicsChecks` there. - The Rust half of the gate needs the .NET sidecar (see "One-time mandatory steps" above). While that file is missing, the gate skips the Rust tests and Clippy and says so rather than failing, because the command which produces the sidecar is `dotnet run build` itself. - `dotnet run verify-models` reports how long ago somebody last read the pages behind the model rules, and names everything older than six months. It is a report and never a failure: a page nobody has looked at for a while is not a page which changed. Everything else about the model rules — whether two rules claim the same names, whether every family names a page and a day, whether every pattern is written the way model names arrive — is checked by the test project, and therefore by `dotnet test`. diff --git a/runtime/src/data_restore.rs b/runtime/src/data_restore.rs index a7f84b523..393053e23 100644 --- a/runtime/src/data_restore.rs +++ b/runtime/src/data_restore.rs @@ -52,7 +52,7 @@ const SETTINGS_FILE_NAME: &str = "settings.json"; /// (`DataBackupContents`) includes. The runtime refuses a plan which names anything else, so that /// whatever goes wrong in the app, a restore never replaces the logs, the downloaded tools, or the /// data folder as a whole. -const REPLACEABLE_ELEMENTS: [(Location, &str); 10] = [ +const REPLACEABLE_ELEMENTS: [(Location, &str); 11] = [ (Location::ConfigDirectory, SETTINGS_FILE_NAME), (Location::DataDirectory, "workspaces/"), (Location::DataDirectory, "tempChats/"), @@ -60,6 +60,7 @@ const REPLACEABLE_ELEMENTS: [(Location, &str); 10] = [ (Location::DataDirectory, "plugins/"), (Location::DataDirectory, "tokenizers/"), (Location::DataDirectory, "visualBriefings/"), + (Location::DataDirectory, "beyondFactsLab/"), (Location::DataDirectory, "databases/sqlite/"), (Location::DataDirectory, "databases/vector_database/stores/"), (Location::DataDirectory, "mediaTranscripts/"), @@ -811,6 +812,21 @@ mod tests { assert!(!test.exists(Location::DataDirectory, "mediaTranscripts/current")); } + #[test] + fn a_restore_replaces_the_preparations_of_the_beyondfacts_lab() { + let test = TestFolders::new(); + test.write_current_data(); + test.write(Location::DataDirectory, "beyondFactsLab/current/project.json", "current preparation"); + test.prepare_restore(r#"{ "location": "DATA_DIRECTORY", "relative_path": "beyondFactsLab/", "keep": [] }"#); + test.write(Location::DataDirectory, ".restore-pending/beyondFactsLab/restored/project.json", "restored preparation"); + + let outcome = apply_pending_restore_in(&test.folders(), &[]); + + assert_eq!(outcome, Some(RestoreOutcome::Applied)); + assert_eq!(test.read(Location::DataDirectory, "beyondFactsLab/restored/project.json").as_deref(), Some("restored preparation")); + assert!(!test.exists(Location::DataDirectory, "beyondFactsLab/current")); + } + #[test] fn a_plan_which_names_another_element_is_refused() { for relative_path in ["", "events.log", "databases/", "../data/", "pandoc/"] {