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

@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using AIStudio.Provider;
using AIStudio.Tools.RAG;
namespace AIStudio.Tools.ToolCallingSystem.Harness;
@@ -38,6 +39,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var toolCallCount = 0;
var toolResultCharacterCount = 0L;
var toolSources = new List<Source>();
var toolPassages = new List<SourcePassage>();
var hasStreamedTextBefore = false;
var isFirstRound = true;
@@ -111,11 +113,15 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
//
// No outcome means the round failed: the request errored out, or the stream ended
// mid-sentence. Either way the adapter has already reported it.
// mid-sentence. Either way the adapter has already reported it. The answer keeps what
// was written so far, and with it what the tools of the earlier rounds contributed:
//
if (round is null)
{
await context.ResetToolRuntimeStatusAsync();
if (toolSources.Count > 0 || toolPassages.Count > 0)
yield return WithWhatTheToolsContributed(string.Empty);
yield break;
}
@@ -133,7 +139,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
var unanswerable = context.ToolExecutor.CreateInvalidToolCallResult(string.Empty, toolCallCount);
await context.AddToolInvocationAsync(unanswerable.Trace);
await context.ResetToolRuntimeStatusAsync();
yield return new ContentStreamChunk(unanswerable.Content, [..toolSources]);
yield return WithWhatTheToolsContributed(unanswerable.Content);
yield break;
}
@@ -146,10 +152,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
// the tools contributed. An empty chunk is how sources travel on their own; the
// streaming paths of the providers attach their annotations the same way.
//
yield return new ContentStreamChunk(
roundAnswered ? string.Empty : NO_ANSWER_AFTER_LIMIT,
[..toolSources]);
yield return WithWhatTheToolsContributed(roundAnswered ? string.Empty : NO_ANSWER_AFTER_LIMIT);
yield break;
}
@@ -158,13 +161,13 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
await context.ResetToolRuntimeStatusAsync();
if (roundAnswered)
{
yield return new ContentStreamChunk(string.Empty, [..toolSources]);
yield return WithWhatTheToolsContributed(string.Empty);
yield break;
}
if (toolCallCount > 0)
{
yield return new ContentStreamChunk(NO_ANSWER_AFTER_TOOL_CALL, [..toolSources]);
yield return WithWhatTheToolsContributed(NO_ANSWER_AFTER_TOOL_CALL);
yield break;
}
@@ -239,6 +242,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
context.ChatThread.RequireOutboundDataRestriction(outcome.RequiredOutboundDataRestriction);
context.ChatThread.RuntimeWebAddressesFromTools.UnionWith(outcome.ReturnedWebAddresses);
toolSources.MergeSources(outcome.Sources);
toolPassages.MergePassages(outcome.Passages);
await context.AddToolInvocationAsync(outcome.Trace);
// A blocked call counts as a failure towards the model as much as an errored
@@ -252,6 +256,13 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
await context.ResetToolRuntimeStatusAsync();
}
}
//
// What the tools contributed goes out with the last chunk of the conversation: the sources,
// and the passages the model received from them. One way out for every way the conversation
// ends, so none of them hands over the sources and forgets the passages.
//
ContentStreamChunk WithWhatTheToolsContributed(string content) => new(content, [..toolSources]) { Passages = [..toolPassages] };
}
private static string GetDisplayName(ToolCallingLoopContext context, string toolName) => context.RunnableTools
@@ -1,5 +1,6 @@
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.RAG;
namespace AIStudio.Tools.ToolCallingSystem;
@@ -35,6 +36,11 @@ public sealed record ToolCallOutcome(string Content, ToolInvocationTrace Trace)
/// </summary>
public IReadOnlyList<Source> Sources { get; init; } = [];
/// <summary>
/// The passages of the sources, as the model received them in the result.
/// </summary>
public IReadOnlyList<SourcePassage> Passages { get; init; } = [];
/// <summary>
/// The web addresses in the result, as request keys, except those the model wrote into the call.
/// </summary>
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG;
using AIStudio.Tools.Security;
using AIStudio.Tools.Web;
@@ -260,6 +261,11 @@ public sealed class ConfluenceSearchTool(WebPageRetrievalService webPageRetrieva
WebPageModelContent.From(retrievedPage.ExtractedPage, markdown),
PromptInjectionSource.WebContent(page.FinalUrl.ToString()));
//
// The search page is what AI Studio actually read. Pages found on it become sources once
// read_web_page loads them. What the model read of it can be read under its source:
//
var source = new Source(string.Format(TB("Confluence search for “{0}”"), query), page.FinalUrl.ToString(), SourceOrigin.TOOL);
return new ToolExecutionResult
{
JsonContent = new JsonObject
@@ -269,9 +275,8 @@ public sealed class ConfluenceSearchTool(WebPageRetrievalService webPageRetrieva
["text_content"] = modelContent.Markdown,
},
// The search page is what AI Studio actually read. Pages found on it become sources
// once read_web_page loads them:
Sources = [new Source(string.Format(TB("Confluence search for “{0}”"), query), page.FinalUrl.ToString(), SourceOrigin.TOOL)],
Sources = [source],
Passages = [source.ToWebPagePassage(modelContent.Markdown)],
RequiredProviderConfidence = ConfidenceLevel.HIGH,
// Set here only, on a search which returned content: a refused or failed one brought
@@ -0,0 +1,19 @@
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
/// <summary>
/// The attachment of a mail which a passage is from, and where in it, cf. SearchMailsTool.
/// </summary>
/// <param name="Name">The filtered name of the attachment, or an empty string when the passage is from no attachment.</param>
/// <param name="Where">Where in the attachment the passage is, or null when that is not known.</param>
internal readonly record struct MailPassageAttachment(string Name, string? Where)
{
/// <summary>
/// The passage is from no attachment.
/// </summary>
public static readonly MailPassageAttachment NONE = new(string.Empty, null);
/// <summary>
/// The name of the attachment, followed by where in it the passage is, when that is known.
/// </summary>
public string Label => this.Where is null ? this.Name : $"{this.Name}, {this.Where}";
}
@@ -0,0 +1,13 @@
using System.Text.Json.Nodes;
using AIStudio.Tools.RAG;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
/// <summary>
/// What the model and the user get of a mail which the model read, cf. ReadMailTool.
/// </summary>
/// <param name="Json">The result for the model, with every text filtered for prompt injections.</param>
/// <param name="Source">The source of the mail, which the answer lists.</param>
/// <param name="Passage">What the user reads of the mail under its source.</param>
internal sealed record MailReadingResult(JsonObject Json, Source Source, SourcePassage Passage);
@@ -1,10 +1,12 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Mail;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
@@ -18,9 +20,55 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
/// </remarks>
internal static class MailToolResults
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MailToolResults).Namespace, nameof(MailToolResults));
public const string NO_SUBJECT = "(no subject)";
public const string UNKNOWN_SENDER = "(unknown sender)";
/// <summary>
/// Writes what the model received of a mail, for the user to read under the source of the mail.
/// </summary>
/// <remarks>
/// Every value is the filtered one the model got. Only the layout is made for the user: a short
/// list of the header, then the text of the mail, or the passage the search found, where the
/// model got one. A mail listed by its sender or date carries the header alone, which is all
/// the model received of it.
/// </remarks>
/// <param name="from">The sender, as the model got it.</param>
/// <param name="recipients">The recipients, as the model got them.</param>
/// <param name="subject">The subject.</param>
/// <param name="received">When the mail arrived, as the model got it.</param>
/// <param name="attachments">The names of the attachments.</param>
/// <param name="readFrom">The attachment the text is from, and where in it, or null for the text of the mail itself.</param>
/// <param name="text">The text of the mail, or the passage found, or null when the model got neither.</param>
/// <returns>The mail as Markdown.</returns>
public static string ComposePassage(string from, IReadOnlyList<string> recipients, string subject, string received, IReadOnlyList<string> attachments, string? readFrom, string? text)
{
var builder = new StringBuilder();
AppendField(TB("From"), from);
AppendField(TB("To"), string.Join(", ", recipients));
AppendField(TB("Subject"), subject);
AppendField(TB("Received"), received);
AppendField(TB("Attachments"), string.Join(", ", attachments));
AppendField(TB("Text from the attachment"), readFrom);
if (!string.IsNullOrWhiteSpace(text))
{
builder.AppendLine();
builder.AppendLine("---");
builder.AppendLine();
builder.Append(text);
}
return builder.ToString().TrimEnd();
void AppendField(string label, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
builder.AppendLine($"- **{label}:** {value}");
}
}
/// <summary>
/// How many folders a result lists when the folder the model asked for does not exist.
/// </summary>
@@ -205,7 +253,8 @@ internal static class MailToolResults
/// <remarks>
/// The address leads nowhere yet, so the list of sources shows the title as text; the mail
/// viewer will open it. Subject and sender have to be filtered already, since the user reads
/// the title.
/// the title. A mail found by a passage of one of its attachments names that attachment, so
/// the user knows where in the mail the answer comes from.
/// </remarks>
/// <param name="mailbox">The mailbox the mail belongs to.</param>
/// <param name="mailId">The id of the mail.</param>
@@ -213,8 +262,9 @@ internal static class MailToolResults
/// <param name="senderName">The name of the sender, filtered as well.</param>
/// <param name="receivedAtUtc">When the mail arrived at the server.</param>
/// <param name="timeZone">The time zone of the user.</param>
public static Source CreateSource(DataSourceMailbox mailbox, string mailId, string subject, string senderName, DateTimeOffset receivedAtUtc, TimeZoneInfo timeZone) => new(
string.Create(CultureInfo.InvariantCulture, $"Mail: {subject} — {senderName}, {TimeZoneInfo.ConvertTime(receivedAtUtc, timeZone):yyyy-MM-dd}"),
/// <param name="passageAttachment">The attachment the passage found is from, and where in it, e.g., "board-report.pdf, page 3", filtered as well; null for the text of the mail, or when it is not known.</param>
public static Source CreateSource(DataSourceMailbox mailbox, string mailId, string subject, string senderName, DateTimeOffset receivedAtUtc, TimeZoneInfo timeZone, string? passageAttachment = null) => new(
string.Create(CultureInfo.InvariantCulture, $"Mail: {subject} — {senderName}, {TimeZoneInfo.ConvertTime(receivedAtUtc, timeZone):yyyy-MM-dd}{(passageAttachment is null ? string.Empty : $" ({passageAttachment})")}"),
SourceExtensions.CreateMailSourceUrl(mailbox.Id, mailId),
SourceOrigin.TOOL);
@@ -6,6 +6,7 @@ using AIStudio.Settings;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Mail;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG;
using AIStudio.Tools.Security;
using AIStudio.Tools.Services;
@@ -149,6 +150,7 @@ public sealed class ReadMailTool(SettingsManager settingsManager, MailboxRetriev
{
JsonContent = description.Json,
Sources = [description.Source],
Passages = [description.Passage],
RequiredProviderConfidence = requirements.Confidence,
RequiredOutboundDataRestriction = requirements.OutboundData,
};
@@ -230,9 +232,10 @@ public sealed class ReadMailTool(SettingsManager settingsManager, MailboxRetriev
};
/// <summary>
/// Builds the result of a reading, with every text of the mail filtered for prompt injections in one request.
/// Builds the result of a reading, with every text of the mail filtered for prompt injections in
/// one request, and what the user reads of it under the source of the mail.
/// </summary>
private async Task<(JsonObject Json, Source Source)> DescribeAsync(MailReading reading, ReadMailRequest request, string pageText, MailPartRecord? part, int lastPage, TimeZoneInfo timeZone)
private async Task<MailReadingResult> DescribeAsync(MailReading reading, ReadMailRequest request, string pageText, MailPartRecord? part, int lastPage, TimeZoneInfo timeZone)
{
var mailbox = reading.Mailbox;
var summary = reading.Summary;
@@ -315,7 +318,21 @@ public sealed class ReadMailTool(SettingsManager settingsManager, MailboxRetriev
if (issues.Count > 0)
json["issues"] = issues;
return (json, MailToolResults.CreateSource(mailbox, summary.MailId, texts[subject], texts[senderName], summary.ReceivedAtUtc, timeZone));
var source = MailToolResults.CreateSource(mailbox, summary.MailId, texts[subject], texts[senderName], summary.ReceivedAtUtc, timeZone);
var readAttachment = part is { Kind: MailPartKind.ATTACHMENT } ? attachments.IndexOf(part) : -1;
var passage = source.ToMailPassage(MailToolResults.ComposePassage(
string.Join(", ", AddressesOf(MailAddressRole.FROM)),
[..AddressesOf(MailAddressRole.TO), ..AddressesOf(MailAddressRole.CC)],
texts[subject],
MailToolResults.FormatTime(summary.ReceivedAtUtc, timeZone),
[..attachmentNames.Select(name => texts[name])],
readAttachment < 0 ? null : texts[attachmentNames[readAttachment]],
texts[text]));
return new(json, source, passage);
// The addresses of one role, as far as the model got them:
IEnumerable<string> AddressesOf(MailAddressRole role) => addressLists.Where(list => list.Role == role).SelectMany(list => list.Indices).Select(index => texts[index]);
}
private static JsonArray DescribeAttachments(IReadOnlyList<MailPartRecord> attachments, IReadOnlyList<int> names, MailTexts texts)
@@ -6,6 +6,7 @@ using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Mail;
using AIStudio.Tools.PluginSystem;
@@ -115,7 +116,7 @@ public sealed class SearchMailsTool(SettingsManager settingsManager, MailboxRetr
- Search whenever a question concerns the mails of the user: what somebody wrote, what arrived, or what is still open.
- Put what the question names into the conditions, such as `from`, `after`, `is_unread`, or `folder`, rather than into the query. Without a query, the mails meeting the conditions are listed, the most recently received first.
- Write a query only to find mails by their content: self-contained, naming the subject, in the language the mails are most likely written in.
- The passage of a mail is only an excerpt. Read the whole mail and its attachments with `read_mail` and its `mail_id` before you answer from it. When `read_mail` is not available, answer from the passages and say so.
- The passage of a mail is only an excerpt. When it comes from an attachment, `passage_location` names the attachment and where in it the passage is. Read the whole mail and its attachments with `read_mail` and its `mail_id` before you answer from it. When `read_mail` is not available, answer from the passages and say so.
- Dates without an offset are read in the time zone of the user. The `conditions` of the result show how they were read.
- The index holds the mails the user sent and the drafts of the user as well, unless a mailbox leaves them out. `special_folder` searches only the sent mails or only the drafts, however the server names their folders, and a result marks every mail in one of them with `special_folder`. For mails the user wrote to somebody, combine `special_folder` set to `sent` with `to`.
- A draft has not been sent. When the user asks about a draft, search with `special_folder` set to `drafts`: AI Studio then fetches the drafts saved since its last sync. To improve a draft, read it with `read_mail` and propose the improved text in your answer. You cannot save or send it.
@@ -242,6 +243,7 @@ public sealed class SearchMailsTool(SettingsManager settingsManager, MailboxRetr
var mails = searches.Select(_ => new JsonArray()).ToArray();
var leftOutCounts = new int[searches.Length];
var sources = new List<Source>();
var passages = new List<SourcePassage>();
var resultCharacters = 0;
var mostMails = pendingMails.Select(list => list.Count).DefaultIfEmpty(0).Max();
for (var rank = 0; rank < mostMails; rank++)
@@ -264,7 +266,11 @@ public sealed class SearchMailsTool(SettingsManager settingsManager, MailboxRetr
resultCharacters += mailCharacters;
mails[index].Add(mail);
sources.Add(pendingMail.ToSource(texts, timeZone));
// Mails are listed without a link, so what the model received of one is all there is to read under it:
var source = pendingMail.ToSource(texts, timeZone);
sources.Add(source);
passages.Add(pendingMail.ToPassage(source, texts, timeZone));
}
}
@@ -296,6 +302,7 @@ public sealed class SearchMailsTool(SettingsManager settingsManager, MailboxRetr
["mailboxes"] = mailboxResults,
},
Sources = sources,
Passages = passages,
RequiredProviderConfidence = requirements.Confidence,
RequiredOutboundDataRestriction = requirements.OutboundData,
};
@@ -425,7 +432,7 @@ public sealed class SearchMailsTool(SettingsManager settingsManager, MailboxRetr
/// <remarks>
/// Whether the mail lies among the sent mails or the drafts is AI Studio's own value, so it needs no filtering.
/// </remarks>
private sealed record PendingMail(DataSourceMailbox Mailbox, MailSummary Summary, int Subject, int From, int Sender, IReadOnlyList<int> Recipients, int MoreRecipients, IReadOnlyList<int> Folders, string? SpecialFolder, IReadOnlyList<int> Attachments, int? Passage)
private sealed record PendingMail(DataSourceMailbox Mailbox, MailSummary Summary, int Subject, int From, int Sender, IReadOnlyList<int> Recipients, int MoreRecipients, IReadOnlyList<int> Folders, string? SpecialFolder, IReadOnlyList<int> Attachments, int? Passage, ChunkLocation? PassageLocation)
{
public static PendingMail Register(MailSearchHit hit, DataSourceMailbox mailbox, IReadOnlyList<MailFolderRecord> mailboxFolders, MailTexts texts)
{
@@ -444,7 +451,8 @@ public sealed class SearchMailsTool(SettingsManager settingsManager, MailboxRetr
summary.FolderPaths.Select(folder => texts.Add(folder, mailbox)).ToList(),
MailToolResults.GetSpecialFolder(summary.FolderPaths, mailboxFolders),
summary.AttachmentNames.Select(name => texts.Add(name, mailbox)).ToList(),
hit.Passage is null ? null : texts.Add(hit.Passage.Shorten(MAX_PASSAGE_CHARACTERS), mailbox));
hit.Passage is null ? null : texts.Add(hit.Passage.Shorten(MAX_PASSAGE_CHARACTERS), mailbox),
hit.PassageLocation);
}
public JsonObject Describe(MailTexts texts, TimeZoneInfo timeZone)
@@ -476,11 +484,68 @@ public sealed class SearchMailsTool(SettingsManager settingsManager, MailboxRetr
description["attachments"] = new JsonArray([..this.Attachments.Select(attachment => (JsonNode?)texts[attachment])]);
if (this.Passage is { } passage)
{
description["passage"] = texts[passage];
var attachment = this.LocatePassageInAttachment(texts);
if (attachment != MailPassageAttachment.NONE)
description["passage_location"] = attachment.Where is null ? $"attachment \"{attachment.Name}\"" : $"attachment \"{attachment.Name}\", {attachment.Where}";
}
return description;
}
public Source ToSource(MailTexts texts, TimeZoneInfo timeZone) => MailToolResults.CreateSource(this.Mailbox, this.Summary.MailId, texts[this.Subject], texts[this.Sender], this.Summary.ReceivedAtUtc, timeZone);
/// <summary>
/// Writes what the model received of this mail, for the user to read under its source.
/// </summary>
/// <param name="source">The source of this mail, see ToSource.</param>
/// <param name="texts">The filtered texts of the result.</param>
/// <param name="timeZone">The time zone of the user.</param>
/// <returns>The passage of this mail.</returns>
public SourcePassage ToPassage(Source source, MailTexts texts, TimeZoneInfo timeZone)
{
var attachment = this.LocatePassageInAttachment(texts);
return source.ToMailPassage(MailToolResults.ComposePassage(
texts[this.From],
[..this.Recipients.Select(recipient => texts[recipient])],
texts[this.Subject],
MailToolResults.FormatTime(this.Summary.ReceivedAtUtc, timeZone),
[..this.Attachments.Select(name => texts[name])],
attachment == MailPassageAttachment.NONE ? null : attachment.Label,
this.Passage is { } passage ? texts[passage] : null));
}
public Source ToSource(MailTexts texts, TimeZoneInfo timeZone)
{
var attachment = this.LocatePassageInAttachment(texts);
return MailToolResults.CreateSource(
this.Mailbox,
this.Summary.MailId,
texts[this.Subject],
texts[this.Sender],
this.Summary.ReceivedAtUtc,
timeZone,
attachment == MailPassageAttachment.NONE ? null : attachment.Label);
}
/// <summary>
/// The attachment the passage is from, and where in it.
/// </summary>
/// <remarks>
/// The attachments are numbered the way the index numbers them, cf. MailSummary.AttachmentNames.
/// Where in an attachment is worded like the location of any other passage, cf.
/// IRetrievalContextExtensions.DescribeLocation. A passage of the text of the mail names
/// no attachment, and neither does one of a mail indexed before attachments were told apart.
/// </remarks>
/// <param name="texts">The filtered texts of the result.</param>
/// <returns>The filtered name of the attachment, and where in it the passage is, if known; MailPassageAttachment.NONE when the passage is from no attachment.</returns>
private MailPassageAttachment LocatePassageInAttachment(MailTexts texts)
{
if (this.Passage is null || this.PassageLocation is not { AttachmentPosition: { } position } location || position < 0 || position >= this.Attachments.Count || position >= this.Summary.AttachmentNames.Count)
return MailPassageAttachment.NONE;
var type = DataSourceLocalRetrievalService.GetRetrievalContentType(Path.GetExtension(this.Summary.AttachmentNames[position]));
var located = DataSourceLocalRetrievalService.LocatePassage(location, type, chunkNumber: 1);
return new(texts[this.Attachments[position]], IRetrievalContextExtensions.DescribeLocation(located.PageNumber, located.EndPageNumber, located.SheetName, located.StartRow, located.EndRow, located.StartLine, located.EndLine));
}
}
}
@@ -5,6 +5,7 @@ using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG;
using AIStudio.Tools.Security;
using AIStudio.Tools.Web;
@@ -481,12 +482,19 @@ public sealed class ReadWebPageTool(WebPageRetrievalService webPageRetrievalServ
ReadInternalPageOutboundDataRestriction(context.SettingsValues.GetValueOrDefault(INTERNAL_PAGE_OUTBOUND_DATA_RESTRICTION_SETTING)),
await ConfluenceSearchTool.ReadConfiguredWikiAsync(toolSettingsService));
//
// A page without readable content is no source. One with content is, together with what the
// model read of it, so the user can read that under the source:
//
var source = string.IsNullOrWhiteSpace(modelContent.Markdown)
? null
: new Source(string.IsNullOrWhiteSpace(modelContent.Title) ? page.FinalUrl.ToString() : modelContent.Title, page.FinalUrl.ToString(), SourceOrigin.TOOL);
return new ToolExecutionResult
{
JsonContent = BuildModelContent(page, retrievedPage.ContentKind, modelContent, retrievedPage.RetrievedAtUtc, originalContentCharacters, contentTruncated, warnings),
Sources = string.IsNullOrWhiteSpace(modelContent.Markdown)
? []
: [new Source(string.IsNullOrWhiteSpace(modelContent.Title) ? page.FinalUrl.ToString() : modelContent.Title, page.FinalUrl.ToString(), SourceOrigin.TOOL)],
Sources = source is null ? [] : [source],
Passages = source is null ? [] : [source.ToWebPagePassage(modelContent.Markdown)],
RequiredProviderConfidence = retrievedPage.RequiredProviderConfidence,
// Set here only, on a page which was read: a refused or failed call brought nothing
@@ -371,14 +371,15 @@ public sealed class SemanticSearchTool(SettingsManager settingsManager, DataSour
var textContent = new StringBuilder();
var sources = new List<Source>();
var passages = new List<SourcePassage>();
var resultCounts = new int[pages.Length];
var leftOutCounts = new int[pages.Length];
var passageCount = 0;
//
// Every passage goes through the same filter for prompt injections and into the same shape
// as with the classic RAG process. The user hears about what was filtered once for the
// whole search, not once per passage.
// as with the classic RAG process. All of them are filtered in one request, so the user
// hears about what was filtered once for the whole search, not once per passage.
//
// The data sources take turns: first the best passage of each, then the second best of
// each, and so on. Otherwise, the data source offered first would take the budget, and the
@@ -386,6 +387,13 @@ public sealed class SemanticSearchTool(SettingsManager settingsManager, DataSour
//
await using (var reportingScope = guardService.BeginAction())
{
var preparedContexts = await pages.SelectMany(page => page.Contexts).ToList().PrepareForModelAsync(guardService, reportingScope, token);
// Where the prepared contexts of each data source begin, since they are prepared all together:
var firstOfPage = new int[pages.Length];
for (var index = 1; index < pages.Length; index++)
firstOfPage[index] = firstOfPage[index - 1] + pages[index - 1].Contexts.Count;
var mostPassages = pages.Select(page => page.Contexts.Count).DefaultIfEmpty(0).Max();
for (var rank = 0; rank < mostPassages; rank++)
{
@@ -394,11 +402,11 @@ public sealed class SemanticSearchTool(SettingsManager settingsManager, DataSour
if (rank >= pages[index].Contexts.Count)
continue;
var retrievalContext = pages[index].Contexts[rank];
var passage = await retrievalContext.AsMarkdown(index: passageCount + 1, reportingScope: reportingScope, token: token);
var preparedContext = preparedContexts[firstOfPage[index] + rank];
var markdown = preparedContext.ToMarkdown(passageCount + 1);
// A passage too long for what is left makes room for shorter ones after it:
if (textContent.Length + passage.Length > MAX_RESULT_CHARACTERS)
if (textContent.Length + markdown.Length > MAX_RESULT_CHARACTERS)
{
leftOutCounts[index]++;
continue;
@@ -406,8 +414,12 @@ public sealed class SemanticSearchTool(SettingsManager settingsManager, DataSour
passageCount++;
resultCounts[index]++;
textContent.Append(passage);
sources.AddRange(retrievalContext.ToSources());
textContent.Append(markdown);
// The user is shown what the model received, so only the passages which fit:
sources.AddRange(preparedContext.Sources);
if (preparedContext.Passage is not null)
passages.Add(preparedContext.Passage);
}
}
}
@@ -434,6 +446,7 @@ public sealed class SemanticSearchTool(SettingsManager settingsManager, DataSour
["text_content"] = textContent.ToString(),
},
Sources = sources,
Passages = passages,
RequiredProviderConfidence = requirements.Confidence,
RequiredDataSecurity = requirements.Security,
RequiredOutboundDataRestriction = requirements.OutboundData,
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG;
using AIStudio.Tools.Security;
using AIStudio.Tools.Web;
@@ -579,6 +580,7 @@ public sealed class WebSearchTool(IEnumerable<IWebSearchBackend> backends, WebPa
var resultArray = new JsonArray();
var sources = new List<Source>();
var passages = new List<SourcePassage>();
for (var resultIndex = 0; resultIndex < retrievalResult.Results.Count; resultIndex++)
{
var result = retrievalResult.Results[resultIndex];
@@ -595,7 +597,11 @@ public sealed class WebSearchTool(IEnumerable<IWebSearchBackend> backends, WebPa
var finalUrl = result.CitationUrl.ToString();
var title = SearchCandidate.FirstNonEmpty(sanitizedContent.Title, finalUrl);
sources.Add(new Source(title, finalUrl, SourceOrigin.TOOL));
var source = new Source(title, finalUrl, SourceOrigin.TOOL);
sources.Add(source);
// What the model read of the page, so the user can read it under its source:
passages.Add(source.ToWebPagePassage(sanitizedContent.Markdown));
}
var retrievedPageCount = retrievalResult.Results.Count(result => result.HasPageContent);
@@ -665,6 +671,7 @@ public sealed class WebSearchTool(IEnumerable<IWebSearchBackend> backends, WebPa
{
JsonContent = resultObject,
Sources = sources,
Passages = passages,
};
}
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.RAG;
namespace AIStudio.Tools.ToolCallingSystem;
@@ -28,6 +29,16 @@ public sealed class ToolExecutionResult
public IReadOnlyList<Source> Sources { get; init; } = [];
/// <summary>
/// The passages of the sources, exactly as the model reads them in this result.
/// </summary>
/// <remarks>
/// The result itself is not stored with the chat, see ToolInvocationTrace.Result. The passages
/// are, so the user can still read what the answer rests on after the chat was reloaded. A
/// passage belongs to one of the Sources, see SourcePassageExtensions.GetPassagesOf.
/// </remarks>
public IReadOnlyList<SourcePassage> Passages { get; init; } = [];
public ConfidenceLevel RequiredProviderConfidence { get; init; } = ConfidenceLevel.NONE;
/// <summary>
@@ -164,6 +164,7 @@ public sealed class ToolExecutor(ToolSettingsService toolSettingsService, ILogge
RequiredDataSecurity = result.RequiredDataSecurity,
RequiredOutboundDataRestriction = result.RequiredOutboundDataRestriction,
Sources = result.Sources,
Passages = result.Passages,
ReturnedWebAddresses = FindReturnedWebAddresses(result, document.RootElement),
};
}