Added confirmed work states to the BeyondFacts Lab (#1182)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-10 11:27:51 +01:00
1 parent 6d0052ecdf
commit a9579dd282
39 files changed
+3160 -16

No files matched your search

@@ -0,0 +1,231 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Tools.AssistantSessions;
namespace AIStudio.Assistants.BeyondFactsLab;
/// <summary>
/// Lets the model write the draft of the work state of a step, together with suggestions for the empty fields of its
/// form.
/// </summary>
/// <remarks>
/// The model writes in a hidden copy of the conversation of the step, with the same system prompt, so it knows all
/// the step knows; the person never sees the request, and the chat stays as it is. Like the conversations, a
/// singleton, so that the work goes on while the person looks at another page. Writing the work state needs no tool:
/// the model answers in Markdown sections, which BflMarkdownSections reads strictly. When the sections are wrong, the
/// model gets one chance to correct them. Nothing the model writes reaches a prompt of the lab before the person
/// confirms it, and no suggestion reaches the form before the person takes it over.
/// </remarks>
/// <param name="store">The store of the lab.</param>
/// <param name="conversations">The conversations of the lab, whose prompts and checks the work state shares.</param>
/// <param name="sessionService">The registry of running assistant sessions.</param>
/// <param name="messageBus">The message bus, for errors.</param>
/// <param name="logger">The logger.</param>
public sealed class BflWorkStateGenerator(BeyondFactsLabStore store, BflConversationService conversations, AssistantSessionService sessionService, MessageBus messageBus, ILogger<BflWorkStateGenerator> logger)
{
private static string TA(string fallbackEN) => Tools.PluginSystem.I18N.I.TA(fallbackEN, typeof(BflWorkStateGenerator).Namespace, nameof(BflWorkStateGenerator));
/// <summary>
/// The steps whose work state the model writes right now.
/// </summary>
private readonly HashSet<BflStepThreadLocation> running = [];
/// <summary>
/// Guards the steps whose work state the model writes right now.
/// </summary>
private readonly Lock runningLock = new();
/// <summary>
/// Happens when the model starts or ends writing a work state, and when a new draft is stored.
/// </summary>
/// <remarks>
/// Called on the thread of the request. A page renders through InvokeAsync.
/// </remarks>
public event Func<BflStepThreadLocation, Task>? WorkStateChanged;
/// <summary>
/// Tells whether the model writes the work state of a step right now.
/// </summary>
/// <param name="location">The step.</param>
public bool IsRunning(BflStepThreadLocation location)
{
lock (this.runningLock)
return this.running.Contains(location);
}
/// <summary>
/// Lets the model write the draft of the work state of a step and stores it, replacing an earlier draft and
/// earlier suggestions.
/// </summary>
/// <remarks>
/// What goes wrong is reported through the message bus; the method itself does not fail. A confirmed work state
/// stays as it is until the person confirms the new draft.
/// </remarks>
/// <param name="location">The step.</param>
/// <param name="provider">The provider which writes the work state.</param>
public async Task GenerateAsync(BflStepThreadLocation location, AIStudio.Settings.Provider provider)
{
var conversation = await conversations.GetAsync(location);
if (conversation.Thread is not { } thread || conversation.IsRunning || !await conversations.CheckProviderAsync(location, thread, provider))
return;
lock (this.runningLock)
{
if (!this.running.Add(location))
return;
}
var sessionKey = SessionKey(location);
var sessionId = Guid.Empty;
var status = AssistantSessionStatus.FAILED;
try
{
var form = await store.LoadFormAsync(location.ProjectId, location.Step);
var requestMaterial = await conversations.ComposeRequestAsync(location, provider);
var template = await BflPromptLibrary.ReadAsync(BflPromptLibrary.WORK_STATE_PATH, conversations.PromptOverridesDirectory);
var request = BflPromptComposer.ComposeWorkStateRequest(template, location.Step, form);
// A copy of the conversation, which the person never sees and the lab never stores:
var hidden = thread with { Blocks = [.. thread.Blocks] };
BflThreadFactory.PrepareForRequest(hidden, provider, requestMaterial.SystemPrompt);
var cancellation = new CancellationTokenSource();
sessionId = (await sessionService.TryBeginAsync(sessionKey, Tools.Components.BEYOND_FACTS_LAB_ASSISTANT.Name, cancellation, null, new(StringComparer.Ordinal))).SessionId;
await this.NotifyAsync(location);
var answer = BflMarkdownSections.Read(await AskAsync(hidden, provider, request, requestMaterial.Images, cancellation.Token), location.Step, form);
if (answer.Problems.Count > 0 && !cancellation.IsCancellationRequested)
{
logger.LogInformation("The model wrote a work state of the BeyondFacts Lab with {Count} problems; it may correct them once.", answer.Problems.Count);
answer = BflMarkdownSections.Read(await AskAsync(hidden, provider, BflPromptComposer.ComposeWorkStateRepairRequest(answer.Problems), requestMaterial.Images, cancellation.Token), location.Step, form);
}
if (cancellation.IsCancellationRequested)
{
status = AssistantSessionStatus.CANCELED;
return;
}
// Even after the second try, the person gets what the model wrote, as long as it wrote anything; they check and edit the draft anyway:
if (answer.Sections.Count is 0)
{
logger.LogWarning("The model wrote no readable work state of the BeyondFacts Lab: {Problems}", string.Join(" ", answer.Problems));
await messageBus.SendError(new(Icons.Material.Filled.ErrorOutline, TA("The model did not write a work state the lab can read. Please try again, or try another provider.")));
return;
}
var workState = await store.LoadWorkStateAsync(location.ProjectId, location.Step);
workState.Draft = BflWorkStateSections.ForStep(location.Step).ToDictionary(section => section.Key, section => answer.Sections.GetValueOrDefault(section.Key, string.Empty), StringComparer.Ordinal);
workState.FieldSuggestions = new(answer.FieldSuggestions, StringComparer.Ordinal);
await store.SaveWorkStateAsync(location.ProjectId, location.Step, workState);
status = AssistantSessionStatus.COMPLETED;
}
catch (OperationCanceledException)
{
status = AssistantSessionStatus.CANCELED;
}
catch (BflPromptOverrideException exception)
{
logger.LogWarning(exception, "A prompt override of the BeyondFacts Lab has a placeholder which the lab does not fill.");
await messageBus.SendError(new(Icons.Material.Filled.ErrorOutline, string.Format(TA("The file '{0}' in the test folder of the BeyondFacts Lab contains the placeholder '{1}', which the lab does not fill in this file. Please correct the file and try again."), exception.FileName, exception.Placeholder)));
}
catch (ProviderRequestException exception)
{
logger.LogError(exception, "The provider request for a work state of the BeyondFacts Lab failed. Status={StatusCode}, Reason='{ReasonPhrase}'.", exception.StatusCode, exception.ReasonPhrase);
await messageBus.SendError(new(Icons.Material.Filled.CloudOff, exception.UserMessage));
}
catch (FileNotFoundException)
{
// The person deleted the preparation while the model wrote; nothing is left to store.
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or FormatException)
{
logger.LogError(exception, "The BeyondFacts Lab could not write the work state of '{Location}'.", location.Key);
await messageBus.SendError(new(Icons.Material.Filled.SaveAs, TA("AI Studio could not create the work state. Please try again.")));
}
finally
{
lock (this.runningLock)
this.running.Remove(location);
if (sessionId != Guid.Empty)
{
await sessionService.CompleteAsync(sessionKey, sessionId, status, string.Empty, null, new(StringComparer.Ordinal));
await sessionService.ClearAsync(sessionKey);
}
await this.NotifyAsync(location);
}
}
/// <summary>
/// Stops the model writing the work state of a step.
/// </summary>
/// <param name="location">The step.</param>
public Task CancelAsync(BflStepThreadLocation location) => sessionService.CancelAsync(SessionKey(location));
/// <summary>
/// Sends a request in the hidden copy of a conversation and waits for the whole answer.
/// </summary>
/// <param name="hidden">The hidden copy of the conversation, which keeps the request and the answer.</param>
/// <param name="provider">The provider which answers.</param>
/// <param name="text">The request.</param>
/// <param name="images">The images of the material, which go along with the first message.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The answer, empty when the provider failed.</returns>
private static async Task<string> AskAsync(ChatThread hidden, AIStudio.Settings.Provider provider, string text, IReadOnlyList<FileAttachmentImage> images, CancellationToken token)
{
var request = new ContentText { Text = text };
hidden.Blocks.Add(new ContentBlock
{
Time = DateTimeOffset.Now,
ContentType = ContentType.TEXT,
Role = ChatRole.USER,
Content = request,
HideFromUser = true
});
var answer = new ContentText { InitialRemoteWait = true };
hidden.Blocks.Add(new ContentBlock
{
Time = DateTimeOffset.Now,
ContentType = ContentType.TEXT,
Role = ChatRole.AI,
Content = answer,
HideFromUser = true
});
await answer.CreateFromProviderAsync(provider.CreateProvider(), provider.Model, request, BflConversationService.WithImages(hidden, images), token);
return answer.Text;
}
/// <summary>
/// Tells every page which listens that a work state changed.
/// </summary>
/// <param name="location">The step.</param>
private async Task NotifyAsync(BflStepThreadLocation location)
{
if (this.WorkStateChanged is not { } handlers)
return;
foreach (var handler in handlers.GetInvocationList().Cast<Func<BflStepThreadLocation, Task>>())
{
try
{
await handler(location);
}
catch (Exception exception)
{
// A page which went away meanwhile must not stop the work:
logger.LogDebug(exception, "A page of the BeyondFacts Lab could not show a change of a work state.");
}
}
}
/// <summary>
/// Gets the key under which writing the work state of a step registers in AssistantSessionService, beside the
/// conversation of the step.
/// </summary>
/// <param name="location">The step.</param>
private static AssistantSessionKey SessionKey(BflStepThreadLocation location) => new(Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, $"{location.Key}/work-state");
}