using AIStudio.Chat; using AIStudio.Chat.Embedded; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Tools.ToolCallingSystem; namespace AIStudio.Assistants.BeyondFactsLab; /// /// 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. /// /// /// 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 /// 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. /// /// The store of the lab. /// The service which runs the embedded chats. /// The settings. /// The message bus, for errors. /// The registry of the tools, which tells the chats what they can do on the web. /// The logger. public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedChatService chatService, SettingsManager settingsManager, MessageBus messageBus, ToolRegistry toolRegistry, ILogger logger) { /// /// The request which lets the model open the conversation of a step. The person does not see it. /// /// /// German, like the prompts: what the first message does is up to the prompt of the step. /// public const string OPENING_REQUEST = "Beginne das Gespräch dieses Schritts mit deiner ersten Nachricht, wie es der Abschnitt „Dieser Schritt“ beschreibt."; /// /// The request which lets the model open the conversation about whether the lab fits a task. The person does not /// see it. /// public const string ONBOARDING_OPENING_REQUEST = "Beginne das Gespräch mit deiner ersten Nachricht, wie es der Abschnitt „Gesprächsführung“ beschreibt."; private static string TA(string fallbackEN) => Tools.PluginSystem.I18N.I.TA(fallbackEN, typeof(BflConversationService).Namespace, nameof(BflConversationService)); /// /// 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. /// internal string? PromptOverridesDirectory => BflPromptOverrides.ActiveDirectory(settingsManager.ConfigurationData.BeyondFactsLab, store.PromptOverridesDirectory); /// /// Gets a chat, loading it on the first call. /// /// Where the chat belongs. /// The cancellation token. /// The chat; it is not available when this version cannot open the stored one. public Task GetAsync(BflThreadLocation location, CancellationToken token = default) => chatService.GetAsync(this.StoreOf(location), Tools.Components.BEYOND_FACTS_LAB_ASSISTANT, location.ChatId, () => BflThreadFactory.Create(location), token); /// /// Gets where a chat of the lab lives. /// /// Where the chat belongs. /// The store, which serves this chat only. public IEmbeddedChatStore StoreOf(BflThreadLocation location) => new BflChatStore(store, location); /// /// Gets the request which lets the model open a conversation, see EmbeddedChat.StartRequest. The person does not see /// it. /// /// Where the chat belongs. /// The request, which names the section of the prompt that describes the first message. public static string OpeningRequestFor(BflThreadLocation location) => location is BflOnboardingThreadLocation ? ONBOARDING_OPENING_REQUEST : OPENING_REQUEST; /// /// Stops every answer of a preparation and forgets its chats, e.g., because the person deleted it. /// /// The ID of the preparation. /// The IDs of the chats the preparation could have had. public async Task> ForgetProjectAsync(Guid projectId) { List chatIds = [.. BflStep.All.Select(step => new BflStepThreadLocation(projectId, step).ChatId)]; foreach (var chatId in chatIds) await chatService.ForgetAsync(chatId); return chatIds; } /// /// Readies a chat of the lab for a request, see EmbeddedChatRequest.PrepareAsync: checks the provider and puts /// together what the request carries. /// /// /// What goes wrong is reported through the message bus; the request then does not go out. /// /// Where the chat belongs. /// The provider which answers. /// The chat. /// What the request carries, or EmbeddedChatPreparation.REFUSED. public async Task PrepareAsync(BflThreadLocation location, AIStudio.Settings.Provider provider, ChatThread thread) { if (!await this.CheckProviderAsync(location, thread, provider)) return EmbeddedChatPreparation.REFUSED; BflRequestMaterial requestMaterial; try { requestMaterial = await this.ComposeRequestAsync(location, provider); } 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(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; } 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 chat '{Location}'.", location.Key); 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."))); return EmbeddedChatPreparation.REFUSED; } // The dossiers of the people the chat plays are never shortened, so a window which is known to be too small refuses: if (BflSimulationBudget.Check(requestMaterial.Simulation ?? BflEffectiveSimulation.GENERIC, provider.GetModelProfile().Context) is BflSimulationFit.TOO_SMALL) { await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, TA("The model of the selected provider likely reads too little at once for the people you chose for the simulation. Please choose fewer people or another provider."))); return EmbeddedChatPreparation.REFUSED; } return Prepare(location, provider, thread, requestMaterial); } /// /// Readies a chat of the lab for a request with what the lab put together for it. /// /// Where the chat belongs. /// The provider which answers. /// The chat, which gets its prompt, its tools, and its restriction. /// The system prompt, the images of the material, and the guidelines of the organization. /// The preparation, whose request carries the images and the guidelines, see ForRequest. internal static EmbeddedChatPreparation Prepare(BflThreadLocation location, AIStudio.Settings.Provider provider, ChatThread thread, BflRequestMaterial requestMaterial) { // 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)); var images = requestMaterial.Images; var rules = requestMaterial.OrganizationRules; return EmbeddedChatPreparation.READY with { ThreadForRequest = request => ForRequest(request, images, rules) }; } /// /// 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. /// /// /// Both are put together anew for every request, so that changed details, material, guidelines, and a changed form /// of address count right away. /// /// Where the chat belongs. /// The provider which answers; its model decides how much of the material fits. /// The text to count. /// The app comes without one of the prompts or without the value deck. /// A prompt has a placeholder without a value, or something is wrong with the value deck. public async Task ComposeTextForCountAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) { var requestMaterial = await this.ComposeRequestAsync(location, provider); return requestMaterial.SystemPrompt + BflOrganizationRules.Frame(requestMaterial.OrganizationRules); } /// /// Checks whether files in the test folder replace prompts of the lab right now, so that the lab can tell the /// person. /// /// The cancellation token. /// True while the setting allows prompt overrides and a file replaces a prompt with another text. public async Task UsesPromptOverridesAsync(CancellationToken token = default) => this.PromptOverridesDirectory is { } directory && (await BflPromptOverrides.FindAsync(directory, token)).Count > 0; /// /// 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. /// /// Where the chat belongs. /// The provider which answers; its model decides how much of the material fits. /// The system prompt, the images, and the guidelines. /// The app comes without one of the prompts or without the value deck. /// A prompt has a placeholder without a value, or something is wrong with the value deck. internal async Task ComposeRequestAsync(BflThreadLocation location, AIStudio.Settings.Provider provider) { var addressForm = Tools.PluginSystem.I18N.I.AddressForm; var language = (await settingsManager.GetActiveLanguagePlugin()).LangName; var tools = await BflResearchAvailability.CheckAsync(toolRegistry, provider); var rules = BflOrganizationRules.For(location, settingsManager.ConfigurationData.BeyondFactsLab); switch (location) { case BflStepThreadLocation stepLocation: var forms = await store.LoadFormsAsync(stepLocation.ProjectId); var workStates = await store.LoadWorkStatesAsync(stepLocation.ProjectId); var simulation = await this.LoadSimulationAsync(stepLocation, forms); var parts = await BflPromptLibrary.LoadPartsAsync(stepLocation.Step, addressForm, this.PromptOverridesDirectory, simulation.Mode); var research = await this.LoadResearchAsync(stepLocation); var valueDeck = await LoadValueDeckAsync(stepLocation); var shares = await this.ShareMaterialsAsync(stepLocation, parts, forms, workStates, tools, research, rules, valueDeck, simulation, language, provider); var materials = await this.ReadMaterialsAsync(stepLocation.ProjectId, shares); return new(BflPromptComposer.Compose(parts, stepLocation.Step, forms, materials, language, workStates, tools, research.Topics, rules, valueDeck, research.People, simulation), this.ImagesOf(stepLocation.ProjectId, shares), rules, simulation); case BflOnboardingThreadLocation: // 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); default: throw new ArgumentOutOfRangeException(nameof(location), location, "The lab has no prompt for this chat."); } } /// /// 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. /// /// The chat. /// The images to send. /// The guidelines of the organization; empty without any. /// The chat for the request, see WithImages and BflOrganizationRules.WithRules. internal static ChatThread ForRequest(ChatThread thread, IReadOnlyList images, string rules) => BflOrganizationRules.WithRules(WithImages(thread, images), rules); /// /// Gives a request the images of the material, attached to the first message of the person, without storing them /// in the chat. /// /// /// 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 /// 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. /// /// The chat. /// The images to send. /// The chat for the request: the chat itself when there is no image to send. internal static ChatThread WithImages(ChatThread thread, IReadOnlyList 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 blocks = [.. thread.Blocks]; blocks[index] = firstMessage; return thread with { Blocks = blocks }; } /// /// Gets how many images the model of a provider takes in one message. /// /// The provider which answers. /// 0 when the model sees no images, and int.MaxValue when nobody knows a limit. 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; } /// /// Gets the images of the material which go along with a request. /// /// The ID of the preparation. /// The share of each material. /// The copies of the images to send, in the order the person added them. private IReadOnlyList ImagesOf(Guid projectId, IReadOnlyList shares) { List 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; } /// /// 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. /// /// The step. /// The provider which answers; its model decides how much of the material fits. /// The share of each material this version can open, in the order the person added them. /// The app comes without one of the prompts or without the value deck. /// A prompt has a placeholder without a value, or something is wrong with the value deck. public async Task> ShareMaterialsAsync(BflStepThreadLocation location, AIStudio.Settings.Provider provider) { var language = (await settingsManager.GetActiveLanguagePlugin()).LangName; var forms = await store.LoadFormsAsync(location.ProjectId); var workStates = await store.LoadWorkStatesAsync(location.ProjectId); var simulation = await this.LoadSimulationAsync(location, forms); var parts = await BflPromptLibrary.LoadPartsAsync(location.Step, Tools.PluginSystem.I18N.I.AddressForm, this.PromptOverridesDirectory, simulation.Mode); var tools = await BflResearchAvailability.CheckAsync(toolRegistry, provider); var research = await this.LoadResearchAsync(location); var rules = BflOrganizationRules.For(location, settingsManager.ConfigurationData.BeyondFactsLab); return await this.ShareMaterialsAsync(location, parts, forms, workStates, tools, research, rules, await LoadValueDeckAsync(location), simulation, language, provider); } /// /// Loads whom the chat of a step plays: in the step "Test situation" what the person chose of the people who /// qualify, in every other step generic counterparts. /// /// The step. /// The forms of the preparation, with the choice of the person. /// The simulation in effect. public async Task LoadSimulationAsync(BflStepThreadLocation location, IReadOnlyDictionary forms) { if (location.Step is not BflStep.TEST_SITUATION || forms.GetValueOrDefault(BflStep.TEST_SITUATION)?.Simulation is not { Mode.HasPeople: true } simulation) return BflEffectiveSimulation.GENERIC; var candidates = BflSimulationCandidates.Of(forms, await store.LoadAllResearchAsync(location.ProjectId), await store.LoadAllPersonResearchAsync(location.ProjectId)); return BflEffectiveSimulation.Of(simulation, candidates); } /// /// Loads the research a step carries in its prompt: the step "Strategy" all of it, every other step none. /// /// The step. /// The research this version can open: on the topics, and on the people of the current list. private async Task LoadResearchAsync(BflStepThreadLocation location) => location.Step is BflStep.STRATEGY ? BflStepResearch.Of(await store.LoadAllResearchAsync(location.ProjectId), await store.LoadAllPersonResearchAsync(location.ProjectId)) : BflStepResearch.NONE; /// /// 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. /// /// The step. /// The deck, or null for every other step. /// The app comes without the deck. /// Something is wrong with the deck. private static async Task LoadValueDeckAsync(BflStepThreadLocation location) => location.Step is BflStep.VALUES_AND_PERSPECTIVES ? await BflValueDeck.LoadAsync(Tools.PluginSystem.I18N.I.Culture) : null; /// /// 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. /// /// The step. /// The texts of the prompt of the step. /// The forms of the preparation. /// The work states of the preparation, whose confirmed ones take room in the prompt as well. /// What keeps the tools of the lab from being offered, which the prompt describes as well. /// The research the prompt of the step carries, on the topics and on people. /// The guidelines of the organization, which take room in the prompt and in the first message. /// The value deck with which the prompt of the step shows the card sort, which takes room as well; null for the other steps. /// Whom the chat of the step "Test situation" plays, whose dossiers take room as well. /// The name of the language the model answers in. /// The provider which answers. /// The share of each material this version can open, in the order the person added them. private async Task> ShareMaterialsAsync(BflStepThreadLocation location, BflPromptParts parts, IReadOnlyDictionary forms, IReadOnlyDictionary workStates, BflResearchAvailability tools, BflStepResearch research, string organizationRules, BflValueDeck? valueDeck, BflEffectiveSimulation simulation, string language, AIStudio.Settings.Provider provider) { var materials = (await store.ListMaterialsAsync(location.ProjectId)).Select(entry => entry.Material).OfType().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); var promptWithoutMaterials = BflPromptComposer.Compose(parts, location.Step, forms, [], language, workStates, tools, research.Topics, organizationRules, valueDeck, research.People, simulation); var characters = BflMaterialBudget.CharactersFor(provider.GetModelProfile().Context, promptWithoutMaterials.Length + BflOrganizationRules.Frame(organizationRules).Length, sentImages); 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])]; } /// /// Reads as much of each material as its share allows, and no more. /// /// The ID of the preparation. /// The share of each material. /// What the prompt carries of each material which could be read. private async Task> ReadMaterialsAsync(Guid projectId, IReadOnlyList shares) { List 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; } /// /// Checks whether a provider may answer in a chat, and tells the person why not. /// /// Where the chat belongs. /// The chat. /// The provider. /// True when the provider may answer. internal async Task 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(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) return true; await messageBus.SendError(new(AppIcons.PROVIDER_ERROR, reason)); return false; } }