Skip to content

Commit cb5153c

Browse files
.Net: Tavily image search and integration tests (#11203)
### Motivation and Context - [x] Support for image search - [x] Enum for search depth - [x] Integration tests ### Description <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄
1 parent d4cbfa6 commit cb5153c

File tree

6 files changed

+233
-19
lines changed

6 files changed

+233
-19
lines changed

dotnet/src/IntegrationTests/Data/BaseTextSearchTests.cs

+7-7
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace SemanticKernel.IntegrationTests.Data;
1818
/// </summary>
1919
public abstract class BaseTextSearchTests : BaseIntegrationTest
2020
{
21-
[Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")]
21+
[Fact(Skip = "For manual verification only.")]
2222
public virtual async Task CanSearchAsync()
2323
{
2424
// Arrange
@@ -42,7 +42,7 @@ public virtual async Task CanSearchAsync()
4242
}
4343
}
4444

45-
[Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")]
45+
[Fact(Skip = "For manual verification only.")]
4646
public virtual async Task CanGetTextSearchResultsAsync()
4747
{
4848
// Arrange
@@ -72,7 +72,7 @@ public virtual async Task CanGetTextSearchResultsAsync()
7272
}
7373
}
7474

75-
[Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")]
75+
[Fact(Skip = "For manual verification only.")]
7676
public virtual async Task CanGetSearchResultsAsync()
7777
{
7878
// Arrange
@@ -92,7 +92,7 @@ public virtual async Task CanGetSearchResultsAsync()
9292
Assert.True(this.VerifySearchResults(results, query));
9393
}
9494

95-
[Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")]
95+
[Fact(Skip = "For manual verification only.")]
9696
public virtual async Task UsingTextSearchWithAFilterAsync()
9797
{
9898
// Arrange
@@ -113,7 +113,7 @@ public virtual async Task UsingTextSearchWithAFilterAsync()
113113
Assert.True(this.VerifySearchResults(results, query, filter));
114114
}
115115

116-
[Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")]
116+
[Fact(Skip = "For manual verification only.")]
117117
public virtual async Task FunctionCallingUsingCreateWithSearchAsync()
118118
{
119119
// Arrange
@@ -142,7 +142,7 @@ public virtual async Task FunctionCallingUsingCreateWithSearchAsync()
142142
Assert.NotEmpty(results);
143143
}
144144

145-
[Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")]
145+
[Fact(Skip = "For manual verification only.")]
146146
public virtual async Task FunctionCallingUsingCreateWithGetSearchResultsAsync()
147147
{
148148
// Arrange
@@ -171,7 +171,7 @@ public virtual async Task FunctionCallingUsingCreateWithGetSearchResultsAsync()
171171
Assert.NotEmpty(results);
172172
}
173173

174-
[Fact(Skip = "Failing in integration tests pipeline with - HTTP 429 (insufficient_quota) error.")]
174+
[Fact(Skip = "For manual verification only.")]
175175
public virtual async Task FunctionCallingUsingGetTextSearchResultsAsync()
176176
{
177177
// Arrange
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Threading.Tasks;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.SemanticKernel.Data;
6+
using Microsoft.SemanticKernel.Plugins.Web.Tavily;
7+
using SemanticKernel.IntegrationTests.Data;
8+
using SemanticKernel.IntegrationTests.TestSettings;
9+
using Xunit;
10+
11+
namespace SemanticKernel.IntegrationTests.Plugins.Web.Tavily;
12+
13+
/// <summary>
14+
/// Integration tests for <see cref="TavilyTextSearch"/>.
15+
/// </summary>
16+
public class TavilyTextSearchTests : BaseTextSearchTests
17+
{
18+
/// <inheritdoc/>
19+
public override Task<ITextSearch> CreateTextSearchAsync()
20+
{
21+
var configuration = this.Configuration.GetSection("Tavily").Get<TavilyConfiguration>();
22+
Assert.NotNull(configuration);
23+
Assert.NotNull(configuration.ApiKey);
24+
25+
return Task.FromResult<ITextSearch>(new TavilyTextSearch(apiKey: configuration.ApiKey));
26+
}
27+
28+
/// <inheritdoc/>
29+
public override string GetQuery() => "What is the Semantic Kernel?";
30+
31+
/// <inheritdoc/>
32+
public override TextSearchFilter GetTextSearchFilter() => new TextSearchFilter().Equality("include_domain", "devblogs.microsoft.com");
33+
34+
/// <inheritdoc/>
35+
public override bool VerifySearchResults(object[] results, string query, TextSearchFilter? filter = null)
36+
{
37+
Assert.NotNull(results);
38+
Assert.NotEmpty(results);
39+
Assert.Equal(4, results.Length);
40+
foreach (var result in results)
41+
{
42+
Assert.NotNull(result);
43+
Assert.IsType<TavilySearchResult>(result);
44+
}
45+
46+
return true;
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Diagnostics.CodeAnalysis;
4+
5+
namespace SemanticKernel.IntegrationTests.TestSettings;
6+
7+
[SuppressMessage("Performance", "CA1812:Internal class that is apparently never instantiated",
8+
Justification = "Configuration classes are instantiated through IConfiguration.")]
9+
10+
internal sealed class TavilyConfiguration(string apiKey)
11+
{
12+
public string ApiKey { get; init; } = apiKey;
13+
}

dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs

+100
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,106 @@ public async Task GetTextSearchResultsWithCustomResultMapperReturnsSuccessfullyA
172172
}
173173
}
174174

175+
[Fact]
176+
public async Task SearchWithAnswerReturnsSuccessfullyAsync()
177+
{
178+
// Arrange
179+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
180+
181+
// Create an ITextSearch instance using Tavily search
182+
var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeAnswer = true });
183+
184+
// Act
185+
KernelSearchResults<string> result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 });
186+
187+
// Assert
188+
Assert.NotNull(result);
189+
Assert.NotNull(result.Results);
190+
var resultList = await result.Results.ToListAsync();
191+
Assert.NotNull(resultList);
192+
Assert.Equal(5, resultList.Count);
193+
foreach (var stringResult in resultList)
194+
{
195+
Assert.NotEmpty(stringResult);
196+
}
197+
}
198+
199+
[Fact]
200+
public async Task SearchWithImagesReturnsSuccessfullyAsync()
201+
{
202+
// Arrange
203+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
204+
205+
// Create an ITextSearch instance using Tavily search
206+
var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true });
207+
208+
// Act
209+
KernelSearchResults<string> result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 });
210+
211+
// Assert
212+
Assert.NotNull(result);
213+
Assert.NotNull(result.Results);
214+
var resultList = await result.Results.ToListAsync();
215+
Assert.NotNull(resultList);
216+
Assert.Equal(9, resultList.Count);
217+
foreach (var stringResult in resultList)
218+
{
219+
Assert.NotEmpty(stringResult);
220+
}
221+
}
222+
223+
[Fact]
224+
public async Task GetTextSearchResultsWithAnswerReturnsSuccessfullyAsync()
225+
{
226+
// Arrange
227+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
228+
229+
// Create an ITextSearch instance using Tavily search
230+
var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeAnswer = true });
231+
232+
// Act
233+
KernelSearchResults<TextSearchResult> result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 });
234+
235+
// Assert
236+
Assert.NotNull(result);
237+
Assert.NotNull(result.Results);
238+
var resultList = await result.Results.ToListAsync();
239+
Assert.NotNull(resultList);
240+
Assert.Equal(4, resultList.Count);
241+
foreach (var textSearchResult in resultList)
242+
{
243+
Assert.NotNull(textSearchResult.Name);
244+
Assert.NotNull(textSearchResult.Value);
245+
Assert.NotNull(textSearchResult.Link);
246+
}
247+
}
248+
249+
[Fact]
250+
public async Task GetTextSearchResultsWithImagesReturnsSuccessfullyAsync()
251+
{
252+
// Arrange
253+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
254+
255+
// Create an ITextSearch instance using Tavily search
256+
var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true, IncludeImageDescriptions = true });
257+
258+
// Act
259+
KernelSearchResults<TextSearchResult> result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 });
260+
261+
// Assert
262+
Assert.NotNull(result);
263+
Assert.NotNull(result.Results);
264+
var resultList = await result.Results.ToListAsync();
265+
Assert.NotNull(resultList);
266+
Assert.Equal(9, resultList.Count);
267+
foreach (var textSearchResult in resultList)
268+
{
269+
Assert.NotNull(textSearchResult.Name);
270+
Assert.NotNull(textSearchResult.Value);
271+
Assert.NotNull(textSearchResult.Link);
272+
}
273+
}
274+
175275
[Theory]
176276
[InlineData("topic", "general", "{\"query\":\"What is the Semantic Kernel?\",\"topic\":\"general\",\"max_results\":4}")]
177277
[InlineData("topic", "news", "{\"query\":\"What is the Semantic Kernel?\",\"topic\":\"news\",\"max_results\":4}")]

dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs

+46-9
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,15 @@ private async IAsyncEnumerable<object> GetSearchResultsAsync(TavilySearchRespons
164164
yield return result;
165165
await Task.Yield();
166166
}
167+
168+
if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null)
169+
{
170+
foreach (var image in searchResponse.Images!)
171+
{
172+
yield return image;
173+
await Task.Yield();
174+
}
175+
}
167176
}
168177

169178
/// <summary>
@@ -183,6 +192,15 @@ private async IAsyncEnumerable<TextSearchResult> GetResultsAsTextSearchResultAsy
183192
yield return this._resultMapper.MapFromResultToTextSearchResult(result);
184193
await Task.Yield();
185194
}
195+
196+
if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null)
197+
{
198+
foreach (var image in searchResponse.Images!)
199+
{
200+
yield return this._resultMapper.MapFromResultToTextSearchResult(image);
201+
await Task.Yield();
202+
}
203+
}
186204
}
187205

188206
/// <summary>
@@ -208,6 +226,15 @@ private async IAsyncEnumerable<string> GetResultsAsStringAsync(TavilySearchRespo
208226
yield return this._stringMapper.MapFromResultToString(result);
209227
await Task.Yield();
210228
}
229+
230+
if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null)
231+
{
232+
foreach (var image in searchResponse.Images!)
233+
{
234+
yield return this._stringMapper.MapFromResultToString(image);
235+
await Task.Yield();
236+
}
237+
}
211238
}
212239

213240
/// <summary>
@@ -230,12 +257,15 @@ private sealed class DefaultTextSearchStringMapper : ITextSearchStringMapper
230257
/// <inheritdoc />
231258
public string MapFromResultToString(object result)
232259
{
233-
if (result is not TavilySearchResult searchResult)
260+
if (result is TavilySearchResult searchResult)
234261
{
235-
throw new ArgumentException("Result must be a TavilySearchResult", nameof(result));
262+
return searchResult.RawContent ?? searchResult.Content ?? string.Empty;
236263
}
237-
238-
return searchResult.RawContent ?? searchResult.Content ?? string.Empty;
264+
else if (result is TavilyImageResult imageResult)
265+
{
266+
return imageResult.Description ?? string.Empty;
267+
}
268+
throw new ArgumentException("Result must be a TavilySearchResult", nameof(result));
239269
}
240270
}
241271

@@ -247,12 +277,17 @@ private sealed class DefaultTextSearchResultMapper : ITextSearchResultMapper
247277
/// <inheritdoc />
248278
public TextSearchResult MapFromResultToTextSearchResult(object result)
249279
{
250-
if (result is not TavilySearchResult searchResult)
280+
if (result is TavilySearchResult searchResult)
251281
{
252-
throw new ArgumentException("Result must be a TavilySearchResult", nameof(result));
282+
return new TextSearchResult(searchResult.RawContent ?? searchResult.Content ?? string.Empty) { Name = searchResult.Title, Link = searchResult.Url };
253283
}
254-
255-
return new TextSearchResult(searchResult.RawContent ?? searchResult.Content ?? string.Empty) { Name = searchResult.Title, Link = searchResult.Url };
284+
else if (result is TavilyImageResult imageResult)
285+
{
286+
var uri = new Uri(imageResult.Url);
287+
var name = uri.Segments[^1];
288+
return new TextSearchResult(imageResult.Description ?? string.Empty) { Name = name, Link = imageResult.Url };
289+
}
290+
throw new ArgumentException("Result must be a TavilySearchResult", nameof(result));
256291
}
257292
}
258293

@@ -316,7 +351,9 @@ private TavilySearchRequest BuildRequestContent(string query, TextSearchOptions
316351
topic,
317352
timeRange,
318353
days,
319-
this._searchOptions?.SearchDepth,
354+
#pragma warning disable CA1308 // Lower is preferred over uppercase
355+
this._searchOptions?.SearchDepth?.ToString()?.ToLowerInvariant(),
356+
#pragma warning restore CA1308
320357
this._searchOptions?.ChunksPerSource,
321358
this._searchOptions?.IncludeImages,
322359
this._searchOptions?.IncludeImageDescriptions,

dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs

+19-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
namespace Microsoft.SemanticKernel.Plugins.Web.Tavily;
99

1010
/// <summary>
11-
/// Options used to construct an instance of <see cref="TavilyTextSearch"/>
11+
/// Options used to construct an instance of <see cref="TavilyTextSearch"/>.
1212
/// </summary>
1313
public sealed class TavilyTextSearchOptions
1414
{
@@ -24,8 +24,7 @@ public sealed class TavilyTextSearchOptions
2424
/// A basic search costs 1 API Credit, while an advanced search costs 2 API Credits.
2525
/// Available options: basic, advanced
2626
/// </summary>
27-
// TODO Create an enum
28-
public string? SearchDepth { get; set; }
27+
public SearchDepth? SearchDepth { get; set; }
2928

3029
/// <summary>
3130
/// The number of content chunks to retrieve from each source.
@@ -76,3 +75,20 @@ public sealed class TavilyTextSearchOptions
7675
/// </summary>
7776
public ITextSearchResultMapper? ResultMapper { get; init; } = null;
7877
}
78+
79+
/// <summary>
80+
/// The depth of the search. advanced search is tailored to retrieve
81+
/// the most relevant sources and content snippets for your query,
82+
/// while basic search provides generic content snippets from each source.
83+
/// </summary>
84+
public enum SearchDepth
85+
{
86+
/// <summary>
87+
/// Basic search costs 1 API Credit.
88+
/// </summary>
89+
Basic,
90+
/// <summary>
91+
/// Advanced search costs 2 API Credits.
92+
/// </summary>
93+
Advanced
94+
}

0 commit comments

Comments
 (0)