Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.33% covered (success)
98.33%
59 / 60
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiLambdaApiQueryGeneratorBase
98.33% covered (success)
98.33%
59 / 60
85.71% covered (warning)
85.71%
6 / 7
17
0.00% covered (danger)
0.00%
0 / 1
 execute
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 executeGenerator
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 run
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMatchRate
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
8
 getSubstringMatchRate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\WikiLambda\ActionAPI;
4
5use MediaWiki\Api\ApiPageSet;
6use MediaWiki\Api\ApiQueryGeneratorBase;
7use MediaWiki\Extension\WikiLambda\HttpStatus;
8use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
9use MediaWiki\Extension\WikiLambda\ZErrorFactory;
10use MediaWiki\Extension\WikiLambda\ZObjectUtils;
11use Psr\Log\LoggerAwareInterface;
12use Psr\Log\LoggerInterface;
13
14/**
15 * WikiLambda Query Generator Base API util
16 *
17 * This abstract class extends the Wikimedia's ApiBase class
18 * and provides specific additional methods.
19 *
20 * @stable to extend
21 *
22 * @ingroup API
23 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
24 * @license MIT
25 */
26abstract class WikiLambdaApiQueryGeneratorBase extends ApiQueryGeneratorBase implements LoggerAwareInterface {
27
28    protected LoggerInterface $logger;
29
30    /**
31     * @inheritDoc
32     */
33    public function execute() {
34        // Exit if we're running in non-repo mode (e.g. on a client wiki)
35        if ( !$this->getConfig()->get( 'WikiLambdaEnableRepoMode' ) ) {
36            WikiLambdaApiBase::dieWithZError(
37                ZErrorFactory::createZErrorInstance(
38                    ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN,
39                    []
40                ),
41                HttpStatus::BAD_REQUEST
42            );
43        }
44
45        $this->run( null );
46    }
47
48    /**
49     * @inheritDoc
50     */
51    public function executeGenerator( $resultPageSet ) {
52        // Exit if we're running in non-repo mode (e.g. on a client wiki)
53        if ( !$this->getConfig()->get( 'WikiLambdaEnableRepoMode' ) ) {
54            WikiLambdaApiBase::dieWithZError(
55                ZErrorFactory::createZErrorInstance(
56                    ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN,
57                    []
58                ),
59                HttpStatus::BAD_REQUEST
60            );
61        }
62
63        $this->run( $resultPageSet );
64    }
65
66    /**
67     * @param ApiPageSet|null $resultPageSet
68     */
69    protected function run( $resultPageSet ) {
70        // Throw, not implemented
71        WikiLambdaApiBase::dieWithZError(
72            ZErrorFactory::createZErrorInstance(
73                ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
74                [ 'message' => 'You must implement your run() method when using WikiLambdaApiBase' ]
75            ),
76            HttpStatus::NOT_IMPLEMENTED
77        );
78    }
79
80    /** @inheritDoc */
81    public function setLogger( LoggerInterface $logger ): void {
82        $this->logger = $logger;
83    }
84
85    /** @inheritDoc */
86    public function getLogger(): LoggerInterface {
87        return $this->logger;
88    }
89
90    private const PREFIX_BOOST_FACTOR = 1.5;
91    private const POSITION_WEIGHT_FACTOR = 3.5;
92    private const COVERAGE_FACTOR = 0.3;
93
94    /**
95     * Return the float match rate between a substring and the returned hit.
96     * The match rate is an aggregation of the match rates of each substring token.
97     *
98     * @param string $substring
99     * @param string $hit
100     * @param bool $exact
101     * @return float
102     */
103    protected static function getMatchRate( $substring, $hit, $exact = false ) {
104        if ( !$exact ) {
105            $substring = ZObjectUtils::comparableString( $substring );
106            $hit = ZObjectUtils::comparableString( $hit );
107        }
108
109        // Try match rate of full search term if substring is part of the hit
110        if ( strpos( $hit, $substring ) !== false ) {
111            return self::getSubstringMatchRate( $substring, $hit );
112        }
113
114        // Tokenize substring and calculate match rate for each token
115        $tokens = preg_split( '/\s+/', trim( $substring ), -1, PREG_SPLIT_NO_EMPTY );
116        if ( count( $tokens ) === 0 ) {
117            return 0.0;
118        }
119
120        $weightedSum = 0;
121        $totalWeight = 0;
122        $matchedTokens = 0;
123
124        // Aggregate weighted token's rates
125        foreach ( $tokens as $index => $token ) {
126            $rate = self::getSubstringMatchRate( $token, $hit );
127
128            // Count number of matched tokens to promote full token coverate
129            if ( $rate > 0 ) {
130                $matchedTokens++;
131            }
132
133            // Boost if first token matches the first characters of the hit
134            if ( $index === 0 && strpos( $hit, $token ) === 0 ) {
135                $rate = min( 1.0, $rate * self::PREFIX_BOOST_FACTOR );
136            }
137
138            // Weight by token length
139            $lengthWeight = strlen( $token );
140            // Weight by token position
141            $positionWeight = ( 1 / ( $index + 1 ) ) * self::POSITION_WEIGHT_FACTOR;
142            // Combined weights
143            $weight = $lengthWeight + $positionWeight;
144
145            $weightedSum += $rate * $weight;
146            $totalWeight += $weight;
147        }
148
149        $baseScore = ( $weightedSum / $totalWeight ) * ( 1 - self::COVERAGE_FACTOR );
150        $coverageScore = ( $matchedTokens / count( $tokens ) ) * self::COVERAGE_FACTOR;
151
152        return $baseScore + $coverageScore;
153    }
154
155    /**
156     * Calculates the float match rate between a substring token and the returned hit.
157     * The match rate is calculated considering:
158     * * Levenshtein distance
159     * * Position score
160     *
161     * @param string $substring
162     * @param string $hit
163     * @return float
164     */
165    private static function getSubstringMatchRate( $substring, $hit ) {
166        // Zero score if token not present at all
167        if ( strpos( $hit, $substring ) === false ) {
168            return 0.0;
169        }
170
171        // Calculate the base match rate with the Levenshtein distance
172        $distance = levenshtein( $substring, $hit );
173        $max = max( strlen( $substring ), strlen( $hit ) );
174        $baseMatchRate = ( $max - $distance ) / $max;
175
176        // Find the position of the substring in the hit
177        $position = strpos( $hit, $substring );
178
179        // Normalize the position to a score (earlier positions get higher weight)
180        $positionScore = 1 - ( $position / strlen( $hit ) );
181
182        // Combine the base match rate and position score
183        return $baseMatchRate * 0.5 + $positionScore * 0.5;
184    }
185}