using System.Text; using AIStudio.Agents; using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Security; using AIStudio.Tools.Services; namespace AIStudio.Tools.RAG.AugmentationProcesses; public sealed class AugmentationOne : IAugmentationProcess { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AugmentationOne).Namespace, nameof(AugmentationOne)); #region Implementation of IAugmentationProcess /// public string TechnicalName => "AugmentationOne"; /// public string UIName => TB("Standard augmentation process"); /// public string Description => TB("This is the standard augmentation process, which augments the chat thread with as many retrieval contexts as the selected model can read."); /// public async Task ProcessAsync(IProvider provider, Model chatModel, IContent lastUserPrompt, ChatThread chatThread, IReadOnlyList> retrievalContextsByDataSource, CancellationToken token = default) { var settings = Program.SERVICE_PROVIDER.GetService()!; var retrievalContexts = retrievalContextsByDataSource.SelectMany(contexts => contexts).ToList(); if(retrievalContexts.Count == 0) { LOGGER.LogWarning("No retrieval contexts were issued. Skipping the augmentation process."); return new(chatThread, [], [..retrievalContextsByDataSource.Select(_ => 0)]); } // // One report for the whole answer. Every passage is filtered once, before the check of // which passages fit: the check then judges exactly what the model would receive, and the // model receives exactly what the check judged. The same holds for the budget below. // var guardService = Program.SERVICE_PROVIDER.GetRequiredService(); await using var reportingScope = guardService.BeginAction(); var preparedContexts = await retrievalContexts.PrepareForModelAsync(guardService, reportingScope, token); var usedContexts = preparedContexts; // Want the user to validate all retrieval contexts? if (settings.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation && chatThread.DataSourceOptions.AutomaticValidation) { // Let's get the validation agent & set up its provider: var validationAgent = Program.SERVICE_PROVIDER.GetService()!; if (validationAgent.SetLLMProvider(provider, chatThread.DataSecurity, chatThread.RequiredProviderConfidence)) { try { // Let's validate all retrieval contexts: var validationResults = await validationAgent.ValidateRetrievalContextsAsync(lastUserPrompt, chatThread, preparedContexts, token); if (validationResults.Count == 0) LOGGER.LogWarning("Retrieval context validation returned no results. Continuing augmentation with all retrieved contexts."); else { // // Now, filter the retrieval contexts to the most relevant ones: // var targetWindow = validationResults.DetermineTargetWindow(TargetWindowStrategy.TOP10_BETTER_THAN_GUESSING); var threshold = validationResults.GetConfidenceThreshold(targetWindow); // Filter the retrieval contexts: usedContexts = validationResults.Where(x => x.RetrievalContext is not null && x.Confidence >= threshold).Select(x => x.RetrievalContext!).ToList(); } } catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } catch (Exception exception) { LOGGER.LogError(exception, "Retrieval context validation failed. Continuing augmentation with all retrieved contexts."); // // The user switched this check on. Continuing without it silently would hide // that the answer rests on unfiltered passages: // await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.FactCheck, TB("The check of which passages fit your question failed. This answer uses all passages that were found."))); } } else { // // No message to the user here: which providers are trusted enough is a setting, not // an event. It does not change between two answers, so a message would repeat itself // with every single one until the setting changes. // LOGGER.LogWarning("Skipping retrieval context validation because no sufficiently trusted validation agent provider is available. Continuing augmentation with all retrieved contexts."); } } // // Of what the check left, the model receives as much as its context window holds: // var selection = await SelectWithinBudgetAsync(provider.CreateSettingsProvider(chatModel), ByDataSource(retrievalContextsByDataSource, preparedContexts, usedContexts), guardService, reportingScope, token); LOGGER.LogInformation($"Starting the augmentation process over {selection.Contexts.Count:###,###,###,##0} of {retrievalContexts.Count:###,###,###,##0} retrieved contexts; {selection.LeftOutCount:###,###,###,##0} of them did not fit into what the model can read."); // // The user learns about it with every answer: each answer rests on the passages it got, // and each one may miss something else. // if (selection.LeftOutCount > 0) await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.ContentCut, string.Format(TB("Not every passage found in your data sources fits into what the selected model can read. This answer had to do without {0} of them. To give the AI every passage, switch on Semantic Search for this chat, lower the token limit of your data sources, or choose fewer data sources."), selection.LeftOutCount.CompactCount()))); // // Add the augmented data to the chat thread, as the very last step: the caller restricts // the chat for these passages once this process returns, and a request whose RAG process // failed goes on without it, so nothing may fail between the two: // chatThread.AugmentedData = WriteAugmentedData(selection); return new(chatThread, selection.Contexts, selection.PassageCounts); } #endregion /// /// Writes what the model reads along with the user prompt: the passages which fit, and how many did not. /// /// /// The model learns about the passages which were left out, so that it does not take what it /// read for everything the data sources hold on the user prompt. /// /// The passages which fit into the budget, and how many did not. /// The augmented data, or nothing when there is nothing to tell. internal static string WriteAugmentedData(PassageSelection selection) { var sb = new StringBuilder(); if (selection.Contexts.Count > 0) { sb.AppendLine("The following useful information will help you in processing the user prompt:"); sb.AppendLine(); // Let's convert the retrieval contexts to Markdown: for (var index = 0; index < selection.Contexts.Count; index++) sb.Append(selection.Contexts[index].ToMarkdown(index + 1, selection.Contexts.Count)); } if (selection.LeftOutCount > 0) sb.AppendLine(selection.LeftOutCount is 1 ? "Of the passages which the search found in the data sources, 1 did not fit into this request and was left out. An answer based on the data sources may therefore be incomplete." : $"Of the passages which the search found in the data sources, {selection.LeftOutCount} did not fit into this request and were left out. An answer based on the data sources may therefore be incomplete."); return sb.ToString(); } /// /// Sorts the prepared contexts which the check of which passages fit left back to their data sources. /// /// /// The contexts were prepared in one batch, in the order of their data sources, and the check /// hands back the very same objects. So each context left is found by reference, and keeps its /// rank within its data source, whichever order the check reported them in. /// /// The retrieval contexts per data source, as the retrieval issued them. /// All contexts, prepared in the same order. /// The prepared contexts the check left. /// The prepared contexts left of each data source, each in the order of their rank. private static IReadOnlyList> ByDataSource(IReadOnlyList> retrievalContextsByDataSource, IReadOnlyList preparedContexts, IReadOnlyList usedContexts) { var used = new HashSet(usedContexts, ReferenceEqualityComparer.Instance); var byDataSource = new List>(retrievalContextsByDataSource.Count); var first = 0; foreach (var contexts in retrievalContextsByDataSource) { byDataSource.Add(preparedContexts.Skip(first).Take(contexts.Count).Where(used.Contains).ToList()); first += contexts.Count; } return byDataSource; } /// /// Selects the contexts which fit into the share of the model's context window the passages get, see PassageBudget. /// /// /// The tokens are counted with the tokenizer of the provider, or the built-in one where none is /// configured, see ConversationTokenCounter. When the tokenizer does not answer, the characters /// are counted against the same limit instead: a token of prose holds several characters, and /// hardly ever less than one, so the passages then get less room rather than more. /// /// The settings of the provider and the model which answers. /// The prepared contexts of each data source, each in the order of their rank. /// The prompt injection filter, for contexts which have to be shortened. /// The action the contexts were retrieved for. /// The cancellation token. /// The contexts which fit, and how many did not. private static async Task SelectWithinBudgetAsync(AIStudio.Settings.Provider settingsProvider, IReadOnlyList> contextsByDataSource, PromptInjectionGuardService guardService, PromptInjectionReportingScope reportingScope, CancellationToken token) { var contextWindow = settingsProvider.GetModelProfile().Context; var budget = PassageBudget.For(contextWindow, CountTokensAsync); try { return await budget.SelectAsync(contextsByDataSource, guardService, reportingScope, token); } catch (Exception exception) when (contextWindow.IsKnown && exception is not OperationCanceledException) { LOGGER.LogWarning(exception, "Could not count the tokens of the retrieval contexts. Counting their characters against the same limit instead."); return await PassageBudget.InCharacters(budget.Limit).SelectAsync(contextsByDataSource, guardService, reportingScope, token); } Task CountTokensAsync(string text, CancellationToken cancellationToken) => Program.SERVICE_PROVIDER.GetRequiredService().CountTextAsync(settingsProvider, text, cancellationToken); } }