using System.Globalization; using System.Text; using System.Text.RegularExpressions; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.RAG; using AIStudio.Tools.Security; using AIStudio.Tools.Services; using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.SemanticSearch; using Microsoft.Extensions.Logging.Abstractions; namespace AIStudio.Tests.Tools.ToolCalling; /// /// Checks how Semantic Search cuts what the data sources found into the pages the model reads. /// /// /// A search returns no more text than its budget allows, and nothing is kept between two searches. /// Still, every passage a data source found has to reach the model on exactly one page, so that /// turning pages brings up what did not fit before. FakeRuntime plays the filter for prompt /// injections, and each test makes up what the data sources found, page by page. /// [TestFixture] public sealed partial class SemanticSearchToolPagingTests { /// /// What the filter removes. It is shorter than the marker which takes its place, so the filter makes a passage longer. /// private const string SUSPICIOUS_WORD = "PWNED"; /// /// About the length of a chunk of 8,192 tokens: three such passages fill a search. /// private const int LONG = 30_000; /// /// A length of which a whole page of the data source fits into a search. /// private const int SHORT = 1_000; /// /// A length no page holds, so that a passage this long has to be shortened. /// private const int OVERLONG = 150_000; /// /// How many pages a test turns at most before it gives up on the search ever ending. /// private const int MOST_PAGES = 100; private static readonly IDataSource HANDBOOK = new DataSourceLocalDirectory { Num = 1, Id = "11111111-1111-1111-1111-111111111111", Name = "Handbook", MaxMatches = 10 }; private static readonly IDataSource MINUTES = new DataSourceLocalFile { Num = 2, Id = "22222222-2222-2222-2222-222222222222", Name = "Minutes", MaxMatches = 10 }; private static readonly IDataSource INTRANET = new DataSourceERI_V1 { Num = 3, Id = "33333333-3333-3333-3333-333333333333", Name = "Intranet", MaxMatches = 10 }; private FakeRuntime runtime = null!; private RustService rustService = null!; private PromptInjectionGuardService guardService = null!; [SetUp] public void PrepareRuntime() { this.runtime = new FakeRuntime(SUSPICIOUS_WORD); this.rustService = new RustService(this.runtime); var settingsManager = new SettingsManager(NullLogger.Instance, this.rustService); this.guardService = new PromptInjectionGuardService(this.rustService, settingsManager, NullLogger.Instance, NullLoggerFactory.Instance); } [TearDown] public void DisposeRuntime() { this.rustService.Dispose(); this.runtime.Dispose(); } [Test] public async Task EveryPassageOfADataSourceReachesTheModelOnExactlyOnePage() { var pages = await this.PageThroughAsync(HANDBOOK, Found(Passages(1, 10, LONG), Passages(11, 10, LONG), Passages(21, 10, LONG))); using (Assert.EnterMultipleScope()) { Assert.That(pages.SelectMany(NumbersOf), Is.EqualTo(Enumerable.Range(1, 30)), "Turning pages has to bring up every passage which did not fit before, in the order the data source ranked them."); Assert.That(pages.Select(page => page.TextContent.Length), Has.All.LessThanOrEqualTo(SemanticSearchTool.MAX_RESULT_CHARACTERS)); Assert.That(pages.Select(page => page.Passages.Count), Is.EqualTo(pages.Select(page => NumbersOf(page).Count)), "The user is shown exactly the passages the model received."); Assert.That(pages.Select(page => page.Outcomes.Single().ResultCount), Is.EqualTo(pages.Select(page => NumbersOf(page).Count))); } } [Test] public async Task WhereEverythingFitsAPageIsWhatTheDataSourceFound() { var pages = await this.PageThroughAsync(HANDBOOK, Found(Passages(1, 10, SHORT), Passages(11, 10, SHORT))); Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { Enumerable.Range(1, 10).ToList(), Enumerable.Range(11, 10).ToList() })); } [Test] public async Task AnEmptyPageOfTheDataSourceDoesNotEndThePaging() { var pages = await this.PageThroughAsync(HANDBOOK, Found(Passages(1, 10, SHORT), [], Passages(11, 10, SHORT))); Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { Enumerable.Range(1, 10).ToList(), Enumerable.Range(11, 10).ToList() }), "A page of the data source can turn out empty, e.g., when its chunks have no text, and the next one still holds passages."); } [Test] public async Task ThereIsNothingMoreWhenTheFurtherPagesOfTheDataSourceAreEmpty() { var result = await this.SearchAsync([HANDBOOK], 1, Found(Passages(1, 10, SHORT), [])); Assert.That(result.Outcomes.Single().HasMore, Is.False, "The data source only said that its next page might hold more. The model must not be sent there for nothing."); } [Test] public async Task APageBeyondTheLastIsRefusedWithTheLastPage() { var found = Found(Passages(1, 10, LONG)); var pages = await this.PageThroughAsync(HANDBOOK, found); var message = (await Assert.ThrowsAsync(() => this.SearchAsync([HANDBOOK], pages.Count + 1, found)))!.Message; using (Assert.EnterMultipleScope()) { Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { new List { 1, 2, 3 }, [4, 5, 6], [7, 8, 9], [10] })); Assert.That(message, Does.Contain("at most 4").And.Contain("but was 5")); } } [Test] public async Task ThePassagesOfAFurtherPageGoOnWithTheirNumbers() { var result = await this.SearchAsync([HANDBOOK], 2, Found(Passages(1, 10, LONG))); Assert.That(result.TextContent, Does.StartWith($"# Retrieval context 4{Environment.NewLine}"), "The model reads the fourth passage of the search, not the first one a second time."); } [Test] public async Task AnERIDataSourcePagesWithinItsMatches() { var askedPages = new List(); var found = Found(Passages(1, 10, LONG)); var pages = await this.PageThroughAsync(INTRANET, (dataSource, page) => { askedPages.Add(page); return found(dataSource, page); }); using (Assert.EnterMultipleScope()) { Assert.That(pages.SelectMany(NumbersOf), Is.EqualTo(Enumerable.Range(1, 10))); Assert.That(askedPages, Has.All.EqualTo(1), "ERI v1 knows no offset, so each page asks the server for the configured number of matches again."); } } [Test] public async Task SeveralDataSourcesTakeTurnsUntilTheirNextPassageNoLongerFits() { var handbook = Found(Passages(1, 10, LONG)); var minutes = Found([..Passages(101, 2, LONG, "Minutes"), ..Passages(103, 8, SHORT, "Minutes")]); var result = await this.SearchAsync([HANDBOOK, MINUTES], 1, (dataSource, page) => dataSource == HANDBOOK ? handbook(dataSource, page) : minutes(dataSource, page)); using (Assert.EnterMultipleScope()) { Assert.That(NumbersOf(result), Is.EqualTo(new[] { 1, 101, 2 }), "The short passages of the minutes rank below one which did not fit, so they must not take its place."); Assert.That(result.Outcomes.Select(outcome => (outcome.ResultCount, outcome.LeftOutCount, outcome.HasMore)), Is.EqualTo(new[] { (2, 8, true), (1, 9, true) })); } } [Test] public async Task PassagesAreMeasuredAfterTheFilter() { // Together, both fit into a search, but not once the filter put its marker in: const int LENGTH = 49_000; const int SUSPICIOUS_WORDS = 100; var pages = await this.PageThroughAsync(HANDBOOK, Found([Passage(1, LENGTH, suspiciousWords: SUSPICIOUS_WORDS), Passage(2, LENGTH, suspiciousWords: SUSPICIOUS_WORDS)])); using (Assert.EnterMultipleScope()) { Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { new List { 1 }, [2] })); Assert.That(pages.Select(page => page.TextContent.Length), Has.All.LessThanOrEqualTo(SemanticSearchTool.MAX_RESULT_CHARACTERS)); } } [Test] public async Task APassageLongerThanAPageIsShortenedToFillAPageOfItsOwn() { var pages = await this.PageThroughAsync(HANDBOOK, Found([Passage(1, SHORT), Passage(2, OVERLONG), Passage(3, SHORT)])); var shortened = pages[1]; using (Assert.EnterMultipleScope()) { Assert.That(pages.Select(NumbersOf), Is.EqualTo(new[] { new List { 1 }, [2], [3] }), "Neither the passage itself nor those after it may go missing."); Assert.That(shortened.TextContent.Length, Is.LessThanOrEqualTo(SemanticSearchTool.MAX_RESULT_CHARACTERS)); Assert.That(shortened.TextContent, Does.Contain("Matched text content, shortened to fit the size limit:"), "The model has to know that the passage goes on."); Assert.That(shortened.Passages.Single().Text, Does.StartWith("Passage 002").And.Length.LessThan(OVERLONG), "The user is shown what the model read."); Assert.That(shortened.TextContent, Does.Contain(shortened.Passages.Single().Text)); } } [Test] public async Task AShortenedPassageIsFilteredAgain() { var result = await this.SearchAsync([HANDBOOK], 1, Found([Passage(1, OVERLONG, suspiciousWords: 10)])); var shown = result.Passages.Single().Text; var filteredShorterTexts = this.runtime.FilteredTexts.Where(text => text.StartsWith("Passage 001", StringComparison.Ordinal) && text.Length < OVERLONG); using (Assert.EnterMultipleScope()) { Assert.That(filteredShorterTexts.Select(text => text.Replace(SUSPICIOUS_WORD, FakeRuntime.REDACTION_MARKER, StringComparison.Ordinal)), Has.Member(shown), "The model reads exactly what the filter returned for the shortened text, not a cut of what it returned for the whole one."); Assert.That(shown, Does.Contain(FakeRuntime.REDACTION_MARKER).And.Not.Contain(SUSPICIOUS_WORD)); Assert.That(result.TextContent.Length, Is.LessThanOrEqualTo(SemanticSearchTool.MAX_RESULT_CHARACTERS)); } } [Test] public async Task TheSurroundingContentIsShortenedBeforeTheMatchedText() { const int LENGTH = 40_000; var passage = Passage(1, LENGTH); passage.SurroundingContent = [Text("Before ", LENGTH), Text("After ", LENGTH)]; var result = await this.SearchAsync([HANDBOOK], 1, Found([passage])); var shown = result.Passages.Single(); using (Assert.EnterMultipleScope()) { Assert.That(shown.Text, Is.EqualTo(passage.MatchedText), "The matched text is what the search found, so it is the last to be shortened."); Assert.That(shown.SurroundingContent[0], Is.EqualTo(passage.SurroundingContent[0])); Assert.That(shown.SurroundingContent[^1], Does.StartWith("After ").And.Length.LessThan(LENGTH), "The surrounding content is shortened from its end."); Assert.That(result.TextContent, Does.Contain($"Matched text content:{Environment.NewLine}").And.Contain("Surrounding text content, shortened to fit the size limit:")); Assert.That(result.TextContent.Length, Is.LessThanOrEqualTo(SemanticSearchTool.MAX_RESULT_CHARACTERS)); } } [Test] public async Task SurroundingContentWhichHadToGoIsNamed() { var passage = Passage(1, OVERLONG); passage.SurroundingContent = [Text("Before ", SHORT)]; var result = await this.SearchAsync([HANDBOOK], 1, Found([passage])); using (Assert.EnterMultipleScope()) { Assert.That(result.Passages.Single().SurroundingContent, Is.Empty); Assert.That(result.TextContent, Does.Contain("Matched text content, shortened to fit the size limit:").And.Contain("Surrounding text content: left out to fit the size limit."), "Otherwise, the model would take the passage for one without any."); } } [Test] public async Task InASearchOfSeveralDataSourcesAShortenedPassageStillHasToFit() { var handbook = Found(Passages(1, 10, LONG)); var minutes = Found([Passage(101, OVERLONG, "Minutes"), ..Passages(102, 9, SHORT, "Minutes")]); var result = await this.SearchAsync([HANDBOOK, MINUTES], 1, (dataSource, page) => dataSource == HANDBOOK ? handbook(dataSource, page) : minutes(dataSource, page)); using (Assert.EnterMultipleScope()) { Assert.That(NumbersOf(result), Is.EqualTo(new[] { 1, 2, 3 }), "Shortened to a whole page, the best passage of the minutes no longer fits after the first of the handbook."); Assert.That(result.Outcomes.Select(outcome => (outcome.ResultCount, outcome.LeftOutCount, outcome.HasMore)), Is.EqualTo(new[] { (3, 7, true), (0, 10, true) }), "The minutes are left out with a way to them, searched alone."); } } [Test] public async Task ADataSourceWhichFailsOnAnEarlierPageCountsAsNotSearched() { var result = await this.SearchAsync([HANDBOOK], 2, (_, _) => Task.FromResult(RetrievalPage.EMPTY with { Gaps = [RetrievalGap.NOT_SEARCHED] })); using (Assert.EnterMultipleScope()) { Assert.That(result.Outcomes.Single().Gaps, Is.EqualTo(new[] { RetrievalGap.NOT_SEARCHED }), "Without the pages before it, nobody knows where the page starts."); Assert.That(result.Outcomes.Single().HasMore, Is.False); Assert.That(result.TextContent, Is.Empty); } } /// /// Turns the pages of a search in one data source, until it says there is nothing more. /// private async Task> PageThroughAsync(IDataSource dataSource, Func> retrievePage) { var pages = new List(); for (var page = 1; page <= MOST_PAGES; page++) { var result = await this.SearchAsync([dataSource], page, retrievePage); pages.Add(result); if (!result.Outcomes.Single().HasMore) return pages; } Assert.Fail($"The search still had more after {MOST_PAGES} pages."); return pages; } private async Task SearchAsync(IReadOnlyList dataSources, int page, Func> retrievePage) { await using var reportingScope = this.guardService.BeginAction(); return await SemanticSearchTool.SearchAsync(new SemanticSearchRequest("mixing console", dataSources, page), retrievePage, this.guardService, reportingScope, CancellationToken.None); } /// /// Plays what a data source found, page by page: each page as given, and nothing after the last one. /// private static Func> Found(params IReadOnlyList[] retrievalPages) => (_, page) => Task.FromResult(page <= retrievalPages.Length ? new RetrievalPage(retrievalPages[page - 1], page < retrievalPages.Length) : RetrievalPage.EMPTY); private static IReadOnlyList Passages(int firstNumber, int count, int length, string dataSourceName = "Handbook") => Enumerable.Range(firstNumber, count).Select(IRetrievalContext (number) => Passage(number, length, dataSourceName)).ToList(); /// /// A passage whose matched text starts with its number and is as long as asked for. /// private static RetrievalTextContext Passage(int number, int length, string dataSourceName = "Handbook", int suspiciousWords = 0) => new() { DataSourceName = dataSourceName, Category = RetrievalContentCategory.TEXT, Type = RetrievalContentType.TEXT_DOCUMENT, Path = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"{dataSourceName}-{number}.md")), MatchedText = Text($"Passage {number:D3} ", length, suspiciousWords), }; /// /// A text which starts as given, holds the suspicious word as often as asked for, and is as long as asked for. /// private static string Text(string start, int length, int suspiciousWords = 0) { var text = new StringBuilder(start); for (var word = 0; word < suspiciousWords; word++) text.Append(SUSPICIOUS_WORD).Append(' '); text.Append('x', Math.Max(0, length - text.Length)); return text.ToString(); } /// /// The numbers of the passages the model reads on a page, in the order it reads them. /// private static List NumbersOf(SemanticSearchResult result) => PassageNumber().Matches(result.TextContent).Select(match => int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)).ToList(); [GeneratedRegex(@"Passage (\d{3}) ")] private static partial Regex PassageNumber(); }