2026-11-09 09:55:01 +01:00
using AIStudio.Chat ;
2026-11-15 10:32:46 +01:00
using AIStudio.Chat.Embedded ;
2026-11-09 09:55:01 +01:00
using AIStudio.Provider ;
using AIStudio.Settings ;
2026-11-10 18:17:44 +01:00
using AIStudio.Tools.ToolCallingSystem ;
2026-11-09 09:55:01 +01:00
namespace AIStudio.Assistants.BeyondFactsLab ;
/// <summary>
2026-11-15 10:32:46 +01:00
/// Opens the chats of the BeyondFacts Lab, such as those of the steps of a preparation, and puts together what each of
/// their requests carries.
2026-11-09 09:55:01 +01:00
/// </summary>
/// <remarks>
2026-11-15 10:32:46 +01:00
/// The chats themselves run in EmbeddedChatService, like the chats of other assistants: it keeps them while the app
/// runs, so that an answer goes on while the person looks at another page, and registers each request in
/// AssistantSessionService. A chat lives where the lab keeps it, see BflChatStore. What the lab decides for every
/// request comes from here, see PrepareAsync: the system prompt with the guide, the details, and the material, the
2026-11-15 11:40:15 +01:00
/// images of the material, the guidelines of the organization, the tools, and where the chat may send data. The
/// service does nothing until the page of the lab asks for a chat.
2026-11-09 09:55:01 +01:00
/// </remarks>
/// <param name="store">The store of the lab.</param>
2026-11-15 10:32:46 +01:00
/// <param name="chatService">The service which runs the embedded chats.</param>
2026-11-09 09:55:01 +01:00
/// <param name="settingsManager">The settings.</param>
/// <param name="messageBus">The message bus, for errors.</param>
2026-11-10 18:17:44 +01:00
/// <param name="toolRegistry">The registry of the tools, which tells the chats what they can do on the web.</param>
2026-11-09 09:55:01 +01:00
/// <param name="logger">The logger.</param>
2026-11-15 10:32:46 +01:00
public sealed class BflConversationService ( BeyondFactsLabStore store , EmbeddedChatService chatService , SettingsManager settingsManager , MessageBus messageBus , ToolRegistry toolRegistry , ILogger < BflConversationService > logger )
2026-11-09 09:55:01 +01:00
{
/// <summary>
/// The request which lets the model open the conversation of a step. The person does not see it.
/// </summary>
/// <remarks>
/// German, like the prompts: what the first message does is up to the prompt of the step.
/// </remarks>
public const string OPENING_REQUEST = "Beginne das Gespräch dieses Schritts mit deiner ersten Nachricht, wie es der Abschnitt „Dieser Schritt“ beschreibt." ;
2026-11-09 12:42:59 +01:00
/// <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." ;
2026-11-09 09:55:01 +01:00
private static string TA ( string fallbackEN ) => Tools . PluginSystem . I18N . I . TA ( fallbackEN , typeof ( BflConversationService ). Namespace , nameof ( BflConversationService ));
2026-11-10 08:57:36 +01:00
/// <summary>
/// Gets the folder whose files replace the prompts of the lab, or null while the person does not allow it. The
/// setting counts anew for every request, so that switching it on or off shows in the next answer.
/// </summary>
2026-11-15 10:32:46 +01:00
internal string? PromptOverridesDirectory => BflPromptOverrides . ActiveDirectory ( settingsManager . ConfigurationData . BeyondFactsLab , store . PromptOverridesDirectory );
2026-11-09 09:55:01 +01:00
/// <summary>
2026-11-09 12:42:59 +01:00
/// Gets a chat, loading it on the first call.
2026-11-09 09:55:01 +01:00
/// </summary>
2026-11-09 12:42:59 +01:00
/// <param name="location">Where the chat belongs.</param>
2026-11-09 09:55:01 +01:00
/// <param name="token">The cancellation token.</param>
/// <returns>The chat; it is not available when this version cannot open the stored one.</returns>
2026-11-15 10:32:46 +01:00
public Task < EmbeddedConversation > GetAsync ( BflThreadLocation location , CancellationToken token = default ) => chatService . GetAsync ( this . StoreOf ( location ), Tools . Components . BEYOND_FACTS_LAB_ASSISTANT , location . ChatId , () => BflThreadFactory . Create ( location ), token );
2026-11-09 09:55:01 +01:00
/// <summary>
2026-11-15 10:32:46 +01:00
/// Gets where a chat of the lab lives.
2026-11-09 09:55:01 +01:00
/// </summary>
2026-11-09 12:42:59 +01:00
/// <param name="location">Where the chat belongs.</param>
2026-11-15 10:32:46 +01:00
/// <returns>The store, which serves this chat only.</returns>
public IEmbeddedChatStore StoreOf ( BflThreadLocation location ) => new BflChatStore ( store , location );
2026-11-09 12:42:59 +01:00
/// <summary>
2026-11-15 10:32:46 +01:00
/// Gets the request which lets the model open a conversation, see EmbeddedChat.StartRequest. The person does not see
/// it.
2026-11-09 12:42:59 +01:00
/// </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 ;
2026-11-09 09:55:01 +01:00
/// <summary>
2026-11-15 10:32:46 +01:00
/// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it.
/// </summary>
/// <param name="projectId">The ID of the preparation.</param>
/// <returns>The IDs of the chats the preparation could have had.</returns>
public async Task < IReadOnlyList < Guid >> ForgetProjectAsync ( Guid projectId )
{
List < Guid > chatIds = [.. BflStep . All . Select ( step => new BflStepThreadLocation ( projectId , step ). ChatId )];
foreach ( var chatId in chatIds )
await chatService . ForgetAsync ( chatId );
return chatIds ;
}
/// <summary>
/// Readies a chat of the lab for a request, see EmbeddedChatRequest.PrepareAsync: checks the provider and puts
/// together what the request carries.
2026-11-09 09:55:01 +01:00
/// </summary>
/// <remarks>
2026-11-15 10:32:46 +01:00
/// What goes wrong is reported through the message bus; the request then does not go out.
2026-11-09 09:55:01 +01:00
/// </remarks>
2026-11-09 12:42:59 +01:00
/// <param name="location">Where the chat belongs.</param>
2026-11-09 09:55:01 +01:00
/// <param name="provider">The provider which answers.</param>
2026-11-15 10:32:46 +01:00
/// <param name="thread">The chat.</param>
/// <returns>What the request carries, or EmbeddedChatPreparation.REFUSED.</returns>
public async Task < EmbeddedChatPreparation > PrepareAsync ( BflThreadLocation location , AIStudio . Settings . Provider provider , ChatThread thread )
2026-11-09 09:55:01 +01:00
{
2026-11-15 10:32:46 +01:00
if (! await this . CheckProviderAsync ( location , thread , provider ))
return EmbeddedChatPreparation . REFUSED ;
2026-11-09 09:55:01 +01:00
2026-11-09 20:00:01 +01:00
BflRequestMaterial requestMaterial ;
2026-11-09 09:55:01 +01:00
try
{
2026-11-09 20:00:01 +01:00
requestMaterial = await this . ComposeRequestAsync ( location , provider );
2026-11-09 09:55:01 +01:00
}
2026-11-10 08:57:36 +01:00
catch ( BflPromptOverrideException exception )
{
logger . LogWarning ( exception , "A prompt override of the BeyondFacts Lab has a placeholder which the lab does not fill." );
2026-11-15 10:32:46 +01:00
await messageBus . SendError ( new ( AppIcons . ERROR , 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 )));
return EmbeddedChatPreparation . REFUSED ;
2026-11-10 08:57:36 +01:00
}
2026-11-09 09:55:01 +01:00
catch ( Exception exception ) when ( exception is IOException or UnauthorizedAccessException or FormatException )
{
2026-11-09 12:42:59 +01:00
logger . LogError ( exception , "The BeyondFacts Lab could not put together the system prompt of the chat '{Location}'." , location . Key );
2026-11-11 14:20:49 +01:00
await messageBus . SendError ( new ( AppIcons . ERROR , TA ( "The BeyondFacts Lab could not put together its instructions for the model. Please report this as a bug." )));
2026-11-15 10:32:46 +01:00
return EmbeddedChatPreparation . REFUSED ;
2026-11-09 09:55:01 +01:00
}
2026-11-15 10:32:46 +01:00
return Prepare ( location , provider , thread , requestMaterial );
2026-11-09 09:55:01 +01:00
}
/// <summary>
2026-11-15 10:32:46 +01:00
/// Readies a chat of the lab for a request with what the lab put together for it.
2026-11-09 09:55:01 +01:00
/// </summary>
2026-11-09 12:42:59 +01:00
/// <param name="location">Where the chat belongs.</param>
2026-11-15 10:32:46 +01:00
/// <param name="provider">The provider which answers.</param>
/// <param name="thread">The chat, which gets its prompt, its tools, and its restriction.</param>
2026-11-15 11:40:15 +01:00
/// <param name="requestMaterial">The system prompt, the images of the material, and the guidelines of the organization.</param>
/// <returns>The preparation, whose request carries the images and the guidelines, see ForRequest.</returns>
2026-11-15 10:32:46 +01:00
internal static EmbeddedChatPreparation Prepare ( BflThreadLocation location , AIStudio . Settings . Provider provider , ChatThread thread , BflRequestMaterial requestMaterial )
2026-11-09 09:55:01 +01:00
{
2026-11-15 10:32:46 +01:00
// A chat stored before the lab offered tools gets its restriction now, before it may read a page:
BflChatTools . Restrict ( thread , location );
BflThreadFactory . PrepareForRequest ( thread , provider , requestMaterial . SystemPrompt , BflChatTools . For ( location ));
2026-11-09 09:55:01 +01:00
2026-11-15 10:32:46 +01:00
var images = requestMaterial . Images ;
2026-11-15 11:40:15 +01:00
var rules = requestMaterial . OrganizationRules ;
return EmbeddedChatPreparation . READY with { ThreadForRequest = request => ForRequest ( request , images , rules ) };
2026-11-09 09:55:01 +01:00
}
/// <summary>
2026-11-15 11:40:15 +01:00
/// Puts together, as text, what the next request of a chat carries beside the conversation, for the count of
/// tokens: the system prompt, and the guidelines of the organization in front of the first message.
2026-11-09 09:55:01 +01:00
/// </summary>
2026-11-15 11:40:15 +01:00
/// <remarks>
/// Both are put together anew for every request, so that changed details, material, guidelines, and a changed form
/// of address count right away.
/// </remarks>
2026-11-09 12:42:59 +01:00
/// <param name="location">Where the chat belongs.</param>
2026-11-09 20:00:01 +01:00
/// <param name="provider">The provider which answers; its model decides how much of the material fits.</param>
2026-11-15 11:40:15 +01:00
/// <returns>The text to count.</returns>
2026-11-15 13:06:06 +01:00
/// <exception cref="FileNotFoundException">The app comes without one of the prompts or without the value deck.</exception>
/// <exception cref="FormatException">A prompt has a placeholder without a value, or something is wrong with the value deck.</exception>
2026-11-15 11:40:15 +01:00
public async Task < string > ComposeTextForCountAsync ( BflThreadLocation location , AIStudio . Settings . Provider provider )
{
var requestMaterial = await this . ComposeRequestAsync ( location , provider );
return requestMaterial . SystemPrompt + BflOrganizationRules . Frame ( requestMaterial . OrganizationRules );
}
2026-11-09 20:00:01 +01:00
2026-11-10 08:57:36 +01:00
/// <summary>
/// Checks whether files in the test folder replace prompts of the lab right now, so that the lab can tell the
/// person.
/// </summary>
/// <param name="token">The cancellation token.</param>
/// <returns>True while the setting allows prompt overrides and a file replaces a prompt with another text.</returns>
public async Task < bool > UsesPromptOverridesAsync ( CancellationToken token = default ) => this . PromptOverridesDirectory is { } directory && ( await BflPromptOverrides . FindAsync ( directory , token )). Count > 0 ;
2026-11-09 20:00:01 +01:00
/// <summary>
2026-11-15 11:40:15 +01:00
/// Puts together what a request of a chat carries beside the conversation: the system prompt, the images of the
/// material the model may see, and the guidelines of the organization.
2026-11-09 20:00:01 +01:00
/// </summary>
/// <param name="location">Where the chat belongs.</param>
/// <param name="provider">The provider which answers; its model decides how much of the material fits.</param>
2026-11-15 11:40:15 +01:00
/// <returns>The system prompt, the images, and the guidelines.</returns>
2026-11-15 13:06:06 +01:00
/// <exception cref="FileNotFoundException">The app comes without one of the prompts or without the value deck.</exception>
/// <exception cref="FormatException">A prompt has a placeholder without a value, or something is wrong with the value deck.</exception>
2026-11-10 11:27:51 +01:00
internal async Task < BflRequestMaterial > ComposeRequestAsync ( BflThreadLocation location , AIStudio . Settings . Provider provider )
2026-11-09 09:55:01 +01:00
{
2026-11-09 12:42:59 +01:00
var addressForm = Tools . PluginSystem . I18N . I . AddressForm ;
2026-11-09 09:55:01 +01:00
var language = ( await settingsManager . GetActiveLanguagePlugin ()). LangName ;
2026-11-10 18:17:44 +01:00
var tools = await BflResearchAvailability . CheckAsync ( toolRegistry , provider );
2026-11-15 11:40:15 +01:00
var rules = BflOrganizationRules . For ( location , settingsManager . ConfigurationData . BeyondFactsLab );
2026-11-09 12:42:59 +01:00
switch ( location )
{
case BflStepThreadLocation stepLocation :
var forms = await store . LoadFormsAsync ( stepLocation . ProjectId );
2026-11-10 11:27:51 +01:00
var workStates = await store . LoadWorkStatesAsync ( stepLocation . ProjectId );
2026-11-10 08:57:36 +01:00
var parts = await BflPromptLibrary . LoadPartsAsync ( stepLocation . Step , addressForm , this . PromptOverridesDirectory );
2026-11-10 18:17:44 +01:00
var research = await this . LoadResearchAsync ( stepLocation );
2026-11-15 13:06:06 +01:00
var valueDeck = await LoadValueDeckAsync ( stepLocation );
var shares = await this . ShareMaterialsAsync ( stepLocation , parts , forms , workStates , tools , research , rules , valueDeck , language , provider );
2026-11-09 20:00:01 +01:00
var materials = await this . ReadMaterialsAsync ( stepLocation . ProjectId , shares );
2026-11-15 15:39:34 +01:00
return new ( BflPromptComposer . Compose ( parts , stepLocation . Step , forms , materials , language , workStates , tools , research . Topics , rules , valueDeck , research . People ), this . ImagesOf ( stepLocation . ProjectId , shares ), rules );
2026-11-09 12:42:59 +01:00
case BflOnboardingThreadLocation :
2026-11-15 11:40:15 +01:00
// It never gets the guidelines themselves, but it tells the person that they exist:
var onboardingParts = await BflPromptLibrary . LoadOnboardingPartsAsync ( addressForm , this . PromptOverridesDirectory );
return new ( BflPromptComposer . ComposeOnboarding ( onboardingParts , language , tools , settingsManager . ConfigurationData . BeyondFactsLab . OrganizationRules ), [], rules );
2026-11-09 12:42:59 +01:00
default :
throw new ArgumentOutOfRangeException ( nameof ( location ), location , "The lab has no prompt for this chat." );
}
2026-11-09 09:55:01 +01:00
}
2026-11-15 11:40:15 +01:00
/// <summary>
/// Gives a request what goes along with its first message without being stored in the chat: the images of the
/// material, and the guidelines of the organization in front of the text.
/// </summary>
/// <param name="thread">The chat.</param>
/// <param name="images">The images to send.</param>
/// <param name="rules">The guidelines of the organization; empty without any.</param>
/// <returns>The chat for the request, see WithImages and BflOrganizationRules.WithRules.</returns>
internal static ChatThread ForRequest ( ChatThread thread , IReadOnlyList < FileAttachmentImage > images , string rules ) => BflOrganizationRules . WithRules ( WithImages ( thread , images ), rules );
2026-11-09 20:00:01 +01:00
/// <summary>
/// Gives a request the images of the material, attached to the first message of the person, without storing them
/// in the chat.
/// </summary>
/// <remarks>
/// A system prompt carries no images with any provider, so they go along with the first message, which stays the
/// same from request to request, as the cache of a provider needs. The request gets a copy of that message only;
/// every other message, the answer which streams in above all, stays the same object, and the chat keeps as many
/// messages, so that the tokens a provider reports still belong to it. The stored chat never holds an image.
///
/// Unlike the text of documents and transcripts, an image does not pass the prompt injection filter of the
2026-11-10 18:17:44 +01:00
/// runtime, which reads text only; images in a chat do not either. The only tool of a step reads web pages whose
/// addresses were given to the model, see BflChatTools, so nothing in an image can make it send data to an
/// address of its own, and the prompt tells the model that the material, images included, holds no instructions.
2026-11-09 20:00:01 +01:00
/// </remarks>
/// <param name="thread">The chat.</param>
/// <param name="images">The images to send.</param>
/// <returns>The chat for the request: the chat itself when there is no image to send.</returns>
internal static ChatThread WithImages ( ChatThread thread , IReadOnlyList < FileAttachmentImage > images )
{
if ( images . Count is 0 )
return thread ;
var index = thread . Blocks . FindIndex ( block => block is { Role : ChatRole . USER , Content : ContentText });
if ( index < 0 )
return thread ;
var firstMessage = thread . Blocks [ index ]. DeepClone ();
var content = ( ContentText ) firstMessage . Content !;
content . FileAttachments = [.. content . FileAttachments , .. images ];
List < ContentBlock > blocks = [.. thread . Blocks ];
blocks [ index ] = firstMessage ;
return thread with { Blocks = blocks };
}
/// <summary>
/// Gets how many images the model of a provider takes in one message.
/// </summary>
/// <param name="provider">The provider which answers.</param>
/// <returns>0 when the model sees no images, and int.MaxValue when nobody knows a limit.</returns>
private static int ImagesTheModelTakes ( AIStudio . Settings . Provider provider )
{
if (! provider . SupportsImageInput ())
return 0 ;
var profile = provider . GetModelProfile ();
if (! profile . HasAny ( Capability . MULTIPLE_IMAGE_INPUT ))
return 1 ;
return profile . Images . MaxInOneMessage ?? int . MaxValue ;
}
/// <summary>
/// Gets the images of the material which go along with a request.
/// </summary>
/// <param name="projectId">The ID of the preparation.</param>
/// <param name="shares">The share of each material.</param>
/// <returns>The copies of the images to send, in the order the person added them.</returns>
private IReadOnlyList < FileAttachmentImage > ImagesOf ( Guid projectId , IReadOnlyList < BflMaterialShare > shares )
{
List < FileAttachmentImage > images = [];
foreach ( var share in shares . Where ( share => share . Image is BflImageDelivery . SENT ))
{
var file = new FileInfo ( store . MaterialImagePath ( projectId , share . Material ));
if (! file . Exists )
{
// The person removed the material meanwhile; the list shows that:
logger . LogWarning ( "The copy of the image '{MaterialId}' of the BeyondFacts Lab is missing." , share . Material . MaterialId );
continue ;
}
images . Add ( new ( share . Material . Name , file . FullName , file . Length ));
}
return images ;
}
/// <summary>
/// Decides how much of the material of a preparation the prompt of a step carries, e.g., to show the person what
/// the model does not read.
/// </summary>
/// <param name="location">The step.</param>
/// <param name="provider">The provider which answers; its model decides how much of the material fits.</param>
/// <returns>The share of each material this version can open, in the order the person added them.</returns>
2026-11-15 13:06:06 +01:00
/// <exception cref="FileNotFoundException">The app comes without one of the prompts or without the value deck.</exception>
/// <exception cref="FormatException">A prompt has a placeholder without a value, or something is wrong with the value deck.</exception>
2026-11-09 20:00:01 +01:00
public async Task < IReadOnlyList < BflMaterialShare >> ShareMaterialsAsync ( BflStepThreadLocation location , AIStudio . Settings . Provider provider )
{
var language = ( await settingsManager . GetActiveLanguagePlugin ()). LangName ;
var forms = await store . LoadFormsAsync ( location . ProjectId );
2026-11-10 11:27:51 +01:00
var workStates = await store . LoadWorkStatesAsync ( location . ProjectId );
2026-11-10 08:57:36 +01:00
var parts = await BflPromptLibrary . LoadPartsAsync ( location . Step , Tools . PluginSystem . I18N . I . AddressForm , this . PromptOverridesDirectory );
2026-11-10 18:17:44 +01:00
var tools = await BflResearchAvailability . CheckAsync ( toolRegistry , provider );
var research = await this . LoadResearchAsync ( location );
2026-11-15 11:40:15 +01:00
var rules = BflOrganizationRules . For ( location , settingsManager . ConfigurationData . BeyondFactsLab );
2026-11-15 13:06:06 +01:00
return await this . ShareMaterialsAsync ( location , parts , forms , workStates , tools , research , rules , await LoadValueDeckAsync ( location ), language , provider );
2026-11-09 20:00:01 +01:00
}
2026-11-10 18:17:44 +01:00
/// <summary>
/// Loads the research a step carries in its prompt: the step "Strategy" all of it, every other step none.
/// </summary>
/// <param name="location">The step.</param>
2026-11-15 15:39:34 +01:00
/// <returns>The research this version can open: on the topics, and on the people of the current list.</returns>
private async Task < BflStepResearch > LoadResearchAsync ( BflStepThreadLocation location ) => location . Step is BflStep . STRATEGY
? BflStepResearch . Of ( await store . LoadAllResearchAsync ( location . ProjectId ), await store . LoadAllPersonResearchAsync ( location . ProjectId ))
: BflStepResearch . NONE ;
2026-11-10 18:17:44 +01:00
2026-11-15 13:06:06 +01:00
/// <summary>
/// Loads the value deck a step carries in its prompt, with the card sort of the person: the step "Values and
/// perspectives" the deck in the language of the app, every other step none.
/// </summary>
/// <param name="location">The step.</param>
/// <returns>The deck, or null for every other step.</returns>
/// <exception cref="FileNotFoundException">The app comes without the deck.</exception>
/// <exception cref="FormatException">Something is wrong with the deck.</exception>
private static async Task < BflValueDeck ?> LoadValueDeckAsync ( BflStepThreadLocation location ) => location . Step is BflStep . VALUES_AND_PERSPECTIVES
? await BflValueDeck . LoadAsync ( Tools . PluginSystem . I18N . I . Culture )
: null ;
2026-11-09 20:00:01 +01:00
/// <summary>
/// Decides how much of the material of a preparation a request of a step carries: which images go along, and how
/// much of each text fits beside them.
/// </summary>
/// <param name="location">The step.</param>
/// <param name="parts">The texts of the prompt of the step.</param>
/// <param name="forms">The forms of the preparation.</param>
2026-11-10 11:27:51 +01:00
/// <param name="workStates">The work states of the preparation, whose confirmed ones take room in the prompt as well.</param>
2026-11-10 18:17:44 +01:00
/// <param name="tools">What keeps the tools of the lab from being offered, which the prompt describes as well.</param>
2026-11-15 15:39:34 +01:00
/// <param name="research">The research the prompt of the step carries, on the topics and on people.</param>
2026-11-15 11:40:15 +01:00
/// <param name="organizationRules">The guidelines of the organization, which take room in the prompt and in the first message.</param>
2026-11-15 13:06:06 +01:00
/// <param name="valueDeck">The value deck with which the prompt of the step shows the card sort, which takes room as well; null for the other steps.</param>
2026-11-09 20:00:01 +01:00
/// <param name="language">The name of the language the model answers in.</param>
/// <param name="provider">The provider which answers.</param>
/// <returns>The share of each material this version can open, in the order the person added them.</returns>
2026-11-15 15:39:34 +01:00
private async Task < IReadOnlyList < BflMaterialShare >> ShareMaterialsAsync ( BflStepThreadLocation location , BflPromptParts parts , IReadOnlyDictionary < BflStep , BflStepFormData > forms , IReadOnlyDictionary < BflStep , BflWorkState > workStates , BflResearchAvailability tools , BflStepResearch research , string organizationRules , BflValueDeck ? valueDeck , string language , AIStudio . Settings . Provider provider )
2026-11-09 20:00:01 +01:00
{
var materials = ( await store . ListMaterialsAsync ( location . ProjectId )). Select ( entry => entry . Material ). OfType < BflMaterial >(). ToList ();
if ( materials . Count is 0 )
return [];
var imageShares = BflMaterialBudget . ShareImages ([.. materials . Where ( material => material . Kind is BflMaterialKind . IMAGE )], ImagesTheModelTakes ( provider ));
var sentImages = imageShares . Count ( share => share . Image is BflImageDelivery . SENT );
2026-11-15 15:39:34 +01:00
var promptWithoutMaterials = BflPromptComposer . Compose ( parts , location . Step , forms , [], language , workStates , tools , research . Topics , organizationRules , valueDeck , research . People );
2026-11-15 11:40:15 +01:00
var characters = BflMaterialBudget . CharactersFor ( provider . GetModelProfile (). Context , promptWithoutMaterials . Length + BflOrganizationRules . Frame ( organizationRules ). Length , sentImages );
2026-11-09 20:00:01 +01:00
var textShares = BflMaterialBudget . Share ([.. materials . Where ( material => material . Kind is not BflMaterialKind . IMAGE )], characters );
var sharesById = textShares . Concat ( imageShares ). ToDictionary ( share => share . Material . MaterialId );
return [.. materials . Select ( material => sharesById [ material . MaterialId ])];
}
/// <summary>
/// Reads as much of each material as its share allows, and no more.
/// </summary>
/// <param name="projectId">The ID of the preparation.</param>
/// <param name="shares">The share of each material.</param>
/// <returns>What the prompt carries of each material which could be read.</returns>
private async Task < IReadOnlyList < BflMaterialExcerpt >> ReadMaterialsAsync ( Guid projectId , IReadOnlyList < BflMaterialShare > shares )
{
List < BflMaterialExcerpt > excerpts = [];
foreach ( var share in shares )
{
// An image has no text; the prompt names it and says whether it goes along:
if ( share . Material . Kind is BflMaterialKind . IMAGE )
{
excerpts . Add ( new ( share , string . Empty ));
continue ;
}
try
{
var text = await store . ReadMaterialTextAsync ( projectId , share . Material . MaterialId , share . Characters );
excerpts . Add ( new ( share , share . IsComplete ? text : BflMaterialBudget . EndAtLineEnd ( text )));
}
catch ( InvalidDataException exception )
{
// The person removed the material meanwhile, or its file went missing; the list shows that:
logger . LogWarning ( exception , "Could not read the material '{MaterialId}' of the BeyondFacts Lab." , share . Material . MaterialId );
}
}
return excerpts ;
}
2026-11-09 09:55:01 +01:00
/// <summary>
/// Checks whether a provider may answer in a chat, and tells the person why not.
/// </summary>
2026-11-09 12:42:59 +01:00
/// <param name="location">Where the chat belongs.</param>
2026-11-09 09:55:01 +01:00
/// <param name="thread">The chat.</param>
/// <param name="provider">The provider.</param>
/// <returns>True when the provider may answer.</returns>
2026-11-10 11:27:51 +01:00
internal async Task < bool > CheckProviderAsync ( BflThreadLocation location , ChatThread thread , AIStudio . Settings . Provider provider )
2026-11-09 09:55:01 +01:00
{
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." );
2026-11-09 12:42:59 +01:00
else if (! BflModelRequirements . HasEnoughContext ( location , provider . GetModelProfile (). Context ))
2026-11-09 09:55:01 +01:00
reason = TA ( "The model of the selected provider reads too little at once for the lab. Please select another provider." );
if ( reason is null )
return true ;
2026-11-11 14:20:49 +01:00
await messageBus . SendError ( new ( AppIcons . PROVIDER_ERROR , reason ));
2026-11-09 09:55:01 +01:00
return false ;
}
}