From bea7668995b490ae0fe82a41f34558910c9eca7f Mon Sep 17 00:00:00 2001 From: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:08:07 +0000 Subject: [PATCH 1/3] Tavily image search and integration tests --- .../Data/BaseTextSearchTests.cs | 14 +-- .../Web/Tavily/TavilyTextSearchTests.cs | 48 +++++++++ .../TestSettings/TavilyConfiguration.cs | 13 +++ .../Web/Tavily/TavilyTextSearchTests.cs | 100 ++++++++++++++++++ .../Plugins.Web/Tavily/TavilyTextSearch.cs | 55 ++++++++-- .../Tavily/TavilyTextSearchOptions.cs | 19 +++- 6 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 dotnet/src/IntegrationTests/Plugins/Web/Tavily/TavilyTextSearchTests.cs create mode 100644 dotnet/src/IntegrationTests/TestSettings/TavilyConfiguration.cs diff --git a/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs b/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs index d1f5c2e518ae..bd2821a532cf 100644 --- a/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs +++ b/dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs @@ -18,7 +18,7 @@ namespace SemanticKernel.IntegrationTests.Data; /// public abstract class BaseTextSearchTests : BaseIntegrationTest { - [Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")] + [Fact(Skip = "For manual verification only.")] public virtual async Task CanSearchAsync() { // Arrange @@ -42,7 +42,7 @@ public virtual async Task CanSearchAsync() } } - [Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")] + [Fact(Skip = "For manual verification only.")] public virtual async Task CanGetTextSearchResultsAsync() { // Arrange @@ -72,7 +72,7 @@ public virtual async Task CanGetTextSearchResultsAsync() } } - [Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")] + [Fact(Skip = "For manual verification only.")] public virtual async Task CanGetSearchResultsAsync() { // Arrange @@ -92,7 +92,7 @@ public virtual async Task CanGetSearchResultsAsync() Assert.True(this.VerifySearchResults(results, query)); } - [Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")] + [Fact(Skip = "For manual verification only.")] public virtual async Task UsingTextSearchWithAFilterAsync() { // Arrange @@ -113,7 +113,7 @@ public virtual async Task UsingTextSearchWithAFilterAsync() Assert.True(this.VerifySearchResults(results, query, filter)); } - [Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")] + [Fact(Skip = "For manual verification only.")] public virtual async Task FunctionCallingUsingCreateWithSearchAsync() { // Arrange @@ -142,7 +142,7 @@ public virtual async Task FunctionCallingUsingCreateWithSearchAsync() Assert.NotEmpty(results); } - [Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")] + [Fact(Skip = "For manual verification only.")] public virtual async Task FunctionCallingUsingCreateWithGetSearchResultsAsync() { // Arrange @@ -171,7 +171,7 @@ public virtual async Task FunctionCallingUsingCreateWithGetSearchResultsAsync() Assert.NotEmpty(results); } - [Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")] + [Fact(Skip = "For manual verification only.")] public virtual async Task FunctionCallingUsingGetTextSearchResultsAsync() { // Arrange diff --git a/dotnet/src/IntegrationTests/Plugins/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/IntegrationTests/Plugins/Web/Tavily/TavilyTextSearchTests.cs new file mode 100644 index 000000000000..ffc0e066b8d4 --- /dev/null +++ b/dotnet/src/IntegrationTests/Plugins/Web/Tavily/TavilyTextSearchTests.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.Tavily; +using SemanticKernel.IntegrationTests.Data; +using SemanticKernel.IntegrationTests.TestSettings; +using Xunit; + +namespace SemanticKernel.IntegrationTests.Plugins.Web.Tavily; + +/// +/// Integration tests for . +/// +public class TavilyTextSearchTests : BaseTextSearchTests +{ + /// + public override Task CreateTextSearchAsync() + { + var configuration = this.Configuration.GetSection("Tavily").Get(); + Assert.NotNull(configuration); + Assert.NotNull(configuration.ApiKey); + + return Task.FromResult(new TavilyTextSearch(apiKey: configuration.ApiKey)); + } + + /// + public override string GetQuery() => "What is the Semantic Kernel?"; + + /// + public override TextSearchFilter GetTextSearchFilter() => new TextSearchFilter().Equality("include_domain", "devblogs.microsoft.com"); + + /// + 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/TavilyConfiguration.cs b/dotnet/src/IntegrationTests/TestSettings/TavilyConfiguration.cs new file mode 100644 index 000000000000..9a0cdca8bbde --- /dev/null +++ b/dotnet/src/IntegrationTests/TestSettings/TavilyConfiguration.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; + +namespace SemanticKernel.IntegrationTests.TestSettings; + +[SuppressMessage("Performance", "CA1812:Internal class that is apparently never instantiated", + Justification = "Configuration classes are instantiated through IConfiguration.")] + +internal sealed class TavilyConfiguration(string apiKey) +{ + public string ApiKey { get; init; } = apiKey; +} diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index ac20d80e9aaf..923fb8357d32 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -172,6 +172,106 @@ public async Task GetTextSearchResultsWithCustomResultMapperReturnsSuccessfullyA } } + [Fact] + public async Task SearchWithAnswerReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Tavily search + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeAnswer = true }); + + // Act + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(5, resultList.Count); + foreach (var stringResult in resultList) + { + Assert.NotEmpty(stringResult); + } + } + + [Fact] + public async Task SearchWithImagesReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Tavily search + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(9, resultList.Count); + foreach (var stringResult in resultList) + { + Assert.NotEmpty(stringResult); + } + } + + [Fact] + public async Task GetTextSearchResultsWithAnswerReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Tavily search + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeAnswer = true }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + foreach (var textSearchResult in resultList) + { + Assert.NotNull(textSearchResult.Name); + Assert.NotNull(textSearchResult.Value); + Assert.NotNull(textSearchResult.Link); + } + } + + [Fact] + public async Task GetTextSearchResultsWithImagesReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Tavily search + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true, IncludeImageDescriptions = true }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(9, resultList.Count); + foreach (var textSearchResult in resultList) + { + Assert.NotNull(textSearchResult.Name); + Assert.NotNull(textSearchResult.Value); + Assert.NotNull(textSearchResult.Link); + } + } + [Theory] [InlineData("topic", "general", "{\"query\":\"What is the Semantic Kernel?\",\"topic\":\"general\",\"max_results\":4}")] [InlineData("topic", "news", "{\"query\":\"What is the Semantic Kernel?\",\"topic\":\"news\",\"max_results\":4}")] diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index 858199523bcd..4e01d0ffb88b 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -164,6 +164,15 @@ private async IAsyncEnumerable GetSearchResultsAsync(TavilySearchRespons yield return result; await Task.Yield(); } + + if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + { + foreach (var image in searchResponse.Images!) + { + yield return image; + await Task.Yield(); + } + } } /// @@ -183,6 +192,15 @@ private async IAsyncEnumerable GetResultsAsTextSearchResultAsy yield return this._resultMapper.MapFromResultToTextSearchResult(result); await Task.Yield(); } + + if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + { + foreach (var image in searchResponse.Images!) + { + yield return this._resultMapper.MapFromResultToTextSearchResult(image); + await Task.Yield(); + } + } } /// @@ -208,6 +226,15 @@ private async IAsyncEnumerable GetResultsAsStringAsync(TavilySearchRespo yield return this._stringMapper.MapFromResultToString(result); await Task.Yield(); } + + if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + { + foreach (var image in searchResponse.Images!) + { + yield return this._stringMapper.MapFromResultToString(image); + await Task.Yield(); + } + } } /// @@ -230,12 +257,15 @@ private sealed class DefaultTextSearchStringMapper : ITextSearchStringMapper /// public string MapFromResultToString(object result) { - if (result is not TavilySearchResult searchResult) + if (result is TavilySearchResult searchResult) { - throw new ArgumentException("Result must be a TavilySearchResult", nameof(result)); + return searchResult.RawContent ?? searchResult.Content ?? string.Empty; } - - return searchResult.RawContent ?? searchResult.Content ?? string.Empty; + else if (result is TavilyImageResult imageResult) + { + return imageResult.Description ?? string.Empty; + } + throw new ArgumentException("Result must be a TavilySearchResult", nameof(result)); } } @@ -247,12 +277,17 @@ private sealed class DefaultTextSearchResultMapper : ITextSearchResultMapper /// public TextSearchResult MapFromResultToTextSearchResult(object result) { - if (result is not TavilySearchResult searchResult) + if (result is TavilySearchResult searchResult) { - throw new ArgumentException("Result must be a TavilySearchResult", nameof(result)); + return new TextSearchResult(searchResult.RawContent ?? searchResult.Content ?? string.Empty) { Name = searchResult.Title, Link = searchResult.Url }; } - - return new TextSearchResult(searchResult.RawContent ?? searchResult.Content ?? string.Empty) { Name = searchResult.Title, Link = searchResult.Url }; + else if (result is TavilyImageResult imageResult) + { + var uri = new Uri(imageResult.Url); + var name = uri.Segments[^1]; + return new TextSearchResult(imageResult.Description ?? string.Empty) { Name = name, Link = imageResult.Url }; + } + throw new ArgumentException("Result must be a TavilySearchResult", nameof(result)); } } @@ -316,7 +351,9 @@ private TavilySearchRequest BuildRequestContent(string query, TextSearchOptions topic, timeRange, days, - this._searchOptions?.SearchDepth, +#pragma warning disable CA1308 // Lower is preferred over uppercase + this._searchOptions?.SearchDepth?.ToString()?.ToLowerInvariant(), +#pragma warning restore CA1308 this._searchOptions?.ChunksPerSource, this._searchOptions?.IncludeImages, this._searchOptions?.IncludeImageDescriptions, diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index f1df0a7d181b..bad160a9862e 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -25,7 +25,7 @@ public sealed class TavilyTextSearchOptions /// Available options: basic, advanced /// // TODO Create an enum - public string? SearchDepth { get; set; } + public SearchDepth? SearchDepth { get; set; } /// /// The number of content chunks to retrieve from each source. @@ -76,3 +76,20 @@ public sealed class TavilyTextSearchOptions /// public ITextSearchResultMapper? ResultMapper { get; init; } = null; } + +/// +/// The depth of the search. advanced search is tailored to retrieve the +/// most relevant sources and content snippets for your query, +/// while basic search provides generic content snippets from each source. +/// +public enum SearchDepth +{ + /// + /// Basic search costs 1 API Credit. + /// + Basic, + /// + /// Advanced search costs 2 API Credits. + /// + Advanced +} From 05658c825b23e3e5bce98d7fbc47660fa1d18803 Mon Sep 17 00:00:00 2001 From: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Date: Wed, 26 Mar 2025 19:46:19 +0000 Subject: [PATCH 2/3] Remove TODO comment in TavilyTextSearchOptions.cs --- .../src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index bad160a9862e..97fb13e69f48 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Net.Http; @@ -24,7 +24,6 @@ public sealed class TavilyTextSearchOptions /// A basic search costs 1 API Credit, while an advanced search costs 2 API Credits. /// Available options: basic, advanced /// - // TODO Create an enum public SearchDepth? SearchDepth { get; set; } /// From 1694721ed57b994215bd42fa1ee2d9e51d333d9e Mon Sep 17 00:00:00 2001 From: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com> Date: Wed, 26 Mar 2025 19:46:19 +0000 Subject: [PATCH 3/3] Remove TODO comment in TavilyTextSearchOptions.cs --- .../Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index bad160a9862e..d2189aee0886 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -24,7 +24,6 @@ public sealed class TavilyTextSearchOptions /// A basic search costs 1 API Credit, while an advanced search costs 2 API Credits. /// Available options: basic, advanced /// - // TODO Create an enum public SearchDepth? SearchDepth { get; set; } /// @@ -78,8 +77,8 @@ public sealed class TavilyTextSearchOptions } /// -/// The depth of the search. advanced search is tailored to retrieve the -/// most relevant sources and content snippets for your query, +/// The depth of the search. advanced search is tailored to retrieve +/// the most relevant sources and content snippets for your query, /// while basic search provides generic content snippets from each source. /// public enum SearchDepth