using System.Text; using AIStudio.Chat; using AIStudio.Tools.Security; namespace AIStudio.Tools.RAG; public static class IRetrievalContextExtensions { private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); /// /// Writes what the AI is told about a retrieval context, before its content follows. /// /// /// The location is what lets the AI say where an answer comes from. Naming only the file is /// not enough in a document of two hundred pages, and we know the page: it travels from the /// runtime through the index into the context. A slide or a sheet has no page, and then /// nothing is claimed rather than something made up. /// /// The builder to write into. /// The context to describe. internal static void AppendContextDescription(StringBuilder contextBuilder, IRetrievalContext retrievalContext) { contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}"); contextBuilder.AppendLine($"Content category: {retrievalContext.Category}"); contextBuilder.AppendLine($"Content type: {retrievalContext.Type}"); contextBuilder.AppendLine($"Content path: {retrievalContext.Path}"); if(retrievalContext is RetrievalTextContext { PageNumber: > 0 } locatedContext) contextBuilder.AppendLine($"Content location: page {locatedContext.PageNumber}"); if(retrievalContext.Links.Count is 0) return; contextBuilder.AppendLine("Additional links:"); foreach(var link in retrievalContext.Links) contextBuilder.AppendLine($"- {link}"); } public static async Task AsMarkdown(this IReadOnlyList retrievalContexts, StringBuilder? sb = null, CancellationToken token = default) { sb ??= new StringBuilder(); var index = 0; // // One report for the whole retrieval run: a query may pull in dozens of contexts, and // the user wants to know that something was filtered, not to acknowledge it per context. // var guardService = Program.SERVICE_PROVIDER.GetRequiredService(); await using var reportingScope = guardService.BeginAction(); foreach(var retrievalContext in retrievalContexts) { index++; await retrievalContext.AsMarkdown(sb, index, retrievalContexts.Count, token); } return sb.ToString(); } public static async Task AsMarkdown(this IRetrievalContext retrievalContext, StringBuilder? sb = null, int index = -1, int numTotalRetrievalContexts = -1, CancellationToken token = default) { sb ??= new StringBuilder(); var contextBuilder = new StringBuilder(); switch (index) { case > 0 when numTotalRetrievalContexts is -1: contextBuilder.AppendLine($"# Retrieval context {index}"); break; case > 0 when numTotalRetrievalContexts > 0: contextBuilder.AppendLine($"# Retrieval context {index} of {numTotalRetrievalContexts}"); break; default: contextBuilder.AppendLine("# Retrieval context"); break; } AppendContextDescription(contextBuilder, retrievalContext); var guardService = Program.SERVICE_PROVIDER.GetRequiredService(); var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path); switch(retrievalContext) { case RetrievalTextContext textContext: contextBuilder.AppendLine(); contextBuilder.AppendLine("Matched text content:"); contextBuilder.AppendLine("````"); contextBuilder.AppendLine(textContext.MatchedText); contextBuilder.AppendLine("````"); if(textContext.SurroundingContent.Count > 0) { contextBuilder.AppendLine(); contextBuilder.AppendLine("Surrounding text content:"); foreach(var surrounding in textContext.SurroundingContent) { contextBuilder.AppendLine(); contextBuilder.AppendLine("````"); contextBuilder.AppendLine(surrounding); contextBuilder.AppendLine("````"); } } await FilterWhatWeHaveSoFar(); break; case RetrievalImageContext imageContext: // // Filtering happens before the image is appended, and only covers the text // around it. Base64 image data is not prose, and running it through the filter // would have it treated as one enormous encoded carrier. // await FilterWhatWeHaveSoFar(); contextBuilder.AppendLine(); contextBuilder.AppendLine("Matched image content as base64-encoded data:"); contextBuilder.AppendLine("````"); contextBuilder.AppendLine(await imageContext.TryAsBase64(token) is (success: true, { } base64Image) ? base64Image : string.Empty); contextBuilder.AppendLine("````"); break; default: await FilterWhatWeHaveSoFar(); LOGGER.LogWarning($"The retrieval content type '{retrievalContext.Type}' of data source '{retrievalContext.DataSourceName}' at location '{retrievalContext.Path}' is not supported yet."); break; } contextBuilder.AppendLine(); sb.Append(contextBuilder); return sb.ToString(); // // Replaces what has been built so far with its filtered version. A data source is as // untrusted as any other external content: it may serve text written to steer the model // rather than to answer the query. // async Task FilterWhatWeHaveSoFar() { var sanitized = await guardService.SanitizeAsync(contextBuilder.ToString(), source); contextBuilder.Clear(); contextBuilder.Append(sanitized); } } /// /// The sources a retrieval context lends to an answer, as they are listed below it. /// /// /// The reference comes first: the title and link of the passage itself where the data source /// names them, e.g., a local file with its page, and otherwise the data source and the path. /// The further links of the context follow. Only what can be opened becomes a source, i.e., a /// web address or a file with an absolute path. A relative path would point elsewhere depending /// on where it is opened from. /// /// The retrieval context. /// The sources, which may be none. public static IReadOnlyList ToSources(this IRetrievalContext retrievalContext) { var sources = new List(); AddSource(sources, GetReferenceTitle(retrievalContext), GetReferenceLink(retrievalContext)); foreach (var link in retrievalContext.Links) AddSource(sources, retrievalContext.DataSourceName, link); return sources; } private static void AddSource(ICollection sources, string title, string link) { if (string.IsNullOrWhiteSpace(title) || !TryNormalizeSourceLink(link, out var normalizedLink)) return; sources.Add(new Source(title, normalizedLink, SourceOrigin.RAG)); } private static string GetReferenceTitle(IRetrievalContext retrievalContext) => retrievalContext is RetrievalTextContext { ReferenceTitle: { Length: > 0 } referenceTitle } ? referenceTitle : retrievalContext.DataSourceName; private static string GetReferenceLink(IRetrievalContext retrievalContext) => retrievalContext is RetrievalTextContext { ReferenceLink: { Length: > 0 } referenceLink } ? referenceLink : retrievalContext.Path; private static bool TryNormalizeSourceLink(string link, out string normalizedLink) { normalizedLink = string.Empty; if (string.IsNullOrWhiteSpace(link)) return false; if (Uri.TryCreate(link, UriKind.Absolute, out var absoluteUri) && IsSupportedSourceUri(absoluteUri)) { normalizedLink = absoluteUri.AbsoluteUri; return true; } try { if (!Path.IsPathRooted(link)) return false; normalizedLink = new Uri(Path.GetFullPath(link)).AbsoluteUri; return true; } catch { return false; } } private static bool IsSupportedSourceUri(Uri uri) => string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || string.Equals(uri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); }