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

@@ -0,0 +1,71 @@
using AIStudio.Tools;
using AIStudio.Tools.Databases.ProvenanceStore;
using AIStudio.Tools.RAG;
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
namespace AIStudio.Tests.Tools.ToolCalling;
/// <summary>
/// Checks the passage a mail gets, which lets the user read under its source what the AI received of it.
/// </summary>
/// <remarks>
/// A mail is listed without a link, so this passage is the only way to see what the AI got: the
/// header it saw, and the text or the passage found, where it got one. Nothing here asserts on the
/// translated labels.
/// </remarks>
[TestFixture]
public sealed class MailPassageTests
{
private static readonly Source MAIL_SOURCE = new("Mail: Budget 2026 — Anna Muster, 2026-10-20", SourceExtensions.CreateMailSourceUrl("6a1f2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "mail-1"), SourceOrigin.TOOL);
[Test]
public void TheMailShowsItsHeaderAndThenItsText()
{
var content = MailToolResults.ComposePassage("Anna Muster <anna@example.org>", ["Ben <ben@example.org>", "Cleo <cleo@example.org>"], "Budget 2026", "2026-10-20T14:03+02:00", ["report.pdf"], null, "The budget grows by 4 %.");
Assert.Multiple(() =>
{
Assert.That(content, Does.Contain("Anna Muster <anna@example.org>"));
Assert.That(content, Does.Contain("Ben <ben@example.org>, Cleo <cleo@example.org>"));
Assert.That(content, Does.Contain("Budget 2026"));
Assert.That(content, Does.Contain("2026-10-20T14:03+02:00"), "The time as the model got it.");
Assert.That(content, Does.Contain("report.pdf"));
Assert.That(content, Does.EndWith("The budget grows by 4 %."), "The text follows the header.");
});
}
[Test]
public void AMailListedWithoutTextShowsItsHeaderAlone()
{
var content = MailToolResults.ComposePassage("Anna Muster <anna@example.org>", [], "Budget 2026", "2026-10-20T14:03+02:00", [], null, null);
Assert.Multiple(() =>
{
Assert.That(content, Does.Contain("Budget 2026"), "A mail listed by sender or date is still something the model received.");
Assert.That(content, Does.Not.Contain("---"), "There is no text to set apart.");
Assert.That(content.Split('\n'), Has.Length.EqualTo(3), "Empty fields are left out: sender, subject, and time remain.");
});
}
[Test]
public void APassageFromAnAttachmentNamesTheAttachment()
{
var content = MailToolResults.ComposePassage("Anna Muster <anna@example.org>", [], "Budget 2026", "2026-10-20T14:03+02:00", ["report.pdf"], "report.pdf, page 3", "Revenue rose.");
Assert.That(content, Does.Contain("report.pdf, page 3"));
}
[Test]
public void TheMailPassageIsListedUnderItsSourceWithoutAPath()
{
var passage = MAIL_SOURCE.ToMailPassage("The budget grows by 4 %.");
Assert.Multiple(() =>
{
Assert.That(new[] { passage }.GetPassagesOf(MAIL_SOURCE), Is.EqualTo(new[] { passage }), "Only a passage under its source gets the eye next to it.");
Assert.That(passage.Path, Is.Empty, "The address of a mail names it for AI Studio only, so there is nothing to copy or open.");
Assert.That(passage.DataSourceName, Is.EqualTo(MAIL_SOURCE.Title));
Assert.That(passage.GetProvenanceKind(), Is.EqualTo(ProvenanceKind.MAIL));
});
}
}
@@ -292,6 +292,25 @@ public sealed class SearchMailsToolTests : ToolRegistryTestBase
Assert.That(issues.Single()!.GetValue<string>(), Does.Contain("no folder with the path given in 'folder'").And.Contain("The first 2 of its 120 folders").And.Not.Contain("special_folder"));
}
/// <remarks>
/// A mail found by a passage of its attachment names that attachment, so the user knows where
/// in the mail the answer comes from. It stays the same source as the mail itself.
/// </remarks>
[Test]
public void AMailFoundByAnAttachmentNamesItInItsSource()
{
var receivedAtUtc = new DateTimeOffset(2026, 10, 23, 9, 0, 0, TimeSpan.Zero);
var mail = MailToolResults.CreateSource(WORK, "mail-id", "Budget", "Anna", receivedAtUtc, TimeZoneInfo.Utc);
var attachment = MailToolResults.CreateSource(WORK, "mail-id", "Budget", "Anna", receivedAtUtc, TimeZoneInfo.Utc, "board-report.pdf, page 3");
Assert.Multiple(() =>
{
Assert.That(mail.Title, Is.EqualTo("Mail: Budget — Anna, 2026-10-23"));
Assert.That(attachment.Title, Is.EqualTo("Mail: Budget — Anna, 2026-10-23 (board-report.pdf, page 3)"));
Assert.That(attachment.URL, Is.EqualTo(mail.URL));
});
}
[Test]
public void SemanticSearchNeverSeesAMailbox()
{
@@ -4,6 +4,8 @@ using System.Text.Json.Nodes;
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.RAG;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.ToolCallingSystem.Harness;
using AIStudio.Tools.Web;
@@ -155,6 +157,28 @@ public sealed class ToolExecutorTests : ToolRegistryTestBase
Assert.That(thread.IsWebAddressGivenToTheModel(new Uri("https://example.org/newsletter/2026-10")), Is.True, "The user may ask in the next message to open the link.");
}
[Test]
public async Task ThePassagesOfAResultReachTheAnswer()
{
var passage = new SourcePassage { SourceUrl = "file:///C:/docs/handbook.pdf#page=11", DataSourceName = "handbook.pdf", Text = "A passage from the handbook.", PageNumber = 11 };
var tool = new TestTool(Definition(), execute: _ => new ToolExecutionResult
{
TextContent = passage.Text,
Sources = [new Source("handbook.pdf (Page 11)", passage.SourceUrl, SourceOrigin.RAG)],
Passages = [passage],
});
var answer = new ContentText();
await foreach (var chunk in new ToolCallingLoop(NullLogger<ToolCallingLoop>.Instance).RunAsync(new OneToolCallAdapter(), this.LoopContext(tool, new ChatThread())))
answer.ApplyStreamChunk(chunk, "test-model", blockCount: 2);
Assert.Multiple(() =>
{
Assert.That(answer.SourcePassages, Is.EqualTo(new[] { passage }), "The result of a tool call is not stored with the chat, but what the model received of the sources is.");
Assert.That(answer.Sources.Select(source => source.URL), Is.EqualTo(new[] { passage.SourceUrl }));
});
}
[TestCase("web_search", ExpectedResult = true)]
[TestCase("read-web-page_2", ExpectedResult = true)]
[TestCase(null, ExpectedResult = false)]
@@ -182,6 +206,7 @@ public sealed class ToolExecutorTests : ToolRegistryTestBase
Assert.That(outcome.RequiredDataSecurity, Is.EqualTo(DataSourceSecurity.NOT_SPECIFIED), REASON);
Assert.That(outcome.RequiredOutboundDataRestriction, Is.EqualTo(OutboundDataRequirement.NONE), REASON);
Assert.That(outcome.Sources, Is.Empty, REASON);
Assert.That(outcome.Passages, Is.Empty, REASON);
}
private ToolCallingLoopContext LoopContext(TestTool tool, ChatThread thread) => new()