diff --git a/dotnet/samples/Concepts/Search/Google_TextSearch.cs b/dotnet/samples/Concepts/Search/Google_TextSearch.cs index a77f65bcfbc3..749405422faf 100644 --- a/dotnet/samples/Concepts/Search/Google_TextSearch.cs +++ b/dotnet/samples/Concepts/Search/Google_TextSearch.cs @@ -26,7 +26,7 @@ public async Task UsingGoogleTextSearchAsync() var query = "What is the Semantic Kernel?"; // Search and return results as string items - KernelSearchResults stringResults = await textSearch.SearchAsync(query, new() { Top = 4, Skip = 0 }); + KernelSearchResults stringResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 4, Skip = 0 }); Console.WriteLine("——— String Results ———\n"); await foreach (string result in stringResults.Results) { @@ -35,7 +35,7 @@ public async Task UsingGoogleTextSearchAsync() } // Search and return results as TextSearchResult items - KernelSearchResults textResults = await textSearch.GetTextSearchResultsAsync(query, new() { Top = 4, Skip = 4 }); + KernelSearchResults textResults = await textSearch.GetTextSearchResultsAsync(query, new TextSearchOptions { Top = 4, Skip = 4 }); Console.WriteLine("\n——— Text Search Results ———\n"); await foreach (TextSearchResult result in textResults.Results) { @@ -46,7 +46,7 @@ public async Task UsingGoogleTextSearchAsync() } // Search and return results as Google.Apis.CustomSearchAPI.v1.Data.Result items - KernelSearchResults fullResults = await textSearch.GetSearchResultsAsync(query, new() { Top = 4, Skip = 8 }); + KernelSearchResults fullResults = await textSearch.GetSearchResultsAsync(query, new TextSearchOptions { Top = 4, Skip = 8 }); Console.WriteLine("\n——— Google Web Page Results ———\n"); await foreach (Google.Apis.CustomSearchAPI.v1.Data.Result result in fullResults.Results) { @@ -74,7 +74,7 @@ public async Task UsingGoogleTextSearchWithACustomMapperAsync() var query = "What is the Semantic Kernel?"; // Search with TextSearchResult textResult type - KernelSearchResults stringResults = await textSearch.SearchAsync(query, new() { Top = 2, Skip = 0 }); + KernelSearchResults stringResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 2, Skip = 0 }); Console.WriteLine("--- Serialized JSON Results ---"); await foreach (string result in stringResults.Results) { @@ -107,6 +107,113 @@ public async Task UsingGoogleTextSearchWithASiteSearchFilterAsync() } } + /// + /// Show how to use enhanced LINQ filtering with GoogleTextSearch including Contains, NOT, FileType, and compound AND expressions. + /// + [Fact] + public async Task UsingGoogleTextSearchWithEnhancedLinqFilteringAsync() + { + // Create an ITextSearch instance using Google search + var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = TestConfiguration.Google.ApiKey, HttpClientFactory = new CustomHttpClientFactory(this.Output) }, + searchEngineId: TestConfiguration.Google.SearchEngineId); + + var query = "Semantic Kernel AI"; + + // Example 1: Simple equality filtering + Console.WriteLine("——— Example 1: Equality Filter (DisplayLink) ———\n"); + var equalityOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" + }; + var equalityResults = await textSearch.SearchAsync(query, equalityOptions); + await foreach (string result in equalityResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 2: Contains filtering + Console.WriteLine("\n——— Example 2: Contains Filter (Title) ———\n"); + var containsOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("AI") + }; + var containsResults = await textSearch.SearchAsync(query, containsOptions); + await foreach (string result in containsResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 3: NOT Contains filtering (exclusion) + Console.WriteLine("\n——— Example 3: NOT Contains Filter (Exclude 'deprecated') ———\n"); + var notContainsOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.Title != null && !page.Title.Contains("deprecated") + }; + var notContainsResults = await textSearch.SearchAsync(query, notContainsOptions); + await foreach (string result in notContainsResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 4: FileFormat filtering + Console.WriteLine("\n——— Example 4: FileFormat Filter (PDF files) ———\n"); + var fileFormatOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.FileFormat == "pdf" + }; + var fileFormatResults = await textSearch.SearchAsync(query, fileFormatOptions); + await foreach (string result in fileFormatResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 5: Compound AND filtering (multiple conditions) + Console.WriteLine("\n——— Example 5: Compound AND Filter (Title + Site) ———\n"); + var compoundOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("Semantic") && + page.DisplayLink != null && page.DisplayLink.Contains("microsoft") + }; + var compoundResults = await textSearch.SearchAsync(query, compoundOptions); + await foreach (string result in compoundResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 6: Complex compound filtering (equality + contains + exclusion) + Console.WriteLine("\n——— Example 6: Complex Compound Filter (FileFormat + Contains + NOT Contains) ———\n"); + var complexOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.FileFormat == "pdf" && + page.Title != null && page.Title.Contains("AI") && + page.Snippet != null && !page.Snippet.Contains("deprecated") + }; + var complexResults = await textSearch.SearchAsync(query, complexOptions); + await foreach (string result in complexResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + } + #region private private const int HorizontalRuleLength = 80; diff --git a/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs b/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs index fe33e7f7da10..1d4fe23a3eee 100644 --- a/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs +++ b/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs @@ -25,7 +25,7 @@ public async Task BingSearchAsync() var query = "What is the Semantic Kernel?"; // Search and return results - KernelSearchResults searchResults = await textSearch.SearchAsync(query, new() { Top = 4 }); + KernelSearchResults searchResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 4 }); await foreach (string result in searchResults.Results) { Console.WriteLine(result); @@ -46,7 +46,7 @@ public async Task GoogleSearchAsync() var query = "What is the Semantic Kernel?"; // Search and return results - KernelSearchResults searchResults = await textSearch.SearchAsync(query, new() { Top = 4 }); + KernelSearchResults searchResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 4 }); await foreach (string result in searchResults.Results) { Console.WriteLine(result); diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs index 38a497eac9d1..5eeb12c61c43 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs @@ -55,7 +55,7 @@ public async Task SearchReturnsSuccessfullyAsync() searchEngineId: "SearchEngineId"); // Act - KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); // Assert Assert.NotNull(result); @@ -81,7 +81,7 @@ public async Task GetTextSearchResultsReturnsSuccessfullyAsync() searchEngineId: "SearchEngineId"); // Act - KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); // Assert Assert.NotNull(result); @@ -109,7 +109,7 @@ public async Task GetSearchResultsReturnsSuccessfullyAsync() searchEngineId: "SearchEngineId"); // Act - KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); // Assert Assert.NotNull(results); @@ -140,7 +140,7 @@ public async Task SearchWithCustomStringMapperReturnsSuccessfullyAsync() options: new() { StringMapper = new TestTextSearchStringMapper() }); // Act - KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); // Assert Assert.NotNull(result); @@ -169,7 +169,7 @@ public async Task GetTextSearchResultsWithCustomResultMapperReturnsSuccessfullyA options: new() { ResultMapper = new TestTextSearchResultMapper() }); // Act - KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); // Assert Assert.NotNull(result); @@ -234,9 +234,505 @@ public async Task DoesNotBuildsUriForInvalidQueryParameterAsync() // Act && Assert var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions)); - Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of cr,dateRestrict,exactTerms,excludeTerms,filter,gl,hl,linkSite,lr,orTerms,rights,siteSearch (Parameter 'searchOptions')", e.Message); + Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of cr,dateRestrict,exactTerms,excludeTerms,fileType,filter,gl,hl,linkSite,lr,orTerms,rights,siteSearch (Parameter 'searchOptions')", e.Message); } + [Fact] + public async Task GenericSearchAsyncReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Google search + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with GoogleWebPage + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new TextSearchOptions { 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 stringResult in resultList) + { + Assert.NotEmpty(stringResult); + } + } + + [Fact] + public async Task GenericGetTextSearchResultsReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Google search + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with GoogleWebPage + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, 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 GenericGetSearchResultsReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Google search + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with GoogleWebPage + KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(results); + Assert.NotNull(results.Results); + var resultList = await results.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + foreach (GoogleWebPage result in resultList) + { + Assert.NotNull(result.Title); + Assert.NotNull(result.Snippet); + Assert.NotNull(result.Link); + Assert.NotNull(result.DisplayLink); + } + } + + [Fact] + public async Task GenericSearchWithContainsFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with Contains filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("Semantic") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithEqualityFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with equality filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithNotEqualFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with NOT EQUAL filtering (excludes terms) + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != "Deprecated" + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithNotContainsFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with NOT Contains filtering (excludes terms) + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != null && !page.Title.Contains("deprecated") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithFileFormatFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with FileFormat filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithCompoundAndFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with compound AND filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("Semantic") && page.DisplayLink != null && page.DisplayLink.Contains("microsoft") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithComplexCompoundFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with complex compound filtering (equality + contains + exclusion) + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" && page.Title != null && page.Title.Contains("AI") && page.Snippet != null && !page.Snippet.Contains("deprecated") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + #region LINQ Filter Verification Tests + // These tests verify that LINQ expressions produce correct Google API URL parameters + // Addressing reviewer feedback: "Some tests to verify the filter url that is created from the different linq expressions would be good" + + [Fact] + public async Task LinqEqualityFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ equality filter for DisplayLink + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" + }); + + // Assert - Verify URL contains correct siteSearch parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("siteSearch=microsoft.com", absoluteUri); + Assert.Contains("siteSearchFilter=i", absoluteUri); + } + + [Fact] + public async Task LinqFileFormatEqualityFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ equality filter for FileFormat + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" + }); + + // Assert - Verify URL contains correct fileType parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("fileType=pdf", absoluteUri); + } + + [Fact] + public async Task LinqContainsFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ Contains filter for Title + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("Semantic") + }); + + // Assert - Verify URL contains correct orTerms parameter (Contains uses orTerms for flexibility) + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("orTerms=Semantic", absoluteUri); + } + + [Fact] + public async Task LinqNotEqualFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ NOT Equal filter for Title + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != "deprecated" + }); + + // Assert - Verify URL contains correct excludeTerms parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("excludeTerms=deprecated", absoluteUri); + } + + [Fact] + public async Task LinqNotContainsFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ NOT Contains filter for Snippet + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Snippet != null && !page.Snippet.Contains("outdated") + }); + + // Assert - Verify URL contains correct excludeTerms parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("excludeTerms=outdated", absoluteUri); + } + + [Fact] + public async Task LinqCompoundAndFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ compound AND filter + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" && page.FileFormat == "pdf" + }); + + // Assert - Verify URL contains both parameters + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("siteSearch=microsoft.com", absoluteUri); + Assert.Contains("siteSearchFilter=i", absoluteUri); + Assert.Contains("fileType=pdf", absoluteUri); + } + + [Fact] + public async Task LinqComplexCompoundFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ complex compound filter (equality + contains + exclusion) + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" && + page.Title != null && page.Title.Contains("AI") && + page.Snippet != null && !page.Snippet.Contains("deprecated") + }); + + // Assert - Verify URL contains all expected parameters + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("fileType=pdf", absoluteUri); + Assert.Contains("orTerms=AI", absoluteUri); // Contains uses orTerms for flexibility + Assert.Contains("excludeTerms=deprecated", absoluteUri); + } + + [Fact] + public async Task CollectionContainsFilterThrowsNotSupportedExceptionAsync() + { + // Arrange + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act & Assert - Collection Contains (both Enumerable.Contains and MemoryExtensions.Contains) + // This same code resolves differently based on C# language version: + // - C# 13 and earlier: Enumerable.Contains (LINQ extension method) + // - C# 14 and later: MemoryExtensions.Contains (span-based optimization) + // Our implementation handles both identically - both throw NotSupportedException + string[] sites = ["microsoft.com", "github.com"]; + var exception = await Assert.ThrowsAsync(async () => + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => sites.Contains(page.DisplayLink!) + })); + + // Verify exception message is clear and actionable + Assert.Contains("Collection Contains filters", exception.Message); + Assert.Contains("not supported by Google Custom Search API", exception.Message); + Assert.Contains("OR logic", exception.Message); + } + + #endregion + /// public void Dispose() { diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs index 38b2a705ed42..c450e4f0d4e5 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -18,7 +20,7 @@ namespace Microsoft.SemanticKernel.Plugins.Web.Google; /// A Google Text Search implementation that can be used to perform searches using the Google Web Search API. /// #pragma warning disable CS0618 // ITextSearch is obsolete - this class provides backward compatibility -public sealed class GoogleTextSearch : ITextSearch, IDisposable +public sealed class GoogleTextSearch : ITextSearch, ITextSearch, IDisposable #pragma warning restore CS0618 { /// @@ -89,15 +91,343 @@ public async Task> SearchAsync(string query, TextSea return new KernelSearchResults(this.GetResultsAsStringAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } + #region ITextSearch Implementation + /// - public void Dispose() + public async Task> GetSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) { - this._search.Dispose(); + var legacyOptions = ConvertToLegacyOptions(searchOptions); + var searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = searchOptions?.IncludeTotalCount == true ? long.Parse(searchResponse.SearchInformation.TotalResults) : null; + + return new KernelSearchResults(this.GetResultsAsGoogleWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } - #region private + /// + public async Task> GetTextSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) + { + var legacyOptions = ConvertToLegacyOptions(searchOptions); + var searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); - private const int MaxCount = 10; + long? totalCount = searchOptions?.IncludeTotalCount == true ? long.Parse(searchResponse.SearchInformation.TotalResults) : null; + + return new KernelSearchResults(this.GetResultsAsTextSearchResultAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + public async Task> SearchAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) + { + var legacyOptions = ConvertToLegacyOptions(searchOptions); + var searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = searchOptions?.IncludeTotalCount == true ? long.Parse(searchResponse.SearchInformation.TotalResults) : null; + + return new KernelSearchResults(this.GetResultsAsStringAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions. + /// Attempts to translate simple LINQ expressions to Google API filters where possible. + /// + /// The generic search options with LINQ filtering. + /// Legacy TextSearchOptions with equivalent filtering. + private static TextSearchOptions ConvertToLegacyOptions(TextSearchOptions? genericOptions) + { + if (genericOptions == null) + { + return new TextSearchOptions(); + } + + return new TextSearchOptions + { + Top = genericOptions.Top, + Skip = genericOptions.Skip, + IncludeTotalCount = genericOptions.IncludeTotalCount, + Filter = genericOptions.Filter != null ? ConvertLinqExpressionToGoogleFilter(genericOptions.Filter) : null + }; + } + + /// + /// Converts a LINQ expression to a TextSearchFilter compatible with Google Custom Search API. + /// Supports property equality expressions, string Contains operations, NOT operations (inequality and negation), + /// and compound AND expressions that map to Google's filter capabilities. + /// + /// The LINQ expression to convert. + /// A TextSearchFilter with equivalent filtering. + /// Thrown when the expression cannot be converted to Google filters. + private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Expression> linqExpression) + { + // Handle compound AND expressions: expr1 && expr2 + if (linqExpression.Body is BinaryExpression andExpr && andExpr.NodeType == ExpressionType.AndAlso) + { + var filter = new TextSearchFilter(); + CollectAndCombineFilters(andExpr, filter); + return filter; + } + + // Handle simple expressions using the shared processing logic + var textSearchFilter = new TextSearchFilter(); + if (TryProcessSingleExpression(linqExpression.Body, textSearchFilter)) + { + return textSearchFilter; + } + + // Generate helpful error message with supported patterns + var supportedProperties = s_queryParameters.Select(p => + MapGoogleFilterToProperty(p)).Where(p => p != null).Distinct(); + + throw new NotSupportedException( + $"LINQ expression '{linqExpression}' cannot be converted to Google API filters. " + + $"Supported patterns: {string.Join(", ", s_supportedPatterns)}. " + + $"Supported properties: {string.Join(", ", supportedProperties)}."); + } + + /// + /// Recursively collects and combines filters from compound AND expressions. + /// + /// The expression to process. + /// The filter to accumulate results into. + private static void CollectAndCombineFilters(Expression expression, TextSearchFilter filter) + { + if (expression is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.AndAlso) + { + // Recursively process both sides of the AND + CollectAndCombineFilters(binaryExpr.Left, filter); + CollectAndCombineFilters(binaryExpr.Right, filter); + } + else + { + // Process individual expression using shared logic + TryProcessSingleExpression(expression, filter); + } + } + + /// + /// Shared logic to process a single LINQ expression and add appropriate filters. + /// Consolidates duplicate code between ConvertLinqExpressionToGoogleFilter and CollectAndCombineFilters. + /// + /// The expression to process. + /// The filter to add results to. + /// True if the expression was successfully processed, false otherwise. + private static bool TryProcessSingleExpression(Expression expression, TextSearchFilter filter) + { + // Handle equality: record.PropertyName == "value" + if (expression is BinaryExpression equalExpr && equalExpr.NodeType == ExpressionType.Equal) + { + return TryProcessEqualityExpression(equalExpr, filter); + } + + // Handle inequality (NOT): record.PropertyName != "value" + if (expression is BinaryExpression notEqualExpr && notEqualExpr.NodeType == ExpressionType.NotEqual) + { + return TryProcessInequalityExpression(notEqualExpr, filter); + } + + // Handle Contains method calls + if (expression is MethodCallExpression methodCall && methodCall.Method.Name == "Contains") + { + // String.Contains (instance method) - supported for substring search + if (methodCall.Method.DeclaringType == typeof(string)) + { + return TryProcessContainsExpression(methodCall, filter); + } + + // Collection Contains (static methods) - NOT supported due to Google API limitations + // This handles both Enumerable.Contains (C# 13-) and MemoryExtensions.Contains (C# 14+) + // User's C# language version determines which method is resolved, but both are unsupported + if (methodCall.Object == null) // Static method + { + // Enumerable.Contains or MemoryExtensions.Contains + if (methodCall.Method.DeclaringType == typeof(Enumerable) || + (methodCall.Method.DeclaringType == typeof(MemoryExtensions) && IsMemoryExtensionsContains(methodCall))) + { + throw new NotSupportedException( + "Collection Contains filters (e.g., array.Contains(page.Property)) are not supported by Google Custom Search API. " + + "Google's search operators do not support OR logic across multiple values. " + + "Consider either: (1) performing multiple separate searches for each value, or " + + "(2) retrieving broader results and filtering on the client side."); + } + } + } + + // Handle NOT expressions: !record.PropertyName.Contains("value") + if (expression is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Not) + { + return TryProcessNotExpression(unaryExpr, filter); + } + + return false; + } + + /// + /// Checks if a method call expression is MemoryExtensions.Contains. + /// This handles C# 14's "first-class spans" feature where collection.Contains(item) resolves to + /// MemoryExtensions.Contains instead of Enumerable.Contains. + /// + private static bool IsMemoryExtensionsContains(MethodCallExpression methodExpr) + { + // MemoryExtensions.Contains has 2-3 parameters (source, value, optional comparer) + // We only support the case without a comparer (or with null comparer) + return methodExpr.Method.Name == nameof(MemoryExtensions.Contains) && + methodExpr.Arguments.Count >= 2 && + methodExpr.Arguments.Count <= 3 && + (methodExpr.Arguments.Count == 2 || + (methodExpr.Arguments.Count == 3 && methodExpr.Arguments[2] is ConstantExpression { Value: null })); + } + + /// + /// Processes equality expressions: record.PropertyName == "value" + /// + private static bool TryProcessEqualityExpression(BinaryExpression equalExpr, TextSearchFilter filter) + { + if (equalExpr.Left is MemberExpression memberExpr && equalExpr.Right is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) + { + filter.Equality(googleFilterName, value); + return true; + } + } + return false; + } + + /// + /// Processes inequality expressions: record.PropertyName != "value" + /// + private static bool TryProcessInequalityExpression(BinaryExpression notEqualExpr, TextSearchFilter filter) + { + if (notEqualExpr.Left is MemberExpression memberExpr && notEqualExpr.Right is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + // Map to excludeTerms for text fields + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) + { + filter.Equality("excludeTerms", value); + return true; + } + } + return false; + } + + /// + /// Processes Contains expressions: record.PropertyName.Contains("value") + /// + private static bool TryProcessContainsExpression(MethodCallExpression methodCall, TextSearchFilter filter) + { + if (methodCall.Object is MemberExpression memberExpr && + methodCall.Arguments.Count == 1 && + methodCall.Arguments[0] is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) + { + // For Contains operations on text fields, use exactTerms or orTerms + if (googleFilterName == "exactTerms") + { + filter.Equality("orTerms", value); // More flexible than exactTerms + } + else + { + filter.Equality(googleFilterName, value); + } + return true; + } + } + return false; + } + + /// + /// Processes NOT expressions: !record.PropertyName.Contains("value") + /// + private static bool TryProcessNotExpression(UnaryExpression unaryExpr, TextSearchFilter filter) + { + if (unaryExpr.Operand is MethodCallExpression notMethodCall && + notMethodCall.Method.Name == "Contains" && + notMethodCall.Method.DeclaringType == typeof(string)) + { + if (notMethodCall.Object is MemberExpression memberExpr && + notMethodCall.Arguments.Count == 1 && + notMethodCall.Arguments[0] is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) + { + filter.Equality("excludeTerms", value); + return true; + } + } + } + return false; + } + + /// + /// Maps GoogleWebPage property names to Google Custom Search API filter field names. + /// + /// The GoogleWebPage property name. + /// The corresponding Google API filter name, or null if not mappable. + private static string? MapPropertyToGoogleFilter(string propertyName) + { + return propertyName.ToUpperInvariant() switch + { + // Map GoogleWebPage properties to Google API equivalents + "LINK" => "siteSearch", // Maps to site search + "DISPLAYLINK" => "siteSearch", // Maps to site search + "TITLE" => "exactTerms", // Exact title match + "SNIPPET" => "exactTerms", // Exact content match + + // Direct API parameters mapped from GoogleWebPage metadata properties + "FILEFORMAT" => "fileType", // File type/extension filtering + "MIME" => "filter", // MIME type filtering + + // Locale/Language parameters (if we extend GoogleWebPage) + "HL" => "hl", // Interface language + "GL" => "gl", // Geolocation + "CR" => "cr", // Country restrict + "LR" => "lr", // Language restrict + + _ => null // Property not mappable to Google filters + }; + } + + /// + /// Maps Google Custom Search API filter field names back to example GoogleWebPage property names. + /// Used for generating helpful error messages. + /// + /// The Google API filter name. + /// An example property name, or null if not mappable. + private static string? MapGoogleFilterToProperty(string googleFilterName) + { + return googleFilterName switch + { + "siteSearch" => "DisplayLink", + "exactTerms" => "Title", + "orTerms" => "Title", + "excludeTerms" => "Title", + "fileType" => "FileFormat", + "filter" => "Mime", + "hl" => "HL", + "gl" => "GL", + "cr" => "CR", + "lr" => "LR", + _ => null + }; + } + + #endregion + + /// + public void Dispose() + { + this._search.Dispose(); + } private readonly ILogger _logger; private readonly CustomSearchAPIService _search; @@ -108,8 +438,19 @@ public void Dispose() private static readonly ITextSearchStringMapper s_defaultStringMapper = new DefaultTextSearchStringMapper(); private static readonly ITextSearchResultMapper s_defaultResultMapper = new DefaultTextSearchResultMapper(); + private const int MaxCount = 10; + // See https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list - private static readonly string[] s_queryParameters = ["cr", "dateRestrict", "exactTerms", "excludeTerms", "filter", "gl", "hl", "linkSite", "lr", "orTerms", "rights", "siteSearch"]; + private static readonly string[] s_queryParameters = ["cr", "dateRestrict", "exactTerms", "excludeTerms", "fileType", "filter", "gl", "hl", "linkSite", "lr", "orTerms", "rights", "siteSearch"]; + + // Performance optimization: Static error message arrays to avoid allocations in error paths + private static readonly string[] s_supportedPatterns = [ + "page.Property == \"value\" (exact match)", + "page.Property != \"value\" (exclude)", + "page.Property.Contains(\"text\") (partial match)", + "!page.Property.Contains(\"text\") (exclude partial)", + "page.Prop1 == \"val1\" && page.Prop2.Contains(\"val2\") (compound AND)" + ]; private delegate void SetSearchProperty(CseResource.ListRequest search, string value); @@ -118,6 +459,7 @@ public void Dispose() { "DATERESTRICT", (search, value) => search.DateRestrict = value }, { "EXACTTERMS", (search, value) => search.ExactTerms = value }, { "EXCLUDETERMS", (search, value) => search.ExcludeTerms = value }, + { "FILETYPE", (search, value) => search.FileType = value }, { "FILTER", (search, value) => search.Filter = value }, { "GL", (search, value) => search.Gl = value }, { "HL", (search, value) => search.Hl = value }, @@ -141,7 +483,7 @@ public void Dispose() var count = searchOptions.Top; var offset = searchOptions.Skip; - if (count is <= 0 or > MaxCount) + if (count <= 0 || count > MaxCount) { throw new ArgumentOutOfRangeException(nameof(searchOptions), count, $"{nameof(searchOptions)}.Count value must be must be greater than 0 and less than or equals 10."); } @@ -235,6 +577,25 @@ private async IAsyncEnumerable GetResultsAsStringAsync(global::Google.Ap } } + /// + /// Return the search results as instances of . + /// + /// Google search response + /// Cancellation token + private async IAsyncEnumerable GetResultsAsGoogleWebPageAsync(global::Google.Apis.CustomSearchAPI.v1.Data.Search searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (searchResponse is null || searchResponse.Items is null) + { + yield break; + } + + foreach (var item in searchResponse.Items) + { + yield return ConvertToGoogleWebPage(item); + await Task.Yield(); + } + } + /// /// Return the search results as instances of . /// @@ -266,6 +627,29 @@ private async IAsyncEnumerable GetResultsAsStringAsync(global::Google.Ap }; } + /// + /// Converts a Google CustomSearchAPI Result to a GoogleWebPage instance. + /// + /// The Google search result to convert. + /// A GoogleWebPage with mapped properties. + private static GoogleWebPage ConvertToGoogleWebPage(global::Google.Apis.CustomSearchAPI.v1.Data.Result googleResult) + { + return new GoogleWebPage + { + Title = googleResult.Title, + Link = googleResult.Link, + Snippet = googleResult.Snippet, + DisplayLink = googleResult.DisplayLink, + FormattedUrl = googleResult.FormattedUrl, + HtmlFormattedUrl = googleResult.HtmlFormattedUrl, + HtmlSnippet = googleResult.HtmlSnippet, + HtmlTitle = googleResult.HtmlTitle, + Mime = googleResult.Mime, + FileFormat = googleResult.FileFormat, + Labels = googleResult.Labels?.Select(l => l.Name).ToArray() + }; + } + /// /// Default implementation which maps from a to a /// @@ -299,5 +683,4 @@ public TextSearchResult MapFromResultToTextSearchResult(object result) return new TextSearchResult(googleResult.Snippet) { Name = googleResult.Title, Link = googleResult.Link }; } } - #endregion } diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs new file mode 100644 index 000000000000..8eab2153d27b --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.Web.Google; + +/// +/// Defines a webpage result from Google Custom Search API. +/// +public sealed class GoogleWebPage +{ + /// + /// Only allow creation within this package. + /// + [JsonConstructorAttribute] + internal GoogleWebPage() + { + } + + /// + /// Gets or sets the title of the webpage. + /// + /// + /// Use this title along with Link to create a hyperlink that when clicked takes the user to the webpage. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the URL to the webpage. + /// + /// + /// Use this URL along with Title to create a hyperlink that when clicked takes the user to the webpage. + /// + [JsonPropertyName("link")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? Link { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// Gets or sets a snippet of text from the webpage that describes its contents. + /// + [JsonPropertyName("snippet")] + public string? Snippet { get; set; } + + /// + /// Gets or sets the formatted URL display string. + /// + /// + /// The URL is meant for display purposes only and may not be well formed. + /// + [JsonPropertyName("displayLink")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? DisplayLink { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// Gets or sets the MIME type of the result. + /// + [JsonPropertyName("mime")] + public string? Mime { get; set; } + + /// + /// Gets or sets the file format of the result. + /// + [JsonPropertyName("fileFormat")] + public string? FileFormat { get; set; } + + /// + /// Gets or sets the HTML title of the webpage. + /// + [JsonPropertyName("htmlTitle")] + public string? HtmlTitle { get; set; } + + /// + /// Gets or sets the HTML snippet of the webpage. + /// + [JsonPropertyName("htmlSnippet")] + public string? HtmlSnippet { get; set; } + + /// + /// Gets or sets the formatted URL of the webpage. + /// + [JsonPropertyName("formattedUrl")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? FormattedUrl { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// Gets or sets the HTML-formatted URL of the webpage. + /// + [JsonPropertyName("htmlFormattedUrl")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? HtmlFormattedUrl { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// Gets or sets labels associated with the webpage. + /// + [JsonPropertyName("labels")] + public IReadOnlyList? Labels { get; set; } +} diff --git a/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs b/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs index 57da1a9ec677..e955af86bc6c 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs @@ -36,12 +36,12 @@ Task> GetTextSearchResultsAsync( CancellationToken cancellationToken = default); /// - /// Perform a search for content related to the specified query and return values representing the search results. + /// Perform a search for content related to the specified query and return strongly-typed values representing the search results. /// /// What to search for. /// Options used when executing a text search. /// The to monitor for cancellation requests. The default is . - Task> GetSearchResultsAsync( + Task> GetSearchResultsAsync( string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default); diff --git a/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs b/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs index 121ff9b6c7bb..f1b18483c43a 100644 --- a/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs +++ b/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs @@ -213,11 +213,11 @@ Task> ITextSearch.GetTextSearchRe } /// - Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { var searchResponse = this.ExecuteVectorSearchAsync(query, searchOptions, cancellationToken); - return Task.FromResult(new KernelSearchResults(this.GetResultsAsRecordAsync(searchResponse, cancellationToken))); + return Task.FromResult(new KernelSearchResults(this.GetResultsAsTRecordAsync(searchResponse, cancellationToken))); } #region private @@ -367,6 +367,28 @@ private async IAsyncEnumerable GetResultsAsRecordAsync(IAsyncEnumerable< } } + /// + /// Return the search results as strongly-typed instances. + /// + /// Response containing the records matching the query. + /// Cancellation token + private async IAsyncEnumerable GetResultsAsTRecordAsync(IAsyncEnumerable>? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (searchResponse is null) + { + yield break; + } + + await foreach (var result in searchResponse.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (result.Record is not null) + { + yield return result.Record; + await Task.Yield(); + } + } + } + /// /// Return the search results as instances of . /// diff --git a/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs b/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs index 8dd095710c06..75f4b090590e 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs @@ -78,12 +78,14 @@ public async Task CanGetSearchResultAsync() { // Arrange. var sut = await CreateVectorStoreTextSearchAsync(); + ITextSearch typeSafeInterface = sut; // Act. - KernelSearchResults searchResults = await sut.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 2, Skip = 0 }); + KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 2, Skip = 0 }); var results = await searchResults.Results.ToListAsync(); Assert.Equal(2, results.Count); + Assert.All(results, result => Assert.IsType(result)); } [Fact] @@ -117,12 +119,14 @@ public async Task CanGetSearchResultsWithEmbeddingGeneratorAsync() { // Arrange. var sut = await CreateVectorStoreTextSearchWithEmbeddingGeneratorAsync(); + ITextSearch typeSafeInterface = sut; // Act. - KernelSearchResults searchResults = await sut.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 2, Skip = 0 }); + KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 2, Skip = 0 }); var results = await searchResults.Results.ToListAsync(); Assert.Equal(2, results.Count); + Assert.All(results, result => Assert.IsType(result)); } #pragma warning disable CS0618 // VectorStoreTextSearch with ITextEmbeddingGenerationService is obsolete @@ -270,17 +274,16 @@ public async Task LinqGetSearchResultsAsync() Filter = r => r.Tag == "Even" }; - KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync( + KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync( "What is the Semantic Kernel?", searchOptions); var results = await searchResults.Results.ToListAsync(); - // Assert - Results should be DataModel objects with Tag == "Even" + // Assert - Results should be strongly-typed DataModel objects with Tag == "Even" Assert.NotEmpty(results); Assert.All(results, result => { - var dataModel = Assert.IsType(result); - Assert.Equal("Even", dataModel.Tag); + Assert.Equal("Even", result.Tag); // Direct property access - no cast needed! }); }