.Net: Add Keenable web search connector - #14389
.Net: Add Keenable web search connector#14389Ilya Bogin (ilya-bogin-keenable) wants to merge 2 commits into
Conversation
Adds KeenableTextSearch to Plugins.Web, implementing ITextSearch and ITextSearch<KeenableWebPage> in the same shape as the Brave and Tavily connectors. The API key is optional. Without a key the connector posts to the public endpoint with the X-Keenable-Title header; with a key it posts to the authenticated endpoint with X-API-Key. Supported filters are site, published_after and published_before, via TextSearchFilter equality clauses or LINQ on KeenableWebPage. Results map title, snippet (falling back to description) and url to TextSearchResult. Includes unit tests with recorded fixtures, an integration test in the BaseTextSearchTests shape, a Concepts sample and a TestConfiguration entry with an optional ApiKey.
There was a problem hiding this comment.
🟡 Changes recommended
The LINQ filter handling currently treats logical OR incorrectly and some exception messages provide guidance that contradicts actual supported behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a new Keenable web-search connector to the .NET Plugins.Web package, aligning with existing Bing/Brave/Google/Tavily text search connectors and providing a “works without an API key” default option for samples and RAG/function-calling scenarios.
Changes:
- Introduces
KeenableTextSearchimplementingITextSearchandITextSearch<KeenableWebPage>with request/response models and DI registration helpers. - Adds unit tests (with recorded JSON fixtures) plus an integration test in the existing
BaseTextSearchTestsstyle. - Adds a Concepts sample and configuration plumbing (
TestConfiguration.Keenable) for optional API key usage.
File summaries
| File | Description |
|---|---|
| dotnet/src/Plugins/Plugins.Web/WebServiceCollectionExtensions.cs | Adds AddKeenableTextSearch registration for DI via IServiceCollection. |
| dotnet/src/Plugins/Plugins.Web/WebKernelBuilderExtensions.cs | Adds AddKeenableTextSearch registration for DI via IKernelBuilder. |
| dotnet/src/Plugins/Plugins.Web/Keenable/KeenableWebPage.cs | Defines typed result record for generic ITextSearch<KeenableWebPage> usage and LINQ filtering surface. |
| dotnet/src/Plugins/Plugins.Web/Keenable/KeenableTextSearchOptions.cs | Adds connector options (endpoint, snippet length hint, HttpClient, logger, mappers). |
| dotnet/src/Plugins/Plugins.Web/Keenable/KeenableTextSearch.cs | Implements Keenable search execution, mapping, legacy filter shim, and LINQ-expression filter extraction. |
| dotnet/src/Plugins/Plugins.Web/Keenable/KeenableSearchResult.cs | Models Keenable search result payload, including extension data passthrough. |
| dotnet/src/Plugins/Plugins.Web/Keenable/KeenableSearchResponse.cs | Models Keenable search response payload. |
| dotnet/src/Plugins/Plugins.Web/Keenable/KeenableSearchRequest.cs | Models Keenable search request payload sent to the API. |
| dotnet/src/Plugins/Plugins.UnitTests/Web/Keenable/KeenableTextSearchTests.cs | Adds unit tests for DI, request shape, filters, mapping, skip/top validation, and non-2xx behavior. |
| dotnet/src/Plugins/Plugins.UnitTests/TestData/keenable_what_is_the_semantic_kernel.json | Adds recorded fixture for general search response. |
| dotnet/src/Plugins/Plugins.UnitTests/TestData/keenable_site_filter_what_is_the_semantic_kernel.json | Adds recorded fixture for site-filtered response. |
| dotnet/src/InternalUtilities/samples/InternalUtilities/TestConfiguration.cs | Adds TestConfiguration.Keenable section for samples/tests configuration. |
| dotnet/src/IntegrationTests/TestSettings/KeenableConfiguration.cs | Adds integration-test configuration binding type for optional API key. |
| dotnet/src/IntegrationTests/Plugins/Web/Keenable/KeenableTextSearchTests.cs | Adds integration test implementation using the base text search test suite. |
| dotnet/samples/Concepts/Search/Keenable_TextSearch.cs | Adds Concepts sample demonstrating keyless and keyed usage plus filtering. |
Review details
Suppressed comments (2)
dotnet/src/Plugins/Plugins.Web/Keenable/KeenableTextSearch.cs:179
ExpressionType.OrElseis currently treated the same asAndAlso(both sides are accumulated into a single filter set), which changes the meaning of the query (OR becomes AND / last-write-wins). If OR can’t be expressed in the Keenable API, it should be rejected explicitly (similar to BingTextSearch).
case BinaryExpression { NodeType: ExpressionType.AndAlso or ExpressionType.OrElse } binaryExpr:
// Handle AND/OR expressions by recursively analyzing both sides
ExtractFiltersFromExpression(binaryExpr.Left, filters, queryTerms);
ExtractFiltersFromExpression(binaryExpr.Right, filters, queryTerms);
break;
dotnet/src/Plugins/Plugins.Web/Keenable/KeenableTextSearch.cs:279
- This message implies NOT may be supported in some cases ("only supported with simple equality expressions"), but the method always throws. The message should clearly state that NOT is unsupported.
throw new NotSupportedException("NOT operator (!) is only supported with simple equality expressions.");
- Files reviewed: 15/15 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| if (propertyName != null) | ||
| { | ||
| throw new NotSupportedException($"Inequality operator (!=) is not directly supported for property '{propertyName}'. Use NOT operator instead: !(page.{propertyName} == value)."); |
| /// <summary> | ||
| /// Walks a LINQ expression tree and extracts Keenable API filter key-value pairs and query terms directly. | ||
| /// Supports equality expressions, Contains() method calls, and logical AND/OR operators. | ||
| /// </summary> |
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: Findings reported
Scope: full PR (1 commit(s)): 7e0d5486a680
Model: gpt-5.6-sol-fast
Overview
The connector follows the established Plugins.Web shape and has strong coverage for keyless/keyed requests, filter validation, skip behavior, mapping, DI registration, and non-success responses. Four contract gaps remain: custom endpoints can downgrade authenticated requests to plaintext HTTP, OR predicates are executed with different semantics, total-count metadata reports page size, and the new LINQ sample uses an expression the connector rejects.
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
4 verified findings remained after source verification (4 medium) across 2 files. Details are attached to the affected lines below.
Affected areas: dotnet/samples/Concepts/Search/Keenable_TextSearch.cs, dotnet/src/Plugins/Plugins.Web/Keenable/KeenableTextSearch.cs
| /// </summary> | ||
| private static Uri BuildSearchUri(Uri endpoint, bool isPublic) | ||
| { | ||
| var baseUri = endpoint.AbsoluteUri.EndsWith("/", StringComparison.Ordinal) ? endpoint : new Uri(endpoint.AbsoluteUri + "/"); |
There was a problem hiding this comment.
Endpoint is documented as HTTPS-only, but this preserves an http:// scheme and authenticated requests later attach X-API-Key. A custom HTTP endpoint therefore sends the key and query in plaintext. Please reject non-HTTPS endpoints before building the request URI.
| { | ||
| switch (expression) | ||
| { | ||
| case BinaryExpression { NodeType: ExpressionType.AndAlso or ExpressionType.OrElse } binaryExpr: |
There was a problem hiding this comment.
Treating OrElse like AndAlso changes the predicate: different fields are sent together as an AND, while repeated fields are overwritten by the last value. For example, Site == "a.com" || Site == "b.com" searches only b.com. Please reject OR predicates as unsupported, or execute and merge the alternatives so the requested semantics are preserved.
| var filters = ExtractFiltersFromLegacy(searchOptions.Filter); | ||
| KeenableSearchResponse? searchResponse = await this.ExecuteSearchAsync(query, searchOptions.Top, searchOptions.Skip, filters, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| long? totalCount = searchOptions.IncludeTotalCount ? searchResponse?.Results.Count : null; |
There was a problem hiding this comment.
KernelSearchResults.TotalCount is defined as all results available for the query, not the size of the returned page. When more matches exist than Top (and especially after local Skip), this reports a smaller false total to pagination callers. Since this response has no total field, please return null rather than Results.Count in all six entry points.
| var compoundOptions = new TextSearchOptions<KeenableWebPage> | ||
| { | ||
| Top = 2, | ||
| Filter = page => page.Title != null && page.Title.Contains("Kernel") && page.PublishedAfter == "2025-01-01" |
There was a problem hiding this comment.
This sample always throws before sending a request because the connector rejects every NotEqual expression, including page.Title != null. Please use an expression supported by the parser or explicitly support this nullable-property guard so the Concepts example can run as written.
- Reject logical OR in LINQ filters instead of merging both sides into one request, which silently searched only the last site. The XML docs and the error messages now list what is supported: equality on Site, PublishedAfter and PublishedBefore, Title.Contains and Site.Contains, combined with &&. - Reword the != and NOT messages; neither is supported, so they no longer suggest rewriting one as the other. - Require an HTTPS endpoint in the constructor, since the API key travels as a request header. - Return a null TotalCount from every entry point, as the Tavily connector does; the API has no total field. - Fix the LINQ sample, which used Title != null and could not run. - Tests for all of the above.
Motivation and Context
This adds a web search connector for Keenable to
Plugins.Web, next to the Bing, Brave, Google and Tavily connectors. It is the only connector inPlugins.Webthat works with no API key:apiKeyis optional, and a key only lifts the per-IP rate limits of the public endpoint. That makes it a usable default for the text search samples, RAG and function-calling scenarios where a user has not signed up for a search provider yet.CONTRIBUTING.md asks for plugins to live in separate repos, but
Plugins.Webhas kept accepting web search connectors from outside (#11308, Brave) and inside (#11227, Tavily), so this follows those two.I work at Keenable.
Description
KeenableTextSearchimplementsITextSearchandITextSearch<KeenableWebPage>in the same shape asBraveTextSearchandTavilyTextSearchafter the LINQ refactor: sharedExecuteSearchAsync, the legacyTextSearchFiltershim, the expression walker, default string and result mappers, andAddKeenableTextSearchonIKernelBuilderandIServiceCollection.https://api.keenable.ai/v1/search/publicwith theX-Keenable-Title: semantic-kernelheader the public endpoint requires; with a key it posts to/v1/searchwithX-API-Key. The key is sent as a header only, never in the body.Endpoint(base address),SnippetMaxLength(a hint to the service), plus the usualHttpClient,LoggerFactory,StringMapper,ResultMapper. Nothing else, to stay 1:1 with what the API exposes.site,published_after,published_beforethroughTextSearchFilter.Equality(...)or LINQ onKeenableWebPage(page.Site == "learn.microsoft.com");Title.Contains(...)appends to the query like the other connectors.DateTimeandDateTimeOffsetfilter values are formatted asYYYY-MM-DD.TextSearchResultmapstitle,snippet(falling back todescriptionwhen the snippet is empty) andurl. The rawKeenableSearchResultis public and keeps unknown fields inAdditionalProperties.Topis 1 to 50. The API has no offset, soSkiprequestsTop + Skipresults and drops the firstSkipclient side.SendWithSuccessCheckAsyncand surface asHttpOperationException, so a 429 is an error, not an empty result.Also included: unit tests with recorded fixtures (request shape for keyless and keyed calls, endpoint override, filters, mapping and fallback, skip, validation, non-2xx), an integration test in the
BaseTextSearchTestsshape (skipped by default like the others, no key needed to run it), aKeenable_TextSearchConcepts sample with an optionalKeenable:ApiKeysetting, and theTestConfiguration.Keenableentry.Verified locally with SDK 10.0.400:
Plugins.Web,Plugins.UnitTests,IntegrationTestsandConceptsbuild with 0 warnings,dotnet format --verify-no-changespasses on all four, and the 40 new unit tests pass. A live keyless run through the connector returned 5 of 5 results with non-empty snippets.Contribution Checklist