diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/WebSearchEngineSkillTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/WebSearchEngineSkillTests.cs index e184ec3648b6..5f740a7ca556 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/WebSearchEngineSkillTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/WebSearchEngineSkillTests.cs @@ -19,7 +19,7 @@ public async Task SearchAsyncSucceedsAsync() IEnumerable expected = new[] { Guid.NewGuid().ToString() }; Mock connectorMock = new(); - connectorMock.Setup(c => c.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + connectorMock.Setup(c => c.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(expected); WebSearchEnginePlugin target = new(connectorMock.Object); @@ -32,4 +32,25 @@ public async Task SearchAsyncSucceedsAsync() // Assert connectorMock.VerifyAll(); } + + [Fact] + public async Task GetSearchResultsSucceedsAsync() + { + // Arrange + IEnumerable expected = new List(); + + Mock connectorMock = new(); + connectorMock.Setup(c => c.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(expected); + + WebSearchEnginePlugin target = new(connectorMock.Object); + + string anyQuery = Guid.NewGuid().ToString(); + + // Act + await target.GetSearchResultsAsync(anyQuery); + + // Assert + connectorMock.VerifyAll(); + } } diff --git a/dotnet/src/Plugins/Plugins.Web/Bing/BingConnector.cs b/dotnet/src/Plugins/Plugins.Web/Bing/BingConnector.cs index 69c4019c52b2..8fa1ca1378b4 100644 --- a/dotnet/src/Plugins/Plugins.Web/Bing/BingConnector.cs +++ b/dotnet/src/Plugins/Plugins.Web/Bing/BingConnector.cs @@ -2,11 +2,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Net.Http; using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -23,14 +21,17 @@ public sealed class BingConnector : IWebSearchEngineConnector private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly string? _apiKey; + private readonly Uri? _uri = null; + private const string DefaultUri = "https://api.bing.microsoft.com/v7.0/search?q"; /// /// Initializes a new instance of the class. /// /// The API key to authenticate the connector. + /// The URI of the Bing Search instance. Defaults to "https://api.bing.microsoft.com/v7.0/search?q". /// The to use for logging. If null, no logging will be performed. - public BingConnector(string apiKey, ILoggerFactory? loggerFactory = null) : - this(apiKey, HttpClientProvider.GetHttpClient(), loggerFactory) + public BingConnector(string apiKey, Uri? uri = null, ILoggerFactory? loggerFactory = null) : + this(apiKey, HttpClientProvider.GetHttpClient(), uri, loggerFactory) { } @@ -39,8 +40,9 @@ public sealed class BingConnector : IWebSearchEngineConnector /// /// The API key to authenticate the connector. /// The HTTP client to use for making requests. + /// The URI of the Bing Search instance. Defaults to "https://api.bing.microsoft.com/v7.0/search?q". /// The to use for logging. If null, no logging will be performed. - public BingConnector(string apiKey, HttpClient httpClient, ILoggerFactory? loggerFactory = null) + public BingConnector(string apiKey, HttpClient httpClient, Uri? uri = null, ILoggerFactory? loggerFactory = null) { Verify.NotNull(httpClient); @@ -49,22 +51,18 @@ public BingConnector(string apiKey, HttpClient httpClient, ILoggerFactory? logge this._httpClient = httpClient; this._httpClient.DefaultRequestHeaders.Add("User-Agent", HttpHeaderConstant.Values.UserAgent); this._httpClient.DefaultRequestHeaders.Add(HttpHeaderConstant.Names.SemanticKernelVersion, HttpHeaderConstant.Values.GetAssemblyVersion(typeof(BingConnector))); + this._uri = uri ?? new Uri(DefaultUri); } /// - public async Task> SearchAsync(string query, int count = 1, int offset = 0, CancellationToken cancellationToken = default) + public async Task> SearchAsync(string query, int count = 1, int offset = 0, CancellationToken cancellationToken = default) { if (count is <= 0 or >= 50) { throw new ArgumentOutOfRangeException(nameof(count), count, $"{nameof(count)} value must be greater than 0 and less than 50."); } - if (offset < 0) - { - throw new ArgumentOutOfRangeException(nameof(offset)); - } - - Uri uri = new($"https://api.bing.microsoft.com/v7.0/search?q={Uri.EscapeDataString(query)}&count={count}&offset={offset}"); + Uri uri = new($"{this._uri}={Uri.EscapeDataString(query.Trim())}&count={count}&offset={offset}"); this._logger.LogDebug("Sending request: {Uri}", uri); @@ -77,11 +75,33 @@ public async Task> SearchAsync(string query, int count = 1, // Sensitive data, logging as trace, disabled by default this._logger.LogTrace("Response content received: {Data}", json); - BingSearchResponse? data = JsonSerializer.Deserialize(json); + WebSearchResponse? data = JsonSerializer.Deserialize(json); - WebPage[]? results = data?.WebPages?.Value; - - return results == null ? Enumerable.Empty() : results.Select(x => x.Snippet); + List? returnValues = new(); + if (data?.WebPages?.Value != null) + { + if (typeof(T) == typeof(string)) + { + WebPage[]? results = data?.WebPages?.Value; + returnValues = results?.Select(x => x.Snippet).ToList() as List; + } + else if (typeof(T) == typeof(WebPage)) + { + List? webPages = new(); + + foreach (var webPage in data.WebPages.Value) + + { + webPages.Add(webPage); + } + returnValues = webPages.Take(count).ToList() as List; + } + else + { + throw new NotSupportedException($"Type {typeof(T)} is not supported."); + } + } + return returnValues != null && returnValues.Count == 0 ? returnValues : returnValues.Take(count); } /// @@ -101,34 +121,4 @@ private async Task SendGetRequestAsync(Uri uri, Cancellatio return await this._httpClient.SendWithSuccessCheckAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); } - - [SuppressMessage("Performance", "CA1812:Internal class that is apparently never instantiated", - Justification = "Class is instantiated through deserialization.")] - private sealed class BingSearchResponse - { - [JsonPropertyName("webPages")] - public WebPages? WebPages { get; set; } - } - - [SuppressMessage("Performance", "CA1812:Internal class that is apparently never instantiated", - Justification = "Class is instantiated through deserialization.")] - private sealed class WebPages - { - [JsonPropertyName("value")] - public WebPage[]? Value { get; set; } - } - - [SuppressMessage("Performance", "CA1812:Internal class that is apparently never instantiated", - Justification = "Class is instantiated through deserialization.")] - private sealed class WebPage - { - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("url")] - public string Url { get; set; } = string.Empty; - - [JsonPropertyName("snippet")] - public string Snippet { get; set; } = string.Empty; - } } diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleConnector.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleConnector.cs index 99fd86da7fbd..6cfcde2a4634 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleConnector.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleConnector.cs @@ -56,7 +56,7 @@ public sealed class GoogleConnector : IWebSearchEngineConnector, IDisposable } /// - public async Task> SearchAsync( + public async Task> SearchAsync( string query, int count, int offset, @@ -80,7 +80,34 @@ public sealed class GoogleConnector : IWebSearchEngineConnector, IDisposable var results = await search.ExecuteAsync(cancellationToken).ConfigureAwait(false); - return results.Items.Select(item => item.Snippet); + List? returnValues = new(); + if (results.Items != null) + { + if (typeof(T) == typeof(string)) + { + returnValues = results.Items.Select(item => item.Snippet).ToList() as List; + } + else if (typeof(T) == typeof(WebPage)) + { + List webPages = new(); + foreach (var item in results.Items) + { + WebPage webPage = new() + { + Name = item.Title, + Snippet = item.Snippet, + Url = item.Link + }; + webPages.Add(webPage); + } + returnValues = webPages.Take(count).ToList() as List; + } + else + { + throw new NotSupportedException($"Type {typeof(T)} is not supported."); + } + } + return returnValues != null && returnValues.Count == 0 ? returnValues : returnValues.Take(count); } /// diff --git a/dotnet/src/Plugins/Plugins.Web/IWebSearchEngineConnector.cs b/dotnet/src/Plugins/Plugins.Web/IWebSearchEngineConnector.cs index c027c30f4058..b08de28c0515 100644 --- a/dotnet/src/Plugins/Plugins.Web/IWebSearchEngineConnector.cs +++ b/dotnet/src/Plugins/Plugins.Web/IWebSearchEngineConnector.cs @@ -19,5 +19,5 @@ public interface IWebSearchEngineConnector /// Number of results to skip. /// The to monitor for cancellation requests. The default is . /// First snippet returned from search. - Task> SearchAsync(string query, int count = 1, int offset = 0, CancellationToken cancellationToken = default); + Task> SearchAsync(string query, int count = 1, int offset = 0, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Plugins/Plugins.Web/WebPage.cs b/dotnet/src/Plugins/Plugins.Web/WebPage.cs new file mode 100644 index 000000000000..3a227fc8a259 --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/WebPage.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.Web; + +/// +/// A sealed class containing the deserialized response from the respective Web Search API. +/// +/// A WebPage object containing the Web Search API response data. +[SuppressMessage("Performance", "CA1056:Change the type of parameter 'uri'...", +Justification = "A constant Uri cannot be defined, as required by this class")] +public sealed class WebPage +{ + /// + /// The name of the result. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + /// + /// The URL of the result. + /// + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + /// + /// The result snippet. + /// + [JsonPropertyName("snippet")] + public string Snippet { get; set; } = string.Empty; +} + +/// +/// A sealed class containing the deserialized response from the respective Web Search API. +/// +/// A WebPages? object containing the WebPages array from a Search API response data or null. +public sealed class WebSearchResponse +{ + /// + /// A nullable WebPages object containing the Web Search API response data. + /// + [JsonPropertyName("webPages")] + public WebPages? WebPages { get; set; } +} + +/// +/// A sealed class containing the deserialized response from the Web respective Search API. +/// +/// A WebPages array object containing the Web Search API response data. +[SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Required by the Web Search API")] +public sealed class WebPages +{ + /// + /// a nullable WebPage array object containing the Web Search API response data. + /// + [JsonPropertyName("value")] + public WebPage[]? Value { get; set; } +} diff --git a/dotnet/src/Plugins/Plugins.Web/WebSearchEnginePlugin.cs b/dotnet/src/Plugins/Plugins.Web/WebSearchEnginePlugin.cs index c9abab4b4f86..65c651d9ae84 100644 --- a/dotnet/src/Plugins/Plugins.Web/WebSearchEnginePlugin.cs +++ b/dotnet/src/Plugins/Plugins.Web/WebSearchEnginePlugin.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Text.Encodings.Web; @@ -63,14 +64,46 @@ public WebSearchEnginePlugin(IWebSearchEngineConnector connector) [Description("Number of results to skip")] int offset = 0, CancellationToken cancellationToken = default) { - var results = (await this._connector.SearchAsync(query, count, offset, cancellationToken).ConfigureAwait(false)).ToArray(); - if (results.Length == 0) + var results = await this._connector.SearchAsync(query, count, offset, cancellationToken).ConfigureAwait(false); + if (!results.Any()) { throw new InvalidOperationException("Failed to get a response from the web search engine."); } return count == 1 - ? results[0] ?? string.Empty + ? results.First() ?? string.Empty : JsonSerializer.Serialize(results, s_jsonOptionsCache); } + + /// + /// Performs a web search using the provided query, count, and offset. + /// + /// The text to search for. + /// The number of results to return. Default is 1. + /// The number of results to skip. Default is 0. + /// A cancellation token to observe while waiting for the task to complete. + /// The return value contains the search results as an IEnumerable WebPage object serialized as a string + [KernelFunction, Description("Perform a web search and return complete results.")] + public async Task GetSearchResultsAsync( + [Description("Text to search for")] string query, + [Description("Number of results")] int count = 1, + [Description("Number of results to skip")] int offset = 0, + CancellationToken cancellationToken = default) + { + IEnumerable? results = null; + try + { + results = await this._connector.SearchAsync(query, count, offset, cancellationToken).ConfigureAwait(false); + if (!results.Any()) + { + throw new InvalidOperationException("Failed to get a response from the web search engine."); + } + } + catch (InvalidOperationException ex) + { + Console.WriteLine(ex.Message); + } + + return JsonSerializer.Serialize(results); + } }