Skip to content

Commit 92f783e

Browse files
committed
feat: Modernize TavilyTextSearch and BraveTextSearch with ITextSearch<TRecord> interface
- Add TavilyWebPage and BraveWebPage model classes for type-safe LINQ filtering - Implement dual interface support (ITextSearch + ITextSearch<TRecord>) 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 #10456
1 parent b94f3f3 commit 92f783e

4 files changed

Lines changed: 814 additions & 4 deletions

File tree

dotnet/src/Plugins/Plugins.Web/Brave/BraveTextSearch.cs

Lines changed: 280 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System;
44
using System.Collections.Generic;
55
using System.Linq;
6+
using System.Linq.Expressions;
67
using System.Net.Http;
78
using System.Runtime.CompilerServices;
89
using System.Text;
@@ -20,7 +21,7 @@ namespace Microsoft.SemanticKernel.Plugins.Web.Brave;
2021
/// <summary>
2122
/// A Brave Text Search implementation that can be used to perform searches using the Brave Web Search API.
2223
/// </summary>
23-
public sealed class BraveTextSearch : ITextSearch
24+
public sealed class BraveTextSearch : ITextSearch, ITextSearch<BraveWebPage>
2425
{
2526
/// <summary>
2627
/// Create an instance of the <see cref="BraveTextSearch"/> with API key authentication.
@@ -78,7 +79,265 @@ public async Task<KernelSearchResults<object>> GetSearchResultsAsync(string quer
7879
return new KernelSearchResults<object>(this.GetResultsAsWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse));
7980
}
8081

81-
#region private
82+
#region Generic ITextSearch<BraveWebPage> Implementation
83+
84+
/// <inheritdoc/>
85+
async Task<KernelSearchResults<string>> ITextSearch<BraveWebPage>.SearchAsync(string query, TextSearchOptions<BraveWebPage>? searchOptions, CancellationToken cancellationToken)
86+
{
87+
var legacyOptions = this.ConvertToLegacyOptions(searchOptions);
88+
BraveSearchResponse<BraveWebResult>? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false);
89+
90+
long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null;
91+
92+
return new KernelSearchResults<string>(this.GetResultsAsStringAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse));
93+
}
94+
95+
/// <inheritdoc/>
96+
async Task<KernelSearchResults<TextSearchResult>> ITextSearch<BraveWebPage>.GetTextSearchResultsAsync(string query, TextSearchOptions<BraveWebPage>? searchOptions, CancellationToken cancellationToken)
97+
{
98+
var legacyOptions = this.ConvertToLegacyOptions(searchOptions);
99+
BraveSearchResponse<BraveWebResult>? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false);
100+
101+
long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null;
102+
103+
return new KernelSearchResults<TextSearchResult>(this.GetResultsAsTextSearchResultAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse));
104+
}
105+
106+
/// <inheritdoc/>
107+
async Task<KernelSearchResults<object>> ITextSearch<BraveWebPage>.GetSearchResultsAsync(string query, TextSearchOptions<BraveWebPage>? searchOptions, CancellationToken cancellationToken)
108+
{
109+
var legacyOptions = this.ConvertToLegacyOptions(searchOptions);
110+
BraveSearchResponse<BraveWebResult>? searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false);
111+
112+
long? totalCount = legacyOptions.IncludeTotalCount ? searchResponse?.Web?.Results.Count : null;
113+
114+
return new KernelSearchResults<object>(this.GetResultsAsBraveWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse));
115+
}
116+
117+
#endregion
118+
119+
#region LINQ-to-Brave Conversion Logic
120+
121+
/// <summary>
122+
/// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions.
123+
/// </summary>
124+
/// <param name="options">The generic search options with LINQ filter.</param>
125+
/// <returns>Legacy TextSearchOptions with converted filters.</returns>
126+
private TextSearchOptions ConvertToLegacyOptions<TRecord>(TextSearchOptions<TRecord>? options)
127+
{
128+
if (options == null)
129+
{
130+
return new TextSearchOptions();
131+
}
132+
133+
var legacyOptions = new TextSearchOptions
134+
{
135+
Top = options.Top,
136+
Skip = options.Skip,
137+
IncludeTotalCount = options.IncludeTotalCount
138+
};
139+
140+
// Convert LINQ expression to TextSearchFilter if present
141+
if (options.Filter != null)
142+
{
143+
try
144+
{
145+
var convertedFilter = ConvertLinqExpressionToBraveFilter(options.Filter);
146+
legacyOptions = new TextSearchOptions
147+
{
148+
Top = options.Top,
149+
Skip = options.Skip,
150+
IncludeTotalCount = options.IncludeTotalCount,
151+
Filter = convertedFilter
152+
};
153+
}
154+
catch (NotSupportedException ex)
155+
{
156+
this._logger.LogWarning("LINQ expression not fully supported by Brave API, performing search without some filters: {Message}", ex.Message);
157+
// Continue with basic search - graceful degradation
158+
}
159+
}
160+
161+
return legacyOptions;
162+
}
163+
164+
/// <summary>
165+
/// Converts a LINQ expression to Brave-compatible TextSearchFilter.
166+
/// </summary>
167+
/// <param name="linqExpression">The LINQ expression to convert.</param>
168+
/// <returns>A TextSearchFilter with Brave-compatible filter clauses.</returns>
169+
private static TextSearchFilter ConvertLinqExpressionToBraveFilter<TRecord>(Expression<Func<TRecord, bool>> linqExpression)
170+
{
171+
var filter = new TextSearchFilter();
172+
var filterClauses = new List<FilterClause>();
173+
174+
// Analyze the LINQ expression and convert to filter clauses
175+
AnalyzeExpression(linqExpression.Body, filterClauses);
176+
177+
// Validate and add clauses that are supported by Brave
178+
foreach (var clause in filterClauses)
179+
{
180+
if (clause is EqualToFilterClause equalityClause)
181+
{
182+
var mappedFieldName = MapPropertyToBraveFilter(equalityClause.FieldName);
183+
if (mappedFieldName != null)
184+
{
185+
filter.Equality(mappedFieldName, equalityClause.Value);
186+
}
187+
else
188+
{
189+
throw new NotSupportedException($"Property '{equalityClause.FieldName}' cannot be mapped to Brave API filters. Supported properties: {string.Join(", ", s_queryParameters)}");
190+
}
191+
}
192+
}
193+
194+
return filter;
195+
}
196+
197+
/// <summary>
198+
/// Maps BraveWebPage property names to Brave API filter parameter names.
199+
/// </summary>
200+
/// <param name="propertyName">The property name from BraveWebPage.</param>
201+
/// <returns>The corresponding Brave API parameter name, or null if not mappable.</returns>
202+
private static string? MapPropertyToBraveFilter(string propertyName) =>
203+
propertyName.ToUpperInvariant() switch
204+
{
205+
"COUNTRY" => "country",
206+
"SEARCHLANG" => "search_lang",
207+
"UILANG" => "ui_lang",
208+
"SAFESEARCH" => "safesearch",
209+
"TEXTDECORATIONS" => "text_decorations",
210+
"SPELLCHECK" => "spellcheck",
211+
"RESULTFILTER" => "result_filter",
212+
"UNITS" => "units",
213+
"EXTRASNIPPETS" => "extra_snippets",
214+
_ => null // Property not mappable to Brave filters
215+
};
216+
217+
/// <summary>
218+
/// Analyzes a LINQ expression and extracts filter clauses.
219+
/// </summary>
220+
/// <param name="expression">The expression to analyze.</param>
221+
/// <param name="filterClauses">The list to add extracted filter clauses to.</param>
222+
private static void AnalyzeExpression(Expression expression, List<FilterClause> filterClauses)
223+
{
224+
switch (expression)
225+
{
226+
case BinaryExpression binaryExpr:
227+
if (binaryExpr.NodeType == ExpressionType.AndAlso)
228+
{
229+
// Handle AND expressions by recursively analyzing both sides
230+
AnalyzeExpression(binaryExpr.Left, filterClauses);
231+
AnalyzeExpression(binaryExpr.Right, filterClauses);
232+
}
233+
else if (binaryExpr.NodeType == ExpressionType.Equal)
234+
{
235+
// Handle equality expressions
236+
ExtractEqualityClause(binaryExpr, filterClauses);
237+
}
238+
else
239+
{
240+
throw new NotSupportedException($"Binary expression type '{binaryExpr.NodeType}' is not supported. Only AndAlso and Equal are supported.");
241+
}
242+
break;
243+
244+
case MethodCallExpression methodCall:
245+
// Handle method calls like Contains, StartsWith, etc.
246+
ExtractMethodCallClause(methodCall, filterClauses);
247+
break;
248+
249+
default:
250+
throw new NotSupportedException($"Expression type '{expression.NodeType}' is not supported in Brave search filters.");
251+
}
252+
}
253+
254+
/// <summary>
255+
/// Extracts an equality filter clause from a binary equality expression.
256+
/// </summary>
257+
/// <param name="binaryExpr">The binary equality expression.</param>
258+
/// <param name="filterClauses">The list to add the extracted clause to.</param>
259+
private static void ExtractEqualityClause(BinaryExpression binaryExpr, List<FilterClause> filterClauses)
260+
{
261+
string? propertyName = null;
262+
object? value = null;
263+
264+
// Determine which side is the property and which is the value
265+
if (binaryExpr.Left is MemberExpression leftMember)
266+
{
267+
propertyName = leftMember.Member.Name;
268+
value = ExtractValue(binaryExpr.Right);
269+
}
270+
else if (binaryExpr.Right is MemberExpression rightMember)
271+
{
272+
propertyName = rightMember.Member.Name;
273+
value = ExtractValue(binaryExpr.Left);
274+
}
275+
276+
if (propertyName != null && value != null)
277+
{
278+
filterClauses.Add(new EqualToFilterClause(propertyName, value));
279+
}
280+
else
281+
{
282+
throw new NotSupportedException("Unable to extract property name and value from equality expression.");
283+
}
284+
}
285+
286+
/// <summary>
287+
/// Extracts a filter clause from a method call expression (e.g., Contains, StartsWith).
288+
/// </summary>
289+
/// <param name="methodCall">The method call expression.</param>
290+
/// <param name="filterClauses">The list to add the extracted clause to.</param>
291+
private static void ExtractMethodCallClause(MethodCallExpression methodCall, List<FilterClause> filterClauses)
292+
{
293+
if (methodCall.Method.Name == "Contains" && methodCall.Object is MemberExpression member)
294+
{
295+
var propertyName = member.Member.Name;
296+
var value = ExtractValue(methodCall.Arguments[0]);
297+
298+
if (value != null)
299+
{
300+
// For Contains, we'll map it to equality for certain properties
301+
if (propertyName.Equals("ResultFilter", StringComparison.OrdinalIgnoreCase))
302+
{
303+
filterClauses.Add(new EqualToFilterClause(propertyName, value));
304+
}
305+
else
306+
{
307+
throw new NotSupportedException($"Contains method is only supported for ResultFilter property, not '{propertyName}'.");
308+
}
309+
}
310+
}
311+
else
312+
{
313+
throw new NotSupportedException($"Method '{methodCall.Method.Name}' is not supported in Brave search filters.");
314+
}
315+
}
316+
317+
/// <summary>
318+
/// Extracts a constant value from an expression.
319+
/// </summary>
320+
/// <param name="expression">The expression to extract the value from.</param>
321+
/// <returns>The extracted value, or null if extraction failed.</returns>
322+
private static object? ExtractValue(Expression expression)
323+
{
324+
return expression switch
325+
{
326+
ConstantExpression constant => constant.Value,
327+
MemberExpression member when member.Expression is ConstantExpression constantExpr =>
328+
member.Member switch
329+
{
330+
System.Reflection.FieldInfo field => field.GetValue(constantExpr.Value),
331+
System.Reflection.PropertyInfo property => property.GetValue(constantExpr.Value),
332+
_ => null
333+
},
334+
_ => Expression.Lambda(expression).Compile().DynamicInvoke()
335+
};
336+
}
337+
338+
#endregion
339+
340+
#region Private Methods
82341

83342
private readonly ILogger _logger;
84343
private readonly HttpClient _httpClient;
@@ -178,6 +437,25 @@ private async IAsyncEnumerable<object> GetResultsAsWebPageAsync(BraveSearchRespo
178437
}
179438
}
180439

440+
/// <summary>
441+
/// Return the search results as instances of <see cref="BraveWebPage"/>.
442+
/// </summary>
443+
/// <param name="searchResponse">Response containing the web pages matching the query.</param>
444+
/// <param name="cancellationToken">Cancellation token</param>
445+
private async IAsyncEnumerable<object> GetResultsAsBraveWebPageAsync(BraveSearchResponse<BraveWebResult>? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken)
446+
{
447+
if (searchResponse is null) { yield break; }
448+
449+
if (searchResponse.Web?.Results is { Count: > 0 } webResults)
450+
{
451+
foreach (var webPage in webResults)
452+
{
453+
yield return BraveWebPage.FromWebResult(webPage);
454+
await Task.Yield();
455+
}
456+
}
457+
}
458+
181459
/// <summary>
182460
/// Return the search results as instances of <see cref="TextSearchResult"/>.
183461
/// </summary>

0 commit comments

Comments
 (0)