Added an introduction and an onboarding chat to the BeyondFacts Lab (#1178)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-09 12:42:59 +01:00
1 parent 79e0c80031
commit 22d94eb8fd
35 files changed
+1377 -361

No files matched your search

@@ -8,7 +8,7 @@ using AIStudio.Tools.AssistantSessions;
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// Runs the chats of the steps of the BeyondFacts Lab.
/// Runs the chats of the BeyondFacts Lab, such as those of the steps of a preparation.
/// </summary>
/// <remarks>
/// A singleton, so that an answer goes on while the person looks at another page: the page asks for the chat again
@@ -30,17 +30,21 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
/// </remarks>
public const string OPENING_REQUEST = "Beginne das Gespräch dieses Schritts mit deiner ersten Nachricht, wie es der Abschnitt „Dieser Schritt“ beschreibt.";
/// <summary>
/// The request which lets the model open the conversation about whether the lab fits a task. The person does not
/// see it.
/// </summary>
public const string ONBOARDING_OPENING_REQUEST = "Beginne das Gespräch mit deiner ersten Nachricht, wie es der Abschnitt „Gesprächsführung“ beschreibt.";
/// <summary>
/// How often a streamed answer renders at most.
/// </summary>
private static readonly TimeSpan STREAMING_RENDER_MIN_TIME = TimeSpan.FromMilliseconds(100);
private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflConversationService).Namespace, nameof(BflConversationService));
private static string TA(string fallbackEN) => Tools.PluginSystem.I18N.I.TA(fallbackEN, typeof(BflConversationService).Namespace, nameof(BflConversationService));
/// <summary>
/// The chats loaded so far, by their steps.
/// The chats loaded so far, by their places.
/// </summary>
private readonly ConcurrentDictionary<BflThreadLocation, BflConversation> conversations = new();
@@ -63,9 +67,9 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
public event Func<BflThreadLocation, Task>? ConversationChanged;
/// <summary>
/// Gets the chat of a step, loading it on the first call.
/// Gets a chat, loading it on the first call.
/// </summary>
/// <param name="location">The step of the preparation.</param>
/// <param name="location">Where the chat belongs.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The chat; it is not available when this version cannot open the stored one.</returns>
public async Task<BflConversation> GetAsync(BflThreadLocation location, CancellationToken token = default)
@@ -87,7 +91,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
catch (InvalidDataException exception)
{
// The file stays as it is, see BeyondFactsLabStore.SaveThreadAsync:
logger.LogWarning(exception, "Could not open the chat of the step '{Step}' of the preparation '{ProjectId}' of the BeyondFacts Lab.", location.Step, location.ProjectId);
logger.LogWarning(exception, "Could not open the chat '{Location}' of the BeyondFacts Lab.", location.Key);
thread = null;
}
@@ -102,11 +106,18 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
}
/// <summary>
/// Lets the model open the conversation of a step, as the prompt of the step describes.
/// Lets the model open a conversation, as its prompt describes.
/// </summary>
/// <param name="location">The step of the preparation.</param>
/// <param name="location">Where the chat belongs.</param>
/// <param name="provider">The provider which answers.</param>
public Task StartAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => this.SendAsync(location, provider, OPENING_REQUEST, isHidden: true);
public Task StartAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) => this.SendAsync(location, provider, OpeningRequestFor(location), isHidden: true);
/// <summary>
/// Gets the request which lets the model open a conversation.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
/// <returns>The request, which names the section of the prompt that describes the first message.</returns>
public static string OpeningRequestFor(BflThreadLocation location) => location is BflOnboardingThreadLocation ? ONBOARDING_OPENING_REQUEST : OPENING_REQUEST;
/// <summary>
/// Sends a message of the person and lets the model answer it.
@@ -115,7 +126,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
/// The message is stored before the model answers, so that a crash loses at most the answer. What goes wrong is
/// reported through the message bus; the method itself does not fail.
/// </remarks>
/// <param name="location">The step of the preparation.</param>
/// <param name="location">Where the chat belongs.</param>
/// <param name="provider">The provider which answers.</param>
/// <param name="text">The message.</param>
/// <param name="isHidden">Whether the person does not see the message, e.g., the request which opens a conversation.</param>
@@ -125,7 +136,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
return;
var conversation = await this.GetAsync(location);
if (conversation.Thread is not { } thread || !await this.CheckProviderAsync(thread, provider))
if (conversation.Thread is not { } thread || !await this.CheckProviderAsync(location, thread, provider))
return;
string systemPrompt;
@@ -135,8 +146,8 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or FormatException)
{
logger.LogError(exception, "The BeyondFacts Lab could not put together the system prompt of the step '{Step}'.", location.Step);
await messageBus.SendError(new(Icons.Material.Filled.ErrorOutline, TB("The BeyondFacts Lab could not put together its instructions for the model. Please report this as a bug.")));
logger.LogError(exception, "The BeyondFacts Lab could not put together the system prompt of the chat '{Location}'.", location.Key);
await messageBus.SendError(new(Icons.Material.Filled.ErrorOutline, TA("The BeyondFacts Lab could not put together its instructions for the model. Please report this as a bug.")));
return;
}
@@ -208,7 +219,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(exception, "The BeyondFacts Lab could not store the chat of the step '{Step}'.", location.Step);
logger.LogError(exception, "The BeyondFacts Lab could not store the chat '{Location}'.", location.Key);
await messageBus.SendError(new(Icons.Material.Filled.SaveAs, TA("AI Studio could not save this conversation. Please try again.")));
}
finally
@@ -218,16 +229,16 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
}
/// <summary>
/// Stops the answer the model writes in a step.
/// Stops the answer the model writes in a chat.
/// </summary>
/// <param name="location">The step of the preparation.</param>
/// <param name="location">Where the chat belongs.</param>
public Task CancelAsync(BflThreadLocation location) => sessionService.CancelAsync(SessionKey(location));
/// <summary>
/// Deletes the conversation of a step, so that it starts over.
/// Deletes a conversation, so that it starts over.
/// </summary>
/// <param name="location">The step of the preparation.</param>
/// <returns>False while the model answers in the step.</returns>
/// <param name="location">Where the chat belongs.</param>
/// <returns>False while the model answers in the chat.</returns>
public async Task<bool> ResetAsync(BflThreadLocation location)
{
var conversation = await this.GetAsync(location);
@@ -253,7 +264,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
/// <returns>The IDs of the chats the preparation could have had.</returns>
public async Task<IReadOnlyList<Guid>> ForgetProjectAsync(Guid projectId)
{
var locations = BflStep.All.Select(step => new BflThreadLocation(projectId, step)).ToList();
var locations = BflStep.All.Select(step => new BflStepThreadLocation(projectId, step)).ToList();
foreach (var location in locations)
{
await sessionService.CancelAsync(SessionKey(location));
@@ -264,35 +275,47 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
}
/// <summary>
/// Puts together the system prompt of a step, anew for every request, so that changed details and a changed form
/// Puts together the system prompt of a chat, anew for every request, so that changed details and a changed form
/// of address count right away. The chat also uses it to count what the next request carries.
/// </summary>
/// <param name="location">The step of the preparation.</param>
/// <param name="location">Where the chat belongs.</param>
/// <returns>The system prompt.</returns>
/// <exception cref="FileNotFoundException">The app comes without one of the prompts.</exception>
/// <exception cref="FormatException">A prompt has a placeholder without a value.</exception>
public async Task<string> ComposeSystemPromptAsync(BflThreadLocation location)
{
var forms = await store.LoadFormsAsync(location.ProjectId);
var parts = await BflPromptLibrary.LoadPartsAsync(location.Step, Tools.PluginSystem.I18N.I.AddressForm);
var addressForm = Tools.PluginSystem.I18N.I.AddressForm;
var language = (await settingsManager.GetActiveLanguagePlugin()).LangName;
return BflPromptComposer.Compose(parts, location.Step, forms, language);
switch (location)
{
case BflStepThreadLocation stepLocation:
var forms = await store.LoadFormsAsync(stepLocation.ProjectId);
var parts = await BflPromptLibrary.LoadPartsAsync(stepLocation.Step, addressForm);
return BflPromptComposer.Compose(parts, stepLocation.Step, forms, language);
case BflOnboardingThreadLocation:
return BflPromptComposer.ComposeOnboarding(await BflPromptLibrary.LoadOnboardingPartsAsync(addressForm), language);
default:
throw new ArgumentOutOfRangeException(nameof(location), location, "The lab has no prompt for this chat.");
}
}
/// <summary>
/// Checks whether a provider may answer in a chat, and tells the person why not.
/// </summary>
/// <param name="location">Where the chat belongs.</param>
/// <param name="thread">The chat.</param>
/// <param name="provider">The provider.</param>
/// <returns>True when the provider may answer.</returns>
private async Task<bool> CheckProviderAsync(ChatThread thread, AIStudio.Settings.Provider provider)
private async Task<bool> CheckProviderAsync(BflThreadLocation location, ChatThread thread, AIStudio.Settings.Provider provider)
{
string? reason = null;
if (provider.UsedLLMProvider is LLMProviders.NONE)
reason = TA("Please select a provider.");
else if (!settingsManager.IsProviderConfident(provider, Tools.Components.BEYOND_FACTS_LAB_ASSISTANT) || !thread.IsLLMProviderAllowed(provider))
reason = TA("The selected provider may not see your preparation. Please select another provider.");
else if (!BflModelRequirements.HasEnoughContext(provider.GetModelProfile().Context))
else if (!BflModelRequirements.HasEnoughContext(location, provider.GetModelProfile().Context))
reason = TA("The model of the selected provider reads too little at once for the lab. Please select another provider.");
if (reason is null)
@@ -328,7 +351,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(exception, "The BeyondFacts Lab could not store the chat of the step '{Step}'.", conversation.Location.Step);
logger.LogError(exception, "The BeyondFacts Lab could not store the chat '{Location}'.", conversation.Location.Key);
await messageBus.SendError(new(Icons.Material.Filled.SaveAs, TA("AI Studio could not save this conversation. Please try again.")));
}
}
@@ -350,7 +373,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
/// <summary>
/// Tells every page which listens that a chat changed.
/// </summary>
/// <param name="location">The step of the chat.</param>
/// <param name="location">Where the chat belongs.</param>
private async Task NotifyAsync(BflThreadLocation location)
{
if (this.ConversationChanged is not { } handlers)
@@ -371,8 +394,8 @@ public sealed class BflConversationService(BeyondFactsLabStore store, SettingsMa
}
/// <summary>
/// Gets the key under which a request of a step registers in AssistantSessionService.
/// Gets the key under which a request of a chat registers in AssistantSessionService.
/// </summary>
/// <param name="location">The step of the preparation.</param>
private static AssistantSessionKey SessionKey(BflThreadLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, $"{location.ProjectId:D}/{location.Step.FolderName}");
/// <param name="location">Where the chat belongs.</param>
private static AssistantSessionKey SessionKey(BflThreadLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.Key);
}