2026-11-12 15:02:00 +01:00
using System.Globalization ;
using System.Reflection ;
using AIStudio.Components ;
using AIStudio.Dialogs ;
using AIStudio.Tools.Metadata ;
using AIStudio.Tools.Services ;
using Microsoft.AspNetCore.Components ;
using DialogOptions = AIStudio . Dialogs . DialogOptions ;
namespace AIStudio.Assistants.BeyondFactsLab ;
/// <summary>
/// Lets the person share a session of the BeyondFacts Lab with its research team: anonymized, checked in a preview
/// which the person may edit, exported as a file, and sent by mail from the mail program of the person.
/// </summary>
/// <remarks>
/// Nothing of the dialog is stored. The lab suggests the names it knows each time, and the person enters their own
/// name anew; the session itself stays as it is.
/// </remarks>
public partial class BflShareDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get ; set ; } = null !;
[Inject]
private BeyondFactsLabStore Store { get ; init ; } = null !;
[Inject]
private BflConversationService ConversationService { get ; init ; } = null !;
[Inject]
private RustService RustService { get ; init ; } = null !;
[Inject]
private AIGeneratedContentDisclosureService DisclosureService { get ; init ; } = null !;
[Inject]
private IDialogService DialogService { get ; init ; } = null !;
[Inject]
private ILogger < BflShareDialog > Logger { get ; init ; } = null !;
/// <summary>
/// Gets or sets the ID of the preparation to share.
/// </summary>
[Parameter]
public Guid ProjectId { get ; set ; }
/// <summary>
/// Gets or sets whether the lab asks right after the person finished the last step, so that it thanks them first.
/// </summary>
[Parameter]
public bool AfterCompletion { get ; set ; }
/// <summary>
/// The version of AI Studio, which goes into the session, since the prompts of the lab change with it.
/// </summary>
private static readonly string APP_VERSION = Assembly . GetExecutingAssembly (). GetCustomAttribute < MetaDataAttribute >()?. Version ?? "unknown" ;
/// <summary>
/// The format of every shared session, so that the research team gets all of them in the same one.
/// </summary>
/// <remarks>
/// Markdown needs no Pandoc, so that everybody can share a session. The research team may ask for another format,
/// but only one which AI Studio writes itself, see FileExportFormatExtensions.IsPlainText.
/// </remarks>
private const FileExportFormat EXPORT_FORMAT = FileExportFormat . MARKDOWN ;
/// <summary>
/// Gets the culture of the active language plugin, for numbers.
/// </summary>
private static CultureInfo Culture => Tools . PluginSystem . I18N . I . Culture ;
/// <summary>
/// Gets the link which opens the mail to the research team.
/// </summary>
private string MailUri { get ; } = BflShareMail . CreateUri ();
/// <summary>
/// What the session is put together from, or null when the preparation cannot be opened.
/// </summary>
private BflShareInput ? source ;
/// <summary>
/// Whether the dialog still reads the preparation.
/// </summary>
private bool isLoading = true ;
/// <summary>
/// The name of the person, which the session should not contain.
/// </summary>
private string ownName = string . Empty ;
/// <summary>
/// The names of other people, one per line, which the session should not contain.
/// </summary>
private string otherNames = string . Empty ;
/// <summary>
/// Further terms, one per line, which the session should not contain.
/// </summary>
private string terms = string . Empty ;
/// <summary>
/// Whether personal disclosures go into the session.
/// </summary>
private bool includePersonalDisclosures ;
/// <summary>
/// The anonymized session as the person sees it in the preview, with what was replaced; null before the first
/// preview.
/// </summary>
private BflAnonymization ? preview ;
/// <summary>
/// Whether the person changed the preview, which a new preview would discard.
/// </summary>
private bool previewEdited ;
/// <summary>
/// Whether an export runs.
/// </summary>
private bool isExporting ;
/// <inheritdoc />
protected override async Task OnInitializedAsync ()
{
await base . OnInitializedAsync ();
try
{
var forms = await this . Store . LoadFormsAsync ( this . ProjectId );
var workStates = await this . Store . LoadWorkStatesAsync ( this . ProjectId );
var results = await this . Store . LoadResultsAsync ( this . ProjectId );
var research = await this . Store . LoadAllResearchAsync ( this . ProjectId );
var materials = ( await this . Store . ListMaterialsAsync ( this . ProjectId )). Select ( entry => entry . Material ). OfType < BflMaterial >(). ToList ();
var conversations = new Dictionary < BflStep , IReadOnlyList < BflConversationMessage >>();
foreach ( var step in BflStep . All )
{
if (( await this . ConversationService . GetAsync ( new BflStepThreadLocation ( this . ProjectId , step ))). Thread is { } thread )
conversations [ step ] = BflReflectionFolder . MessagesOf ( thread );
}
var researchTopics = research . Where ( entry => ! string . IsNullOrWhiteSpace ( entry . Value . Text )). Select ( entry => entry . Key ). ToList ();
2026-11-15 16:36:23 +01:00
var people = await this . Store . LoadAllPersonResearchAsync ( this . ProjectId );
var researchedPeople = BflStepResearch . Of ( research , people ). People . Count ( person => person . Stages . Count > 0 );
var simulation = BflEffectiveSimulation . Of ( forms . GetValueOrDefault ( BflStep . TEST_SITUATION )?. Simulation , BflSimulationCandidates . Of ( forms , research , people ));
this . source = new BflShareInput ( forms , workStates , results , conversations , materials , researchTopics , researchedPeople , false , APP_VERSION , DateTimeOffset . UtcNow , Culture , await this . LoadValueDeckAsync (), simulation );
2026-11-12 15:02:00 +01:00
this . otherNames = string . Join ( '\n' , BflShareNames . Suggest ( forms , workStates , research . GetValueOrDefault ( BflResearchTopic . PARTICIPANTS )));
}
catch ( Exception exception ) when ( exception is IOException or UnauthorizedAccessException or InvalidDataException )
{
this . Logger . LogError ( exception , "Could not read the preparation '{ProjectId}' of the BeyondFacts Lab to share it." , this . ProjectId );
this . source = null ;
}
finally
{
this . isLoading = false ;
}
}
2026-11-15 13:06:06 +01:00
/// <summary>
/// Reads the value deck in the language of the app, with which the session shows the card sort.
/// </summary>
/// <returns>The deck, or null when the app comes without a readable one; the session then leaves the card sort out.</returns>
private async Task < BflValueDeck ?> LoadValueDeckAsync ()
{
try
{
return await BflValueDeck . LoadAsync ( Culture );
}
catch ( Exception exception ) when ( exception is FileNotFoundException or FormatException )
{
this . Logger . LogError ( exception , "Could not read the value deck of the BeyondFacts Lab to share the card sort." );
return null ;
}
}
2026-11-12 15:02:00 +01:00
/// <summary>
/// Puts the session together and anonymizes it with the names and terms the person listed; asks first when that
/// would discard changes of the person in the preview.
/// </summary>
private async Task CreatePreviewAsync ()
{
if ( this . source is null )
return ;
if ( this . previewEdited )
{
var parameters = new DialogParameters < ConfirmDialog >
{
{ dialog => dialog . Message , TA ( "Create the preview anew? Your changes in the preview will be lost." ) },
};
var reference = await this . DialogService . ShowAsync < ConfirmDialog >( T ( "Create preview anew" ), parameters , DialogOptions . FULLSCREEN );
var answer = await reference . Result ;
if ( answer is null || answer . Canceled )
return ;
}
var session = BflShareModelBuilder . Build ( this . source with { IncludePersonalDisclosures = this . includePersonalDisclosures , SharedAtUtc = DateTimeOffset . UtcNow });
this . preview = this . Anonymize ( session );
this . previewEdited = false ;
}
/// <summary>
/// Anonymizes the preview once more after the person changed it, so that a name the person typed goes as well.
/// </summary>
/// <param name="text">The preview as the person changed it.</param>
private void PreviewChanged ( string? text )
{
if ( this . preview is null )
return ;
this . preview = this . preview . CombinedWith ( this . Anonymize ( text ?? string . Empty ));
this . previewEdited = true ;
}
/// <summary>
/// Exports the preview in the one format the research team gets, anonymized once more with the names and terms as
/// they stand now, and with the notice that AI generated it when the person or the organization switched that on.
/// </summary>
private async Task ExportAsync ()
{
if ( this . preview is null )
return ;
this . preview = this . preview . CombinedWith ( this . Anonymize ( this . preview . Text ));
var fileName = $"{T(" BeyondFacts Lab ")} - {T(" Shared session ")}" ;
this . isExporting = true ;
try
{
await PlainFileExport . ToFile ( this . RustService , T ( "Share with the research team" ), EXPORT_FORMAT , this . DisclosureService . AddDisclosureToMarkdown ( this . preview . Text ), fileName );
}
finally
{
this . isExporting = false ;
}
}
/// <summary>
/// Anonymizes a text with the names and terms the person listed, the own name first.
/// </summary>
/// <param name="text">The text.</param>
private BflAnonymization Anonymize ( string text ) => BflAnonymizer . Anonymize ( text , [ this . ownName , .. LinesOf ( this . otherNames )], LinesOf ( this . terms ));
/// <summary>
/// Gets the entries of a field with one entry per line.
/// </summary>
/// <param name="text">The text of the field.</param>
private static IReadOnlyList < string > LinesOf ( string text ) => text . Split ( '\n' , StringSplitOptions . TrimEntries | StringSplitOptions . RemoveEmptyEntries );
private void Close () => this . MudDialog . Close ( DialogResult . Ok ( true ));
}