Skip to content

Commit

Permalink
Discoverer v2 support. Spelling suggestions feature (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrispenny authored Nov 6, 2024
1 parent c9b9f5c commit 89ee60e
Show file tree
Hide file tree
Showing 15 changed files with 301 additions and 41 deletions.
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

[{*.yml}]
[*.yml]
indent_size = 2
indent_style = space

Expand Down
26 changes: 26 additions & 0 deletions _config/adaptors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
Name: discoverer-bifrost-adaptors
After: discoverer-adaptors
Only:
envvarset: 'BIFROST_QUERY_API_KEY'
---
SilverStripe\Core\Injector\Injector:
# Adaptors provided by this module
SilverStripe\Discoverer\Service\Interfaces\QuerySuggestionAdaptor:
class: SilverStripe\DiscovererBifrost\Service\Adaptors\QuerySuggestionAdaptor
SilverStripe\Discoverer\Service\Interfaces\SpellingSuggestionAdaptor:
class: SilverStripe\DiscovererBifrost\Service\Adaptors\SpellingSuggestionAdaptor
# Adaptors provided by the ElasticEnterprise dependency
SilverStripe\Discoverer\Query\Facet\FacetAdaptor:
class: SilverStripe\DiscovererElasticEnterprise\Query\Facet\FacetAdaptor
SilverStripe\Discoverer\Query\Filter\CriteriaAdaptor:
class: SilverStripe\DiscovererElasticEnterprise\Query\Filter\CriteriaAdaptor
SilverStripe\Discoverer\Query\Filter\CriterionAdaptor:
class: SilverStripe\DiscovererElasticEnterprise\Query\Filter\CriterionAdaptor
SilverStripe\Discoverer\Service\Interfaces\ProcessAnalyticsAdaptor:
class: SilverStripe\DiscovererElasticEnterprise\Service\Adaptors\ProcessAnalyticsAdaptor
SilverStripe\Discoverer\Service\Interfaces\SearchAdaptor:
class: SilverStripe\DiscovererElasticEnterprise\Service\Adaptors\SearchAdaptor

SilverStripe\DiscovererElasticEnterprise\Service\Adaptors\BaseAdaptor:
prefix_env_var: 'BIFROST_ENGINE_PREFIX'
29 changes: 0 additions & 29 deletions _config/config.yml

This file was deleted.

12 changes: 12 additions & 0 deletions _config/factory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
Name: discoverer-bifrost-factory
Only:
envvarset: 'BIFROST_QUERY_API_KEY'
---
SilverStripe\Core\Injector\Injector:
Elastic\EnterpriseSearch\Client.searchClient:
factory: SilverStripe\DiscovererBifrost\Service\ClientFactory
constructor:
host: '`BIFROST_ENDPOINT`'
token: '`BIFROST_QUERY_API_KEY`'
http_client: '%$GuzzleHttp\Client'
10 changes: 10 additions & 0 deletions _config/requests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
Name: discoverer-bifrost-requests
Only:
envvarset: 'BIFROST_QUERY_API_KEY'
---
SilverStripe\Core\Injector\Injector:
Elastic\EnterpriseSearch\AppSearch\Request\Search:
class: SilverStripe\DiscovererBifrost\Service\Requests\Search
Elastic\EnterpriseSearch\AppSearch\Request\LogClickthrough:
class: SilverStripe\DiscovererBifrost\Service\Requests\ClickPost
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"require": {
"php": "^8.1",
"silverstripe/framework": "^5",
"silverstripe/silverstripe-discoverer-elastic-enterprise": "^1.1",
"silverstripe/silverstripe-discoverer-elastic-enterprise": "^2",
"guzzlehttp/guzzle": "^7.5"
},
"require-dev": {
Expand Down
34 changes: 34 additions & 0 deletions src/Processors/SuggestionParamsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace SilverStripe\DiscovererBifrost\Processors;

use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Discoverer\Query\Suggestion;
use SilverStripe\DiscovererBifrost\Service\Requests\Params\SuggestionParams;

class SuggestionParamsProcessor
{

use Injectable;

public function getQueryParams(Suggestion $suggestion): SuggestionParams
{
$suggestionParams = SuggestionParams::create();
$suggestionParams->query = $suggestion->getQueryString();
$suggestionParams->formatted = $suggestion->isFormatted();

$limit = $suggestion->getLimit();
$fields = $suggestion->getFields();

if ($limit) {
$suggestionParams->size = $limit;
}

if ($fields) {
$suggestionParams->fields = $fields;
}

return $suggestionParams;
}

}
49 changes: 49 additions & 0 deletions src/Processors/SuggestionsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace SilverStripe\DiscovererBifrost\Processors;

use Exception;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Discoverer\Service\Results\Field;
use SilverStripe\Discoverer\Service\Results\Suggestions;

class SuggestionsProcessor
{

use Injectable;

/**
* @throws Exception
*/
public function getProcessedSuggestions(Suggestions $suggestions, array $response): void
{
// Check that we have all critical fields in our Elastic response
$this->validateResponse($response);

$results = $response['results'] ?? [];

foreach ($results as $result) {
$suggestions->addSuggestion(Field::create(
$result['raw'] ?? null,
$result['snippet'] ?? null,
));
}
}

private function validateResponse(array $response): void
{
// If any errors are present, then let's throw and track what they were
if (array_key_exists('errors', $response)) {
throw new Exception(sprintf('Elastic response contained errors: %s', json_encode($response['errors'])));
}

// The top level fields that we expect to receive from Elastic for each search
$results = $response['results'] ?? null;

// Specifically checking is_array(), because an empty results array is a valid response
if (!is_array($results)) {
throw new Exception('Missing required top level fields for query suggestions: results');
}
}

}
53 changes: 53 additions & 0 deletions src/Service/Adaptors/QuerySuggestionAdaptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace SilverStripe\DiscovererBifrost\Service\Adaptors;

use Elastic\EnterpriseSearch\Exception\ClientErrorResponseException;
use Elastic\EnterpriseSearch\Response\Response;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Discoverer\Query\Suggestion;
use SilverStripe\Discoverer\Service\Interfaces\QuerySuggestionAdaptor as QuerySuggestionAdaptorInterface;
use SilverStripe\Discoverer\Service\Results\Suggestions;
use SilverStripe\DiscovererBifrost\Processors\SuggestionParamsProcessor;
use SilverStripe\DiscovererBifrost\Processors\SuggestionsProcessor;
use SilverStripe\DiscovererBifrost\Service\Requests\QuerySuggestion;
use SilverStripe\DiscovererElasticEnterprise\Service\Adaptors\BaseAdaptor;
use Throwable;

class QuerySuggestionAdaptor extends BaseAdaptor implements QuerySuggestionAdaptorInterface
{

public function process(Suggestion $suggestion, string $indexName): Suggestions
{
// Instantiate our Suggestions class with empty data. This will still be returned if there is an Exception
// during communication with Elastic (so that the page doesn't seriously break)
$suggestions = Suggestions::create();

try {
$engine = $this->environmentizeIndex($indexName);
$params = SuggestionParamsProcessor::singleton()->getQueryParams($suggestion);
$request = QuerySuggestion::create($engine, $params);

$transportResponse = $this->getClient()->appSearch()->getTransport()->sendRequest($request->getRequest());
$response = Injector::inst()->create(Response::class, $transportResponse);

SuggestionsProcessor::singleton()->getProcessedSuggestions($suggestions, $response->asArray());
// If we got this far, then the request was a success
$suggestions->setSuccess(true);
} catch (ClientErrorResponseException $e) {
$errors = (string) $e->getResponse()->getBody();
// Log the error without breaking the page
$this->getLogger()->error(sprintf('Bifrost error: %s', $errors), ['bifrost' => $e]);
// Our request was not a success
$suggestions->setSuccess(false);
} catch (Throwable $e) {
// Log the error without breaking the page
$this->getLogger()->error(sprintf('Bifrost error: %s', $e->getMessage()), ['bifrost' => $e]);
// Our request was not a success
$suggestions->setSuccess(false);
} finally {
return $suggestions;
}
}

}
53 changes: 53 additions & 0 deletions src/Service/Adaptors/SpellingSuggestionAdaptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace SilverStripe\DiscovererBifrost\Service\Adaptors;

use Elastic\EnterpriseSearch\Exception\ClientErrorResponseException;
use Elastic\EnterpriseSearch\Response\Response;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Discoverer\Query\Suggestion;
use SilverStripe\Discoverer\Service\Interfaces\SpellingSuggestionAdaptor as SpellingSuggestionAdaptorInterface;
use SilverStripe\Discoverer\Service\Results\Suggestions;
use SilverStripe\DiscovererBifrost\Processors\SuggestionParamsProcessor;
use SilverStripe\DiscovererBifrost\Processors\SuggestionsProcessor;
use SilverStripe\DiscovererBifrost\Service\Requests\SpellingSuggestion;
use SilverStripe\DiscovererElasticEnterprise\Service\Adaptors\BaseAdaptor;
use Throwable;

class SpellingSuggestionAdaptor extends BaseAdaptor implements SpellingSuggestionAdaptorInterface
{

public function process(Suggestion $suggestion, string $indexName): Suggestions
{
// Instantiate our Suggestions class with empty data. This will still be returned if there is an Exception
// during communication with Elastic (so that the page doesn't seriously break)
$suggestions = Suggestions::create();

try {
$engine = $this->environmentizeIndex($indexName);
$params = SuggestionParamsProcessor::singleton()->getQueryParams($suggestion);
$request = SpellingSuggestion::create($engine, $params);

$transportResponse = $this->getClient()->appSearch()->getTransport()->sendRequest($request->getRequest());
$response = Injector::inst()->create(Response::class, $transportResponse);

SuggestionsProcessor::singleton()->getProcessedSuggestions($suggestions, $response->asArray());
// If we got this far, then the request was a success
$suggestions->setSuccess(true);
} catch (ClientErrorResponseException $e) {
$errors = (string) $e->getResponse()->getBody();
// Log the error without breaking the page
$this->getLogger()->error(sprintf('Bifrost error: %s', $errors), ['bifrost' => $e]);
// Our request was not a success
$suggestions->setSuccess(false);
} catch (Throwable $e) {
// Log the error without breaking the page
$this->getLogger()->error(sprintf('Bifrost error: %s', $e->getMessage()), ['bifrost' => $e]);
// Our request was not a success
$suggestions->setSuccess(false);
} finally {
return $suggestions;
}
}

}
4 changes: 2 additions & 2 deletions src/Service/Requests/ClickPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
class ClickPost extends AppSearchLogClickthrough
{

public function __construct(string $engineName, ?ClickParams $click_params = null)
public function __construct(string $engineName, ?ClickParams $params = null)
{
parent::__construct($engineName, $click_params);
parent::__construct($engineName, $params);

$this->path = sprintf('/api/v1/%s/click', $engineName);
}
Expand Down
26 changes: 26 additions & 0 deletions src/Service/Requests/Params/SuggestionParams.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace SilverStripe\DiscovererBifrost\Service\Requests\Params;

use SilverStripe\Core\Injector\Injectable;

/**
* The Elastic requests that we use turn properties into arguments for the request, so we are stuck with this paradigm
* for now
*
* @phpcs:disable SlevomatCodingStandard.Classes.ForbiddenPublicProperty.ForbiddenPublicProperty
*/
class SuggestionParams
{

use Injectable;

public ?string $query = null;

public array $fields = [];

public ?int $size = null;

public bool $formatted = false;

}
16 changes: 10 additions & 6 deletions src/Service/Requests/QuerySuggestion.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

namespace SilverStripe\DiscovererBifrost\Service\Requests;

use Elastic\EnterpriseSearch\AppSearch\Request\QuerySuggestion as AppSearchQuerySuggestion;
use Elastic\EnterpriseSearch\AppSearch\Schema\QuerySuggestionRequest;
use Elastic\EnterpriseSearch\Request\Request;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\DiscovererBifrost\Service\Requests\Params\SuggestionParams;

class QuerySuggestion extends AppSearchQuerySuggestion
class QuerySuggestion extends Request
{

public function __construct(string $engineName, ?QuerySuggestionRequest $query_suggestion_request = null)
{
parent::__construct($engineName, $query_suggestion_request);
use Injectable;

public function __construct(string $engineName, SuggestionParams $params)
{
$this->method = 'POST';
$this->path = sprintf('/api/v1/%s/query_suggestion', $engineName);
$this->headers['Content-Type'] = 'application/json';
$this->body = $params;
}

}
4 changes: 2 additions & 2 deletions src/Service/Requests/Search.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
class Search extends AppSearchSearch
{

public function __construct(string $engineName, ?SearchRequestParams $search_request_params = null)
public function __construct(string $engineName, ?SearchRequestParams $params = null)
{
parent::__construct($engineName, $search_request_params);
parent::__construct($engineName, $params);

$this->path = sprintf('/api/v1/%s/search', $engineName);
}
Expand Down
22 changes: 22 additions & 0 deletions src/Service/Requests/SpellingSuggestion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace SilverStripe\DiscovererBifrost\Service\Requests;

use Elastic\EnterpriseSearch\Request\Request;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\DiscovererBifrost\Service\Requests\Params\SuggestionParams;

class SpellingSuggestion extends Request
{

use Injectable;

public function __construct(string $engineName, SuggestionParams $params)
{
$this->method = 'POST';
$this->path = sprintf('/api/v1/%s/spelling_suggestion', $engineName);
$this->headers['Content-Type'] = 'application/json';
$this->body = $params;
}

}

0 comments on commit 89ee60e

Please sign in to comment.