2025-02-22 20:51:06 +01:00
using System.Text ;
using AIStudio.Chat ;
2026-08-23 11:09:11 +02:00
using AIStudio.Tools.Security ;
2025-02-22 20:51:06 +01:00
namespace AIStudio.Tools.RAG ;
public static class IRetrievalContextExtensions
{
2026-06-10 21:01:27 +02:00
private static readonly ILogger < IRetrievalContext > LOGGER = Program . LOGGER_FACTORY . CreateLogger < IRetrievalContext >();
2026-09-15 19:11:39 +02:00
/// <summary>
/// Writes what the AI is told about a retrieval context, before its content follows.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="contextBuilder">The builder to write into.</param>
/// <param name="retrievalContext">The context to describe.</param>
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}" );
}
2025-02-22 20:51:06 +01:00
public static async Task < string > AsMarkdown ( this IReadOnlyList < IRetrievalContext > retrievalContexts , StringBuilder ? sb = null , CancellationToken token = default )
{
sb ??= new StringBuilder ();
var index = 0 ;
2026-08-23 11:09:11 +02:00
//
// 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 < PromptInjectionGuardService >();
await using var reportingScope = guardService . BeginAction ();
2025-02-22 20:51:06 +01:00
foreach ( var retrievalContext in retrievalContexts )
{
index ++;
await retrievalContext . AsMarkdown ( sb , index , retrievalContexts . Count , token );
}
2026-08-23 11:09:11 +02:00
2025-02-22 20:51:06 +01:00
return sb . ToString ();
}
public static async Task < string > AsMarkdown ( this IRetrievalContext retrievalContext , StringBuilder ? sb = null , int index = - 1 , int numTotalRetrievalContexts = - 1 , CancellationToken token = default )
{
sb ??= new StringBuilder ();
2026-08-23 11:09:11 +02:00
var contextBuilder = new StringBuilder ();
2025-02-22 20:51:06 +01:00
switch ( index )
{
case > 0 when numTotalRetrievalContexts is - 1 :
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ( $"# Retrieval context {index}" );
2025-02-22 20:51:06 +01:00
break ;
case > 0 when numTotalRetrievalContexts > 0 :
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ( $"# Retrieval context {index} of {numTotalRetrievalContexts}" );
2025-02-22 20:51:06 +01:00
break ;
default :
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ( "# Retrieval context" );
2025-02-22 20:51:06 +01:00
break ;
}
2026-09-15 19:11:39 +02:00
AppendContextDescription ( contextBuilder , retrievalContext );
2026-08-23 11:09:11 +02:00
var guardService = Program . SERVICE_PROVIDER . GetRequiredService < PromptInjectionGuardService >();
var source = PromptInjectionSource . RetrievalContext ( retrievalContext . DataSourceName , retrievalContext . Path );
2025-02-22 20:51:06 +01:00
switch ( retrievalContext )
{
case RetrievalTextContext textContext :
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ();
contextBuilder . AppendLine ( "Matched text content:" );
contextBuilder . AppendLine ( "````" );
contextBuilder . AppendLine ( textContext . MatchedText );
contextBuilder . AppendLine ( "````" );
2025-02-22 20:51:06 +01:00
if ( textContext . SurroundingContent . Count > 0 )
{
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ();
contextBuilder . AppendLine ( "Surrounding text content:" );
2025-02-22 20:51:06 +01:00
foreach ( var surrounding in textContext . SurroundingContent )
{
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ();
contextBuilder . AppendLine ( "````" );
contextBuilder . AppendLine ( surrounding );
contextBuilder . AppendLine ( "````" );
2025-02-22 20:51:06 +01:00
}
}
2026-08-23 11:09:11 +02:00
await FilterWhatWeHaveSoFar ();
2025-02-22 20:51:06 +01:00
break ;
2026-08-23 11:09:11 +02:00
2025-02-22 20:51:06 +01:00
case RetrievalImageContext imageContext :
2026-08-23 11:09:11 +02:00
//
// 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
2025-12-30 18:30:32 +01:00
: string . Empty );
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ( "````" );
2025-02-22 20:51:06 +01:00
break ;
2026-08-23 11:09:11 +02:00
2025-02-22 20:51:06 +01:00
default :
2026-08-23 11:09:11 +02:00
await FilterWhatWeHaveSoFar ();
2025-02-22 20:51:06 +01:00
LOGGER . LogWarning ( $"The retrieval content type '{retrievalContext.Type}' of data source '{retrievalContext.DataSourceName}' at location '{retrievalContext.Path}' is not supported yet." );
break ;
}
2026-08-23 11:09:11 +02:00
contextBuilder . AppendLine ();
sb . Append ( contextBuilder );
2025-02-22 20:51:06 +01:00
return sb . ToString ();
2026-08-23 11:09:11 +02:00
//
// 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 );
}
2025-02-22 20:51:06 +01:00
}
2026-09-27 16:26:52 +02:00
/// <summary>
/// The sources a retrieval context lends to an answer, as they are listed below it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="retrievalContext">The retrieval context.</param>
/// <returns>The sources, which may be none.</returns>
public static IReadOnlyList < Source > ToSources ( this IRetrievalContext retrievalContext )
{
var sources = new List < Source >();
AddSource ( sources , GetReferenceTitle ( retrievalContext ), GetReferenceLink ( retrievalContext ));
foreach ( var link in retrievalContext . Links )
AddSource ( sources , retrievalContext . DataSourceName , link );
return sources ;
}
private static void AddSource ( ICollection < Source > 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 );
2025-02-22 20:51:06 +01:00
}