Added a way to read what the AI received from your sources (#1100)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul KoudelkaandThorsten Sommer authored and GitHub committed 2026-10-26 10:00:01 +01:00
1 parent e0d8e18f34
commit cbc4ce5413
229 files changed
+12739 -1269

No files matched your search

@@ -27,24 +27,25 @@ public sealed class AugmentationOne : IAugmentationProcess
public string Description => TB("This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread.");
/// <inheritdoc />
public async Task<ChatThread> ProcessAsync(IProvider provider, IContent lastUserPrompt, ChatThread chatThread, IReadOnlyList<IRetrievalContext> retrievalContexts, CancellationToken token = default)
public async Task<AugmentationResult> ProcessAsync(IProvider provider, IContent lastUserPrompt, ChatThread chatThread, IReadOnlyList<IRetrievalContext> retrievalContexts, CancellationToken token = default)
{
var settings = Program.SERVICE_PROVIDER.GetService<SettingsManager>()!;
if(retrievalContexts.Count == 0)
{
LOGGER.LogWarning("No retrieval contexts were issued. Skipping the augmentation process.");
return chatThread;
return new(chatThread, []);
}
var numTotalRetrievalContexts = retrievalContexts.Count;
//
// One report for the whole answer. With the check of which passages fit, every passage is
// filtered twice: once for that check, and once more for the model.
// 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.
//
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
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)
@@ -56,7 +57,7 @@ public sealed class AugmentationOne : IAugmentationProcess
try
{
// Let's validate all retrieval contexts:
var validationResults = await validationAgent.ValidateRetrievalContextsAsync(lastUserPrompt, chatThread, retrievalContexts, reportingScope, token);
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
@@ -68,7 +69,7 @@ public sealed class AugmentationOne : IAugmentationProcess
var threshold = validationResults.GetConfidenceThreshold(targetWindow);
// Filter the retrieval contexts:
retrievalContexts = validationResults.Where(x => x.RetrievalContext is not null && x.Confidence >= threshold).Select(x => x.RetrievalContext!).ToList();
usedContexts = validationResults.Where(x => x.RetrievalContext is not null && x.Confidence >= threshold).Select(x => x.RetrievalContext!).ToList();
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
@@ -97,7 +98,7 @@ public sealed class AugmentationOne : IAugmentationProcess
}
}
LOGGER.LogInformation($"Starting the augmentation process over {retrievalContexts.Count:###,###,###,###} of {numTotalRetrievalContexts:###,###,###,###} retrieved contexts.");
LOGGER.LogInformation($"Starting the augmentation process over {usedContexts.Count:###,###,###,###} of {retrievalContexts.Count:###,###,###,###} retrieved contexts.");
//
// We build a huge prompt from all retrieval contexts:
@@ -107,11 +108,12 @@ public sealed class AugmentationOne : IAugmentationProcess
sb.AppendLine();
// Let's convert all retrieval contexts to Markdown:
await retrievalContexts.AsMarkdown(sb, reportingScope, token);
for (var index = 0; index < usedContexts.Count; index++)
sb.Append(usedContexts[index].ToMarkdown(index + 1, usedContexts.Count));
// Add the augmented data to the chat thread:
chatThread.AugmentedData = sb.ToString();
return chatThread;
return new(chatThread, usedContexts);
}
#endregion
@@ -0,0 +1,15 @@
using AIStudio.Chat;
namespace AIStudio.Tools.RAG;
/// <summary>
/// What an augmentation process did: the chat thread it augmented, and what it handed to the AI.
/// </summary>
/// <remarks>
/// The contexts the AI received can be fewer than the ones retrieved, e.g., when a check of which
/// passages fit the question left some out. Whoever lists the sources and passages of the answer
/// takes them from here, so the user sees what the AI read and nothing else.
/// </remarks>
/// <param name="ChatThread">The augmented chat thread.</param>
/// <param name="UsedContexts">The retrieval contexts handed to the AI, in the order it received them.</param>
public sealed record AugmentationResult(ChatThread ChatThread, IReadOnlyList<PreparedRetrievalContext> UsedContexts);
@@ -28,6 +28,6 @@ public interface IAugmentationProcess
/// <param name="chatThread">The chat thread.</param>
/// <param name="retrievalContexts">The retrieval contexts that were issued by the retrieval process.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The altered chat thread.</returns>
public Task<ChatThread> ProcessAsync(IProvider provider, IContent lastUserPrompt, ChatThread chatThread, IReadOnlyList<IRetrievalContext> retrievalContexts, CancellationToken token = default);
/// <returns>The altered chat thread, together with the retrieval contexts handed to the AI.</returns>
public Task<AugmentationResult> ProcessAsync(IProvider provider, IContent lastUserPrompt, ChatThread chatThread, IReadOnlyList<IRetrievalContext> retrievalContexts, CancellationToken token = default);
}
@@ -14,8 +14,10 @@ public static class IRetrievalContextExtensions
/// <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.
/// runtime through the index into the context, together with the page a passage ends on when it
/// goes on beyond its first one. A document without pages names its sheet and rows, or its
/// lines, the same way. A slide has none of them, 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>
@@ -29,8 +31,8 @@ public static class IRetrievalContextExtensions
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 is RetrievalTextContext textContext && DescribeLocation(textContext.PageNumber, textContext.EndPageNumber, textContext.SheetName, textContext.StartRow, textContext.EndRow, textContext.StartLine, textContext.EndLine) is { } location)
contextBuilder.AppendLine($"Content location: {location}");
if(retrievalContext.Links.Count is 0)
return;
@@ -40,6 +42,38 @@ public static class IRetrievalContextExtensions
contextBuilder.AppendLine($"- {link}");
}
/// <summary>
/// Says where a passage is, the way the AI is told, e.g., "pages 12 to 14", "sheet "Budget", rows 3 to 9" or "lines 40 to 85".
/// </summary>
/// <remarks>
/// In English, like everything else the AI is told about a passage. Pages come first, then
/// rows, then lines: a passage names one kind only. An end only names a range when it lies
/// beyond the start.
/// </remarks>
/// <param name="pageNumber">The page the passage starts on.</param>
/// <param name="endPageNumber">The page it ends on.</param>
/// <param name="sheetName">The sheet its rows are of, or null for a table without sheets.</param>
/// <param name="startRow">The row it starts at.</param>
/// <param name="endRow">The row it ends at.</param>
/// <param name="startLine">The line it starts at.</param>
/// <param name="endLine">The line it ends at.</param>
/// <returns>Where the passage is, or null when it names nothing.</returns>
internal static string? DescribeLocation(int? pageNumber, int? endPageNumber, string? sheetName, int? startRow, int? endRow, int? startLine, int? endLine)
{
if (pageNumber is > 0)
return DescribeRange("page", "pages", pageNumber.Value, endPageNumber);
if (startRow is > 0)
{
var rows = DescribeRange("row", "rows", startRow.Value, endRow);
return string.IsNullOrEmpty(sheetName) ? rows : $"sheet \"{sheetName}\", {rows}";
}
return startLine is > 0 ? DescribeRange("line", "lines", startLine.Value, endLine) : null;
}
private static string DescribeRange(string one, string several, int start, int? end) => end > start ? $"{several} {start} to {end}" : $"{one} {start}";
/// <summary>
/// Writes what the AI is told about an image which matched, in place of the image itself.
/// </summary>
@@ -57,140 +91,184 @@ public static class IRetrievalContextExtensions
}
/// <summary>
/// Writes what the AI is told about several retrieval contexts, filtered for prompt injections.
/// Prepares retrieval contexts for the AI: filters them for prompt injections, writes out what
/// the AI is told about each, and keeps what the user is shown of it.
/// </summary>
/// <param name="retrievalContexts">The contexts to describe.</param>
/// <param name="sb">The builder to write into, or null for a new one.</param>
/// <remarks>
/// 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. So everything a data source wrote passes
/// the filter: the description of each context, its matched text, and each part of its
/// surrounding content. What the app writes around them, such as the labels and the fences,
/// does not need to.<br/><br/>
/// All contexts go to the runtime in one request, which also gives the user one report for all
/// of them. The passages are made from the filtered texts, so the user reads what the AI read.
/// </remarks>
/// <param name="retrievalContexts">The contexts to prepare.</param>
/// <param name="guardService">The prompt injection filter.</param>
/// <param name="reportingScope">The action the contexts are retrieved for, or null when they are retrieved for none.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>Everything written into the builder so far.</returns>
public static async Task<string> AsMarkdown(this IReadOnlyList<IRetrievalContext> retrievalContexts, StringBuilder? sb = null, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default)
/// <returns>One prepared context per context, in the order they came in.</returns>
public static async Task<IReadOnlyList<PreparedRetrievalContext>> PrepareForModelAsync(this IReadOnlyList<IRetrievalContext> retrievalContexts, PromptInjectionGuardService guardService, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default)
{
sb ??= new StringBuilder();
var index = 0;
if (retrievalContexts.Count is 0)
return [];
//
// 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.
// The texts of all contexts in one list, each context with its description first, then its
// matched text, then its surrounding content. The answer comes back in the same order, and
// is read back below in the same way:
//
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
await using var retrievalScope = guardService.BeginAction(within: reportingScope);
foreach(var retrievalContext in retrievalContexts)
var texts = new List<PromptInjectionText>();
foreach (var retrievalContext in retrievalContexts)
{
index++;
await retrievalContext.AsMarkdown(sb, index, retrievalContexts.Count, retrievalScope, token);
var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path);
var description = new StringBuilder();
AppendContextDescription(description, retrievalContext);
texts.Add(new(description.ToString(), source));
if (retrievalContext is not RetrievalTextContext textContext)
continue;
texts.Add(new(textContext.MatchedText, source));
foreach (var surrounding in textContext.SurroundingContent)
texts.Add(new(surrounding, source));
}
return sb.ToString();
token.ThrowIfCancellationRequested();
var sanitizedTexts = await guardService.SanitizeAsync(texts, reportingScope);
var preparedContexts = new List<PreparedRetrievalContext>(retrievalContexts.Count);
var nextText = 0;
foreach (var retrievalContext in retrievalContexts)
{
var body = new StringBuilder();
body.Append(sanitizedTexts[nextText++]);
var matchedText = string.Empty;
IReadOnlyList<string> surroundingContent = [];
switch (retrievalContext)
{
case RetrievalTextContext textContext:
matchedText = sanitizedTexts[nextText++];
surroundingContent = sanitizedTexts.Skip(nextText).Take(textContext.SurroundingContent.Count).ToList();
nextText += surroundingContent.Count;
AppendTextMatch(body, matchedText, surroundingContent);
break;
case RetrievalImageContext:
AppendImageMatch(body);
break;
default:
LOGGER.LogWarning($"The retrieval content type '{retrievalContext.Type}' of data source '{retrievalContext.DataSourceName}' at location '{retrievalContext.Path}' is not supported yet.");
break;
}
var sources = retrievalContext.ToSources();
preparedContexts.Add(new(retrievalContext, body.ToString(), sources, CreatePassage(retrievalContext, sources, matchedText, surroundingContent)));
}
return preparedContexts;
}
/// <summary>
/// Writes what the AI is told about one retrieval context, filtered for prompt injections.
/// Writes the content of a text context, after its description.
/// </summary>
/// <param name="retrievalContext">The context to describe.</param>
/// <param name="sb">The builder to write into, or null for a new one.</param>
/// <param name="index">The number of the context among all of them, or -1 when it stands alone.</param>
/// <param name="numTotalRetrievalContexts">How many contexts there are, or -1 when that is unknown.</param>
/// <param name="reportingScope">The action the context is retrieved for, or null when it is retrieved for none.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>Everything written into the builder so far.</returns>
public static async Task<string> AsMarkdown(this IRetrievalContext retrievalContext, StringBuilder? sb = null, int index = -1, int numTotalRetrievalContexts = -1, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default)
/// <param name="contextBuilder">The builder to write into.</param>
/// <param name="matchedText">The matched text, filtered.</param>
/// <param name="surroundingContent">The surrounding content, filtered.</param>
private static void AppendTextMatch(StringBuilder contextBuilder, string matchedText, IReadOnlyList<string> surroundingContent)
{
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<PromptInjectionGuardService>();
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:
AppendImageMatch(contextBuilder);
await FilterWhatWeHaveSoFar();
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();
contextBuilder.AppendLine("Matched text content:");
contextBuilder.AppendLine("````");
contextBuilder.AppendLine(matchedText);
contextBuilder.AppendLine("````");
//
// 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()
if (surroundingContent.Count is 0)
return;
contextBuilder.AppendLine();
contextBuilder.AppendLine("Surrounding text content:");
foreach (var surrounding in surroundingContent)
{
var sanitized = await guardService.SanitizeAsync(contextBuilder.ToString(), source, reportingScope);
contextBuilder.Clear();
contextBuilder.Append(sanitized);
contextBuilder.AppendLine();
contextBuilder.AppendLine("````");
contextBuilder.AppendLine(surrounding);
contextBuilder.AppendLine("````");
}
}
/// <summary>
/// Keeps what the user is shown of a context the AI received.
/// </summary>
/// <remarks>
/// The name and the path stay as the data source delivered them: they tell the user where the
/// passage is, and the AI read them in the filtered description anyway.
/// </remarks>
/// <param name="retrievalContext">The context.</param>
/// <param name="sources">The sources of the context.</param>
/// <param name="matchedText">The matched text, filtered; empty for anything but text.</param>
/// <param name="surroundingContent">The surrounding content, filtered.</param>
/// <returns>The passage, or null when the context has no source.</returns>
private static SourcePassage? CreatePassage(IRetrievalContext retrievalContext, IReadOnlyList<Source> sources, string matchedText, IReadOnlyList<string> surroundingContent)
{
//
// The user finds a passage under the source it belongs to. A context without a source, i.e.,
// one which names neither a document nor its data source, is listed nowhere, so its passage
// could never be shown and would only take up room:
//
if (sources.Count is 0)
return null;
var textContext = retrievalContext as RetrievalTextContext;
return new SourcePassage
{
SourceUrl = sources[0].URL,
DataSourceName = retrievalContext.DataSourceName,
Path = retrievalContext.Path,
Category = retrievalContext.Category,
Type = retrievalContext.Type,
Text = matchedText,
SurroundingContent = surroundingContent,
PageNumber = textContext?.PageNumber is > 0 ? textContext.PageNumber : null,
EndPageNumber = textContext?.EndPageNumber is > 0 ? textContext.EndPageNumber : null,
SheetName = textContext?.SheetName is { Length: > 0 } sheetName ? sheetName : null,
StartRow = textContext?.StartRow is > 0 ? textContext.StartRow : null,
EndRow = textContext?.EndRow is > 0 ? textContext.EndRow : null,
StartLine = textContext?.StartLine is > 0 ? textContext.StartLine : null,
EndLine = textContext?.EndLine is > 0 ? textContext.EndLine : null,
ChunkNumber = textContext?.ChunkNumber is > 0 ? textContext.ChunkNumber : null,
};
}
/// <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
/// The further links of the context follow. Only what can be opened becomes a link, 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.
/// on where it is opened from.<br/><br/>
///
/// A context which names nothing to open at all, e.g., by a path which only its ERI server knows,
/// still lends one source: the AI received it, and its passage needs a place to be read at. That
/// source is named as text, see SourceExtensions.CreateRetrievalSourceUrl.
/// </remarks>
/// <param name="retrievalContext">The retrieval context.</param>
/// <returns>The sources, which may be none.</returns>
/// <returns>The sources; none only for a context which names neither a document nor its data source.</returns>
public static IReadOnlyList<Source> ToSources(this IRetrievalContext retrievalContext)
{
var sources = new List<Source>();
AddSource(sources, GetReferenceTitle(retrievalContext), GetReferenceLink(retrievalContext));
var referenceTitle = GetReferenceTitle(retrievalContext);
AddSource(sources, referenceTitle, GetReferenceLink(retrievalContext));
foreach (var link in retrievalContext.Links)
AddSource(sources, retrievalContext.DataSourceName, link);
if (sources.Count is 0 && !string.IsNullOrWhiteSpace(referenceTitle))
sources.Add(new Source(referenceTitle, SourceExtensions.CreateRetrievalSourceUrl(referenceTitle, retrievalContext.Path), SourceOrigin.RAG));
return sources;
}
@@ -0,0 +1,15 @@
namespace AIStudio.Tools.RAG;
/// <summary>
/// Where a passage is in its document, counted from one: the pages it spans, or the part it is of a
/// document without pages.
/// </summary>
/// <param name="First">The page the passage starts on, or its part.</param>
/// <param name="Last">The page the passage ends on; the first one again when it stays on one, and always for a part.</param>
public readonly record struct LocationRange(int First, int Last)
{
/// <summary>
/// The range as it is written, e.g., "4" or "4–5".
/// </summary>
public override string ToString() => this.First == this.Last ? $"{this.First}" : $"{this.First}–{this.Last}";
}
@@ -0,0 +1,26 @@
namespace AIStudio.Tools.RAG;
/// <summary>
/// Where passages are in their document: their pages or parts, and the words around them.
/// </summary>
/// <remarks>
/// Kept in parts rather than as one text, so that each page can be shown as a link of its own,
/// while the words around the numbers still come from one translation, e.g., "Pages " and nothing
/// for "Pages 1, 11". Written out as a whole, see ToString, it reads the same as the parts.
/// </remarks>
/// <param name="Prefix">What stands before the numbers, e.g., "Pages ".</param>
/// <param name="Ranges">The pages or parts, in the order of the document, each once.</param>
/// <param name="Suffix">What stands after the numbers, often nothing.</param>
/// <param name="ArePages">Whether the numbers are pages, at which a document can be opened, or the parts of a document without pages.</param>
public sealed record PassageLocation(string Prefix, IReadOnlyList<LocationRange> Ranges, string Suffix, bool ArePages)
{
/// <summary>
/// What stands between two ranges.
/// </summary>
public const string RANGE_SEPARATOR = ", ";
/// <summary>
/// The location as one text, e.g., "Pages 1, 11" or "Part 3".
/// </summary>
public override string ToString() => $"{this.Prefix}{string.Join(RANGE_SEPARATOR, this.Ranges)}{this.Suffix}";
}
@@ -0,0 +1,48 @@
using System.Text;
namespace AIStudio.Tools.RAG;
/// <summary>
/// A retrieval context as the AI receives it: filtered for prompt injections, written out, and
/// ready to be numbered.
/// </summary>
/// <remarks>
/// Made by IRetrievalContextExtensions.PrepareForModelAsync. Which number a context gets depends on
/// which contexts the caller hands on in the end, so the header is written only then, see
/// ToMarkdown. The app writes it itself, which is why it needs no filter.
/// </remarks>
/// <param name="Context">The context as the data source delivered it.</param>
/// <param name="Body">What the AI is told about the context below its header: the filtered description, then the filtered content.</param>
/// <param name="Sources">The sources the context lends to the answer, which may be none.</param>
/// <param name="Passage">What the user is shown of the context, or null when the context has no source to list it under.</param>
public sealed record PreparedRetrievalContext(IRetrievalContext Context, string Body, IReadOnlyList<Source> Sources, SourcePassage? Passage)
{
/// <summary>
/// Writes what the AI is told about the context, headed by its number.
/// </summary>
/// <param name="index">The number of the context among all of them, or null when it stands alone.</param>
/// <param name="numTotalRetrievalContexts">How many contexts there are, or null when that is unknown.</param>
/// <returns>The context as Markdown, ending with an empty line to set it apart from the next one.</returns>
public string ToMarkdown(int? index = null, int? numTotalRetrievalContexts = null)
{
var sb = new StringBuilder();
switch (index)
{
case > 0 when numTotalRetrievalContexts is null:
sb.AppendLine($"# Retrieval context {index}");
break;
case > 0 when numTotalRetrievalContexts > 0:
sb.AppendLine($"# Retrieval context {index} of {numTotalRetrievalContexts}");
break;
default:
sb.AppendLine("# Retrieval context");
break;
}
sb.Append(this.Body);
sb.AppendLine();
return sb.ToString();
}
}
@@ -225,10 +225,13 @@ public sealed class AISrcSelWithRetCtxVal : IRagProcess
//
// Perform the augmentation of the R(A)G process:
//
IReadOnlyList<PreparedRetrievalContext> usedContexts = [];
if (proceedWithRAG)
{
var augmentationProcess = new AugmentationOne();
chatThread = await augmentationProcess.ProcessAsync(provider, lastUserPrompt, chatThread, dataContexts, token);
var augmentation = await augmentationProcess.ProcessAsync(provider, lastUserPrompt, chatThread, dataContexts, token);
chatThread = augmentation.ChatThread;
usedContexts = augmentation.UsedContexts;
}
//
@@ -240,24 +243,19 @@ public sealed class AISrcSelWithRetCtxVal : IRagProcess
chatThread.AugmentedData = WithUnreachableDataSources(chatThread.AugmentedData, dataSources.GetUnreachableOfTheChat(chatThread.DataSourceOptions));
//
// Add sources from the selected data
// Add the sources and passages of what the AI received:
//
// We know that the last block is the AI answer block (cf. check above):
var aiAnswerBlock = chatThread.Blocks.Last();
var aiAnswerSources = aiAnswerBlock.Content?.Sources;
var aiAnswerContent = aiAnswerBlock.Content;
// It should never happen that the AI answer block does not contain a content part.
// Just in case, we check this:
if(aiAnswerSources is null)
if(aiAnswerContent is null)
return chatThread;
var ragSources = new List<ISource>();
foreach (var retrievalContext in dataContexts)
ragSources.AddRange(retrievalContext.ToSources());
// Merge the sources, avoiding duplicates:
aiAnswerSources.MergeSources(ragSources);
AddWhatTheAIReceived(aiAnswerContent, usedContexts);
}
return chatThread;
@@ -265,6 +263,26 @@ public sealed class AISrcSelWithRetCtxVal : IRagProcess
#endregion
/// <summary>
/// Lists below the answer what the AI received: the sources and the passages of the retrieval
/// contexts handed to it.
/// </summary>
/// <remarks>
/// Only those: a passage which the check of which passages fit left out never reached the AI,
/// and the answer does not rest on it. Only a text answer keeps passages; every answer keeps
/// its sources.
/// </remarks>
/// <param name="aiAnswerContent">The content of the answer.</param>
/// <param name="usedContexts">The retrieval contexts handed to the AI.</param>
internal static void AddWhatTheAIReceived(IContent aiAnswerContent, IReadOnlyList<PreparedRetrievalContext> usedContexts)
{
var sources = usedContexts.SelectMany(context => context.Sources);
if (aiAnswerContent is ContentText aiAnswerText)
aiAnswerText.AddSources(sources, usedContexts.Select(context => context.Passage).OfType<SourcePassage>());
else
aiAnswerContent.Sources.MergeSources(sources);
}
/// <summary>
/// Restricts where the chat may still send data, because of the passages the retrieval brought in.
/// </summary>
@@ -63,4 +63,45 @@ public sealed class RetrievalTextContext : IRetrievalContext
/// to. A slide or a sheet has no page and leaves this empty.
/// </remarks>
public int? PageNumber { get; init; }
/// <summary>
/// The page this passage ends on when it goes on beyond the page it starts on, or null when it
/// stays on that page or the end is not known.
/// </summary>
public int? EndPageNumber { get; init; }
/// <summary>
/// The sheet of a spreadsheet this passage is from, or null when it is from none.
/// </summary>
public string? SheetName { get; init; }
/// <summary>
/// The row this passage starts at, counted from one: a row of its sheet, or of a table such as a CSV file.
/// </summary>
public int? StartRow { get; init; }
/// <summary>
/// The row this passage ends at when it goes on beyond the row it starts at, or null.
/// </summary>
public int? EndRow { get; init; }
/// <summary>
/// The line this passage starts at, counted from one, for text and source code, or null.
/// </summary>
public int? StartLine { get; init; }
/// <summary>
/// The line this passage ends at when it goes on beyond the line it starts at, or null.
/// </summary>
public int? EndLine { get; init; }
/// <summary>
/// Which part of its document this passage is, counted from one, or null when the data source does not say.
/// </summary>
/// <remarks>
/// A document is split into parts to be searched, and each part is one passage. The page says
/// better where a passage is, so the part only names the place in a document without pages,
/// such as a text file.
/// </remarks>
public int? ChunkNumber { get; init; }
}
@@ -0,0 +1,130 @@
using System.Text.Json.Serialization;
namespace AIStudio.Tools.RAG;
/// <summary>
/// One passage a data source handed to the AI for an answer, as the AI received it.
/// </summary>
/// <remarks>
/// Stored with the answer, so the user can still read what the answer rests on after the chat was
/// reloaded: where it is with the answer, its texts in the provenance store, see ProvenanceId. The
/// texts are the filtered ones: whatever the prompt injection filter removed did not reach the AI,
/// so it is not shown as if it had.<br/><br/>
/// A passage belongs to the source it is listed under, which is why it is only made for a context
/// with a source, see SourceUrl. A context which names nothing to open, e.g., by a path which only
/// its ERI server knows, gets a source named as text, see IRetrievalContextExtensions.ToSources.<br/><br/>
/// Plain stored values only: a computed property would be written into every chat file as well.
/// </remarks>
public sealed record SourcePassage
{
/// <summary>
/// The address of the source this passage is listed under.
/// </summary>
/// <remarks>
/// The first source of the context, i.e., its own reference, such as the file with the page of
/// the passage. The list of sources tells sources apart by their address without the fragment,
/// and so does the search for the passages of a source, see GetPassagesOf.
/// </remarks>
public string SourceUrl { get; init; } = string.Empty;
/// <summary>
/// The name of where the passage was found, e.g., a document or a database.
/// </summary>
public string DataSourceName { get; init; } = string.Empty;
/// <summary>
/// The path to the content, e.g., a file path or a URL.
/// </summary>
public string Path { get; init; } = string.Empty;
/// <summary>
/// The category of the content, like text or image.
/// </summary>
public RetrievalContentCategory Category { get; init; }
/// <summary>
/// What type of content the passage is, like a document or a transcript.
/// </summary>
public RetrievalContentType Type { get; init; }
/// <summary>
/// The entry of the provenance store which holds the matched text and the content around it,
/// or null when the passage holds them itself.
/// </summary>
/// <remarks>
/// Set once the answer is complete, see ContentText.StoreReceivedContentAsync. Until then, and
/// whenever the store could not take them, the passage holds both texts itself; once they are
/// stored, Text and SurroundingContent are empty. Left out of the chat file while null.
/// </remarks>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Guid? ProvenanceId { get; init; }
/// <summary>
/// The matched text, filtered for prompt injections, as long as it is not stored, see ProvenanceId.
/// </summary>
/// <remarks>
/// Empty for an image which matched: the AI is only told that it did, never shown the image.
/// </remarks>
public string Text { get; init; } = string.Empty;
/// <summary>
/// The content around the matched text, filtered for prompt injections, as long as it is not stored, see ProvenanceId.
/// </summary>
public IReadOnlyList<string> SurroundingContent { get; init; } = [];
/// <summary>
/// The page the passage starts on, or null when it has none.
/// </summary>
public int? PageNumber { get; init; }
/// <summary>
/// The page the passage ends on, or null when it is not known.
/// </summary>
public int? EndPageNumber { get; init; }
//
// Where a passage without pages is. Left out of the chat file while unknown, which they are for
// every passage of a document with pages, and for every passage stored before they existed:
//
/// <summary>
/// The sheet of a spreadsheet the passage is from, or null.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SheetName { get; init; }
/// <summary>
/// The row the passage starts at, counted from one: a row of its sheet, or of a table such as a CSV file.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? StartRow { get; init; }
/// <summary>
/// The row the passage ends at, or null when it is not known.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? EndRow { get; init; }
/// <summary>
/// The line the passage starts at, counted from one, for text and source code, or null.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? StartLine { get; init; }
/// <summary>
/// The line the passage ends at, or null when it is not known.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? EndLine { get; init; }
/// <summary>
/// Which part of its document the passage is, counted from one, or null when the data source
/// does not say.
/// </summary>
/// <remarks>
/// Names the place of a passage which says neither pages, nor rows, nor lines, e.g., one
/// indexed before these were kept. Wherever they are known, they say it better, see
/// SourcePassageExtensions.GetLocation.
/// </remarks>
public int? ChunkNumber { get; init; }
}
@@ -0,0 +1,318 @@
using AIStudio.Tools.Databases.ProvenanceStore;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.RAG;
public static class SourcePassageExtensions
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SourcePassageExtensions).Namespace, nameof(SourcePassageExtensions));
/// <summary>
/// Whether the passage comes from a transcript, e.g., of an audio or a video file.
/// </summary>
/// <param name="passage">The passage.</param>
/// <returns>True for a transcript.</returns>
public static bool IsTranscript(this SourcePassage passage) => passage.Type is RetrievalContentType.TEXT_TRANSCRIPT;
/// <summary>
/// What sort of content the passage is, as the provenance store files it.
/// </summary>
/// <remarks>
/// Told by what the content is rather than by where it came from: a mail is a mail, whether a
/// mailbox data source or a tool handed it over.
/// </remarks>
/// <param name="passage">The passage.</param>
/// <returns>The kind of its entry.</returns>
public static ProvenanceKind GetProvenanceKind(this SourcePassage passage) => passage.Type switch
{
RetrievalContentType.TEXT_EMAIL => ProvenanceKind.MAIL,
RetrievalContentType.TEXT_WEBSITE => ProvenanceKind.WEB_PAGE,
_ => ProvenanceKind.DATA_SOURCE_PASSAGE,
};
/// <summary>
/// Makes the passage of a web page which a tool read for the AI, so that the user can read under
/// the source of the page what the AI received from it.
/// </summary>
/// <remarks>
/// Listed under the source by its address, see GetPassagesOf, and named by its title in the
/// dialog of its passages. A page says nothing about pages or lines, so its source keeps the
/// title the tool gave it, see NamePassagesInTitles.
/// </remarks>
/// <param name="source">The source of the page, as the tool lists it.</param>
/// <param name="content">The content of the page as the model received it: filtered for prompt injections, and cut where the tool cut it.</param>
/// <returns>The passage of the page.</returns>
public static SourcePassage ToWebPagePassage(this Source source, string content) => new()
{
SourceUrl = source.URL,
DataSourceName = source.Title,
Path = source.URL,
Category = RetrievalContentCategory.TEXT,
Type = RetrievalContentType.TEXT_WEBSITE,
Text = content,
};
/// <summary>
/// Makes the passage of a mail which a tool handed to the AI, so that the user can read under
/// the source of the mail what the AI received of it.
/// </summary>
/// <remarks>
/// Listed and named like the passage of a web page, see ToWebPagePassage. A mail has no path:
/// its address names it for AI Studio only, so there is nothing to copy or to open.
/// </remarks>
/// <param name="source">The source of the mail, as the tool lists it.</param>
/// <param name="content">What the model received of the mail, filtered for prompt injections.</param>
/// <returns>The passage of the mail.</returns>
public static SourcePassage ToMailPassage(this Source source, string content) => new()
{
SourceUrl = source.URL,
DataSourceName = source.Title,
Category = RetrievalContentCategory.TEXT,
Type = RetrievalContentType.TEXT_EMAIL,
Text = content,
};
/// <summary>
/// Whether the passage holds texts which belong into the provenance store, see SourcePassage.ProvenanceId.
/// </summary>
/// <param name="passage">The passage.</param>
/// <returns>False once the texts are stored, and for a passage without any, such as an image.</returns>
public static bool HoldsTextsToStore(this SourcePassage passage) => passage.ProvenanceId is null && (passage.Text.Length > 0 || passage.SurroundingContent.Count > 0);
/// <summary>
/// What stands between the sheets of a spreadsheet in a location, e.g., "Budget: rows 3–9 · Summary: row 2".
/// </summary>
private const string SHEET_SEPARATOR = " · ";
/// <summary>
/// Names where the passage is in its document, e.g., "Page 4", "Pages 4–5", "Budget: rows 3–9", "Lines 40–85", or "Part 3".
/// </summary>
/// <param name="passage">The passage.</param>
/// <returns>The location, or an empty string when the data source does not say.</returns>
public static string GetLocationLabel(this SourcePassage passage) => GetLocationLabel([passage]);
/// <summary>
/// Where the passage is in its document, as numbers only, e.g., its pages 4–5, its lines 40–85, or its part 3.
/// </summary>
/// <remarks>
/// Orders the passages of one document, in the order of the document. The passages of the
/// sheets of a spreadsheet give their parts, since rows start anew in every sheet.
/// </remarks>
/// <param name="passage">The passage.</param>
/// <returns>The numbers, or null when the data source does not say.</returns>
public static LocationRange? GetLocationRange(this SourcePassage passage) => GetLocation([passage])?.Ranges[0];
/// <summary>
/// Names where several passages are in their document, e.g., "Pages 1, 11", "Budget: rows 3–9 · Summary: row 2", "Lines 40–85, 120" or "Parts 2, 5".
/// </summary>
/// <param name="passages">The passages.</param>
/// <returns>The location, or an empty string when the data source does not say.</returns>
public static string GetLocationLabel(this IEnumerable<SourcePassage> passages)
{
var passagesOfDocument = passages.ToList();
if (passagesOfDocument.All(passage => passage.PageNumber is not > 0) && GetSheetsLabel(passagesOfDocument) is { } sheets)
return sheets;
return passagesOfDocument.GetLocation()?.ToString() ?? string.Empty;
}
/// <summary>
/// Finds where several passages are in their document, in parts, so that each page can be shown
/// as a link.
/// </summary>
/// <remarks>
/// A document with pages is named by its pages: they are what the user finds again in the
/// document, and what a program can open it at. A table without sheets, such as a CSV file, is
/// named by its rows, and a text by its lines. Rows and lines are nothing a program can open a
/// document at, so they become no links. A document which says none of them is named by the
/// parts it was split into for the search. Either way, the numbers follow the order of the
/// document, each once: two passages on the same page name that page once. An end which is not
/// after the start names no range.<br/><br/>
/// The rows of a spreadsheet start anew in every sheet, so they need the name of their sheet
/// and are named by GetLocationLabel, sheet by sheet. Here, its passages give their parts.
/// </remarks>
/// <param name="passages">The passages.</param>
/// <returns>The location, or null when the data source says nothing about it.</returns>
public static PassageLocation? GetLocation(this IEnumerable<SourcePassage> passages)
{
var passagesOfDocument = passages.ToList();
var pages = passagesOfDocument
.Where(passage => passage.PageNumber is > 0)
.Select(passage => RangeOf(passage.PageNumber!.Value, passage.EndPageNumber))
.ToList();
if (pages.Count > 0)
return CreateLocation(pages, TB("Page {0}"), TB("Pages {0}"), arePages: true);
var rows = passagesOfDocument
.Where(passage => passage is { StartRow: > 0, SheetName: null or "" })
.Select(passage => RangeOf(passage.StartRow!.Value, passage.EndRow))
.ToList();
if (rows.Count > 0)
return CreateLocation(rows, TB("Row {0}"), TB("Rows {0}"), arePages: false);
var lines = passagesOfDocument
.Where(passage => passage.StartLine is > 0)
.Select(passage => RangeOf(passage.StartLine!.Value, passage.EndLine))
.ToList();
if (lines.Count > 0)
return CreateLocation(lines, TB("Line {0}"), TB("Lines {0}"), arePages: false);
var parts = passagesOfDocument
.Where(passage => passage.ChunkNumber is > 0)
.Select(passage => new LocationRange(passage.ChunkNumber!.Value, passage.ChunkNumber!.Value))
.ToList();
return parts.Count > 0 ? CreateLocation(parts, TB("Part {0}"), TB("Parts {0}"), arePages: false) : null;
}
/// <summary>
/// Names the rows of passages from the sheets of a spreadsheet, sheet by sheet, e.g., "Budget: rows 3–9, 20 · Summary: row 2".
/// </summary>
/// <remarks>
/// The sheets follow the order of the document, which is the order of the parts the passages
/// are. A passage never runs from one sheet into the next, cf. TextChunker.SplitBySheets.
/// </remarks>
/// <param name="passages">The passages of one document.</param>
/// <returns>The location, or null when no passage names a sheet.</returns>
private static string? GetSheetsLabel(IReadOnlyList<SourcePassage> passages)
{
var sheets = passages
.Where(passage => passage is { StartRow: > 0, SheetName.Length: > 0 })
.OrderBy(passage => passage.ChunkNumber ?? int.MaxValue)
.GroupBy(passage => passage.SheetName!, StringComparer.Ordinal)
.Select(sheet => GetSheetLabel(sheet.Key, sheet.Select(passage => RangeOf(passage.StartRow!.Value, passage.EndRow))))
.ToList();
return sheets.Count > 0 ? string.Join(SHEET_SEPARATOR, sheets) : null;
}
private static string GetSheetLabel(string sheetName, IEnumerable<LocationRange> rows)
{
var orderedRows = rows
.Distinct()
.OrderBy(range => range.First)
.ThenBy(range => range.Last)
.ToList();
var format = orderedRows is [var single] && single.First == single.Last ? TB("{0}: row {1}") : TB("{0}: rows {1}");
return string.Format(format, sheetName, string.Join(PassageLocation.RANGE_SEPARATOR, orderedRows));
}
/// <summary>
/// The numbers from a start to an end; an end which is not after the start names no range.
/// </summary>
private static LocationRange RangeOf(int first, int? last) => new(first, Math.Max(first, last ?? 0));
private static PassageLocation CreateLocation(IEnumerable<LocationRange> ranges, string oneFormat, string severalFormat, bool arePages)
{
var orderedRanges = ranges
.Distinct()
.OrderBy(range => range.First)
.ThenBy(range => range.Last)
.ToList();
//
// The words around the numbers come from the translation, which may put them before or after
// the numbers. A translation which lost its placeholder still gets the numbers, after its words:
//
const string PLACEHOLDER = "{0}";
var format = orderedRanges is [var single] && single.First == single.Last ? oneFormat : severalFormat;
var placeholderAt = format.IndexOf(PLACEHOLDER, StringComparison.Ordinal);
return placeholderAt < 0
? new PassageLocation($"{format} ", orderedRanges, string.Empty, arePages)
: new PassageLocation(format[..placeholderAt], orderedRanges, format[(placeholderAt + PLACEHOLDER.Length)..], arePages);
}
/// <summary>
/// Names in the title of each source where its passages are, e.g., "handbook.pdf (Pages 1, 11)".
/// </summary>
/// <remarks>
/// The list of sources keeps one entry per document, see SourceExtensions.MergeSources, and its
/// title came from the passage found first, e.g., "handbook.pdf (Page 1)". A second passage on
/// page 11 would go unmentioned, so the title is made anew from all passages of the source. A
/// spreadsheet is named by its sheets and rows the same way, a text file by its lines, see
/// GetLocationLabel. Passages whose data source
/// says neither, e.g., an external one, are counted instead as soon as there are several of
/// them. Otherwise, a source keeps the title its data source gave it, and so does every source
/// without passages, such as a web page.
/// </remarks>
/// <param name="sources">The sources of an answer.</param>
/// <param name="passages">The passages of the same answer.</param>
public static void NamePassagesInTitles(this IList<Source> sources, IReadOnlyCollection<SourcePassage> passages)
{
if (passages.Count is 0)
return;
var passagesBySource = passages.SortBySource();
for (var index = 0; index < sources.Count; index++)
{
var passagesOfSource = passagesBySource.GetPassagesOf(sources[index]);
if (passagesOfSource.Count is 0 || string.IsNullOrWhiteSpace(passagesOfSource[0].DataSourceName))
continue;
var location = passagesOfSource.GetLocationLabel();
if (location.Length is 0 && passagesOfSource.Count > 1)
location = string.Format(TB("{0} passages"), passagesOfSource.Count.CompactCount());
if (location.Length is 0)
continue;
sources[index] = sources[index] with { Title = $"{passagesOfSource[0].DataSourceName} ({location})" };
}
}
/// <summary>
/// Merges added passages into an existing list of passages, leaving out those it holds already.
/// </summary>
/// <remarks>
/// A passage is known when one with the same address and the same text is, e.g., the same chunk
/// found by two searches for one answer. Unlike the sources, the address counts with its page:
/// the same sentence on two pages of a document is two passages. A stored passage is told
/// apart by its entry, since its text lies in the provenance store.
/// </remarks>
/// <param name="passages">The existing list of passages to merge into.</param>
/// <param name="addedPassages">The passages to add.</param>
public static void MergePassages(this IList<SourcePassage> passages, IEnumerable<SourcePassage> addedPassages)
{
var knownPassages = passages
.Select(passage => (passage.SourceUrl, passage.Text, passage.ProvenanceId))
.ToHashSet();
foreach (var addedPassage in addedPassages)
{
if (knownPassages.Add((addedPassage.SourceUrl, addedPassage.Text, addedPassage.ProvenanceId)))
passages.Add(addedPassage);
}
}
/// <summary>
/// Finds the passages listed under a source.
/// </summary>
/// <remarks>
/// The list of sources keeps one entry per document, whichever page its link names, see
/// SourceExtensions.MergeSources. The passages are matched the same way, so the entry of a
/// document shows the passages of all its pages, and every passage shows up under its source.
/// </remarks>
/// <param name="passages">The passages of an answer.</param>
/// <param name="source">The source.</param>
/// <returns>The passages of the source, in their order; empty when it has none.</returns>
public static IReadOnlyList<SourcePassage> GetPassagesOf(this IEnumerable<SourcePassage> passages, ISource source) => passages.SortBySource().GetPassagesOf(source);
/// <summary>
/// Sorts passages by the source they are listed under, to find the passages of many sources.
/// </summary>
/// <param name="passages">The passages of an answer.</param>
/// <returns>The passages by the identity of their source, see SourceExtensions.GetSourceIdentity.</returns>
public static ILookup<string, SourcePassage> SortBySource(this IEnumerable<SourcePassage> passages) => passages.ToLookup(passage => SourceExtensions.GetSourceIdentity(passage.SourceUrl), StringComparer.Ordinal);
/// <summary>
/// Finds the passages listed under a source, among passages sorted by their sources.
/// </summary>
/// <param name="passagesBySource">The passages of an answer, see SortBySource.</param>
/// <param name="source">The source.</param>
/// <returns>The passages of the source, in their order; empty when it has none.</returns>
public static IReadOnlyList<SourcePassage> GetPassagesOf(this ILookup<string, SourcePassage> passagesBySource, ISource source) => [..passagesBySource[SourceExtensions.GetSourceIdentity(source.URL)]];
}