33using System ;
44using System . Collections . Generic ;
55using System . Linq ;
6+ using System . Linq . Expressions ;
67using System . Net . Http ;
78using System . Runtime . CompilerServices ;
89using 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