diff --git a/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs b/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs index cb9bdfa2e85b..87c7b3a521cd 100644 --- a/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs +++ b/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs @@ -18,7 +18,9 @@ namespace SemanticKernel.IntegrationTests.Data; /// public abstract class BaseTextSearchTests : BaseIntegrationTest { - [Fact(Skip = "For manual verification only.")] + private const string SkipReason = "For manual verification only."; + + [Fact(Skip = SkipReason)] public virtual async Task CanSearchAsync() { // Arrange @@ -42,7 +44,7 @@ public virtual async Task CanSearchAsync() } } - [Fact(Skip = "For manual verification only.")] + [Fact(Skip = SkipReason)] public virtual async Task CanGetTextSearchResultsAsync() { // Arrange @@ -72,7 +74,7 @@ public virtual async Task CanGetTextSearchResultsAsync() } } - [Fact(Skip = "For manual verification only.")] + [Fact(Skip = SkipReason)] public virtual async Task CanGetSearchResultsAsync() { // Arrange @@ -92,7 +94,7 @@ public virtual async Task CanGetSearchResultsAsync() Assert.True(this.VerifySearchResults(results, query)); } - [Fact(Skip = "For manual verification only.")] + [Fact(Skip = SkipReason)] public virtual async Task UsingTextSearchWithAFilterAsync() { // Arrange @@ -113,7 +115,7 @@ public virtual async Task UsingTextSearchWithAFilterAsync() Assert.True(this.VerifySearchResults(results, query, filter)); } - [Fact(Skip = "For manual verification only.")] + [Fact(Skip = SkipReason)] public virtual async Task FunctionCallingUsingCreateWithSearchAsync() { // Arrange @@ -142,7 +144,7 @@ public virtual async Task FunctionCallingUsingCreateWithSearchAsync() Assert.NotEmpty(results); } - [Fact(Skip = "For manual verification only.")] + [Fact(Skip = SkipReason)] public virtual async Task FunctionCallingUsingCreateWithGetSearchResultsAsync() { // Arrange @@ -171,7 +173,7 @@ public virtual async Task FunctionCallingUsingCreateWithGetSearchResultsAsync() Assert.NotEmpty(results); } - [Fact(Skip = "For manual verification only.")] + [Fact(Skip = SkipReason)] public virtual async Task FunctionCallingUsingGetTextSearchResultsAsync() { // Arrange diff --git a/dotnet/src/IntegrationTests/Plugins/Web/Brave/BraveTextSearchTests.cs b/dotnet/src/IntegrationTests/Plugins/Web/Brave/BraveTextSearchTests.cs new file mode 100644 index 000000000000..466a250f9fd9 --- /dev/null +++ b/dotnet/src/IntegrationTests/Plugins/Web/Brave/BraveTextSearchTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.SemanticKernel.Data; +using Microsoft.SemanticKernel.Plugins.Web.Brave; +using SemanticKernel.IntegrationTests.Data; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Plugins.Web.Brave; + +/// +/// Integration tests for . +/// +public class BraveTextSearchTests : BaseTextSearchTests +{ + /// + public override Task CreateTextSearchAsync() + { + var configuration = this.Configuration.GetSection("Brave").Get(); + Assert.NotNull(configuration); + Assert.NotNull(configuration.ApiKey); + + return Task.FromResult(new BraveTextSearch(apiKey: configuration.ApiKey)); + } + + /// + public override string GetQuery() => "What is the Semantic Kernel?"; + + /// + public override TextSearchFilter GetTextSearchFilter() => new TextSearchFilter().Equality("search_lang", "de"); + + /// + public override bool VerifySearchResults(object[] results, string query, TextSearchFilter? filter = null) + { + Assert.NotNull(results); + Assert.NotEmpty(results); + Assert.Equal(4, results.Length); + foreach (var result in results) + { + Assert.NotNull(result); + Assert.IsType(result); + } + + return true; + } +} diff --git a/dotnet/src/IntegrationTests/TestSettings/BraveConfiguration.cs b/dotnet/src/IntegrationTests/TestSettings/BraveConfiguration.cs new file mode 100644 index 000000000000..19b19dd3efd4 --- /dev/null +++ b/dotnet/src/IntegrationTests/TestSettings/BraveConfiguration.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft. All rights reserved. +namespace SemanticKernel.IntegrationTests.TestSettings; + +#pragma warning disable CA1812 // Configuration classes are instantiated through IConfiguration. +internal sealed class BraveConfiguration(string apiKey) +{ + public string ApiKey { get; init; } = apiKey; +} diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs index 8a98a3d81a47..68bcc3ca403e 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs @@ -50,12 +50,11 @@ public async Task SearchReturnsSuccessfullyAsync() var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act - KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + var results = textSearch.SearchAsync("What is the Semantic Kernel?", top: 10, new() { Skip = 0 }); // Assert - Assert.NotNull(result); - Assert.NotNull(result.Results); - var resultList = await result.Results.ToListAsync(); + Assert.NotNull(results); + var resultList = await results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(10, resultList.Count); foreach (var stringResult in resultList) @@ -74,12 +73,11 @@ public async Task GetTextSearchResultsReturnsSuccessfullyAsync() var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act - KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + var results = textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", top: 10, new() { Skip = 0 }); // Assert - Assert.NotNull(result); - Assert.NotNull(result.Results); - var resultList = await result.Results.ToListAsync(); + Assert.NotNull(results); + var resultList = await results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(10, resultList.Count); foreach (var textSearchResult in resultList) @@ -100,12 +98,11 @@ public async Task GetSearchResultsReturnsSuccessfullyAsync() var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act - KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + var results = textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", top: 10, new() { Skip = 0 }); // Assert - Assert.NotNull(result); - Assert.NotNull(result.Results); - var resultList = await result.Results.ToListAsync(); + Assert.NotNull(results); + var resultList = await results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(10, resultList.Count); foreach (BraveWebResult webPage in resultList) @@ -126,12 +123,11 @@ public async Task SearchWithCustomStringMapperReturnsSuccessfullyAsync() var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, StringMapper = new TestTextSearchStringMapper() }); // Act - KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + var results = textSearch.SearchAsync("What is the Semantic Kernel?", top: 10, new() { Skip = 0 }); // Assert - Assert.NotNull(result); - Assert.NotNull(result.Results); - var resultList = await result.Results.ToListAsync(); + Assert.NotNull(results); + var resultList = await results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(10, resultList.Count); foreach (var stringResult in resultList) @@ -152,12 +148,11 @@ public async Task GetTextSearchResultsWithCustomResultMapperReturnsSuccessfullyA var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, ResultMapper = new TestTextSearchResultMapper() }); // Act - KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + var results = textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", top: 10, new() { Skip = 0 }); // Assert - Assert.NotNull(result); - Assert.NotNull(result.Results); - var resultList = await result.Results.ToListAsync(); + Assert.NotNull(results); + var resultList = await results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(10, resultList.Count); foreach (var textSearchResult in resultList) @@ -192,8 +187,8 @@ public async Task BuildsCorrectUriForEqualityFilterAsync(string paramName, objec var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act - TextSearchOptions searchOptions = new() { Top = 5, Skip = 0, Filter = new TextSearchFilter().Equality(paramName, paramValue) }; - KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + TextSearchOptions searchOptions = new() { Skip = 0, Filter = new TextSearchFilter().Equality(paramName, paramValue) }; + var results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", top: 5, searchOptions).ToListAsync(); // Assert var requestUris = this._messageHandlerStub.RequestUris; @@ -207,13 +202,13 @@ public async Task DoesNotBuildsUriForInvalidQueryParameterAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterSkResponseJson)); - TextSearchOptions searchOptions = new() { Top = 5, Skip = 0, Filter = new TextSearchFilter().Equality("fooBar", "Baz") }; + TextSearchOptions searchOptions = new() { Skip = 0, Filter = new TextSearchFilter().Equality("fooBar", "Baz") }; // Create an ITextSearch instance using Brave search var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act && Assert - var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions)); + var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", top: 5, searchOptions).ToListAsync()); Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of country,search_lang,ui_lang,safesearch,text_decorations,spellcheck,result_filter,units,extra_snippets (Parameter 'searchOptions')", e.Message); } @@ -222,13 +217,13 @@ public async Task DoesNotBuildsUriForQueryParameterNullInputAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterSkResponseJson)); - TextSearchOptions searchOptions = new() { Top = 5, Skip = 0, Filter = new TextSearchFilter().Equality("country", null!) }; + TextSearchOptions searchOptions = new() { Skip = 0, Filter = new TextSearchFilter().Equality("country", null!) }; // Create an ITextSearch instance using Brave search var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act && Assert - var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions)); + var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", top: 5, searchOptions).ToListAsync()); Assert.Equal("Unknown equality filter clause field name 'country', must be one of country,search_lang,ui_lang,safesearch,text_decorations,spellcheck,result_filter,units,extra_snippets (Parameter 'searchOptions')", e.Message); } diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs index 8fa793ea4efb..d5bfd1d1518d 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs @@ -45,10 +45,48 @@ public BraveTextSearch(string apiKey, BraveTextSearchOptions? options = null) } /// + public async IAsyncEnumerable SearchAsync(string query, int top, TextSearchOptions? searchOptions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + searchOptions ??= new TextSearchOptions(); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, top, searchOptions, cancellationToken).ConfigureAwait(false); + + await foreach (var result in this.GetResultsAsStringAsync(searchResponse, cancellationToken).ConfigureAwait(false)) + { + yield return result; + } + } + + /// + public async IAsyncEnumerable GetTextSearchResultsAsync(string query, int top, TextSearchOptions? searchOptions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + searchOptions ??= new TextSearchOptions(); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, top, searchOptions, cancellationToken).ConfigureAwait(false); + + await foreach (var result in this.GetResultsAsTextSearchResultAsync(searchResponse, cancellationToken).ConfigureAwait(false)) + { + yield return result; + } + } + + /// + public async IAsyncEnumerable GetSearchResultsAsync(string query, int top, TextSearchOptions? searchOptions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + searchOptions ??= new TextSearchOptions(); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, top, searchOptions, cancellationToken).ConfigureAwait(false); + + await foreach (var result in this.GetResultsAsWebPageAsync(searchResponse, cancellationToken).ConfigureAwait(false)) + { + yield return result; + } + } + + #region obsolete + /// + [Obsolete("This method is deprecated and will be removed in future versions. Use SearchAsync that returns IAsyncEnumerable instead.", false)] public async Task> SearchAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = new CancellationToken()) { searchOptions ??= new TextSearchOptions(); - BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, searchOptions, cancellationToken).ConfigureAwait(false); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, searchOptions.Top, searchOptions, cancellationToken).ConfigureAwait(false); long? totalCount = searchOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; @@ -56,10 +94,11 @@ public BraveTextSearch(string apiKey, BraveTextSearchOptions? options = null) } /// + [Obsolete("This method is deprecated and will be removed in future versions. Use GetTextSearchResultsAsync that returns IAsyncEnumerable instead.", false)] public async Task> GetTextSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = new CancellationToken()) { searchOptions ??= new TextSearchOptions(); - BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, searchOptions, cancellationToken).ConfigureAwait(false); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, searchOptions.Top, searchOptions, cancellationToken).ConfigureAwait(false); long? totalCount = searchOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; @@ -67,16 +106,18 @@ public BraveTextSearch(string apiKey, BraveTextSearchOptions? options = null) } /// + [Obsolete("This method is deprecated and will be removed in future versions. Use GetSearchResultsAsync that returns IAsyncEnumerable instead.", false)] public async Task> GetSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = new CancellationToken()) { searchOptions ??= new TextSearchOptions(); - BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, searchOptions, cancellationToken).ConfigureAwait(false); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, searchOptions.Top, searchOptions, cancellationToken).ConfigureAwait(false); long? totalCount = searchOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; return new KernelSearchResults(this.GetResultsAsWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } + #endregion #region private @@ -110,11 +151,12 @@ public async Task> GetSearchResultsAsync(string quer /// Execute a Bing search query and return the results. /// /// What to search for. + /// Maximum number of results to return. /// Search options. /// The to monitor for cancellation requests. The default is . - private async Task?> ExecuteSearchAsync(string query, TextSearchOptions searchOptions, CancellationToken cancellationToken = default) + private async Task?> ExecuteSearchAsync(string query, int top, TextSearchOptions searchOptions, CancellationToken cancellationToken = default) { - using HttpResponseMessage response = await this.SendGetRequestAsync(query, searchOptions, cancellationToken).ConfigureAwait(false); + using HttpResponseMessage response = await this.SendGetRequestAsync(query, top, searchOptions, cancellationToken).ConfigureAwait(false); this._logger.LogDebug("Response received: {StatusCode}", response.StatusCode); @@ -130,16 +172,17 @@ public async Task> GetSearchResultsAsync(string quer /// Sends a GET request to the specified URI. /// /// The query string. + /// Maximum number of results to return. /// The search options. /// A cancellation token to cancel the request. /// A representing the response from the request. - private async Task SendGetRequestAsync(string query, TextSearchOptions searchOptions, CancellationToken cancellationToken = default) + private async Task SendGetRequestAsync(string query, int top, TextSearchOptions searchOptions, CancellationToken cancellationToken = default) { Verify.NotNull(query); - if (searchOptions.Top is <= 0 or >= 21) + if (top is <= 0 or >= 21) { - throw new ArgumentOutOfRangeException(nameof(searchOptions), searchOptions.Top, $"{nameof(searchOptions.Top)} value must be greater than 0 and less than 21."); + throw new ArgumentOutOfRangeException(nameof(searchOptions), top, $"{nameof(top)} value must be greater than 0 and less than 21."); } if (searchOptions.Skip is < 0 or > 10) @@ -147,7 +190,7 @@ private async Task SendGetRequestAsync(string query, TextSe throw new ArgumentOutOfRangeException(nameof(searchOptions), searchOptions.Skip, $"{nameof(searchOptions.Skip)} value must be equal or greater than 0 and less than 10."); } - Uri uri = new($"{this._uri}?q={BuildQuery(query, searchOptions)}"); + Uri uri = new($"{this._uri}?q={BuildQuery(query, top, searchOptions)}"); using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); @@ -289,8 +332,9 @@ public TextSearchResult MapFromResultToTextSearchResult(object result) /// Build a query string from the /// /// The query. + /// Index of the first result to return. /// The search options. - private static string BuildQuery(string query, TextSearchOptions searchOptions) + private static string BuildQuery(string query, int top, TextSearchOptions searchOptions) { StringBuilder fullQuery = new(); fullQuery.Append(Uri.EscapeDataString(query.Trim())); @@ -319,7 +363,7 @@ private static string BuildQuery(string query, TextSearchOptions searchOptions) } } - fullQuery.Append($"&count={searchOptions.Top}&offset={searchOptions.Skip}{queryParams}"); + fullQuery.Append($"&count={top}&offset={searchOptions.Skip}{queryParams}"); return fullQuery.ToString(); }