-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add user-based recommendation command (fixes #25)
- Loading branch information
Showing
2 changed files
with
220 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Lmc\Matej\Model\Command; | ||
|
||
/** | ||
* Deliver personalized recommendations for the given user. | ||
*/ | ||
class UserRecommendation extends AbstractCommand | ||
{ | ||
public const MINIMAL_RELEVANCE_LOW = 'low'; | ||
public const MINIMAL_RELEVANCE_MEDIUM = 'medium'; | ||
public const MINIMAL_RELEVANCE_HIGH = 'high'; | ||
|
||
/** @var string */ | ||
protected $filterOperator = 'and'; | ||
/** @var string */ | ||
private $userId; | ||
/** @var int */ | ||
private $count; | ||
/** @var string */ | ||
private $scenario; | ||
/** @var float */ | ||
private $rotationRate; | ||
/** @var int */ | ||
private $rotationTime; | ||
/** @var bool */ | ||
private $hardRotation = false; | ||
/** @var string */ | ||
private $minimalRelevance = self::MINIMAL_RELEVANCE_LOW; | ||
/** @var array */ | ||
private $filters = ['valid_to >= NOW']; | ||
|
||
private function __construct(string $userId, int $count, string $scenario, float $rotationRate, int $rotationTime) | ||
{ | ||
$this->userId = $userId; // TODO: assert format | ||
$this->count = $count; // TODO: assert greater than 0 | ||
$this->scenario = $scenario; // TODO: assert format | ||
$this->rotationRate = $rotationRate; // TODO: assert value between 0.0 and 1.0 | ||
$this->rotationTime = $rotationTime; // TODO: assert valid time interval | ||
} | ||
|
||
/** | ||
* @param string $userId | ||
* @param int $count Number of requested recommendations. The real number of recommended items could be lower or | ||
* even zero when there are no items relevant for the user. | ||
* @param string $scenario Name of the place where recommendations are applied - eg. 'search-results-page', | ||
* 'emailing', 'empty-search-results, 'homepage', ... | ||
* @param float $rotationRate How much should the item be penalized for being recommended again in the near future. | ||
* Set from 0.0 for no rotation (same items will be recommended) up to 1.0 (same items should not be recommended). | ||
* @param int $rotationTime Specify for how long will the item's rotationRate be taken in account and so the item | ||
* is penalized for recommendations. | ||
* @return UserRecommendation | ||
*/ | ||
public static function create( | ||
string $userId, | ||
int $count, | ||
string $scenario, | ||
float $rotationRate, | ||
int $rotationTime | ||
): self { | ||
return new static($userId, $count, $scenario, $rotationRate, $rotationTime); | ||
} | ||
|
||
/** | ||
* Even with rotation rate 1.0 user could still obtain the same recommendations in some edge cases. | ||
* To prevent this, enable hard rotation - recommended items are then excluded until rotation time is expired. | ||
* By default hard rotation is not enabled. | ||
*/ | ||
public function enableHardRotation(): self | ||
{ | ||
$this->hardRotation = true; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Define threshold of how much relevant must the recommended items be to be returned. | ||
* Default minimal relevance is "low". | ||
*/ | ||
public function setMinimalRelevance(string $minimalRelevance): self | ||
{ | ||
// TODO: assert one of MIN_RELEVANCE_* | ||
$this->minimalRelevance = $minimalRelevance; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Add a filter to already added filters (including the default filter). | ||
*/ | ||
public function addFilter(string $filter): self | ||
{ | ||
$this->filters[] = $filter; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Overwrite all filters by custom one. Note this will override also the default filter. | ||
*/ | ||
public function setFilters(array $filters): self | ||
{ | ||
$this->filters = $filters; | ||
|
||
return $this; | ||
} | ||
|
||
protected function getCommandType(): string | ||
{ | ||
return 'user-based-recommendations'; | ||
} | ||
|
||
protected function getCommandParameters(): array | ||
{ | ||
return [ | ||
'user_id' => $this->userId, | ||
'count' => $this->count, | ||
'scenario' => $this->scenario, | ||
'rotation_rate' => $this->rotationRate, | ||
'rotation_time' => $this->rotationTime, | ||
'hard_rotation' => $this->hardRotation, | ||
'min_relevance' => $this->minimalRelevance, | ||
'filter' => $this->assembleFiltersString(), | ||
]; | ||
} | ||
|
||
protected function assembleFiltersString(): string | ||
{ | ||
return implode(' ' . $this->filterOperator . ' ', $this->filters); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Lmc\Matej\Model\Command; | ||
|
||
use PHPUnit\Framework\TestCase; | ||
|
||
class UserRecommendationTest extends TestCase | ||
{ | ||
/** @test */ | ||
public function shouldBeInstantiableViaNamedConstructorWithDefaultValues(): void | ||
{ | ||
$command = UserRecommendation::create('user-id', 333, 'test-scenario', 1.0, 3600); | ||
|
||
$this->assertInstanceOf(UserRecommendation::class, $command); | ||
$this->assertSame( | ||
[ | ||
'type' => 'user-based-recommendations', | ||
'parameters' => [ | ||
'user_id' => 'user-id', | ||
'count' => 333, | ||
'scenario' => 'test-scenario', | ||
'rotation_rate' => 1.0, | ||
'rotation_time' => 3600, | ||
'hard_rotation' => false, | ||
'min_relevance' => UserRecommendation::MINIMAL_RELEVANCE_LOW, | ||
'filter' => 'valid_to >= NOW', | ||
], | ||
], | ||
$command->jsonSerialize() | ||
); | ||
} | ||
|
||
/** @test */ | ||
public function shouldUseCustomParameters(): void | ||
{ | ||
$userId = 'user-' . md5(microtime()); | ||
$count = random_int(1, 100); | ||
$scenario = 'scenario-' . md5(microtime()); | ||
$rotationRate = mt_rand() / mt_getrandmax(); | ||
$rotationTime = random_int(1, 86400); | ||
|
||
$command = UserRecommendation::create($userId, $count, $scenario, $rotationRate, $rotationTime); | ||
|
||
$command->setMinimalRelevance(UserRecommendation::MINIMAL_RELEVANCE_HIGH) | ||
->enableHardRotation() | ||
->setFilters(['foo = bar', 'baz = ban']); | ||
|
||
$this->assertInstanceOf(UserRecommendation::class, $command); | ||
$this->assertSame( | ||
[ | ||
'type' => 'user-based-recommendations', | ||
'parameters' => [ | ||
'user_id' => $userId, | ||
'count' => $count, | ||
'scenario' => $scenario, | ||
'rotation_rate' => $rotationRate, | ||
'rotation_time' => $rotationTime, | ||
'hard_rotation' => true, | ||
'min_relevance' => UserRecommendation::MINIMAL_RELEVANCE_HIGH, | ||
'filter' => 'foo = bar and baz = ban', | ||
], | ||
], | ||
$command->jsonSerialize() | ||
); | ||
} | ||
|
||
/** @test */ | ||
public function shouldAssembleFilters(): void | ||
{ | ||
$command = UserRecommendation::create('user-id', 333, 'test-scenario', 1.0, 3600); | ||
|
||
// Default filter | ||
$this->assertSame('valid_to >= NOW', $command->jsonSerialize()['parameters']['filter']); | ||
|
||
// Add custom filters to the default one | ||
$command->addFilter('foo = bar') | ||
->addFilter('bar = baz'); | ||
|
||
$this->assertSame( | ||
'valid_to >= NOW and foo = bar and bar = baz', | ||
$command->jsonSerialize()['parameters']['filter'] | ||
); | ||
|
||
// Overwrite all filters | ||
$command->setFilters(['my_filter = 1', 'other_filter = foo']); | ||
|
||
$this->assertSame('my_filter = 1 and other_filter = foo', $command->jsonSerialize()['parameters']['filter']); | ||
} | ||
} |