diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index be5c78d41d0f..2e177f17d1e8 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -340,6 +340,234 @@ public async Task DoesNotBuildRequestForInvalidQueryAsync() Assert.Equal("Value cannot be null. (Parameter 'query')", e.Message); } + [Theory] + [InlineData(2, 1, 3, 3, 1, 2)] + [InlineData(2, 0, 2, 2, 0, 2)] + [InlineData(3, 3, 6, 6, 3, 3)] + [InlineData(15, 10, 20, 20, 10, 10)] + [InlineData(21, 0, 20, 20, 0, 20)] + [InlineData(50, 0, 20, 20, 0, 20)] + [InlineData(21, 5, 20, 20, 5, 15)] + [InlineData(50, 5, 20, 20, 5, 15)] + [InlineData(3, int.MaxValue, 20, 20, 20, 0)] + public async Task SkipIsAppliedWithinCappedMaxResultsWindowAsync(int top, int skip, int expectedMaxResults, int responseCount, int expectedStart, int expectedCount) + { + // Arrange - the stub returns as many results as the capped max_results window allows + this._messageHandlerStub.AddJsonResponse(BuildResponseJson(responseCount)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = top, Skip = skip }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains($"\"max_results\":{expectedMaxResults}", requestBodyJson); + var names = (await result.Results.ToListAsync()).Select(r => r.Name).ToArray(); + Assert.Equal(Enumerable.Range(expectedStart, expectedCount).Select(i => $"Result {i}").ToArray(), names); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(51)] + public async Task TopOutsideAcceptedRangeStillThrowsAsync(int top) + { + // Arrange + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = top, Skip = 0 })); + Assert.Empty(this._messageHandlerStub.RequestContents); + } + + [Fact] + public async Task IncludeImagesWithoutImagesInResponseDoesNotThrowAsync() + { + // Arrange + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node.Remove("images"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults objects = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + var objectList = await objects.Results.ToListAsync(); + KernelSearchResults pages = await ((ITextSearch)textSearch).GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); + var pageList = await pages.Results.ToListAsync(); + + // Assert + Assert.Equal(4, objectList.Count); + Assert.Equal(4, pageList.Count); + } + + [Fact] + public async Task StringImageUrlsInResponseAreReturnedAsImageResultsAsync() + { + // Arrange - without image descriptions Tavily returns the images as URL strings + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node["images"] = new System.Text.Json.Nodes.JsonArray("https://example.com/a.png", "https://example.com/b.jpg"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + var list = await result.Results.ToListAsync(); + + // Assert - the four web results are unchanged and the two images follow with the URL and an empty description + Assert.Equal(6, list.Count); + Assert.Equal("https://example.com/a.png", list[4].Link); + Assert.Equal("a.png", list[4].Name); + Assert.Equal(string.Empty, list[4].Value); + Assert.Equal("https://example.com/b.jpg", list[5].Link); + Assert.Equal(string.Empty, list[5].Value); + } + + [Fact] + public async Task ObjectImagesWithDescriptionsAreStillReadAsync() + { + // Arrange + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node["images"] = System.Text.Json.Nodes.JsonNode.Parse("[{\"url\":\"https://example.com/a.png\",\"description\":\"A diagram\"}]"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + 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 }); + var list = await result.Results.ToListAsync(); + + // Assert + Assert.Equal(5, list.Count); + Assert.Equal("https://example.com/a.png", list[4].Link); + Assert.Equal("A diagram", list[4].Value); + } + + [Fact] + public async Task ObjectImageWithoutDescriptionStillThrowsAsync() + { + // Arrange - the object form keeps its required properties + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node["images"] = System.Text.Json.Nodes.JsonNode.Parse("[{\"url\":\"https://example.com/a.png\"}]"); + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act & Assert + await Assert.ThrowsAsync(async () => await (await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 })).Results.ToListAsync()); + } + + [Fact] + public async Task PerResultImageStringArraysDoNotThrowAsync() + { + // Arrange - Tavily can also return an images string array on each result + var node = System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(WhatIsTheSKResponseJson))!.AsObject(); + node.Remove("images"); + foreach (var item in node["results"]!.AsArray()) + { + item!.AsObject()["images"] = new System.Text.Json.Nodes.JsonArray("https://example.com/a.png"); + } + + this._messageHandlerStub.AddJsonResponse(node.ToJsonString()); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.Equal(4, (await result.Results.ToListAsync()).Count); + } + + [Fact] + public async Task IncludeImagesWithImagesInResponseAppendsImageResultsAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeImages = true }); + + // Act + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + Assert.Equal(9, (await result.Results.ToListAsync()).Count); + } + + [Theory] + [InlineData(TavilySearchDepth.Basic, "basic")] + [InlineData(TavilySearchDepth.Advanced, "advanced")] + [InlineData(TavilySearchDepth.Fast, "fast")] + [InlineData(TavilySearchDepth.UltraFast, "ultra-fast")] + public async Task SearchDepthIsSerializedWithApiValueAsync(TavilySearchDepth depth, string expected) + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, SearchDepth = depth }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains($"\"search_depth\":\"{expected}\"", requestBodyJson); + } + + [Fact] + public async Task AdditionalSearchOptionsAreSerializedWhenConfiguredAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() + { + HttpClient = this._httpClient, + Country = "united states", + StartDate = new DateTime(2025, 2, 9), + EndDate = new DateTime(2025, 12, 29), + SafeSearch = true, + IncludeDomainsMode = TavilyIncludeDomainsMode.Prefer, + }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0, Filter = new TextSearchFilter().Equality("include_domain", "devblogs.microsoft.com") }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains("\"country\":\"united states\"", requestBodyJson); + Assert.Contains("\"start_date\":\"2025-02-09\"", requestBodyJson); + Assert.Contains("\"end_date\":\"2025-12-29\"", requestBodyJson); + Assert.Contains("\"safe_search\":true", requestBodyJson); + Assert.Contains("\"include_domains_mode\":\"prefer\"", requestBodyJson); + } + + [Fact] + public async Task AdditionalSearchOptionsAreOmittedWhenNotConfiguredAsync() + { + // Arrange - include domains mode is configured but there are no include domains in the request + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var unconfigured = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + var modeOnly = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, IncludeDomainsMode = TavilyIncludeDomainsMode.Restrict }); + + // Act + await unconfigured.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + await modeOnly.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + foreach (var content in this._messageHandlerStub.RequestContents) + { + var requestBodyJson = Encoding.UTF8.GetString(content!); + Assert.DoesNotContain("country", requestBodyJson); + Assert.DoesNotContain("start_date", requestBodyJson); + Assert.DoesNotContain("end_date", requestBodyJson); + Assert.DoesNotContain("safe_search", requestBodyJson); + Assert.DoesNotContain("include_domains_mode", requestBodyJson); + } + } + + private static string BuildResponseJson(int resultCount) + { + var results = Enumerable.Range(0, resultCount).Select(i => new { title = $"Result {i}", url = $"https://example.com/{i}", content = $"Content {i}", score = 0.5 }); + return JsonSerializer.Serialize(new { query = "q", results, response_time = 0.1 }); + } + /// public void Dispose() { @@ -496,6 +724,105 @@ public async Task StringContainsStillWorksWithLINQFiltersAsync() Assert.Contains("\"max_results\":5", requestBodyJson); } + [Fact] + public async Task LanguageSearchOptionIsSerializedAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Language = "french" }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.Contains("\"language\":\"french\"", requestBodyJson); + Assert.DoesNotContain("filter_by_language", requestBodyJson); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task BooleanSearchOptionsAreSerializedWhenSetAsync(bool value) + { + // Arrange - explicit false must be sent, not omitted + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() + { + HttpClient = this._httpClient, + Language = "en", + FilterByLanguage = value, + ExactMatch = value, + AutoParameters = value, + }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + var expected = value ? "true" : "false"; + Assert.Contains($"\"filter_by_language\":{expected}", requestBodyJson); + Assert.Contains($"\"exact_match\":{expected}", requestBodyJson); + Assert.Contains($"\"auto_parameters\":{expected}", requestBodyJson); + } + + [Fact] + public async Task LanguageExactMatchAndAutoParametersAreOmittedWhenNotSetAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + + // Assert + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.DoesNotContain("\"language\"", requestBodyJson); + Assert.DoesNotContain("filter_by_language", requestBodyJson); + Assert.DoesNotContain("exact_match", requestBodyJson); + Assert.DoesNotContain("auto_parameters", requestBodyJson); + } + + [Fact] + public async Task RequestIdentifiesTheClientAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(BuildResponseJson(1)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 1 }); + + // Assert - partner identity is sent per request; the API key and body are unchanged + var headers = this._messageHandlerStub.RequestHeaders[0]!; + Assert.Equal(new[] { "semantic-kernel" }, headers.GetValues("X-Client-Name").ToArray()); + Assert.Equal("Bearer ApiKey", headers.GetValues("Authorization").Single()); + Assert.False(headers.Contains("X-Client-Source")); + Assert.Contains("User-Agent", this._httpClient.DefaultRequestHeaders.Select(h => h.Key)); + var requestBodyJson = Encoding.UTF8.GetString(this._messageHandlerStub.RequestContents[0]!); + Assert.DoesNotContain("client_name", requestBodyJson); + Assert.DoesNotContain("client_source", requestBodyJson); + } + + [Fact] + public async Task CallerSuppliedClientNameIsPreservedAsync() + { + // Arrange + this._httpClient.DefaultRequestHeaders.Add("X-Client-Name", "caller-app"); + this._messageHandlerStub.AddJsonResponse(BuildResponseJson(1)); + var textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 1 }); + + // Assert - exactly one X-Client-Name value, the caller's, not duplicated and not semantic-kernel + // HttpClient merges DefaultRequestHeaders into the request before the handler sees it + Assert.Equal(new[] { "caller-app" }, this._messageHandlerStub.RequestHeaders[0]!.GetValues("X-Client-Name").ToArray()); + Assert.Equal(new[] { "caller-app" }, this._httpClient.DefaultRequestHeaders.GetValues("X-Client-Name").ToArray()); + } + #endregion #region private diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs index 461f38a79d0d..6cc04079b295 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResult.cs @@ -9,6 +9,7 @@ namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; /// Represents an image result. /// #pragma warning disable CA1812 // Instantiated by reflection +[JsonConverter(typeof(TavilyImageResultConverter))] internal sealed class TavilyImageResult { /// diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResultConverter.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResultConverter.cs new file mode 100644 index 000000000000..60d4e367f278 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyImageResultConverter.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; + +/// +/// Reads an image result from either a plain image URL string (returned when image descriptions are not +/// requested) or an object with url and description (returned when they are). +/// +internal sealed class TavilyImageResultConverter : JsonConverter +{ + /// + public override TavilyImageResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + return new TavilyImageResult(reader.GetString()!, string.Empty); + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("An image result must be a URL string or an object."); + } + + string? url = null; + string? description = null; + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + string? name = reader.GetString(); + reader.Read(); + if (name == "url") + { + url = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); + } + else if (name == "description") + { + description = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); + } + else + { + reader.Skip(); + } + } + + return new TavilyImageResult( + url ?? throw new JsonException("Required property 'url' was not found in the image result."), + description ?? throw new JsonException("Required property 'description' was not found in the image result.")); + } + + /// + public override void Write(Utf8JsonWriter writer, TavilyImageResult value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("url", value.Url); + writer.WriteString("description", value.Description); + writer.WriteEndObject(); + } +} diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyIncludeDomainsMode.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyIncludeDomainsMode.cs new file mode 100644 index 000000000000..936633eef8a1 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyIncludeDomainsMode.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; + +/// +/// Controls how the include domain filter is applied to a Tavily search. +/// +public enum TavilyIncludeDomainsMode +{ + /// + /// Limit results to only the included domains (Tavily default). + /// + Restrict, + + /// + /// Also search the rest of the web, so results outside the included domains can still surface. + /// + Prefer +} diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs index fce136d68ad3..cb629fe9d7f5 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchDepth.cs @@ -16,5 +16,15 @@ public enum TavilySearchDepth /// /// Advanced search costs 2 API Credits. /// - Advanced + Advanced, + + /// + /// Fast search prioritizes lower latency while maintaining good relevance. Costs 1 API Credit. + /// + Fast, + + /// + /// Ultra-fast search minimizes latency above all else. Costs 1 API Credit. + /// + UltraFast } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs index b7416895bd4d..b6b6134f3cfd 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilySearchRequest.cs @@ -88,6 +88,60 @@ internal sealed class TavilySearchRequest [JsonPropertyName("include_image_descriptions")] public bool? IncludeImageDescriptions { get; set; } + /// + /// Boost search results from a specific country. Available only when topic is general. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } + + /// + /// Return only results after this date. Format: YYYY-MM-DD. + /// + [JsonPropertyName("start_date")] + public string? StartDate { get; set; } + + /// + /// Return only results before this date. Format: YYYY-MM-DD. + /// + [JsonPropertyName("end_date")] + public string? EndDate { get; set; } + + /// + /// Whether to filter out adult or unsafe content from results. + /// + [JsonPropertyName("safe_search")] + public bool? SafeSearch { get; set; } + + /// + /// How include_domains is applied. Available options: restrict, prefer. + /// + [JsonPropertyName("include_domains_mode")] + public string? IncludeDomainsMode { get; set; } + + /// + /// Boost search results in a specific language (ISO 639-1 code or English language name). + /// + [JsonPropertyName("language")] + public string? Language { get; set; } + + /// + /// Strictly filter out results that do not match the language instead of only boosting them. + /// + [JsonPropertyName("filter_by_language")] + public bool? FilterByLanguage { get; set; } + + /// + /// Only return results containing the exact quoted phrase(s) in the query. + /// + [JsonPropertyName("exact_match")] + public bool? ExactMatch { get; set; } + + /// + /// Let Tavily configure search parameters automatically from the query. + /// + [JsonPropertyName("auto_parameters")] + public bool? AutoParameters { get; set; } + /// /// A list of domains to specifically include in the search results. /// @@ -122,7 +176,16 @@ public TavilySearchRequest( bool? includeRawContent, int? maxResults, IList? includeDomains, - IList? excludeDomains) + IList? excludeDomains, + string? country = null, + string? startDate = null, + string? endDate = null, + bool? safeSearch = null, + string? includeDomainsMode = null, + string? language = null, + bool? filterByLanguage = null, + bool? exactMatch = null, + bool? autoParameters = null) { this.Query = query ?? throw new ArgumentNullException(nameof(query)); this.Topic = topic; @@ -137,5 +200,14 @@ public TavilySearchRequest( this.MaxResults = maxResults; this.IncludeDomains = includeDomains; this.ExcludeDomains = excludeDomains; + this.Country = country; + this.StartDate = startDate; + this.EndDate = endDate; + this.SafeSearch = safeSearch; + this.IncludeDomainsMode = includeDomainsMode; + this.Language = language; + this.FilterByLanguage = filterByLanguage; + this.ExactMatch = exactMatch; + this.AutoParameters = autoParameters; } } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index 41fd5cd35df1..436546a996fc 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Linq.Expressions; using System.Net.Http; using System.Runtime.CompilerServices; @@ -383,6 +385,8 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis #region Private Methods private readonly ILogger _logger; + private const string ClientNameHeader = "X-Client-Name"; + private const string ClientName = "semantic-kernel"; private readonly HttpClient _httpClient; private readonly string? _apiKey; private readonly Uri? _uri = null; @@ -395,6 +399,12 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis private const string DefaultUri = "https://api.tavily.com/search"; + /// + /// The maximum value of the Tavily max_results request parameter. The accepted + /// Top range is unchanged; only the outgoing request window is capped. + /// + private const int MaxResultsLimit = 20; + private const string Topic = "topic"; private const string TimeRange = "time_range"; private const string Days = "days"; @@ -422,7 +432,13 @@ private static void ProcessMethodCallClause(MethodCallExpression methodCall, Lis // Sensitive data, logging as trace, disabled by default this._logger.LogTrace("Response content received: {Data}", json); - return JsonSerializer.Deserialize(json); + TavilySearchResponse? searchResponse = JsonSerializer.Deserialize(json); + if (skip > 0 && searchResponse?.Results is not null) + { + searchResponse.Results = [.. searchResponse.Results.Skip(skip).Take(top)]; + } + + return searchResponse; } /// @@ -453,6 +469,12 @@ private async Task SendGetRequestAsync(string query, int to httpRequestMessage.Headers.Add("Authorization", $"Bearer {this._apiKey}"); } + // Identify this connector to Tavily unless the caller already set the header on their HttpClient. + if (!this._httpClient.DefaultRequestHeaders.Contains(ClientNameHeader)) + { + httpRequestMessage.Headers.Add(ClientNameHeader, ClientName); + } + return await this._httpClient.SendWithSuccessCheckAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); } @@ -474,7 +496,7 @@ private async IAsyncEnumerable GetSearchResultsAsync(TavilySearchRespons await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -502,7 +524,7 @@ private async IAsyncEnumerable GetResultsAsWebPageAsync(TavilySea await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -537,7 +559,7 @@ private async IAsyncEnumerable GetResultsAsTextSearchResultAsy await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -571,7 +593,7 @@ private async IAsyncEnumerable GetResultsAsStringAsync(TavilySearchRespo await Task.Yield(); } - if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + if ((this._searchOptions?.IncludeImages ?? false) && searchResponse.Images is not null) { foreach (var image in searchResponse.Images!) { @@ -676,7 +698,9 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, string? topic = null; string? timeRange = null; int? days = null; - int? maxResults = top - skip; + // Tavily has no offset parameter, so skipped results are requested and then dropped client-side. + // The window is limited by max_results, so top + skip beyond MaxResultsLimit returns fewer than top results. + int? maxResults = (int)Math.Min((long)top + Math.Max(skip, 0), MaxResultsLimit); IList? includeDomains = null; IList? excludeDomains = null; @@ -718,7 +742,7 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, timeRange, days, #pragma warning disable CA1308 // Lower is preferred over uppercase - this._searchOptions?.SearchDepth?.ToString()?.ToLowerInvariant(), + this._searchOptions?.SearchDepth is { } searchDepth ? ToApiSearchDepth(searchDepth) : null, #pragma warning restore CA1308 this._searchOptions?.ChunksPerSource, this._searchOptions?.IncludeImages, @@ -727,9 +751,35 @@ private TavilySearchRequest BuildRequestContent(string query, int top, int skip, this._searchOptions?.IncludeRawContent, maxResults, includeDomains, - excludeDomains); + excludeDomains, + this._searchOptions?.Country, + FormatDate(this._searchOptions?.StartDate), + FormatDate(this._searchOptions?.EndDate), + this._searchOptions?.SafeSearch, + includeDomains is { Count: > 0 } ? ToApiIncludeDomainsMode(this._searchOptions?.IncludeDomainsMode) : null, + this._searchOptions?.Language, + this._searchOptions?.FilterByLanguage, + this._searchOptions?.ExactMatch, + this._searchOptions?.AutoParameters); } + private static string? FormatDate(DateTime? date) => date?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + private static string? ToApiIncludeDomainsMode(TavilyIncludeDomainsMode? mode) => mode switch + { + TavilyIncludeDomainsMode.Restrict => "restrict", + TavilyIncludeDomainsMode.Prefer => "prefer", + _ => null, + }; + + private static string ToApiSearchDepth(TavilySearchDepth searchDepth) => searchDepth switch + { + TavilySearchDepth.UltraFast => "ultra-fast", +#pragma warning disable CA1308 // Lower is preferred over uppercase + _ => searchDepth.ToString().ToLowerInvariant(), +#pragma warning restore CA1308 + }; + private static readonly JsonSerializerOptions s_jsonOptionsCache = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs index 04604088fb33..0ec24475f3c9 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearchOptions.cs @@ -29,11 +29,72 @@ public sealed class TavilyTextSearchOptions /// /// The number of content chunks to retrieve from each source. /// Each chunk's length is maximum 500 characters. - /// Available only when search_depth is advanced. - /// Required range: 0 - 3 + /// Available when search_depth is basic, advanced or fast. + /// Required range: 1 - 3 /// public int? ChunksPerSource { get; set; } + /// + /// Boost search results from a specific country, for example "united states". + /// Available only when the search topic is general. Not supported with the + /// or search depths, for which Tavily returns an error. + /// + public string? Country { get; set; } + + /// + /// Return only results published or updated after this date (sent as YYYY-MM-DD). + /// Not sent when null. + /// + public DateTime? StartDate { get; set; } + + /// + /// Return only results published or updated before this date (sent as YYYY-MM-DD). + /// Not sent when null. + /// + public DateTime? EndDate { get; set; } + + /// + /// Whether to filter out adult or unsafe content from results. + /// Not supported by Tavily for the fast or ultra-fast search depths. Not sent when null. + /// + public bool? SafeSearch { get; set; } + + /// + /// Controls how the include domain filter is applied. Only sent when the search has at least + /// one include domain, because Tavily rejects the mode without include domains. Not sent when null. + /// + public TavilyIncludeDomainsMode? IncludeDomainsMode { get; set; } + + /// + /// Boost search results in a specific language. Accepts an ISO 639-1 code (for example "en" or "zh-cn") + /// or an English language name (for example "english"). By default this only boosts matching-language + /// results in ranking; set to strictly filter instead. Not sent when null. + /// + public string? Language { get; set; } + + /// + /// Strictly filter out results that do not match instead of only boosting them. + /// Tavily requires to be set and returns an error otherwise. + /// Sent when not null, including an explicit false. + /// + public bool? FilterByLanguage { get; set; } + + /// + /// Only return results containing the exact quoted phrase(s) in the query, bypassing synonyms or + /// semantic variations. Wrap the target phrases in quotes within the query. + /// Sent when not null, including an explicit false. + /// + public bool? ExactMatch { get; set; } + + /// + /// Let Tavily configure search parameters automatically from the query's content and intent; + /// explicitly set options still take precedence. , + /// and the number of results are never set automatically. Tavily may raise the search depth to advanced, + /// which uses 2 API credits per request; set to basic to avoid the extra cost. + /// Sent when not null, including an explicit false. + /// + public bool? AutoParameters { get; set; } + /// /// Include an LLM-generated answer to the provided query. /// basic or true returns a quick answer. advanced returns a more detailed answer.