From bbfc872871ad2c418aa28229a4b351d18130474d Mon Sep 17 00:00:00 2001 From: Erik Bernhardson Date: Thu, 9 Jul 2015 11:43:04 -0700 Subject: [PATCH] Auto-forward to search suggestion when zero results If the user gets zero results, but gets a "Did you mean" result, just run the query for the "Did you mean" result and inform the user that this happened. Adds a new query param 'runsuggestion' which will, when given a falsy value, prevent running the suggestion and give the result to the original query. Bug: T105202 Change-Id: I7ed79942c242b1957d46bdcad59985f37466fb83 --- includes/DefaultSettings.php | 10 ++ includes/specials/SpecialSearch.php | 140 ++++++++++++++---- languages/i18n/en.json | 1 + languages/i18n/qqq.json | 1 + .../includes/specials/SpecialSearchTest.php | 103 +++++++++++++ 5 files changed, 224 insertions(+), 31 deletions(-) diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index a755029da5..8dc37c0963 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -7679,6 +7679,16 @@ $wgVirtualRestConfig = array( ) ); +/** + * Controls the percentage of zero-result search queries with suggestions that + * run the suggestion automatically. Must be a number between 0 and 1. This + * can be lowered to reduce query volume at the expense of result quality. + * + * @var float + * @since 1.26 + */ +$wgSearchRunSuggestedQueryPercent = 1; + /** * For really cool vim folding this needs to be at the end: * vim: foldmarker=@{,@} foldmethod=marker diff --git a/includes/specials/SpecialSearch.php b/includes/specials/SpecialSearch.php index bc1bb3dfaa..84077e6987 100644 --- a/includes/specials/SpecialSearch.php +++ b/includes/specials/SpecialSearch.php @@ -68,6 +68,11 @@ class SpecialSearch extends SpecialPage { */ protected $fulltext; + /** + * @var bool + */ + protected $runSuggestion = true; + const NAMESPACES_CURRENT = 'sense'; public function __construct() { @@ -169,6 +174,7 @@ class SpecialSearch extends SpecialPage { } $this->fulltext = $request->getVal( 'fulltext' ); + $this->runSuggestion = (bool)$request->getVal( 'runsuggestion', true ); $this->profile = $profile; } @@ -214,7 +220,6 @@ class SpecialSearch extends SpecialPage { $search->setNamespaces( $this->namespaces ); $search->prefix = $this->mPrefix; $term = $search->transformSearchTerm( $term ); - $didYouMeanHtml = ''; Hooks::run( 'SpecialSearchSetupEngine', array( $this, $this->profile, $search ) ); @@ -265,37 +270,17 @@ class SpecialSearch extends SpecialPage { } // did you mean... suggestions - if ( $showSuggestion && $textMatches && !$textStatus && $textMatches->hasSuggestion() ) { - # mirror Go/Search behavior of original request .. - $didYouMeanParams = array( 'search' => $textMatches->getSuggestionQuery() ); - - if ( $this->fulltext != null ) { - $didYouMeanParams['fulltext'] = $this->fulltext; - } - - $stParams = array_merge( - $didYouMeanParams, - $this->powerSearchOptions() - ); - - $suggestionSnippet = $textMatches->getSuggestionSnippet(); - - if ( $suggestionSnippet == '' ) { - $suggestionSnippet = null; + $didYouMeanHtml = ''; + if ( $showSuggestion && $textMatches && !$textStatus ) { + if ( $this->shouldRunSuggestedQuery( $textMatches ) ) { + $newMatches = $search->searchText( $textMatches->getSuggestionQuery() ); + if ( $newMatches instanceof SearchResultSet && $newMatches->numRows() > 0 ) { + $didYouMeanHtml = $this->getDidYouMeanRewrittenHtml( $term, $textMatches ); + $textMatches = $newMatches; + } + } elseif ( $textMatches->hasSuggestion() ) { + $didYouMeanHtml = $this->getDidYouMeanHtml( $textMatches ); } - - $suggestLink = Linker::linkKnown( - $this->getPageTitle(), - $suggestionSnippet, - array(), - $stParams - ); - - # html of did you mean... search suggestion link - $didYouMeanHtml = - Xml::openElement( 'div', array( 'class' => 'searchdidyoumean' ) ) . - $this->msg( 'search-suggest' )->rawParams( $suggestLink )->text() . - Xml::closeElement( 'div' ); } if ( !Hooks::run( 'SpecialSearchResultsPrepend', array( $this, $out, $term ) ) ) { @@ -415,6 +400,99 @@ class SpecialSearch extends SpecialPage { } + /** + * Decide if the suggested query should be run, and it's results returned + * instead of the provided $textMatches + * + * @param SearchResultSet $textMatches The results of a users query + * @return bool + */ + protected function shouldRunSuggestedQuery( SearchResultSet $textMatches ) { + global $wgSearchRunSuggestedQueryPercent; + + if ( !$this->runSuggestion || + !$textMatches->hasSuggestion() || + $textMatches->numRows() > 0 || + $textMatches->searchContainedSyntax() + ) { + return false; + } + + // Generate a random number between 0 and 1. If the + // number is less than the desired percentages run it. + $rand = rand( 0, getrandmax() ) / getrandmax(); + return $wgSearchRunSuggestedQueryPercent > $rand; + } + + /** + * Generates HTML shown to the user when we have a suggestion about a query + * that might give more results than their current query. + */ + protected function getDidYouMeanHtml( SearchResultSet $textMatches ) { + # mirror Go/Search behavior of original request .. + $params = array( 'search' => $textMatches->getSuggestionQuery() ); + if ( $this->fulltext != null ) { + $params['fulltext'] = $this->fulltext; + } + $stParams = array_merge( $params, $this->powerSearchOptions() ); + + $suggest = Linker::linkKnown( + $this->getPageTitle(), + $textMatches->getSuggestionSnippet() ?: null, + array(), + $stParams + ); + + # html of did you mean... search suggestion link + return Html::rawElement( + 'div', + array( 'class' => 'searchdidyoumean' ), + $this->msg( 'search-suggest' )->rawParams( $suggest )->escaped() + ); + } + + /** + * Generates HTML shown to user when their query has been internally rewritten, + * and the results of the rewritten query are being returned. + * + * @param string $term The users search input + * @param SearchResultSet $textMatches The response to the users initial search request + * @return string HTML linking the user to their original $term query, and the one + * suggested by $textMatches. + */ + protected function getDidYouMeanRewrittenHtml( $term, SearchResultSet $textMatches ) { + // Showing results for '$rewritten' + // Search instead for '$orig' + + $params = array( 'search' => $textMatches->getSuggestionQuery() ); + if ( $this->fulltext != null ) { + $params['fulltext'] = $this->fulltext; + } + $stParams = array_merge( $params, $this->powerSearchOptions() ); + + $rewritten = Linker::linkKnown( + $this->getPageTitle(), + $textMatches->getSuggestionSnippet() ?: null, + array(), + $stParams + ); + + $stParams['search'] = $term; + $stParams['runsuggestion'] = 0; + $original = Linker::linkKnown( + $this->getPageTitle(), + htmlspecialchars( $term ), + array(), + $stParams + ); + + return Html::rawElement( + 'div', + array( 'class' => 'searchdidyoumean' ), + $this->msg( 'search-rewritten')->rawParams( $rewritten, $original )->escaped() + ); + } + /** * @param Title $title * @param int $num The number of search results found diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 0cf41d27c0..57b4b4981d 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -914,6 +914,7 @@ "search-category": "(category $1)", "search-file-match": "(matches file content)", "search-suggest": "Did you mean: $1", + "search-rewritten": "Showing results for $1. Search instead for $2.", "search-interwiki-caption": "Sister projects", "search-interwiki-default": "Results from $1:", "search-interwiki-custom": "", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 3bcab84e57..b0ba9f178b 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1084,6 +1084,7 @@ "search-category": "This text will be shown on the search result listing after the page title of a result if the search algorithm thinks that the page being in a particular category is relevant.\n\nParameters:\n* $1 - the category's name with any matching portion highlighted\n{{Identical|Category}}", "search-file-match": "This text will be shown on the search result listing after the page title of a result if the search engine got search results from the contents of files, rather than the pages.", "search-suggest": "Used for \"Did you mean\" suggestions:\n* $1 - suggested link", + "search-rewritten": "Used when the user is served the results for a query other than what they provided. Parameters:\n* $1 - a link to search for the current result set.* $2 - a link to perform the original search without rewriting.", "search-interwiki-caption": "Used in [[Special:Search]], when showing search results from other wikis.", "search-interwiki-default": "Parameters:\n* $1 - the hostname of the remote wiki from where the additional results listed below are returned", "search-interwiki-custom": "#REDIRECT [[MediaWiki:Wmf-search-interwiki-custom/qqq]]", diff --git a/tests/phpunit/includes/specials/SpecialSearchTest.php b/tests/phpunit/includes/specials/SpecialSearchTest.php index 5482b9753d..7e60fddec2 100644 --- a/tests/phpunit/includes/specials/SpecialSearchTest.php +++ b/tests/phpunit/includes/specials/SpecialSearchTest.php @@ -141,4 +141,107 @@ class SpecialSearchTest extends MediaWikiTestCase { "Search term '{$term}' should not be expanded in Special:Search " ); } + + public function provideRewriteQueryWithSuggestion() { + return array( + array( + 'With results and a suggestion does not run suggested query', + '/Did you mean: <a[^>]+>first suggestion/', + array( + new SpecialSearchTestMockResultSet( 'first suggestion', array( + SearchResult::newFromTitle( Title::newMainPage() ), + ) ), + new SpecialSearchTestMockResultSet( 'was never run', array() ), + ), + ), + + array( + 'With no results and a suggestion responds with suggested query results', + '/Showing results for <a[^>]+>first suggestion/', + array( + new SpecialSearchTestMockResultSet( 'first suggestion', array() ), + new SpecialSearchTestMockResultSet( 'second suggestion', array( + SearchResult::newFromTitle( Title::newMainPage() ), + ) ), + ), + ), + + array( + 'When both queries have no results user gets no results', + '/There were no results matching the query/', + array( + new SpecialSearchTestMockResultSet( 'first suggestion', array() ), + new SpecialSearchTestMockResultSet( 'second suggestion', array() ), + ), + ), + ); + } + + /** + * @dataProvider provideRewriteQueryWithSuggestion + */ + public function testRewriteQueryWithSuggestion( $message, $expectRegex, $fromResults ) { + $mockSearchEngine = $this->mockSearchEngine( $fromResults ); + $search = $this->getMockBuilder( 'SpecialSearch' ) + ->setMethods( array( 'getSearchEngine' ) ) + ->getMock(); + $search->expects( $this->any() ) + ->method( 'getSearchEngine' ) + ->will( $this->returnValue( $mockSearchEngine ) ); + + $search->getContext()->setTitle( Title::makeTitle( NS_SPECIAL, 'Search' ) ); + $search->load(); + $search->showResults( 'this is a fake search' ); + + $html = $search->getContext()->getOutput()->getHTML(); + foreach ( (array)$expectRegex as $regex ) { + $this->assertRegExp( $regex, $html, $message ); + } + } + + protected function mockSearchEngine( array $returnValues ) { + $mock = $this->getMockBuilder( 'SearchEngine' ) + ->setMethods( array( 'searchText' ) ) + ->getMock(); + + $mock->expects( $this->any() ) + ->method( 'searchText' ) + ->will( call_user_func_array( + array( $this, 'onConsecutiveCalls' ), + array_map( array( $this, 'returnValue' ), $returnValues ) + ) ); + + return $mock; + } +} + +class SpecialSearchTestMockResultSet extends SearchResultSet { + protected $results; + protected $suggestion; + + public function __construct( $suggestion = null, array $results = array(), $containedSyntax = false) { + $this->results = $results; + $this->suggestion = $suggestion; + $this->containedSyntax = $containedSyntax; + } + + public function numRows() { + return count( $this->results ); + } + + public function getTotalHits() { + return $this->numRows(); + } + + public function hasSuggestion() { + return $this->suggestion !== null; + } + + public function getSuggestionQuery() { + return $this->suggestion; + } + + public function getSuggestionSnippet() { + return $this->suggestion; + } } -- 2.20.1