mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 20:27:21 +00:00
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:
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>
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
+19
@@ -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}";
|
||||
}
|
||||
+13
@@ -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);
|
||||
+53
-3
@@ -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);
|
||||
|
||||
|
||||
+20
-3
@@ -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)
|
||||
|
||||
+70
-5
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -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
|
||||
|
||||
+20
-7
@@ -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,
|
||||
|
||||
+8
-1
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user