Skip to content

Commit

Permalink
Add support for query suggestions (aka "did you mean") (#7)
Browse files Browse the repository at this point in the history
  • Loading branch information
chrispenny authored Aug 21, 2024
1 parent f4c6e83 commit d902a6f
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 7 deletions.
8 changes: 1 addition & 7 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,14 @@
"require": {
"php": "^8.1",
"silverstripe/framework": "^5",
"silverstripe/silverstripe-discoverer": "^1",
"silverstripe/silverstripe-discoverer": "^1.1",
"elastic/enterprise-search": "^8.10",
"guzzlehttp/guzzle": "^7.5"
},
"require-dev": {
"phpunit/phpunit": "^9.6.19",
"slevomat/coding-standard": "^8.8"
},
"repositories": [
{
"type": "vcs",
"url": "[email protected]:silverstripeltd/silverstripe-discoverer.git"
}
],
"autoload": {
"psr-4": {
"SilverStripe\\DiscovererElasticEnterprise\\": "src/",
Expand Down
38 changes: 38 additions & 0 deletions src/Processors/SuggestionParamsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace SilverStripe\DiscovererElasticEnterprise\Processors;

use Elastic\EnterpriseSearch\AppSearch\Schema\QuerySuggestionRequest;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Discoverer\Query\Suggestion;
use stdClass;

class SuggestionParamsProcessor
{

use Injectable;

public function getQueryParams(Suggestion $suggestion): QuerySuggestionRequest
{
$querySuggestionParams = new QuerySuggestionRequest();
$querySuggestionParams->query = $suggestion->getQueryString();

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

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

if ($fields) {
$types = new stdClass();
$types->documents = new stdClass();
$types->documents->fields = $fields;

$querySuggestionParams->types = $types;
}

return $querySuggestionParams;
}

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

namespace SilverStripe\DiscovererElasticEnterprise\Processors;

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

class SuggestionsProcessor
{

use Injectable;

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

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

foreach ($documentSuggestions as $documentSuggestion) {
$suggestion = $documentSuggestion['suggestion'] ?? null;

if (!$suggestion) {
continue;
}

$suggestions->addSuggestion($suggestion);
}
}

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
$meta = $response['meta'] ?? null;
$results = $response['results'] ?? null;
// Check if any required fields are missing
$missingTopLevelFields = [];

// Basic falsy check is fine here. An empty `meta` would still be an error
if (!$meta) {
$missingTopLevelFields[] = 'meta';
}

// Specifically checking for null, because an empty results array is a valid response
if ($results === null) {
$missingTopLevelFields[] = 'results';
}

// We were missing one or more required top level fields
if ($missingTopLevelFields) {
throw new Exception(sprintf(
'Missing required top level fields for query suggestions: %s',
implode(', ', $missingTopLevelFields)
));
}
}

}
36 changes: 36 additions & 0 deletions src/Service/SearchServiceAdaptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace SilverStripe\DiscovererElasticEnterprise\Service;

use Elastic\EnterpriseSearch\AppSearch\Request\LogClickthrough;
use Elastic\EnterpriseSearch\AppSearch\Request\QuerySuggestion;
use Elastic\EnterpriseSearch\AppSearch\Request\Search;
use Elastic\EnterpriseSearch\AppSearch\Schema\ClickParams;
use Elastic\EnterpriseSearch\Client;
Expand All @@ -15,10 +16,14 @@
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Discoverer\Analytics\AnalyticsData;
use SilverStripe\Discoverer\Query\Query;
use SilverStripe\Discoverer\Query\Suggestion;
use SilverStripe\Discoverer\Service\Results\Results;
use SilverStripe\Discoverer\Service\Results\Suggestions;
use SilverStripe\Discoverer\Service\SearchServiceAdaptor as SearchServiceAdaptorInterface;
use SilverStripe\DiscovererElasticEnterprise\Processors\QueryParamsProcessor;
use SilverStripe\DiscovererElasticEnterprise\Processors\ResultsProcessor;
use SilverStripe\DiscovererElasticEnterprise\Processors\SuggestionParamsProcessor;
use SilverStripe\DiscovererElasticEnterprise\Processors\SuggestionsProcessor;
use Throwable;

class SearchServiceAdaptor implements SearchServiceAdaptorInterface
Expand Down Expand Up @@ -82,6 +87,37 @@ public function search(Query $query, string $indexName): Results
}
}

public function querySuggestion(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 = Injector::inst()->create(QuerySuggestion::class, $engine, $params);
$response = $this->client->appSearch()->querySuggestion($request);

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->logger->error(sprintf('Elastic error: %s', $errors), ['elastic' => $e]);
// Our request was not a success
$suggestions->setSuccess(false);
} catch (Throwable $e) {
// Log the error without breaking the page
$this->logger->error(sprintf('Elastic error: %s', $e->getMessage()), ['elastic' => $e]);
// Our request was not a success
$suggestions->setSuccess(false);
} finally {
return $suggestions;
}
}

public function processAnalytics(AnalyticsData $analyticsData): void
{
$query = $analyticsData->getQueryString();
Expand Down

0 comments on commit d902a6f

Please sign in to comment.