Skip to content

Commit 1fcf4ee

Browse files
authored
.Net: Add provider-specific search parameters to Bing/Google connectors (#13580)
# .Net: Add provider-specific search parameters to Bing/Google connectors ## Motivation and Context Addresses [@roji's feedback](#13384 (review)) on PR #13384 (`feature-text-search-linq`). Several Bing/Google API search parameters (`mkt`, `freshness`, `safeSearch`, `cr`, `dateRestrict`, `gl`, etc.) have no corresponding properties on `BingWebPage`/`GoogleWebPage`, making them unreachable through the LINQ filter system. The only way to set them was via the deprecated `TextSearchFilter`. **Issue:** #10456 ## Description - **`BingTextSearchOptions`**: Added 10 request-side search parameters (`Market`, `Freshness`, `SafeSearch`, `CountryCode`, `SetLanguage`, `ResponseFilter`, `AnswerCount`, `Promote`, `TextDecorations`, `TextFormat`) as `{ get; init; }` instance-level defaults - **`GoogleTextSearchOptions`**: Added 8 request-side search parameters (`CountryRestrict`, `DateRestrict`, `GeoLocation`, `InterfaceLanguage`, `LinkSite`, `LanguageRestrict`, `Rights`, `DuplicateContentFilter`) - **`BingTextSearch.cs`** / **`GoogleTextSearch.cs`**: Filter values take precedence over defaults (tracked via `HashSet<string>`) - **`BraveTextSearchOptions.cs`**: Fixed doc comment that incorrectly said "Bing" instead of "Brave" - 14 new unit tests (8 Bing, 6 Google) ## Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone 😄 ## Validation | Check | Result | |---|---| | Format (`dotnet format --verify-no-changes`) | ✅ Pass | | Build (`dotnet build --configuration Release --warnaserror`) | ✅ Pass (0 warnings, 0 errors) | | Unit tests (`SemanticKernel.UnitTests`) | ✅ 1606/1606 passed | | AOT publish (net10.0) | ✅ Pass | --------- Co-authored-by: Alexander Zarei <alzarei@users.noreply.github.com>
1 parent 7d2a06f commit 1fcf4ee

12 files changed

Lines changed: 1071 additions & 750 deletions

File tree

dotnet/src/Plugins/Plugins.UnitTests/Web/Bing/BingTextSearchTests.cs

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#pragma warning disable CS0618 // ITextSearch is obsolete
44
#pragma warning disable CS8602 // Dereference of a possibly null reference - Test LINQ expressions access BingWebPage properties guaranteed non-null in test context
5+
#pragma warning disable CA1859 // Use concrete types when possible for improved performance - tests intentionally use interface types
56

67
using System;
78
using System.IO;
@@ -231,7 +232,8 @@ public async Task DoesNotBuildsUriForInvalidQueryParameterAsync()
231232

232233
// Act && Assert
233234
var e = await Assert.ThrowsAsync<ArgumentException>(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions));
234-
Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of answerCount,cc,freshness,mkt,promote,responseFilter,safeSearch,setLang,textDecorations,textFormat,contains,ext,filetype,inanchor,inbody,intitle,ip,language,loc,location,prefer,site,feed,hasfeed,url (Parameter 'searchOptions')", e.Message);
235+
Assert.Contains("Unknown equality filter clause field name 'fooBar'", e.Message);
236+
Assert.Contains("must be one of", e.Message);
235237
}
236238

237239
#region Generic ITextSearch<BingWebPage> Interface Tests
@@ -930,6 +932,167 @@ public async Task StringContainsStillWorksWithLINQFiltersAsync()
930932

931933
#endregion
932934

935+
#region Default Search Parameter Tests
936+
937+
[Fact]
938+
public async Task DefaultMarketIsAppliedToSearchRequestAsync()
939+
{
940+
// Arrange
941+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
942+
var textSearch = new BingTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Market = "en-US" });
943+
944+
// Act
945+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query");
946+
947+
// Assert
948+
var requestUris = this._messageHandlerStub.RequestUris;
949+
Assert.Single(requestUris);
950+
Assert.Contains("mkt=en-US", requestUris[0]!.AbsoluteUri);
951+
}
952+
953+
[Fact]
954+
public async Task DefaultFreshnessIsAppliedToSearchRequestAsync()
955+
{
956+
// Arrange
957+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
958+
var textSearch = new BingTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Freshness = "Month" });
959+
960+
// Act
961+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query");
962+
963+
// Assert
964+
var requestUris = this._messageHandlerStub.RequestUris;
965+
Assert.Single(requestUris);
966+
Assert.Contains("freshness=Month", requestUris[0]!.AbsoluteUri);
967+
}
968+
969+
[Fact]
970+
public async Task DefaultSafeSearchIsAppliedToSearchRequestAsync()
971+
{
972+
// Arrange
973+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
974+
var textSearch = new BingTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, SafeSearch = "Strict" });
975+
976+
// Act
977+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query");
978+
979+
// Assert
980+
var requestUris = this._messageHandlerStub.RequestUris;
981+
Assert.Single(requestUris);
982+
Assert.Contains("safeSearch=Strict", requestUris[0]!.AbsoluteUri);
983+
}
984+
985+
[Fact]
986+
public async Task MultipleDefaultParametersAreAppliedToSearchRequestAsync()
987+
{
988+
// Arrange
989+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
990+
var textSearch = new BingTextSearch(apiKey: "ApiKey", options: new()
991+
{
992+
HttpClient = this._httpClient,
993+
Market = "en-US",
994+
Freshness = "Week",
995+
SafeSearch = "Moderate",
996+
TextDecorations = true
997+
});
998+
999+
// Act
1000+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query");
1001+
1002+
// Assert
1003+
var requestUris = this._messageHandlerStub.RequestUris;
1004+
Assert.Single(requestUris);
1005+
string uri = requestUris[0]!.AbsoluteUri;
1006+
Assert.Contains("mkt=en-US", uri);
1007+
Assert.Contains("freshness=Week", uri);
1008+
Assert.Contains("safeSearch=Moderate", uri);
1009+
Assert.Contains("textDecorations=true", uri);
1010+
}
1011+
1012+
[Fact]
1013+
public async Task FilterOverridesDefaultParameterAsync()
1014+
{
1015+
// Arrange: Set Market as default, then override via legacy filter
1016+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
1017+
var textSearch = new BingTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Market = "en-US" });
1018+
var searchOptions = new TextSearchOptions { Top = 4, Skip = 0, Filter = new TextSearchFilter().Equality("mkt", "fr-FR") };
1019+
1020+
// Act
1021+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", searchOptions);
1022+
1023+
// Assert: Only fr-FR should appear, not en-US
1024+
var requestUris = this._messageHandlerStub.RequestUris;
1025+
Assert.Single(requestUris);
1026+
string uri = requestUris[0]!.AbsoluteUri;
1027+
Assert.Contains("mkt=fr-FR", uri);
1028+
Assert.DoesNotContain("mkt=en-US", uri);
1029+
}
1030+
1031+
[Fact]
1032+
public async Task DefaultParametersWorkWithGenericInterfaceAsync()
1033+
{
1034+
// Arrange
1035+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
1036+
ITextSearch<BingWebPage> textSearch = new BingTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, Market = "de-DE" });
1037+
1038+
// Act
1039+
var searchOptions = new TextSearchOptions<BingWebPage> { Top = 4, Skip = 0 };
1040+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", searchOptions);
1041+
1042+
// Assert
1043+
var requestUris = this._messageHandlerStub.RequestUris;
1044+
Assert.Single(requestUris);
1045+
Assert.Contains("mkt=de-DE", requestUris[0]!.AbsoluteUri);
1046+
}
1047+
1048+
[Fact]
1049+
public async Task DefaultParametersCoexistWithLinqFiltersAsync()
1050+
{
1051+
// Arrange
1052+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
1053+
ITextSearch<BingWebPage> textSearch = new BingTextSearch(apiKey: "ApiKey", options: new()
1054+
{
1055+
HttpClient = this._httpClient,
1056+
Market = "en-US",
1057+
Freshness = "Month"
1058+
});
1059+
1060+
// Act: LINQ filter for response-side property + default request-side params
1061+
var searchOptions = new TextSearchOptions<BingWebPage>
1062+
{
1063+
Top = 4,
1064+
Skip = 0,
1065+
Filter = page => page.Language == "en"
1066+
};
1067+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", searchOptions);
1068+
1069+
// Assert: Both LINQ filter and defaults should appear
1070+
var requestUris = this._messageHandlerStub.RequestUris;
1071+
Assert.Single(requestUris);
1072+
string uri = requestUris[0]!.AbsoluteUri;
1073+
Assert.Contains("language%3Aen", uri);
1074+
Assert.Contains("mkt=en-US", uri);
1075+
Assert.Contains("freshness=Month", uri);
1076+
}
1077+
1078+
[Fact]
1079+
public async Task DefaultAnswerCountIsAppliedCorrectlyAsync()
1080+
{
1081+
// Arrange
1082+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
1083+
var textSearch = new BingTextSearch(apiKey: "ApiKey", options: new() { HttpClient = this._httpClient, AnswerCount = 3 });
1084+
1085+
// Act
1086+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query");
1087+
1088+
// Assert
1089+
var requestUris = this._messageHandlerStub.RequestUris;
1090+
Assert.Single(requestUris);
1091+
Assert.Contains("answerCount=3", requestUris[0]!.AbsoluteUri);
1092+
}
1093+
1094+
#endregion
1095+
9331096
/// <inheritdoc/>
9341097
public void Dispose()
9351098
{

dotnet/src/Plugins/Plugins.UnitTests/Web/Brave/BraveTextSearchTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ public async Task DoesNotBuildsUriForInvalidQueryParameterAsync()
217217

218218
// Act && Assert
219219
var e = await Assert.ThrowsAsync<ArgumentException>(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions));
220-
Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of country,search_lang,ui_lang,safesearch,text_decorations,spellcheck,result_filter,units,extra_snippets (Parameter 'searchOptions')", e.Message);
220+
Assert.Contains("Unknown equality filter clause field name 'fooBar'", e.Message);
221+
Assert.Contains("must be one of", e.Message);
221222
}
222223

223224
[Fact]
@@ -232,7 +233,8 @@ public async Task DoesNotBuildsUriForQueryParameterNullInputAsync()
232233

233234
// Act && Assert
234235
var e = await Assert.ThrowsAsync<ArgumentException>(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions));
235-
Assert.Equal("Unknown equality filter clause field name 'country', must be one of country,search_lang,ui_lang,safesearch,text_decorations,spellcheck,result_filter,units,extra_snippets (Parameter 'searchOptions')", e.Message);
236+
Assert.Contains("Unknown equality filter clause field name 'country'", e.Message);
237+
Assert.Contains("must be one of", e.Message);
236238
}
237239

238240
/// <inheritdoc/>

dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

33
#pragma warning disable CS0618 // ITextSearch is obsolete
4+
#pragma warning disable CA1859 // Use concrete types when possible for improved performance - tests intentionally use interface types
45

56
using System;
67
using System.IO;
@@ -234,7 +235,8 @@ public async Task DoesNotBuildsUriForInvalidQueryParameterAsync()
234235

235236
// Act && Assert
236237
var e = await Assert.ThrowsAsync<ArgumentException>(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions));
237-
Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of cr,dateRestrict,exactTerms,excludeTerms,fileType,filter,gl,hl,linkSite,lr,orTerms,rights,siteSearch (Parameter 'searchOptions')", e.Message);
238+
Assert.Contains("Unknown equality filter clause field name 'fooBar'", e.Message);
239+
Assert.Contains("must be one of", e.Message);
238240
}
239241

240242
[Fact]
@@ -733,6 +735,157 @@ await textSearch.SearchAsync("test",
733735

734736
#endregion
735737

738+
#region Default Search Parameter Tests
739+
740+
[Fact]
741+
public async Task DefaultGeoLocationIsAppliedToSearchRequestAsync()
742+
{
743+
// Arrange
744+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
745+
using var textSearch = new GoogleTextSearch(
746+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
747+
searchEngineId: "SearchEngineId",
748+
options: new GoogleTextSearchOptions { GeoLocation = "us" });
749+
750+
// Act
751+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", new TextSearchOptions { Top = 4, Skip = 0 });
752+
753+
// Assert
754+
var requestUris = this._messageHandlerStub.RequestUris;
755+
Assert.Single(requestUris);
756+
Assert.Contains("gl=us", requestUris[0]!.AbsoluteUri);
757+
}
758+
759+
[Fact]
760+
public async Task DefaultInterfaceLanguageIsAppliedToSearchRequestAsync()
761+
{
762+
// Arrange
763+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
764+
using var textSearch = new GoogleTextSearch(
765+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
766+
searchEngineId: "SearchEngineId",
767+
options: new GoogleTextSearchOptions { InterfaceLanguage = "en" });
768+
769+
// Act
770+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", new TextSearchOptions { Top = 4, Skip = 0 });
771+
772+
// Assert
773+
var requestUris = this._messageHandlerStub.RequestUris;
774+
Assert.Single(requestUris);
775+
Assert.Contains("hl=en", requestUris[0]!.AbsoluteUri);
776+
}
777+
778+
[Fact]
779+
public async Task MultipleDefaultParametersAreAppliedToSearchRequestAsync()
780+
{
781+
// Arrange
782+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
783+
using var textSearch = new GoogleTextSearch(
784+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
785+
searchEngineId: "SearchEngineId",
786+
options: new GoogleTextSearchOptions
787+
{
788+
GeoLocation = "us",
789+
InterfaceLanguage = "en",
790+
LanguageRestrict = "lang_en",
791+
DuplicateContentFilter = "1"
792+
});
793+
794+
// Act
795+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", new TextSearchOptions { Top = 4, Skip = 0 });
796+
797+
// Assert
798+
var requestUris = this._messageHandlerStub.RequestUris;
799+
Assert.Single(requestUris);
800+
string uri = requestUris[0]!.AbsoluteUri;
801+
Assert.Contains("gl=us", uri);
802+
Assert.Contains("hl=en", uri);
803+
Assert.Contains("lr=lang_en", uri);
804+
Assert.Contains("filter=1", uri);
805+
}
806+
807+
[Fact]
808+
public async Task FilterOverridesDefaultParameterAsync()
809+
{
810+
// Arrange: Set GeoLocation as default, then override via legacy filter
811+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
812+
using var textSearch = new GoogleTextSearch(
813+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
814+
searchEngineId: "SearchEngineId",
815+
options: new GoogleTextSearchOptions { GeoLocation = "us" });
816+
var searchOptions = new TextSearchOptions { Top = 4, Skip = 0, Filter = new TextSearchFilter().Equality("gl", "de") };
817+
818+
// Act
819+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", searchOptions);
820+
821+
// Assert: Only de should appear, not us
822+
var requestUris = this._messageHandlerStub.RequestUris;
823+
Assert.Single(requestUris);
824+
string uri = requestUris[0]!.AbsoluteUri;
825+
Assert.Contains("gl=de", uri);
826+
Assert.DoesNotContain("gl=us", uri);
827+
}
828+
829+
[Fact]
830+
public async Task DefaultParametersWorkWithGenericInterfaceAsync()
831+
{
832+
// Arrange
833+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
834+
ITextSearch<GoogleWebPage> textSearch = new GoogleTextSearch(
835+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
836+
searchEngineId: "SearchEngineId",
837+
options: new GoogleTextSearchOptions { InterfaceLanguage = "fr" });
838+
839+
// Act
840+
var searchOptions = new TextSearchOptions<GoogleWebPage> { Top = 4, Skip = 0 };
841+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", searchOptions);
842+
843+
// Assert
844+
var requestUris = this._messageHandlerStub.RequestUris;
845+
Assert.Single(requestUris);
846+
Assert.Contains("hl=fr", requestUris[0]!.AbsoluteUri);
847+
848+
// Clean up
849+
((IDisposable)textSearch).Dispose();
850+
}
851+
852+
[Fact]
853+
public async Task DefaultParametersCoexistWithLinqFiltersAsync()
854+
{
855+
// Arrange
856+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
857+
ITextSearch<GoogleWebPage> textSearch = new GoogleTextSearch(
858+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
859+
searchEngineId: "SearchEngineId",
860+
options: new GoogleTextSearchOptions
861+
{
862+
GeoLocation = "us",
863+
InterfaceLanguage = "en"
864+
});
865+
866+
// Act: LINQ filter for site search + default search params
867+
var searchOptions = new TextSearchOptions<GoogleWebPage>
868+
{
869+
Top = 4,
870+
Skip = 0,
871+
Filter = page => page.DisplayLink == "microsoft.com"
872+
};
873+
KernelSearchResults<string> result = await textSearch.SearchAsync("test query", searchOptions);
874+
875+
// Assert: Both LINQ filter and defaults should appear
876+
var requestUris = this._messageHandlerStub.RequestUris;
877+
Assert.Single(requestUris);
878+
string uri = requestUris[0]!.AbsoluteUri;
879+
Assert.Contains("siteSearch=microsoft.com", uri);
880+
Assert.Contains("gl=us", uri);
881+
Assert.Contains("hl=en", uri);
882+
883+
// Clean up
884+
((IDisposable)textSearch).Dispose();
885+
}
886+
887+
#endregion
888+
736889
/// <inheritdoc/>
737890
public void Dispose()
738891
{

dotnet/src/Plugins/Plugins.UnitTests/Web/Tavily/TavilyTextSearchTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ public async Task DoesNotBuildRequestForInvalidQueryParameterAsync(string paramN
322322

323323
// Act && Assert
324324
var e = await Assert.ThrowsAsync<ArgumentException>(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions));
325-
Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of topic,time_range,days,include_domain,exclude_domain (Parameter 'searchOptions')", e.Message);
325+
Assert.Contains("Unknown equality filter clause field name 'fooBar'", e.Message);
326+
Assert.Contains("topic,time_range,days,include_domain,exclude_domain", e.Message);
326327
}
327328

328329
[Fact]

0 commit comments

Comments
 (0)