From 16ec4db9cdb30b54f3ac339fa80266629e226135 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Fri, 31 Oct 2025 01:19:14 -0700 Subject: [PATCH 01/14] Change ITextSearch.GetSearchResultsAsync to return KernelSearchResults - Change interface return type from KernelSearchResults to KernelSearchResults - Update VectorStoreTextSearch implementation with new GetResultsAsTRecordAsync helper - Keep GetResultsAsRecordAsync for legacy ITextSearch interface backward compatibility - Update 3 unit tests to use strongly-typed DataModel instead of object Benefits: - Improved type safety - no more casting required - Better IntelliSense and developer experience - Zero breaking changes to legacy ITextSearch interface - All 19 unit tests pass This is Part 2.1 of the Issue #10456 multi-PR chain, refining the API . --- .../Data/TextSearch/ITextSearch.cs | 4 +-- .../Data/TextSearch/VectorStoreTextSearch.cs | 26 +++++++++++++++++-- .../Data/VectorStoreTextSearchTests.cs | 15 ++++++----- 3 files changed, 35 insertions(+), 10 deletions(-) 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! }); } From 750e82b65a7e898c5bf5ec217cbab5e7d38d053e Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Sat, 27 Sep 2025 23:42:47 -0700 Subject: [PATCH 02/14] t rebase --continue [Ifeat: Modernize TavilyTextSearch and BraveTextSearch with ITextSearch interface - Add TavilyWebPage and BraveWebPage model classes for type-safe LINQ filtering - Implement dual interface support (ITextSearch + ITextSearch) in both connectors - Add LINQ-to-API conversion logic for Tavily and Brave search parameters - Maintain 100% backward compatibility with existing ITextSearch usage - Enable compile-time type safety and IntelliSense support for web search filters Addresses microsoft#10456 --- .../Plugins.Web/Brave/BraveTextSearch.cs | 282 ++++++++++++++++- .../Plugins/Plugins.Web/Brave/BraveWebPage.cs | 144 +++++++++ .../Plugins.Web/Tavily/TavilyTextSearch.cs | 293 +++++++++++++++++- .../Plugins.Web/Tavily/TavilyWebPage.cs | 99 ++++++ 4 files changed, 814 insertions(+), 4 deletions(-) create mode 100644 dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs create mode 100644 dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs index af54b42f704c..8e0d6772f57f 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Net.Http; using System.Runtime.CompilerServices; using System.Text; @@ -21,7 +22,7 @@ namespace Microsoft.SemanticKernel.Plugins.Web.Brave; /// A Brave Text Search implementation that can be used to perform searches using the Brave Web Search API. /// #pragma warning disable CS0618 // ITextSearch is obsolete - this class provides backward compatibility -public sealed class BraveTextSearch : ITextSearch +public sealed class BraveTextSearch : ITextSearch, ITextSearch #pragma warning restore CS0618 { /// @@ -80,7 +81,265 @@ public async Task> GetSearchResultsAsync(string quer return new KernelSearchResults(this.GetResultsAsWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } - #region private + #region Generic ITextSearch Implementation + + /// + async Task> ITextSearch.SearchAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + { + var legacyOptions = this.ConvertToLegacyOptions(searchOptions); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; + + return new KernelSearchResults(this.GetResultsAsStringAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + async Task> ITextSearch.GetTextSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + { + var legacyOptions = this.ConvertToLegacyOptions(searchOptions); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; + + return new KernelSearchResults(this.GetResultsAsTextSearchResultAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + { + var legacyOptions = this.ConvertToLegacyOptions(searchOptions); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; + + return new KernelSearchResults(this.GetResultsAsBraveWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + #endregion + + #region LINQ-to-Brave Conversion Logic + + /// + /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions. + /// + /// The generic search options with LINQ filter. + /// Legacy TextSearchOptions with converted filters. + private TextSearchOptions ConvertToLegacyOptions(TextSearchOptions? options) + { + if (options == null) + { + return new TextSearchOptions(); + } + + var legacyOptions = new TextSearchOptions + { + Top = options.Top, + Skip = options.Skip, + IncludeTotalCount = options.IncludeTotalCount + }; + + // Convert LINQ expression to TextSearchFilter if present + if (options.Filter != null) + { + try + { + var convertedFilter = ConvertLinqExpressionToBraveFilter(options.Filter); + legacyOptions = new TextSearchOptions + { + Top = options.Top, + Skip = options.Skip, + IncludeTotalCount = options.IncludeTotalCount, + Filter = convertedFilter + }; + } + catch (NotSupportedException ex) + { + this._logger.LogWarning("LINQ expression not fully supported by Brave API, performing search without some filters: {Message}", ex.Message); + // Continue with basic search - graceful degradation + } + } + + return legacyOptions; + } + + /// + /// Converts a LINQ expression to Brave-compatible TextSearchFilter. + /// + /// The LINQ expression to convert. + /// A TextSearchFilter with Brave-compatible filter clauses. + private static TextSearchFilter ConvertLinqExpressionToBraveFilter(Expression> linqExpression) + { + var filter = new TextSearchFilter(); + var filterClauses = new List(); + + // Analyze the LINQ expression and convert to filter clauses + AnalyzeExpression(linqExpression.Body, filterClauses); + + // Validate and add clauses that are supported by Brave + foreach (var clause in filterClauses) + { + if (clause is EqualToFilterClause equalityClause) + { + var mappedFieldName = MapPropertyToBraveFilter(equalityClause.FieldName); + if (mappedFieldName != null) + { + filter.Equality(mappedFieldName, equalityClause.Value); + } + else + { + throw new NotSupportedException($"Property '{equalityClause.FieldName}' cannot be mapped to Brave API filters. Supported properties: {string.Join(", ", s_queryParameters)}"); + } + } + } + + return filter; + } + + /// + /// Maps BraveWebPage property names to Brave API filter parameter names. + /// + /// The property name from BraveWebPage. + /// The corresponding Brave API parameter name, or null if not mappable. + private static string? MapPropertyToBraveFilter(string propertyName) => + propertyName.ToUpperInvariant() switch + { + "COUNTRY" => "country", + "SEARCHLANG" => "search_lang", + "UILANG" => "ui_lang", + "SAFESEARCH" => "safesearch", + "TEXTDECORATIONS" => "text_decorations", + "SPELLCHECK" => "spellcheck", + "RESULTFILTER" => "result_filter", + "UNITS" => "units", + "EXTRASNIPPETS" => "extra_snippets", + _ => null // Property not mappable to Brave filters + }; + + /// + /// Analyzes a LINQ expression and extracts filter clauses. + /// + /// The expression to analyze. + /// The list to add extracted filter clauses to. + private static void AnalyzeExpression(Expression expression, List filterClauses) + { + switch (expression) + { + case BinaryExpression binaryExpr: + if (binaryExpr.NodeType == ExpressionType.AndAlso) + { + // Handle AND expressions by recursively analyzing both sides + AnalyzeExpression(binaryExpr.Left, filterClauses); + AnalyzeExpression(binaryExpr.Right, filterClauses); + } + else if (binaryExpr.NodeType == ExpressionType.Equal) + { + // Handle equality expressions + ExtractEqualityClause(binaryExpr, filterClauses); + } + else + { + throw new NotSupportedException($"Binary expression type '{binaryExpr.NodeType}' is not supported. Only AndAlso and Equal are supported."); + } + break; + + case MethodCallExpression methodCall: + // Handle method calls like Contains, StartsWith, etc. + ExtractMethodCallClause(methodCall, filterClauses); + break; + + default: + throw new NotSupportedException($"Expression type '{expression.NodeType}' is not supported in Brave search filters."); + } + } + + /// + /// Extracts an equality filter clause from a binary equality expression. + /// + /// The binary equality expression. + /// The list to add the extracted clause to. + private static void ExtractEqualityClause(BinaryExpression binaryExpr, List filterClauses) + { + string? propertyName = null; + object? value = null; + + // Determine which side is the property and which is the value + if (binaryExpr.Left is MemberExpression leftMember) + { + propertyName = leftMember.Member.Name; + value = ExtractValue(binaryExpr.Right); + } + else if (binaryExpr.Right is MemberExpression rightMember) + { + propertyName = rightMember.Member.Name; + value = ExtractValue(binaryExpr.Left); + } + + if (propertyName != null && value != null) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + else + { + throw new NotSupportedException("Unable to extract property name and value from equality expression."); + } + } + + /// + /// Extracts a filter clause from a method call expression (e.g., Contains, StartsWith). + /// + /// The method call expression. + /// The list to add the extracted clause to. + private static void ExtractMethodCallClause(MethodCallExpression methodCall, List filterClauses) + { + if (methodCall.Method.Name == "Contains" && methodCall.Object is MemberExpression member) + { + var propertyName = member.Member.Name; + var value = ExtractValue(methodCall.Arguments[0]); + + if (value != null) + { + // For Contains, we'll map it to equality for certain properties + if (propertyName.Equals("ResultFilter", StringComparison.OrdinalIgnoreCase)) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + else + { + throw new NotSupportedException($"Contains method is only supported for ResultFilter property, not '{propertyName}'."); + } + } + } + else + { + throw new NotSupportedException($"Method '{methodCall.Method.Name}' is not supported in Brave search filters."); + } + } + + /// + /// Extracts a constant value from an expression. + /// + /// The expression to extract the value from. + /// The extracted value, or null if extraction failed. + private static object? ExtractValue(Expression expression) + { + return expression switch + { + ConstantExpression constant => constant.Value, + MemberExpression member when member.Expression is ConstantExpression constantExpr => + member.Member switch + { + System.Reflection.FieldInfo field => field.GetValue(constantExpr.Value), + System.Reflection.PropertyInfo property => property.GetValue(constantExpr.Value), + _ => null + }, + _ => Expression.Lambda(expression).Compile().DynamicInvoke() + }; + } + + #endregion + + #region Private Methods private readonly ILogger _logger; private readonly HttpClient _httpClient; @@ -180,6 +439,25 @@ private async IAsyncEnumerable GetResultsAsWebPageAsync(BraveSearchRespo } } + /// + /// Return the search results as instances of . + /// + /// Response containing the web pages matching the query. + /// Cancellation token + private async IAsyncEnumerable GetResultsAsBraveWebPageAsync(BraveSearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (searchResponse is null) { yield break; } + + if (searchResponse.Web?.Results is { Count: > 0 } webResults) + { + foreach (var webPage in webResults) + { + yield return BraveWebPage.FromWebResult(webPage); + await Task.Yield(); + } + } + } + /// /// Return the search results as instances of . /// diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs new file mode 100644 index 000000000000..220d1e3141bb --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.SemanticKernel.Plugins.Web.Brave; + +/// +/// Represents a type-safe web page result from Brave search for use with generic ITextSearch<TRecord> interface. +/// This class provides compile-time type safety and IntelliSense support for Brave search filtering. +/// +public sealed class BraveWebPage +{ + /// + /// Gets or sets the title of the web page. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the URL of the web page. + /// + public string? Url { get; set; } + + /// + /// Gets or sets the description of the web page. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the type of the search result. + /// + public string? Type { get; set; } + + /// + /// Gets or sets the age of the web search result. + /// + public string? Age { get; set; } + + /// + /// Gets or sets the page age timestamp. + /// + public DateTime? PageAge { get; set; } + + /// + /// Gets or sets the language of the web page. + /// + public string? Language { get; set; } + + /// + /// Gets or sets whether the web page is family friendly. + /// + public bool? FamilyFriendly { get; set; } + + /// + /// Gets or sets the country filter for search results. + /// Maps to Brave's 'country' parameter (e.g., "US", "GB", "CA"). + /// + public string? Country { get; set; } + + /// + /// Gets or sets the search language filter. + /// Maps to Brave's 'search_lang' parameter (e.g., "en", "es", "fr"). + /// + public string? SearchLang { get; set; } + + /// + /// Gets or sets the UI language filter. + /// Maps to Brave's 'ui_lang' parameter (e.g., "en-US", "en-GB"). + /// + public string? UiLang { get; set; } + + /// + /// Gets or sets the safe search filter. + /// Maps to Brave's 'safesearch' parameter ("off", "moderate", "strict"). + /// + public string? SafeSearch { get; set; } + + /// + /// Gets or sets whether text decorations are enabled. + /// Maps to Brave's 'text_decorations' parameter. + /// + public bool? TextDecorations { get; set; } + + /// + /// Gets or sets whether spell check is enabled. + /// Maps to Brave's 'spellcheck' parameter. + /// + public bool? SpellCheck { get; set; } + + /// + /// Gets or sets the result filter for search types. + /// Maps to Brave's 'result_filter' parameter (e.g., "web", "news", "videos"). + /// + public string? ResultFilter { get; set; } + + /// + /// Gets or sets the units system for measurements. + /// Maps to Brave's 'units' parameter ("metric" or "imperial"). + /// + public string? Units { get; set; } + + /// + /// Gets or sets whether extra snippets are included. + /// Maps to Brave's 'extra_snippets' parameter. + /// + public bool? ExtraSnippets { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public BraveWebPage() + { + } + + /// + /// Initializes a new instance of the class with specified values. + /// + /// The title of the web page. + /// The URL of the web page. + /// The description of the web page. + /// The type of the search result. + public BraveWebPage(string? title, string? url, string? description, string? type = null) + { + this.Title = title; + this.Url = url; + this.Description = description; + this.Type = type; + } + + /// + /// Creates a BraveWebPage from a BraveWebResult. + /// + /// The web result to convert. + /// A new BraveWebPage instance. + internal static BraveWebPage FromWebResult(BraveWebResult result) + { + return new BraveWebPage(result.Title, result.Url, result.Description, result.Type) + { + Age = result.Age, + PageAge = result.PageAge, + Language = result.Language, + FamilyFriendly = result.FamilyFriendly + }; + } +} diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index a7ddacab3469..b00282321432 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq.Expressions; using System.Net.Http; using System.Runtime.CompilerServices; using System.Text; @@ -21,7 +22,7 @@ namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; /// A Tavily Text Search implementation that can be used to perform searches using the Tavily Web Search API. /// #pragma warning disable CS0618 // ITextSearch is obsolete - this class provides backward compatibility -public sealed class TavilyTextSearch : ITextSearch +public sealed class TavilyTextSearch : ITextSearch, ITextSearch #pragma warning restore CS0618 { /// @@ -77,7 +78,261 @@ public async Task> GetSearchResultsAsync(string quer return new KernelSearchResults(this.GetSearchResultsAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } - #region private + #region Generic ITextSearch Implementation + + /// + async Task> ITextSearch.SearchAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + { + var legacyOptions = this.ConvertToLegacyOptions(searchOptions); + TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = null; + + return new KernelSearchResults(this.GetResultsAsStringAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + async Task> ITextSearch.GetTextSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + { + var legacyOptions = this.ConvertToLegacyOptions(searchOptions); + TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = null; + + return new KernelSearchResults(this.GetResultsAsTextSearchResultAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + { + var legacyOptions = this.ConvertToLegacyOptions(searchOptions); + TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = null; + + return new KernelSearchResults(this.GetResultsAsWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + #endregion + + #region LINQ-to-Tavily Conversion Logic + + /// + /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions. + /// + /// The generic search options with LINQ filter. + /// Legacy TextSearchOptions with converted filters. + private TextSearchOptions ConvertToLegacyOptions(TextSearchOptions? options) + { + if (options == null) + { + return new TextSearchOptions(); + } + + var legacyOptions = new TextSearchOptions + { + Top = options.Top, + Skip = options.Skip, + IncludeTotalCount = options.IncludeTotalCount + }; + + // Convert LINQ expression to TextSearchFilter if present + if (options.Filter != null) + { + try + { + var convertedFilter = ConvertLinqExpressionToTavilyFilter(options.Filter); + legacyOptions = new TextSearchOptions + { + Top = options.Top, + Skip = options.Skip, + IncludeTotalCount = options.IncludeTotalCount, + Filter = convertedFilter + }; + } + catch (NotSupportedException ex) + { + this._logger.LogWarning("LINQ expression not fully supported by Tavily API, performing search without some filters: {Message}", ex.Message); + // Continue with basic search - graceful degradation + } + } + + return legacyOptions; + } + + /// + /// Converts a LINQ expression to Tavily-compatible TextSearchFilter. + /// + /// The LINQ expression to convert. + /// A TextSearchFilter with Tavily-compatible filter clauses. + private static TextSearchFilter ConvertLinqExpressionToTavilyFilter(Expression> linqExpression) + { + var filter = new TextSearchFilter(); + var filterClauses = new List(); + + // Analyze the LINQ expression and convert to filter clauses + AnalyzeExpression(linqExpression.Body, filterClauses); + + // Validate and add clauses that are supported by Tavily + foreach (var clause in filterClauses) + { + if (clause is EqualToFilterClause equalityClause) + { + var mappedFieldName = MapPropertyToTavilyFilter(equalityClause.FieldName); + if (mappedFieldName != null) + { + filter.Equality(mappedFieldName, equalityClause.Value); + } + else + { + throw new NotSupportedException($"Property '{equalityClause.FieldName}' cannot be mapped to Tavily API filters. Supported properties: {string.Join(", ", s_validFieldNames)}"); + } + } + } + + return filter; + } + + /// + /// Maps TavilyWebPage property names to Tavily API filter parameter names. + /// + /// The property name from TavilyWebPage. + /// The corresponding Tavily API parameter name, or null if not mappable. + private static string? MapPropertyToTavilyFilter(string propertyName) => + propertyName.ToUpperInvariant() switch + { + "TOPIC" => Topic, + "TIMERANGE" => TimeRange, + "DAYS" => Days, + "INCLUDEDOMAIN" => IncludeDomain, + "EXCLUDEDOMAIN" => ExcludeDomain, + _ => null // Property not mappable to Tavily filters + }; + + /// + /// Analyzes a LINQ expression and extracts filter clauses. + /// + /// The expression to analyze. + /// The list to add extracted filter clauses to. + private static void AnalyzeExpression(Expression expression, List filterClauses) + { + switch (expression) + { + case BinaryExpression binaryExpr: + if (binaryExpr.NodeType == ExpressionType.AndAlso) + { + // Handle AND expressions by recursively analyzing both sides + AnalyzeExpression(binaryExpr.Left, filterClauses); + AnalyzeExpression(binaryExpr.Right, filterClauses); + } + else if (binaryExpr.NodeType == ExpressionType.Equal) + { + // Handle equality expressions + ExtractEqualityClause(binaryExpr, filterClauses); + } + else + { + throw new NotSupportedException($"Binary expression type '{binaryExpr.NodeType}' is not supported. Only AndAlso and Equal are supported."); + } + break; + + case MethodCallExpression methodCall: + // Handle method calls like Contains, StartsWith, etc. + ExtractMethodCallClause(methodCall, filterClauses); + break; + + default: + throw new NotSupportedException($"Expression type '{expression.NodeType}' is not supported in Tavily search filters."); + } + } + + /// + /// Extracts an equality filter clause from a binary equality expression. + /// + /// The binary equality expression. + /// The list to add the extracted clause to. + private static void ExtractEqualityClause(BinaryExpression binaryExpr, List filterClauses) + { + string? propertyName = null; + object? value = null; + + // Determine which side is the property and which is the value + if (binaryExpr.Left is MemberExpression leftMember) + { + propertyName = leftMember.Member.Name; + value = ExtractValue(binaryExpr.Right); + } + else if (binaryExpr.Right is MemberExpression rightMember) + { + propertyName = rightMember.Member.Name; + value = ExtractValue(binaryExpr.Left); + } + + if (propertyName != null && value != null) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + else + { + throw new NotSupportedException("Unable to extract property name and value from equality expression."); + } + } + + /// + /// Extracts a filter clause from a method call expression (e.g., Contains, StartsWith). + /// + /// The method call expression. + /// The list to add the extracted clause to. + private static void ExtractMethodCallClause(MethodCallExpression methodCall, List filterClauses) + { + if (methodCall.Method.Name == "Contains" && methodCall.Object is MemberExpression member) + { + var propertyName = member.Member.Name; + var value = ExtractValue(methodCall.Arguments[0]); + + if (value != null) + { + // For Contains, we'll map it to equality for domains (simplified for Tavily's capabilities) + if (propertyName.EndsWith("Domain", StringComparison.OrdinalIgnoreCase)) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + else + { + throw new NotSupportedException($"Contains method is only supported for domain properties, not '{propertyName}'."); + } + } + } + else + { + throw new NotSupportedException($"Method '{methodCall.Method.Name}' is not supported in Tavily search filters."); + } + } + + /// + /// Extracts a constant value from an expression. + /// + /// The expression to extract the value from. + /// The extracted value, or null if extraction failed. + private static object? ExtractValue(Expression expression) + { + return expression switch + { + ConstantExpression constant => constant.Value, + MemberExpression member when member.Expression is ConstantExpression constantExpr => + member.Member switch + { + System.Reflection.FieldInfo field => field.GetValue(constantExpr.Value), + System.Reflection.PropertyInfo property => property.GetValue(constantExpr.Value), + _ => null + }, + _ => Expression.Lambda(expression).Compile().DynamicInvoke() + }; + } + + #endregion + + #region Private Methods private readonly ILogger _logger; private readonly HttpClient _httpClient; @@ -177,6 +432,40 @@ private async IAsyncEnumerable GetSearchResultsAsync(TavilySearchRespons } } + /// + /// Return the search results as instances of . + /// + /// Response containing the web pages matching the query. + /// Cancellation token + private async IAsyncEnumerable GetResultsAsWebPageAsync(TavilySearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (searchResponse is null || searchResponse.Results is null) + { + yield break; + } + + foreach (var result in searchResponse.Results) + { + yield return TavilyWebPage.FromSearchResult(result); + await Task.Yield(); + } + + if (this._searchOptions?.IncludeImages ?? false && searchResponse.Images is not null) + { + foreach (var image in searchResponse.Images!) + { + // For images, create a basic TavilyWebPage representation + yield return new TavilyWebPage( + title: "Image Result", + url: image.Url, + content: image.Description ?? string.Empty, + score: 0.0 + ); + await Task.Yield(); + } + } + } + /// /// Return the search results as instances of . /// diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs new file mode 100644 index 000000000000..1c1b45578fce --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; + +/// +/// Represents a type-safe web page result from Tavily search for use with generic ITextSearch<TRecord> interface. +/// This class provides compile-time type safety and IntelliSense support for Tavily search filtering. +/// +public sealed class TavilyWebPage +{ + /// + /// Gets or sets the title of the web page. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the URL of the web page. + /// + public string? Url { get; set; } + + /// + /// Gets or sets the content/description of the web page. + /// + public string? Content { get; set; } + + /// + /// Gets or sets the raw content of the web page (if available). + /// + public string? RawContent { get; set; } + + /// + /// Gets or sets the relevance score of the search result. + /// + public double Score { get; set; } + + /// + /// Gets or sets the topic filter for search results. + /// Maps to Tavily's 'topic' parameter for focused search. + /// + public string? Topic { get; set; } + + /// + /// Gets or sets the time range filter for search results. + /// Maps to Tavily's 'time_range' parameter (e.g., "day", "week", "month", "year"). + /// + public string? TimeRange { get; set; } + + /// + /// Gets or sets the number of days for time-based filtering. + /// Maps to Tavily's 'days' parameter for custom date ranges. + /// + public int? Days { get; set; } + + /// + /// Gets or sets the domain to include in search results. + /// Maps to Tavily's 'include_domain' parameter. + /// + public string? IncludeDomain { get; set; } + + /// + /// Gets or sets the domain to exclude from search results. + /// Maps to Tavily's 'exclude_domain' parameter. + /// + public string? ExcludeDomain { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public TavilyWebPage() + { + } + + /// + /// Initializes a new instance of the class with specified values. + /// + /// The title of the web page. + /// The URL of the web page. + /// The content/description of the web page. + /// The relevance score. + /// The raw content (optional). + public TavilyWebPage(string? title, string? url, string? content, double score, string? rawContent = null) + { + this.Title = title; + this.Url = url; + this.Content = content; + this.Score = score; + this.RawContent = rawContent; + } + + /// + /// Creates a TavilyWebPage from a TavilySearchResult. + /// + /// The search result to convert. + /// A new TavilyWebPage instance. + internal static TavilyWebPage FromSearchResult(TavilySearchResult result) + { + return new TavilyWebPage(result.Title, result.Url, result.Content, result.Score, result.RawContent); + } +} From 33377aef913739df20cdab2e78a3aa4992fe982b Mon Sep 17 00:00:00 2001 From: alzarei Date: Fri, 3 Oct 2025 01:45:07 -0700 Subject: [PATCH 03/14] Enhance text search LINQ support with OR/NOT operators and improved error messages - Added support for OrElse (||) operators in LINQ expressions - Added NotEqual (!=) operator support with appropriate error handling - Added UnaryExpression (NOT) support with proper limitations - Enhanced Contains method support for array.Contains(property) patterns - Improved error messages with usage examples - Added constants for API parameter names for better maintainability - Added TODO comments for potential code sharing opportunities Changes apply to both BraveTextSearch and TavilyTextSearch implementations. --- .../Plugins.Web/Brave/BraveTextSearch.cs | 170 +++++++++++++++--- .../Plugins.Web/Tavily/TavilyTextSearch.cs | 139 ++++++++++++-- 2 files changed, 277 insertions(+), 32 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs index 8e0d6772f57f..bc204c0d93a3 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs @@ -188,7 +188,10 @@ private static TextSearchFilter ConvertLinqExpressionToBraveFilter(Expr } else { - throw new NotSupportedException($"Property '{equalityClause.FieldName}' cannot be mapped to Brave API filters. Supported properties: {string.Join(", ", s_queryParameters)}"); + throw new NotSupportedException( + $"Property '{equalityClause.FieldName}' cannot be mapped to Brave API filters. " + + $"Supported properties: {string.Join(", ", s_queryParameters)}. " + + "Example: page => page.Country == \"US\" && page.SafeSearch == \"moderate\""); } } } @@ -204,18 +207,21 @@ private static TextSearchFilter ConvertLinqExpressionToBraveFilter(Expr private static string? MapPropertyToBraveFilter(string propertyName) => propertyName.ToUpperInvariant() switch { - "COUNTRY" => "country", - "SEARCHLANG" => "search_lang", - "UILANG" => "ui_lang", - "SAFESEARCH" => "safesearch", - "TEXTDECORATIONS" => "text_decorations", - "SPELLCHECK" => "spellcheck", - "RESULTFILTER" => "result_filter", - "UNITS" => "units", - "EXTRASNIPPETS" => "extra_snippets", + "COUNTRY" => BraveParamCountry, + "SEARCHLANG" => BraveParamSearchLang, + "UILANG" => BraveParamUiLang, + "SAFESEARCH" => BraveParamSafeSearch, + "TEXTDECORATIONS" => BraveParamTextDecorations, + "SPELLCHECK" => BraveParamSpellCheck, + "RESULTFILTER" => BraveParamResultFilter, + "UNITS" => BraveParamUnits, + "EXTRASNIPPETS" => BraveParamExtraSnippets, _ => null // Property not mappable to Brave filters }; + // TODO: Consider extracting LINQ expression analysis logic to a shared utility class + // to reduce duplication across text search connectors (Brave, Tavily, etc.). + // See code review for details. /// /// Analyzes a LINQ expression and extracts filter clauses. /// @@ -232,17 +238,35 @@ private static void AnalyzeExpression(Expression expression, List AnalyzeExpression(binaryExpr.Left, filterClauses); AnalyzeExpression(binaryExpr.Right, filterClauses); } + else if (binaryExpr.NodeType == ExpressionType.OrElse) + { + // Handle OR expressions by recursively analyzing both sides + // Note: OR results in multiple filter values for the same property + AnalyzeExpression(binaryExpr.Left, filterClauses); + AnalyzeExpression(binaryExpr.Right, filterClauses); + } else if (binaryExpr.NodeType == ExpressionType.Equal) { // Handle equality expressions ExtractEqualityClause(binaryExpr, filterClauses); } + else if (binaryExpr.NodeType == ExpressionType.NotEqual) + { + // Handle inequality expressions (property != value) + // This is supported as a negation pattern + ExtractInequalityClause(binaryExpr, filterClauses); + } else { - throw new NotSupportedException($"Binary expression type '{binaryExpr.NodeType}' is not supported. Only AndAlso and Equal are supported."); + throw new NotSupportedException($"Binary expression type '{binaryExpr.NodeType}' is not supported. Supported operators: AndAlso (&&), OrElse (||), Equal (==), NotEqual (!=)."); } break; + case UnaryExpression unaryExpr when unaryExpr.NodeType == ExpressionType.Not: + // Handle NOT expressions (negation) + AnalyzeNotExpression(unaryExpr, filterClauses); + break; + case MethodCallExpression methodCall: // Handle method calls like Contains, StartsWith, etc. ExtractMethodCallClause(methodCall, filterClauses); @@ -285,6 +309,57 @@ private static void ExtractEqualityClause(BinaryExpression binaryExpr, List + /// Extracts an inequality filter clause from a binary not-equal expression. + /// + /// The binary not-equal expression. + /// The list to add the extracted clause to. + private static void ExtractInequalityClause(BinaryExpression binaryExpr, List filterClauses) + { + // Note: Inequality is tracked but handled differently depending on the property + // For now, we log a warning that inequality filtering may not work as expected + string? propertyName = null; + object? value = null; + + if (binaryExpr.Left is MemberExpression leftMember) + { + propertyName = leftMember.Member.Name; + value = ExtractValue(binaryExpr.Right); + } + else if (binaryExpr.Right is MemberExpression rightMember) + { + propertyName = rightMember.Member.Name; + value = ExtractValue(binaryExpr.Left); + } + + if (propertyName != null && value != null) + { + // Add a marker for inequality - this will need special handling in conversion + // For now, we don't add it to filter clauses as Brave API doesn't support direct negation + throw new NotSupportedException($"Inequality operator (!=) is not directly supported for property '{propertyName}'. Use NOT operator instead: !(page.{propertyName} == value)."); + } + + throw new NotSupportedException("Unable to extract property name and value from inequality expression."); + } + + /// + /// Analyzes a NOT (negation) expression. + /// + /// The unary NOT expression. + /// The list to add extracted filter clauses to. + private static void AnalyzeNotExpression(UnaryExpression unaryExpr, List filterClauses) + { + // NOT expressions are complex for web search APIs + // We support simple cases like !(page.SafeSearch == "off") + if (unaryExpr.Operand is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Equal) + { + // This is !(property == value), which we can handle for some properties + throw new NotSupportedException("NOT operator (!) with equality is not directly supported. Most web search APIs don't support negative filtering."); + } + + throw new NotSupportedException("NOT operator (!) is only supported with simple equality expressions."); + } + /// /// Extracts a filter clause from a method call expression (e.g., Contains, StartsWith). /// @@ -292,27 +367,69 @@ private static void ExtractEqualityClause(BinaryExpression binaryExpr, ListThe list to add the extracted clause to. private static void ExtractMethodCallClause(MethodCallExpression methodCall, List filterClauses) { - if (methodCall.Method.Name == "Contains" && methodCall.Object is MemberExpression member) + if (methodCall.Method.Name == "Contains") { - var propertyName = member.Member.Name; - var value = ExtractValue(methodCall.Arguments[0]); + // Check if this is property.Contains(value) or array.Contains(property) + if (methodCall.Object is MemberExpression member) + { + // This is property.Contains(value) - e.g., page.ResultFilter.Contains("web") + var propertyName = member.Member.Name; + var value = ExtractValue(methodCall.Arguments[0]); - if (value != null) + if (value != null) + { + // For Contains, we'll map it to equality for certain properties + if (propertyName.Equals("ResultFilter", StringComparison.OrdinalIgnoreCase)) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + else + { + throw new NotSupportedException($"Contains method is only supported for ResultFilter property, not '{propertyName}'."); + } + } + } + else if (methodCall.Object == null && methodCall.Arguments.Count == 2) { - // For Contains, we'll map it to equality for certain properties - if (propertyName.Equals("ResultFilter", StringComparison.OrdinalIgnoreCase)) + // This is array.Contains(property) - e.g., new[] { "US", "GB" }.Contains(page.Country) + // This is an extension method call where the first argument is the array + var arrayExpr = methodCall.Arguments[0]; + var propertyExpr = methodCall.Arguments[1]; + + if (propertyExpr is MemberExpression propertyMember) { - filterClauses.Add(new EqualToFilterClause(propertyName, value)); + var propertyName = propertyMember.Member.Name; + var arrayValue = ExtractValue(arrayExpr); + + if (arrayValue is System.Collections.IEnumerable enumerable) + { + // Convert to OR expressions - each value becomes an equality clause + foreach (var value in enumerable) + { + if (value != null) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + } + } + else + { + throw new NotSupportedException($"Contains argument must be an array or collection, got: {arrayValue?.GetType().Name}"); + } } else { - throw new NotSupportedException($"Contains method is only supported for ResultFilter property, not '{propertyName}'."); + throw new NotSupportedException("Contains with inline collection requires a property reference as the second argument."); } } + else + { + throw new NotSupportedException("Unsupported Contains expression format."); + } } else { - throw new NotSupportedException($"Method '{methodCall.Method.Name}' is not supported in Brave search filters."); + throw new NotSupportedException($"Method '{methodCall.Method.Name}' is not supported in Brave search filters. Only 'Contains' is supported."); } } @@ -351,8 +468,19 @@ private static void ExtractMethodCallClause(MethodCallExpression methodCall, Lis private static readonly ITextSearchStringMapper s_defaultStringMapper = new DefaultTextSearchStringMapper(); private static readonly ITextSearchResultMapper s_defaultResultMapper = new DefaultTextSearchResultMapper(); + // Constants for Brave API parameter names + private const string BraveParamCountry = "country"; + private const string BraveParamSearchLang = "search_lang"; + private const string BraveParamUiLang = "ui_lang"; + private const string BraveParamSafeSearch = "safesearch"; + private const string BraveParamTextDecorations = "text_decorations"; + private const string BraveParamSpellCheck = "spellcheck"; + private const string BraveParamResultFilter = "result_filter"; + private const string BraveParamUnits = "units"; + private const string BraveParamExtraSnippets = "extra_snippets"; + // See https://api-dashboard.search.brave.com/app/documentation/web-search/query#WebSearchAPIQueryParameters - private static readonly string[] s_queryParameters = ["country", "search_lang", "ui_lang", "safesearch", "text_decorations", "spellcheck", "result_filter", "units", "extra_snippets"]; + private static readonly string[] s_queryParameters = [BraveParamCountry, BraveParamSearchLang, BraveParamUiLang, BraveParamSafeSearch, BraveParamTextDecorations, BraveParamSpellCheck, BraveParamResultFilter, BraveParamUnits, BraveParamExtraSnippets]; private static readonly string[] s_safeSearch = ["off", "moderate", "strict"]; diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index b00282321432..9e19a208a0ef 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -185,7 +185,10 @@ private static TextSearchFilter ConvertLinqExpressionToTavilyFilter(Exp } else { - throw new NotSupportedException($"Property '{equalityClause.FieldName}' cannot be mapped to Tavily API filters. Supported properties: {string.Join(", ", s_validFieldNames)}"); + throw new NotSupportedException( + $"Property '{equalityClause.FieldName}' cannot be mapped to Tavily API filters. " + + $"Supported properties: {string.Join(", ", s_validFieldNames)}. " + + "Example: page => page.Topic == \"general\" && page.TimeRange == \"week\""); } } } @@ -209,6 +212,9 @@ private static TextSearchFilter ConvertLinqExpressionToTavilyFilter(Exp _ => null // Property not mappable to Tavily filters }; + // TODO: Consider extracting LINQ expression analysis logic to a shared utility class + // to reduce duplication across text search connectors (Brave, Tavily, etc.). + // See code review for details. /// /// Analyzes a LINQ expression and extracts filter clauses. /// @@ -225,17 +231,35 @@ private static void AnalyzeExpression(Expression expression, List AnalyzeExpression(binaryExpr.Left, filterClauses); AnalyzeExpression(binaryExpr.Right, filterClauses); } + else if (binaryExpr.NodeType == ExpressionType.OrElse) + { + // Handle OR expressions by recursively analyzing both sides + // Note: OR results in multiple filter values for the same property (especially for domains) + AnalyzeExpression(binaryExpr.Left, filterClauses); + AnalyzeExpression(binaryExpr.Right, filterClauses); + } else if (binaryExpr.NodeType == ExpressionType.Equal) { // Handle equality expressions ExtractEqualityClause(binaryExpr, filterClauses); } + else if (binaryExpr.NodeType == ExpressionType.NotEqual) + { + // Handle inequality expressions (property != value) + // This is supported as a negation pattern + ExtractInequalityClause(binaryExpr, filterClauses); + } else { - throw new NotSupportedException($"Binary expression type '{binaryExpr.NodeType}' is not supported. Only AndAlso and Equal are supported."); + throw new NotSupportedException($"Binary expression type '{binaryExpr.NodeType}' is not supported. Supported operators: AndAlso (&&), OrElse (||), Equal (==), NotEqual (!=)."); } break; + case UnaryExpression unaryExpr when unaryExpr.NodeType == ExpressionType.Not: + // Handle NOT expressions (negation) + AnalyzeNotExpression(unaryExpr, filterClauses); + break; + case MethodCallExpression methodCall: // Handle method calls like Contains, StartsWith, etc. ExtractMethodCallClause(methodCall, filterClauses); @@ -278,6 +302,57 @@ private static void ExtractEqualityClause(BinaryExpression binaryExpr, List + /// Extracts an inequality filter clause from a binary not-equal expression. + /// + /// The binary not-equal expression. + /// The list to add the extracted clause to. + private static void ExtractInequalityClause(BinaryExpression binaryExpr, List filterClauses) + { + // Note: Inequality is tracked but handled differently depending on the property + // For now, we log a warning that inequality filtering may not work as expected + string? propertyName = null; + object? value = null; + + if (binaryExpr.Left is MemberExpression leftMember) + { + propertyName = leftMember.Member.Name; + value = ExtractValue(binaryExpr.Right); + } + else if (binaryExpr.Right is MemberExpression rightMember) + { + propertyName = rightMember.Member.Name; + value = ExtractValue(binaryExpr.Left); + } + + if (propertyName != null && value != null) + { + // Add a marker for inequality - this will need special handling in conversion + // For now, we don't add it to filter clauses as Tavily API doesn't support direct negation + throw new NotSupportedException($"Inequality operator (!=) is not directly supported for property '{propertyName}'. Use NOT operator instead: !(page.{propertyName} == value)."); + } + + throw new NotSupportedException("Unable to extract property name and value from inequality expression."); + } + + /// + /// Analyzes a NOT (negation) expression. + /// + /// The unary NOT expression. + /// The list to add extracted filter clauses to. + private static void AnalyzeNotExpression(UnaryExpression unaryExpr, List filterClauses) + { + // NOT expressions are complex for web search APIs + // We support simple cases like !(page.Topic == "general") + if (unaryExpr.Operand is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Equal) + { + // This is !(property == value), which we can handle for some properties + throw new NotSupportedException("NOT operator (!) with equality is not directly supported. Most web search APIs don't support negative filtering."); + } + + throw new NotSupportedException("NOT operator (!) is only supported with simple equality expressions."); + } + /// /// Extracts a filter clause from a method call expression (e.g., Contains, StartsWith). /// @@ -285,27 +360,69 @@ private static void ExtractEqualityClause(BinaryExpression binaryExpr, ListThe list to add the extracted clause to. private static void ExtractMethodCallClause(MethodCallExpression methodCall, List filterClauses) { - if (methodCall.Method.Name == "Contains" && methodCall.Object is MemberExpression member) + if (methodCall.Method.Name == "Contains") { - var propertyName = member.Member.Name; - var value = ExtractValue(methodCall.Arguments[0]); + // Check if this is property.Contains(value) or array.Contains(property) + if (methodCall.Object is MemberExpression member) + { + // This is property.Contains(value) - e.g., page.IncludeDomain.Contains("wikipedia.org") + var propertyName = member.Member.Name; + var value = ExtractValue(methodCall.Arguments[0]); - if (value != null) + if (value != null) + { + // For Contains, we'll map it to equality for domains (Tavily supports domain filtering) + if (propertyName.EndsWith("Domain", StringComparison.OrdinalIgnoreCase)) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + else + { + throw new NotSupportedException($"Contains method is only supported for domain properties (IncludeDomain, ExcludeDomain), not '{propertyName}'."); + } + } + } + else if (methodCall.Object == null && methodCall.Arguments.Count == 2) { - // For Contains, we'll map it to equality for domains (simplified for Tavily's capabilities) - if (propertyName.EndsWith("Domain", StringComparison.OrdinalIgnoreCase)) + // This is array.Contains(property) - e.g., new[] { "general", "news" }.Contains(page.Topic) + // This is an extension method call where the first argument is the array + var arrayExpr = methodCall.Arguments[0]; + var propertyExpr = methodCall.Arguments[1]; + + if (propertyExpr is MemberExpression propertyMember) { - filterClauses.Add(new EqualToFilterClause(propertyName, value)); + var propertyName = propertyMember.Member.Name; + var arrayValue = ExtractValue(arrayExpr); + + if (arrayValue is System.Collections.IEnumerable enumerable) + { + // Convert to OR expressions - each value becomes an equality clause + foreach (var value in enumerable) + { + if (value != null) + { + filterClauses.Add(new EqualToFilterClause(propertyName, value)); + } + } + } + else + { + throw new NotSupportedException($"Contains argument must be an array or collection, got: {arrayValue?.GetType().Name}"); + } } else { - throw new NotSupportedException($"Contains method is only supported for domain properties, not '{propertyName}'."); + throw new NotSupportedException("Contains with inline collection requires a property reference as the second argument."); } } + else + { + throw new NotSupportedException("Unsupported Contains expression format."); + } } else { - throw new NotSupportedException($"Method '{methodCall.Method.Name}' is not supported in Tavily search filters."); + throw new NotSupportedException($"Method '{methodCall.Method.Name}' is not supported in Tavily search filters. Only 'Contains' is supported."); } } From 725f61c11871f29fb5bff277d25e51c6be6f3f82 Mon Sep 17 00:00:00 2001 From: alzarei Date: Mon, 20 Oct 2025 03:24:27 -0700 Subject: [PATCH 04/14] fix: Resolve CA1056 code analysis violations in web search connectors - Changed Url property from string? to Uri? in TavilyWebPage - Changed Url property from string? to Uri? in BraveWebPage - Updated constructors to accept Uri? parameters - Added null-safe string-to-Uri conversions in factory methods - Fixed image URL handling in TavilyTextSearch Resolves all 31 CA1056 violations (URI properties should not be strings). --- dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs | 7 ++++--- .../src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs | 5 +++-- dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs | 9 ++++++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs index 220d1e3141bb..c6938c7b0ef8 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveWebPage.cs @@ -18,7 +18,7 @@ public sealed class BraveWebPage /// /// Gets or sets the URL of the web page. /// - public string? Url { get; set; } + public Uri? Url { get; set; } /// /// Gets or sets the description of the web page. @@ -118,7 +118,7 @@ public BraveWebPage() /// The URL of the web page. /// The description of the web page. /// The type of the search result. - public BraveWebPage(string? title, string? url, string? description, string? type = null) + public BraveWebPage(string? title, Uri? url, string? description, string? type = null) { this.Title = title; this.Url = url; @@ -133,7 +133,8 @@ public BraveWebPage(string? title, string? url, string? description, string? typ /// A new BraveWebPage instance. internal static BraveWebPage FromWebResult(BraveWebResult result) { - return new BraveWebPage(result.Title, result.Url, result.Description, result.Type) + Uri? url = string.IsNullOrWhiteSpace(result.Url) ? null : new Uri(result.Url); + return new BraveWebPage(result.Title, url, result.Description, result.Type) { Age = result.Age, PageAge = result.PageAge, diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index 9e19a208a0ef..f9052a8fa233 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -571,10 +571,11 @@ private async IAsyncEnumerable GetResultsAsWebPageAsync(TavilySearchResp { foreach (var image in searchResponse.Images!) { - // For images, create a basic TavilyWebPage representation + //For images, create a basic TavilyWebPage representation + Uri? imageUri = string.IsNullOrWhiteSpace(image.Url) ? null : new Uri(image.Url); yield return new TavilyWebPage( title: "Image Result", - url: image.Url, + url: imageUri, content: image.Description ?? string.Empty, score: 0.0 ); diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs index 1c1b45578fce..fddf338e1e06 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyWebPage.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; + namespace Microsoft.SemanticKernel.Plugins.Web.Tavily; /// @@ -16,7 +18,7 @@ public sealed class TavilyWebPage /// /// Gets or sets the URL of the web page. /// - public string? Url { get; set; } + public Uri? Url { get; set; } /// /// Gets or sets the content/description of the web page. @@ -78,7 +80,7 @@ public TavilyWebPage() /// The content/description of the web page. /// The relevance score. /// The raw content (optional). - public TavilyWebPage(string? title, string? url, string? content, double score, string? rawContent = null) + public TavilyWebPage(string? title, Uri? url, string? content, double score, string? rawContent = null) { this.Title = title; this.Url = url; @@ -94,6 +96,7 @@ public TavilyWebPage(string? title, string? url, string? content, double score, /// A new TavilyWebPage instance. internal static TavilyWebPage FromSearchResult(TavilySearchResult result) { - return new TavilyWebPage(result.Title, result.Url, result.Content, result.Score, result.RawContent); + Uri? url = string.IsNullOrWhiteSpace(result.Url) ? null : new Uri(result.Url); + return new TavilyWebPage(result.Title, url, result.Content, result.Score, result.RawContent); } } From 1db77fb8ae791d3528eb651b4cc2331f6e89356e Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Wed, 29 Oct 2025 23:20:02 -0700 Subject: [PATCH 05/14] feat: Add C# 14 MemoryExtensions.Contains compatibility to text search connectors - Implement IsMemoryExtensionsContains() detection in TavilyTextSearch and BraveTextSearch - Handle C# 14+ MemoryExtensions.Contains method resolution (static calls with 2-3 parameters) - Provide clear error messages explaining API limitations and suggesting alternatives - Maintain backward compatibility with C# 13- Enumerable.Contains patterns Validation: Build (0 warnings), Tests (1574 passed), AOT compatible Fixes #10456 --- .../Plugins.Web/Brave/BraveTextSearch.cs | 49 +++++++++++++++++++ .../Plugins.Web/Tavily/TavilyTextSearch.cs | 47 ++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs index bc204c0d93a3..f2e71c562f0b 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs @@ -369,6 +369,18 @@ private static void ExtractMethodCallClause(MethodCallExpression methodCall, Lis { if (methodCall.Method.Name == "Contains") { + // Handle C# 14 MemoryExtensions.Contains compatibility issue + // In C# 14+, array.Contains(property) may resolve to MemoryExtensions.Contains instead of Enumerable.Contains + if (methodCall.Object == null && IsMemoryExtensionsContains(methodCall)) + { + throw new NotSupportedException( + "Collection Contains filters (e.g., array.Contains(page.Property)) using MemoryExtensions.Contains (C# 14+) are not supported by Brave Search API. " + + "Brave's API does 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. " + + "Note: This occurs when using C# 14+ language features with span-based Contains methods."); + } + // Check if this is property.Contains(value) or array.Contains(property) if (methodCall.Object is MemberExpression member) { @@ -791,5 +803,42 @@ private static void CheckQueryValidation(string queryParam, object value) break; } } + + /// + /// Determines if a method call expression is a MemoryExtensions.Contains call (C# 14+ compatibility). + /// In C# 14+, array.Contains(property) may resolve to MemoryExtensions.Contains instead of Enumerable.Contains. + /// + /// The method call expression to check. + /// True if this is a MemoryExtensions.Contains call, false otherwise. + private static bool IsMemoryExtensionsContains(MethodCallExpression methodCall) + { + // Check if this is a static method call (Object is null) + if (methodCall.Object != null) + { + return false; + } + + // Check if it's MemoryExtensions.Contains + if (methodCall.Method.DeclaringType?.Name != "MemoryExtensions") + { + return false; + } + + // MemoryExtensions.Contains has 2-3 parameters: (ReadOnlySpan, T) or (ReadOnlySpan, T, IEqualityComparer) + if (methodCall.Arguments.Count < 2 || methodCall.Arguments.Count > 3) + { + return false; + } + + // For our text search scenarios, we don't support span comparers + if (methodCall.Arguments.Count == 3) + { + throw new NotSupportedException( + "MemoryExtensions.Contains with custom IEqualityComparer is not supported. " + + "Use simple array.Contains(property) expressions without custom comparers."); + } + + return true; + } #endregion } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index f9052a8fa233..074e7b48070f 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -362,6 +362,18 @@ private static void ExtractMethodCallClause(MethodCallExpression methodCall, Lis { if (methodCall.Method.Name == "Contains") { + // Handle C# 14 MemoryExtensions.Contains compatibility issue + // In C# 14+, array.Contains(property) may resolve to MemoryExtensions.Contains instead of Enumerable.Contains + if (methodCall.Object == null && IsMemoryExtensionsContains(methodCall)) + { + throw new NotSupportedException( + "Collection Contains filters (e.g., array.Contains(page.Property)) using MemoryExtensions.Contains (C# 14+) are not supported by Tavily Search API. " + + "Tavily's API does 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. " + + "Note: This occurs when using C# 14+ language features with span-based Contains methods."); + } + // Check if this is property.Contains(value) or array.Contains(property) if (methodCall.Object is MemberExpression member) { @@ -790,5 +802,40 @@ private TavilySearchRequest BuildRequestContent(string query, TextSearchOptions string strPayload = payload as string ?? JsonSerializer.Serialize(payload, s_jsonOptionsCache); return new(strPayload, Encoding.UTF8, "application/json"); } + + /// + /// Determines if a method call expression is a MemoryExtensions.Contains call (C# 14+ compatibility). + /// In C# 14+, array.Contains(property) may resolve to MemoryExtensions.Contains instead of Enumerable.Contains. + /// + /// The method call expression to check. + /// True if this is a MemoryExtensions.Contains call, false otherwise. + private static bool IsMemoryExtensionsContains(MethodCallExpression methodCall) + { + // Check if this is a static method call (Object is null) + if (methodCall.Object != null) + { + return false; + } + + // Check if it's MemoryExtensions.Contains + if (methodCall.Method.DeclaringType?.Name != "MemoryExtensions") + { + return false; + } + + // MemoryExtensions.Contains has 2-3 parameters: (ReadOnlySpan, T) or (ReadOnlySpan, T, IEqualityComparer) + if (methodCall.Arguments.Count < 2 || methodCall.Arguments.Count > 3) + { + return false; + } // For our text search scenarios, we don't support span comparers + if (methodCall.Arguments.Count == 3) + { + throw new NotSupportedException( + "MemoryExtensions.Contains with custom IEqualityComparer is not supported. " + + "Use simple array.Contains(property) expressions without custom comparers."); + } + + return true; + } #endregion } From 73f085f6c6a0f60a183b9741443bb967f30ae0c3 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Thu, 30 Oct 2025 21:37:09 -0700 Subject: [PATCH 06/14] feat: add generic interface tests and fix collection Contains exception handling - Added comprehensive generic interface tests for both Tavily and Brave connectors - TavilyTextSearchTests: 3 new tests for ITextSearch interface - BraveTextSearchTests: 3 new tests for ITextSearch interface - Tests cover SearchAsync, GetSearchResultsAsync, and GetTextSearchResultsAsync methods - Added proper mocking with MultipleHttpMessageHandlerStub and test data - Fixed collection Contains exception handling to prevent graceful degradation - Modified ConvertToLegacyOptions in both connectors to re-throw critical exceptions - Collection Contains patterns now properly throw NotSupportedException instead of degrading - Maintains graceful degradation for other unsupported LINQ patterns - Fixed previously failing CollectionContainsFilterThrowsNotSupportedExceptionAsync test - Enhanced C# 14 MemoryExtensions.Contains compatibility - Improved error messages to cover both C# 13- (Enumerable.Contains) and C# 14+ (MemoryExtensions.Contains) - All collection Contains patterns now properly handled regardless of C# language version Test Results: - Tavily: 30 tests passed (including 3 new generic interface tests) - Brave: 24 tests passed (including 3 new generic interface tests) - All collection Contains exception tests now pass correctly --- .../Web/Brave/BraveTextSearchTests.cs | 146 +++++++++++++++++ .../Web/Tavily/TavilyTextSearchTests.cs | 151 ++++++++++++++++++ .../Plugins.Web/Brave/BraveTextSearch.cs | 59 +++---- .../Plugins.Web/Tavily/TavilyTextSearch.cs | 59 +++---- 4 files changed, 343 insertions(+), 72 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs index 0435df46a31d..0a1a1f2801f1 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. #pragma warning disable CS0618 // ITextSearch is obsolete +#pragma warning disable CS8602 // Dereference of a possibly null reference - for LINQ expression properties using System; using System.IO; @@ -243,6 +244,151 @@ public void Dispose() GC.SuppressFinalize(this); } + #region Generic ITextSearch Interface Tests + + [Fact] + public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + ITextSearch textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + var searchOptions = new TextSearchOptions + { + Top = 4, + Skip = 0 + }; + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", searchOptions); + + // Assert - Verify basic generic interface functionality + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotEmpty(resultList); + + // Verify the request was made correctly + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + Assert.NotNull(requestUris[0]); + Assert.Contains("count=4", requestUris[0].AbsoluteUri); + } + + [Fact] + public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + ITextSearch textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + var searchOptions = new TextSearchOptions + { + Top = 3, + Skip = 0 + }; + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + + // Assert - Verify generic interface returns results + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotEmpty(resultList); + // Results are still objects (BraveSearchResult) not BraveWebPage since that's just for filtering + + // Verify the request was made correctly + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + Assert.NotNull(requestUris[0]); + Assert.Contains("count=3", requestUris[0].AbsoluteUri); + } + + [Fact] + public async Task GenericGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + ITextSearch textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + var searchOptions = new TextSearchOptions + { + Top = 5, + Skip = 0 + }; + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + + // Assert - Verify generic interface returns TextSearchResult objects + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotEmpty(resultList); + Assert.All(resultList, item => Assert.IsType(item)); + + // Verify the request was made correctly + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + Assert.NotNull(requestUris[0]); + Assert.Contains("count=5", requestUris[0].AbsoluteUri); + } + + [Fact] + public async Task CollectionContainsFilterThrowsNotSupportedExceptionAsync() + { + // Arrange - Tests both Enumerable.Contains (C# 13-) and MemoryExtensions.Contains (C# 14+) + // The same code array.Contains() 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 due to "first-class spans") + // Our implementation handles both identically since Brave API has limited query operators + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + ITextSearch textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + string[] sites = ["microsoft.com", "github.com"]; + + // Act & Assert - Verify that collection Contains pattern throws clear exception + var searchOptions = new TextSearchOptions + { + Top = 5, + Skip = 0, + Filter = page => sites.Contains(page.Url!.ToString()) // Enumerable.Contains (C# 13-) or MemoryExtensions.Contains (C# 14+) + }; + + var exception = await Assert.ThrowsAsync(async () => + { + await textSearch.SearchAsync("test", searchOptions); + }); + + // Assert - Verify error message explains the limitation clearly + Assert.Contains("Collection Contains filters", exception.Message); + Assert.Contains("not supported", exception.Message); + } + + [Fact] + public async Task StringContainsStillWorksWithLINQFiltersAsync() + { + // Arrange - Verify that String.Contains (instance method) still works + // String.Contains is NOT affected by C# 14 "first-class spans" - only arrays are + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); + ITextSearch textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act - String.Contains should continue to work + var searchOptions = new TextSearchOptions + { + Top = 5, + Skip = 0, + Filter = page => page.Title.Contains("Kernel") // String.Contains - instance method + }; + KernelSearchResults result = await textSearch.SearchAsync("Semantic Kernel tutorial", searchOptions); + + // Assert - Verify String.Contains works correctly + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + Assert.NotNull(requestUris[0]); + Assert.Contains("Kernel", requestUris[0].AbsoluteUri); + Assert.Contains("count=5", requestUris[0].AbsoluteUri); + } + + #endregion + #region private private const string WhatIsTheSkResponseJson = "./TestData/brave_what_is_the_semantic_kernel.json"; private const string SiteFilterSkResponseJson = "./TestData/brave_site_filter_what_is_the_semantic_kernel.json"; diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index f510d0555168..c78cc28389b1 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. #pragma warning disable CS0618 // ITextSearch is obsolete +#pragma warning disable CS8602 // Dereference of a possibly null reference - for LINQ expression properties using System; using System.IO; @@ -346,6 +347,156 @@ public void Dispose() GC.SuppressFinalize(this); } + #region Generic ITextSearch Interface Tests + + [Fact] + public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); + ITextSearch textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + var searchOptions = new TextSearchOptions + { + Top = 4, + Skip = 0 + }; + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", searchOptions); + + // Assert - Verify basic generic interface functionality + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotEmpty(resultList); + + // Verify the request was made correctly + var requestContents = this._messageHandlerStub.RequestContents; + Assert.Single(requestContents); + Assert.NotNull(requestContents[0]); + var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!); + Assert.Contains("\"query\"", requestBodyJson); + Assert.Contains("\"max_results\":4", requestBodyJson); + } + + [Fact] + public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); + ITextSearch textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + var searchOptions = new TextSearchOptions + { + Top = 3, + Skip = 0 + }; + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + + // Assert - Verify generic interface returns results + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotEmpty(resultList); + // Results are still objects (TavilySearchResult) not TavilyWebPage since that's just for filtering + + // Verify the request was made correctly + var requestContents = this._messageHandlerStub.RequestContents; + Assert.Single(requestContents); + Assert.NotNull(requestContents[0]); + var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!); + Assert.Contains("\"max_results\":3", requestBodyJson); + } + + [Fact] + public async Task GenericGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); + ITextSearch textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act + var searchOptions = new TextSearchOptions + { + Top = 5, + Skip = 0 + }; + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + + // Assert - Verify generic interface returns TextSearchResult objects + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotEmpty(resultList); + Assert.All(resultList, item => Assert.IsType(item)); + + // Verify the request was made correctly + var requestContents = this._messageHandlerStub.RequestContents; + Assert.Single(requestContents); + Assert.NotNull(requestContents[0]); + var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!); + Assert.Contains("\"max_results\":5", requestBodyJson); + } + + [Fact] + public async Task CollectionContainsFilterThrowsNotSupportedExceptionAsync() + { + // Arrange - Tests both Enumerable.Contains (C# 13-) and MemoryExtensions.Contains (C# 14+) + // The same code array.Contains() 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 due to "first-class spans") + // Our implementation handles both identically since Tavily API has limited query operators + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); + ITextSearch textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + string[] domains = ["microsoft.com", "github.com"]; + + // Act & Assert - Verify that collection Contains pattern throws clear exception + var searchOptions = new TextSearchOptions + { + Top = 5, + Skip = 0, + Filter = page => domains.Contains(page.Url!.ToString()) // Enumerable.Contains (C# 13-) or MemoryExtensions.Contains (C# 14+) + }; + + var exception = await Assert.ThrowsAsync(async () => + { + await textSearch.SearchAsync("test", searchOptions); + }); + + // Assert - Verify error message explains the limitation clearly + Assert.Contains("Collection Contains filters", exception.Message); + Assert.Contains("not supported", exception.Message); + } + + [Fact] + public async Task StringContainsStillWorksWithLINQFiltersAsync() + { + // Arrange - Verify that String.Contains (instance method) still works + // String.Contains is NOT affected by C# 14 "first-class spans" - only arrays are + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); + ITextSearch textSearch = new TavilyTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + + // Act - String.Contains should continue to work + var searchOptions = new TextSearchOptions + { + Top = 5, + Skip = 0, + Filter = page => page.Title.Contains("Kernel") // String.Contains - instance method + }; + KernelSearchResults result = await textSearch.SearchAsync("Semantic Kernel tutorial", searchOptions); + + // Assert - Verify String.Contains works correctly + var requestContents = this._messageHandlerStub.RequestContents; + Assert.Single(requestContents); + Assert.NotNull(requestContents[0]); + var requestBodyJson = Encoding.UTF8.GetString(requestContents[0]!); + Assert.Contains("Kernel", requestBodyJson); + Assert.Contains("\"max_results\":5", requestBodyJson); + } + + #endregion + #region private private const string WhatIsTheSKResponseJson = "./TestData/tavily_what_is_the_semantic_kernel.json"; private const string SiteFilterDevBlogsResponseJson = "./TestData/tavily_site_filter_devblogs_microsoft.com.json"; diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs index f2e71c562f0b..dfd6da1316c2 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs @@ -155,8 +155,16 @@ private TextSearchOptions ConvertToLegacyOptions(TextSearchOptions(TextSearchOptions Date: Sun, 2 Nov 2025 21:39:28 -0800 Subject: [PATCH 07/14] Fix API type changes to return TRecord instead of object - Update BraveTextSearch and TavilyTextSearch to properly implement ITextSearch - Fix unit tests to use strongly-typed generic interfaces - Ensure backward compatibility with legacy ITextSearch interface - All 105 text search tests passing, full validation completed Resolves API type safety issues described in How-to-Fix-API-Type-Change-to-TRecord-PR5.md --- .../Web/Brave/BraveTextSearchTests.cs | 16 +++++----- .../Web/Tavily/TavilyTextSearchTests.cs | 4 +-- .../Plugins.Web/Brave/BraveTextSearch.cs | 30 +++++++++++-------- .../Plugins.Web/Tavily/TavilyTextSearch.cs | 6 ++-- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs index 0a1a1f2801f1..ca8e8394460b 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs @@ -100,10 +100,10 @@ public async Task GetSearchResultsReturnsSuccessfullyAsync() this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); // Create an ITextSearch instance using Brave search - var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + ITextSearch textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act - KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); // Assert Assert.NotNull(result); @@ -111,7 +111,7 @@ public async Task GetSearchResultsReturnsSuccessfullyAsync() var resultList = await result.Results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(10, resultList.Count); - foreach (BraveWebResult webPage in resultList) + foreach (BraveWebPage webPage in resultList) { Assert.NotNull(webPage.Title); Assert.NotNull(webPage.Description); @@ -191,12 +191,12 @@ public async Task BuildsCorrectUriForEqualityFilterAsync(string paramName, objec // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterSkResponseJson)); - // Create an ITextSearch instance using Brave search + // Create an ITextSearch instance using Brave search var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act TextSearchOptions searchOptions = new() { Top = 5, Skip = 0, Filter = new TextSearchFilter().Equality(paramName, paramValue) }; - KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + var result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); // Assert var requestUris = this._messageHandlerStub.RequestUris; @@ -287,14 +287,14 @@ public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() Top = 3, Skip = 0 }; - KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); // Assert - Verify generic interface returns results Assert.NotNull(result); Assert.NotNull(result.Results); var resultList = await result.Results.ToListAsync(); Assert.NotEmpty(resultList); - // Results are still objects (BraveSearchResult) not BraveWebPage since that's just for filtering + // Results are now strongly typed as BraveWebPage // Verify the request was made correctly var requestUris = this._messageHandlerStub.RequestUris; @@ -419,7 +419,7 @@ public TextSearchResult MapFromResultToTextSearchResult(object result) { if (result is not BraveWebResult webPage) { - throw new ArgumentException("Result must be a BraveWebPage", nameof(result)); + throw new ArgumentException("Result must be a BraveWebResult", nameof(result)); } return new TextSearchResult(webPage.Description?.ToUpperInvariant() ?? string.Empty) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index c78cc28389b1..dc812eda0fbb 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -392,14 +392,14 @@ public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() Top = 3, Skip = 0 }; - KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions); // Assert - Verify generic interface returns results Assert.NotNull(result); Assert.NotNull(result.Results); var resultList = await result.Results.ToListAsync(); Assert.NotEmpty(resultList); - // Results are still objects (TavilySearchResult) not TavilyWebPage since that's just for filtering + // Results are now strongly typed as TavilyWebPage // Verify the request was made correctly var requestContents = this._messageHandlerStub.RequestContents; diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs index dfd6da1316c2..ecad8f201ea5 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs @@ -78,7 +78,7 @@ public async Task> GetSearchResultsAsync(string quer long? totalCount = searchOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; - return new KernelSearchResults(this.GetResultsAsWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + return new KernelSearchResults(this.GetResultsAsObjectAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } #region Generic ITextSearch Implementation @@ -106,14 +106,14 @@ async Task> ITextSearch.GetT } /// - async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { var legacyOptions = this.ConvertToLegacyOptions(searchOptions); BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; - return new KernelSearchResults(this.GetResultsAsBraveWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + return new KernelSearchResults(this.GetResultsAsBraveWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } #endregion @@ -548,21 +548,27 @@ private async Task SendGetRequestAsync(string query, TextSe } /// - /// Return the search results as instances of . + /// Return the search results as instances of . /// /// Response containing the web pages matching the query. /// Cancellation token - private async IAsyncEnumerable GetResultsAsWebPageAsync(BraveSearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + private async IAsyncEnumerable GetResultsAsObjectAsync(BraveSearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) { - if (searchResponse is null) { yield break; } + if (searchResponse?.Web?.Results is null) + { + yield break; + } - if (searchResponse.Web?.Results is { Count: > 0 } webResults) + foreach (var result in searchResponse.Web.Results) { - foreach (var webPage in webResults) + yield return new BraveWebPage { - yield return webPage; - await Task.Yield(); - } + Title = result.Title, + Url = string.IsNullOrWhiteSpace(result.Url) ? null : new Uri(result.Url), + Description = result.Description, + }; + + await Task.Yield(); } } @@ -571,7 +577,7 @@ private async IAsyncEnumerable GetResultsAsWebPageAsync(BraveSearchRespo /// /// Response containing the web pages matching the query. /// Cancellation token - private async IAsyncEnumerable GetResultsAsBraveWebPageAsync(BraveSearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + private async IAsyncEnumerable GetResultsAsBraveWebPageAsync(BraveSearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) { if (searchResponse is null) { yield break; } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index 6b0eb8742522..33b4bc827934 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -103,14 +103,14 @@ async Task> ITextSearch.Get } /// - async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { var legacyOptions = this.ConvertToLegacyOptions(searchOptions); TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = null; - return new KernelSearchResults(this.GetResultsAsWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + return new KernelSearchResults(this.GetResultsAsWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } #endregion @@ -553,7 +553,7 @@ private async IAsyncEnumerable GetSearchResultsAsync(TavilySearchRespons /// /// Response containing the web pages matching the query. /// Cancellation token - private async IAsyncEnumerable GetResultsAsWebPageAsync(TavilySearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + private async IAsyncEnumerable GetResultsAsWebPageAsync(TavilySearchResponse? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) { if (searchResponse is null || searchResponse.Results is null) { From 9f4dec3406358f11fde2914b43e8716d267327d7 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Thu, 6 Nov 2025 00:31:42 -0800 Subject: [PATCH 08/14] =?UTF-8?q?Correct=20approach:=20Revert=20unnecessar?= =?UTF-8?q?y=20non-generic=20test=20changes=20and=20rename=20Generic?= =?UTF-8?q?=E2=86=92Linq?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert GetSearchResultsReturnsSuccessfullyAsync to use non-generic interface (var textSearch, KernelSearchResults) - This test should test backward compatibility with legacy ITextSearch interface - Only generic interface tests should use ITextSearch and KernelSearchResults - Rename 'Generic' test methods to 'Linq' to match naming convention from other PRs: - GenericSearchAsyncReturnsResultsSuccessfullyAsync → LinqSearchAsyncReturnsResultsSuccessfullyAsync - GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync → LinqGetSearchResultsAsyncReturnsResultsSuccessfullyAsync - GenericGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync → LinqGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync - Applied to both BraveTextSearchTests.cs and TavilyTextSearchTests.cs - All 105 text search tests still passing Fix maintains proper test separation: - Non-generic tests: Test legacy ITextSearch interface compatibility - LINQ tests: Test new ITextSearch strongly-typed interface --- .../Web/Brave/BraveTextSearchTests.cs | 14 +++++++------- .../Web/Tavily/TavilyTextSearchTests.cs | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs index ca8e8394460b..84f7a3a478e9 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs @@ -100,10 +100,10 @@ public async Task GetSearchResultsReturnsSuccessfullyAsync() this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); // Create an ITextSearch instance using Brave search - ITextSearch textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); + var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act - KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + KernelSearchResults result = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); // Assert Assert.NotNull(result); @@ -111,7 +111,7 @@ public async Task GetSearchResultsReturnsSuccessfullyAsync() var resultList = await result.Results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(10, resultList.Count); - foreach (BraveWebPage webPage in resultList) + foreach (BraveWebPage webPage in resultList.Cast()) { Assert.NotNull(webPage.Title); Assert.NotNull(webPage.Description); @@ -191,7 +191,7 @@ public async Task BuildsCorrectUriForEqualityFilterAsync(string paramName, objec // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterSkResponseJson)); - // Create an ITextSearch instance using Brave search + // Create an ITextSearch instance using Brave search var textSearch = new BraveTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient }); // Act @@ -247,7 +247,7 @@ public void Dispose() #region Generic ITextSearch Interface Tests [Fact] - public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync() + public async Task LinqSearchAsyncReturnsResultsSuccessfullyAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); @@ -275,7 +275,7 @@ public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync() } [Fact] - public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() + public async Task LinqGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); @@ -304,7 +304,7 @@ public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() } [Fact] - public async Task GenericGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync() + public async Task LinqGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSkResponseJson)); diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs index dc812eda0fbb..c51dbb769e34 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs @@ -350,7 +350,7 @@ public void Dispose() #region Generic ITextSearch Interface Tests [Fact] - public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync() + public async Task LinqSearchAsyncReturnsResultsSuccessfullyAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); @@ -380,7 +380,7 @@ public async Task GenericSearchAsyncReturnsResultsSuccessfullyAsync() } [Fact] - public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() + public async Task LinqGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); @@ -410,7 +410,7 @@ public async Task GenericGetSearchResultsAsyncReturnsResultsSuccessfullyAsync() } [Fact] - public async Task GenericGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync() + public async Task LinqGetTextSearchResultsAsyncReturnsResultsSuccessfullyAsync() { // Arrange this._messageHandlerStub.AddJsonResponse(File.ReadAllText(SiteFilterDevBlogsResponseJson)); From bcb307dbd5111e1d1e7757e8fd128a266b9e4838 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Thu, 6 Nov 2025 02:29:52 -0800 Subject: [PATCH 09/14] feat: Implement Title.Contains() support for Brave and Tavily text search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SearchQueryFilterClause for query enhancement pattern - Implement Title.Contains() → query modification for Brave and Tavily APIs - Support LINQ expressions: results.Where(r => r.Title.Contains('AI')) - Query enhancement: 'base query' + ' AI' for improved relevance - Maintains backward compatibility with legacy ITextSearch interface - All 130 web plugin tests passing Technical approach: - SearchQueryFilterClause: New filter clause type for search term addition - ConvertToLegacyOptionsWithQuery: Extract and append search terms to query - Different from Bing (direct operators) and Google (API parameters) - Optimized for APIs without field-specific search capabilities --- .../Plugins.Web/Brave/BraveTextSearch.cs | 90 +++++++++++++++---- .../Plugins.Web/Tavily/TavilyTextSearch.cs | 90 +++++++++++++++---- .../FilterClauses/SearchQueryFilterClause.cs | 36 ++++++++ 3 files changed, 180 insertions(+), 36 deletions(-) create mode 100644 dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs diff --git a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs index ecad8f201ea5..e7b6eab6f780 100644 --- a/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs @@ -86,8 +86,8 @@ public async Task> GetSearchResultsAsync(string quer /// async Task> ITextSearch.SearchAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { - var legacyOptions = this.ConvertToLegacyOptions(searchOptions); - BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + var (modifiedQuery, legacyOptions) = this.ConvertToLegacyOptionsWithQuery(query, searchOptions); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(modifiedQuery, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; @@ -97,8 +97,8 @@ async Task> ITextSearch.SearchAsync(st /// async Task> ITextSearch.GetTextSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { - var legacyOptions = this.ConvertToLegacyOptions(searchOptions); - BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + var (modifiedQuery, legacyOptions) = this.ConvertToLegacyOptionsWithQuery(query, searchOptions); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(modifiedQuery, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; @@ -108,8 +108,8 @@ async Task> ITextSearch.GetT /// async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { - var legacyOptions = this.ConvertToLegacyOptions(searchOptions); - BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + var (modifiedQuery, legacyOptions) = this.ConvertToLegacyOptionsWithQuery(query, searchOptions); + BraveSearchResponse? searchResponse = await this.ExecuteSearchAsync(modifiedQuery, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null; @@ -120,6 +120,31 @@ async Task> ITextSearch.GetSearc #region LINQ-to-Brave Conversion Logic + /// + /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions and extracts additional search terms. + /// + /// The original search query. + /// The generic search options with LINQ filter. + /// A tuple containing the modified query and legacy TextSearchOptions with converted filters. + private (string modifiedQuery, TextSearchOptions legacyOptions) ConvertToLegacyOptionsWithQuery(string query, TextSearchOptions? options) + { + var legacyOptions = this.ConvertToLegacyOptions(options); + + if (options?.Filter != null) + { + // Extract search terms from the LINQ expression + var additionalSearchTerms = ExtractSearchTermsFromLinqExpression(options.Filter); + if (additionalSearchTerms.Count > 0) + { + // Append additional search terms to the original query + var modifiedQuery = $"{query} {string.Join(" ", additionalSearchTerms)}".Trim(); + return (modifiedQuery, legacyOptions); + } + } + + return (query, legacyOptions); + } + /// /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions. /// @@ -153,24 +178,42 @@ private TextSearchOptions ConvertToLegacyOptions(TextSearchOptions + /// Extracts search terms that should be added to the search query from a LINQ expression. + /// + /// The LINQ expression to analyze. + /// A list of search terms to add to the query. + private static List ExtractSearchTermsFromLinqExpression(Expression> linqExpression) + { + var searchTerms = new List(); + var filterClauses = new List(); + + // Analyze the LINQ expression to get all filter clauses + AnalyzeExpression(linqExpression.Body, filterClauses); + + // Extract search terms from SearchQueryFilterClause instances + foreach (var clause in filterClauses) + { + if (clause is SearchQueryFilterClause searchQueryClause) + { + searchTerms.Add(searchQueryClause.SearchTerm); + } + } + + return searchTerms; + } + /// /// Converts a LINQ expression to Brave-compatible TextSearchFilter. /// @@ -202,6 +245,12 @@ private static TextSearchFilter ConvertLinqExpressionToBraveFilter(Expr "Example: page => page.Country == \"US\" && page.SafeSearch == \"moderate\""); } } + else if (clause is SearchQueryFilterClause) + { + // SearchQueryFilterClause is handled at the query level, not the filter level + // Skip it here as it's processed by ConvertToLegacyOptionsWithQuery + continue; + } } return filter; @@ -391,9 +440,14 @@ private static void ExtractMethodCallClause(MethodCallExpression methodCall, Lis { filterClauses.Add(new EqualToFilterClause(propertyName, value)); } + else if (propertyName.Equals("Title", StringComparison.OrdinalIgnoreCase)) + { + // For Title.Contains(), add the term to the search query itself + filterClauses.Add(new SearchQueryFilterClause(value.ToString() ?? string.Empty)); + } else { - throw new NotSupportedException($"Contains method is only supported for ResultFilter property, not '{propertyName}'."); + throw new NotSupportedException($"Contains method is only supported for ResultFilter and Title properties, not '{propertyName}'."); } } } diff --git a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs index 33b4bc827934..ab06f08cb9ad 100644 --- a/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Tavily/TavilyTextSearch.cs @@ -83,8 +83,8 @@ public async Task> GetSearchResultsAsync(string quer /// async Task> ITextSearch.SearchAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { - var legacyOptions = this.ConvertToLegacyOptions(searchOptions); - TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + var (modifiedQuery, legacyOptions) = this.ConvertToLegacyOptionsWithQuery(query, searchOptions); + TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(modifiedQuery, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = null; @@ -94,8 +94,8 @@ async Task> ITextSearch.SearchAsync(s /// async Task> ITextSearch.GetTextSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { - var legacyOptions = this.ConvertToLegacyOptions(searchOptions); - TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + var (modifiedQuery, legacyOptions) = this.ConvertToLegacyOptionsWithQuery(query, searchOptions); + TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(modifiedQuery, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = null; @@ -105,8 +105,8 @@ async Task> ITextSearch.Get /// async Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { - var legacyOptions = this.ConvertToLegacyOptions(searchOptions); - TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + var (modifiedQuery, legacyOptions) = this.ConvertToLegacyOptionsWithQuery(query, searchOptions); + TavilySearchResponse? searchResponse = await this.ExecuteSearchAsync(modifiedQuery, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = null; @@ -117,6 +117,31 @@ async Task> ITextSearch.GetSea #region LINQ-to-Tavily Conversion Logic + /// + /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions and extracts additional search terms. + /// + /// The original search query. + /// The generic search options with LINQ filter. + /// A tuple containing the modified query and legacy TextSearchOptions with converted filters. + private (string modifiedQuery, TextSearchOptions legacyOptions) ConvertToLegacyOptionsWithQuery(string query, TextSearchOptions? options) + { + var legacyOptions = this.ConvertToLegacyOptions(options); + + if (options?.Filter != null) + { + // Extract search terms from the LINQ expression + var additionalSearchTerms = ExtractSearchTermsFromLinqExpression(options.Filter); + if (additionalSearchTerms.Count > 0) + { + // Append additional search terms to the original query + var modifiedQuery = $"{query} {string.Join(" ", additionalSearchTerms)}".Trim(); + return (modifiedQuery, legacyOptions); + } + } + + return (query, legacyOptions); + } + /// /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions. /// @@ -150,24 +175,42 @@ private TextSearchOptions ConvertToLegacyOptions(TextSearchOptions + /// Extracts search terms that should be added to the search query from a LINQ expression. + /// + /// The LINQ expression to analyze. + /// A list of search terms to add to the query. + private static List ExtractSearchTermsFromLinqExpression(Expression> linqExpression) + { + var searchTerms = new List(); + var filterClauses = new List(); + + // Analyze the LINQ expression to get all filter clauses + AnalyzeExpression(linqExpression.Body, filterClauses); + + // Extract search terms from SearchQueryFilterClause instances + foreach (var clause in filterClauses) + { + if (clause is SearchQueryFilterClause searchQueryClause) + { + searchTerms.Add(searchQueryClause.SearchTerm); + } + } + + return searchTerms; + } + /// /// Converts a LINQ expression to Tavily-compatible TextSearchFilter. /// @@ -199,6 +242,12 @@ private static TextSearchFilter ConvertLinqExpressionToTavilyFilter(Exp "Example: page => page.Topic == \"general\" && page.TimeRange == \"week\""); } } + else if (clause is SearchQueryFilterClause) + { + // SearchQueryFilterClause is handled at the query level, not the filter level + // Skip it here as it's processed by ConvertToLegacyOptionsWithQuery + continue; + } } return filter; @@ -384,9 +433,14 @@ private static void ExtractMethodCallClause(MethodCallExpression methodCall, Lis { filterClauses.Add(new EqualToFilterClause(propertyName, value)); } + else if (propertyName.Equals("Title", StringComparison.OrdinalIgnoreCase)) + { + // For Title.Contains(), add the term to the search query itself + filterClauses.Add(new SearchQueryFilterClause(value.ToString() ?? string.Empty)); + } else { - throw new NotSupportedException($"Contains method is only supported for domain properties (IncludeDomain, ExcludeDomain), not '{propertyName}'."); + throw new NotSupportedException($"Contains method is only supported for domain properties (IncludeDomain, ExcludeDomain) and Title, not '{propertyName}'."); } } } diff --git a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs new file mode 100644 index 000000000000..5a284fad2146 --- /dev/null +++ b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Extensions.VectorData; + +/// +/// Represents a filter clause that adds terms to the search query itself for text search engines. +/// +/// +/// This filter clause is used when the underlying search service should add the specified +/// terms to the search query to help find matching results, rather than filtering results +/// after they are returned. +/// +/// Primary use case: Supporting Title.Contains("value") LINQ expressions for search engines +/// that don't have field-specific operators (e.g., Brave, Tavily). The implementation extracts +/// the search term and appends it to the base query for enhanced relevance. +/// +/// Example: Title.Contains("AI") → SearchQueryFilterClause("AI") → query + " AI" +/// +/// See ADR-TextSearch-Contains-Support.md for architectural context and cross-engine comparison. +/// +public sealed class SearchQueryFilterClause : FilterClause +{ + /// + /// Initializes a new instance of the class. + /// + /// The term to add to the search query. + public SearchQueryFilterClause(string searchTerm) + { + this.SearchTerm = searchTerm; + } + + /// + /// Gets the search term to add to the query. + /// + public string SearchTerm { get; private set; } +} \ No newline at end of file From 119776249327c8931602c96f59b16c6ee9a09191 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Thu, 6 Nov 2025 22:25:56 -0800 Subject: [PATCH 10/14] fix: Remove trailing whitespace from SearchQueryFilterClause.cs - Fix RCS1037 trailing whitespace errors on lines 12, 16, 18 - Add missing final newline to prevent FINALNEWLINE error - Fix file encoding issues (BOM removal) - Addresses GitHub CI/CD build failures for net8.0, net462, netstandard2.0 targets All formatting issues resolved to match GitHub Actions requirements. --- .../FilterClauses/SearchQueryFilterClause.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs index 5a284fad2146..ee8bf17a84be 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs +++ b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Extensions.VectorData; @@ -9,13 +9,13 @@ namespace Microsoft.Extensions.VectorData; /// This filter clause is used when the underlying search service should add the specified /// terms to the search query to help find matching results, rather than filtering results /// after they are returned. -/// +/// /// Primary use case: Supporting Title.Contains("value") LINQ expressions for search engines /// that don't have field-specific operators (e.g., Brave, Tavily). The implementation extracts /// the search term and appends it to the base query for enhanced relevance. -/// +/// /// Example: Title.Contains("AI") → SearchQueryFilterClause("AI") → query + " AI" -/// +/// /// See ADR-TextSearch-Contains-Support.md for architectural context and cross-engine comparison. /// public sealed class SearchQueryFilterClause : FilterClause @@ -33,4 +33,4 @@ public SearchQueryFilterClause(string searchTerm) /// Gets the search term to add to the query. /// public string SearchTerm { get; private set; } -} \ No newline at end of file +} From a3113a94a38f773eca25b0fefa677857bd61ba8a Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Wed, 12 Nov 2025 04:13:54 -0800 Subject: [PATCH 11/14] refactor: Make SearchQueryFilterClause internal with InternalsVisibleTo Changed SearchQueryFilterClause from public to internal in VectorData.Abstractions to reduce public API surface area per reviewer feedback. Since FilterClause has an internal constructor, SearchQueryFilterClause cannot be moved to Plugins.Web. Using InternalsVisibleTo pattern (already used 20+ times in codebase) to grant Plugins.Web access to this implementation detail. - Changed 'public sealed class' to 'internal sealed class' in SearchQueryFilterClause.cs - Added InternalsVisibleTo for Microsoft.SemanticKernel.Plugins.Web in VectorData.Abstractions.csproj - Verified: 0 warnings, 0 errors, 54 web plugin tests passing --- .../FilterClauses/SearchQueryFilterClause.cs | 2 +- .../VectorData.Abstractions/VectorData.Abstractions.csproj | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs index ee8bf17a84be..c454f7e5bce0 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs +++ b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs @@ -18,7 +18,7 @@ namespace Microsoft.Extensions.VectorData; /// /// See ADR-TextSearch-Contains-Support.md for architectural context and cross-engine comparison. /// -public sealed class SearchQueryFilterClause : FilterClause +internal sealed class SearchQueryFilterClause : FilterClause { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj index 775ccb90c79f..9f4141d235ea 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj +++ b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj @@ -36,6 +36,10 @@ Microsoft.Extensions.VectorData.IVectorStoreRecordCollection<TKey, TRecord> + + + + From a689b0ccf8ac06c5b85d9ccaa513952c98d4a458 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Wed, 12 Nov 2025 05:33:59 -0800 Subject: [PATCH 12/14] refactor: Make SearchQueryFilterClause public (revert internal approach) Reverting the internal + InternalsVisibleTo approach per reviewer feedback. Making SearchQueryFilterClause public is the cleanest solution: - Legitimate, reusable abstraction for text search scenarios - Respects FilterClause architecture (stays in VectorData.Abstractions) - No InternalsVisibleTo complexity or CS0436 warnings - Precedent: Other FilterClause types are public (EqualToFilterClause, etc.) - Other search connectors can benefit from this pattern Trade-off: Adds one class to VectorData.Abstractions public API surface, but it's a well-scoped, documented filter clause pattern with clear use cases. Addresses reviewer question about SearchQueryFilterClause visibility. --- .../FilterClauses/SearchQueryFilterClause.cs | 2 +- .../VectorData.Abstractions/VectorData.Abstractions.csproj | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs index c454f7e5bce0..ee8bf17a84be 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs +++ b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs @@ -18,7 +18,7 @@ namespace Microsoft.Extensions.VectorData; /// /// See ADR-TextSearch-Contains-Support.md for architectural context and cross-engine comparison. /// -internal sealed class SearchQueryFilterClause : FilterClause +public sealed class SearchQueryFilterClause : FilterClause { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj index 9f4141d235ea..775ccb90c79f 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj +++ b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj @@ -36,10 +36,6 @@ Microsoft.Extensions.VectorData.IVectorStoreRecordCollection<TKey, TRecord> - - - - From b637c5911a62e8958602c49a7ecef610ab2c36c6 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Fri, 14 Nov 2025 21:42:18 -0800 Subject: [PATCH 13/14] Move SearchQueryFilterClause to Plugins.Web and make FilterClause constructor public Per reviewer feedback, moving SearchQueryFilterClause from Microsoft.Extensions.VectorData to Microsoft.SemanticKernel.Plugins.Web namespace as internal class, where it's actually used (Brave/Tavily connectors). Changes: - FilterClause.cs: Changed constructor from 'internal' to 'public' to allow external inheritance - SearchQueryFilterClause.cs: Moved to Plugins.Web/FilterClauses/ as internal sealed class - VectorData.Abstractions.csproj: Added CA1012 suppression (abstract types with public constructors) Trade-off: Opening FilterClause to external inheritance is accepted to minimize MEVD public API surface. --- .../Plugins.Web}/FilterClauses/SearchQueryFilterClause.cs | 6 ++++-- .../VectorData.Abstractions/FilterClauses/FilterClause.cs | 5 ++++- .../VectorData.Abstractions/VectorData.Abstractions.csproj | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) rename dotnet/src/{VectorData/VectorData.Abstractions => Plugins/Plugins.Web}/FilterClauses/SearchQueryFilterClause.cs (89%) diff --git a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs b/dotnet/src/Plugins/Plugins.Web/FilterClauses/SearchQueryFilterClause.cs similarity index 89% rename from dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs rename to dotnet/src/Plugins/Plugins.Web/FilterClauses/SearchQueryFilterClause.cs index ee8bf17a84be..9909da9579e6 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/SearchQueryFilterClause.cs +++ b/dotnet/src/Plugins/Plugins.Web/FilterClauses/SearchQueryFilterClause.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. -namespace Microsoft.Extensions.VectorData; +using Microsoft.Extensions.VectorData; + +namespace Microsoft.SemanticKernel.Plugins.Web; /// /// Represents a filter clause that adds terms to the search query itself for text search engines. @@ -18,7 +20,7 @@ namespace Microsoft.Extensions.VectorData; /// /// See ADR-TextSearch-Contains-Support.md for architectural context and cross-engine comparison. /// -public sealed class SearchQueryFilterClause : FilterClause +internal sealed class SearchQueryFilterClause : FilterClause { /// /// Initializes a new instance of the class. diff --git a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs index af0c1dac51b3..8fb80d3db018 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs +++ b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs @@ -11,7 +11,10 @@ namespace Microsoft.Extensions.VectorData; /// public abstract class FilterClause { - internal FilterClause() + /// + /// Initializes a new instance of the class. + /// + public FilterClause() { } } diff --git a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj index 775ccb90c79f..0095ba306326 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj +++ b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj @@ -7,6 +7,8 @@ true false + + $(NoWarn);CA1012 From 7d39c882c964edab2e3c1ac868e433a4446e84d7 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Wed, 19 Nov 2025 04:52:10 -0800 Subject: [PATCH 14/14] make FilterClause constructor protected to address CA1012 warning --- .../VectorData.Abstractions/FilterClauses/FilterClause.cs | 2 +- .../VectorData.Abstractions/VectorData.Abstractions.csproj | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs index 8fb80d3db018..be72560ffc2f 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs +++ b/dotnet/src/VectorData/VectorData.Abstractions/FilterClauses/FilterClause.cs @@ -14,7 +14,7 @@ public abstract class FilterClause /// /// Initializes a new instance of the class. /// - public FilterClause() + protected FilterClause() { } } diff --git a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj index 0095ba306326..775ccb90c79f 100644 --- a/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj +++ b/dotnet/src/VectorData/VectorData.Abstractions/VectorData.Abstractions.csproj @@ -7,8 +7,6 @@ true false - - $(NoWarn);CA1012