-
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-baser recommendation command (fixes #25)
- Loading branch information
Showing
2 changed files
with
233 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,144 @@ | ||
<?php declare(strict_types=1); | ||
|
||
namespace Lmc\Matej\Model\Command; | ||
|
||
/** | ||
* Deliver personalized recommendations for the given user. | ||
*/ | ||
class UserRecommendation extends AbstractCommand | ||
{ | ||
const MINIMAL_RELEVANCE_LOW = 'low'; | ||
const MINIMAL_RELEVANCE_MEDIUM = 'medium'; | ||
const MINIMAL_RELEVANCE_HIGH = 'high'; | ||
|
||
/** @var string */ | ||
private $userId; | ||
/** @var int */ | ||
private $count; | ||
/** @var string */ | ||
private $scenario; | ||
/** @var float */ | ||
private $rotationRate = 1.0; | ||
/** @var int */ | ||
private $rotationTime = 3600; | ||
/** @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) | ||
{ | ||
$this->userId = $userId; // TODO: assert format | ||
$this->count = $count; // TODO: assert greater than 0 | ||
$this->scenario = $scenario; // TODO: assert format | ||
} | ||
|
||
/** | ||
* @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', ... | ||
* @return UserRecommendation | ||
*/ | ||
public static function create(string $userId, int $count, string $scenario): self | ||
{ | ||
return new static($userId, $count, $scenario); | ||
} | ||
|
||
/** | ||
* 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). | ||
* Default value is 1.0. | ||
*/ | ||
public function setRotationRate(float $rotationRate): self | ||
{ | ||
// TODO: assert values is between 0 and 1 | ||
$this->rotationRate = $rotationRate; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* Specify for how long will the item's rotationRate be taken in account and so the item is penalized for | ||
* recommendations. | ||
* Default value is 3600 seconds (= 1 hour). | ||
*/ | ||
public function setRotationTime(int $rotationTimeInSeconds): self | ||
{ | ||
// TODO: assert valid time interval | ||
$this->rotationTime = $rotationTimeInSeconds; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* 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 'interaction'; | ||
} | ||
|
||
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(), | ||
]; | ||
} | ||
|
||
private function assembleFiltersString(): string | ||
{ | ||
return implode(' ', $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'); | ||
|
||
$this->assertInstanceOf(UserRecommendation::class, $command); | ||
$this->assertSame( | ||
[ | ||
'type' => 'interaction', | ||
'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()); | ||
|
||
$command = UserRecommendation::create($userId, $count, $scenario); | ||
|
||
$command->setRotationRate(0.1337) | ||
->setRotationTime(666) | ||
->setMinimalRelevance(UserRecommendation::MINIMAL_RELEVANCE_HIGH) | ||
->enableHardRotation() | ||
->setFilters(['foo = bar', 'AND baz = ban']); | ||
|
||
$this->assertInstanceOf(UserRecommendation::class, $command); | ||
$this->assertSame( | ||
[ | ||
'type' => 'interaction', | ||
'parameters' => [ | ||
'user_id' => $userId, | ||
'count' => $count, | ||
'scenario' => $scenario, | ||
'rotation_rate' => 0.1337, | ||
'rotation_time' => 666, | ||
'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'); | ||
|
||
// Default filter | ||
$this->assertSame('valid_to >= NOW', $command->jsonSerialize()['parameters']['filter']); | ||
|
||
// Add custom filters to the default one | ||
$command->addFilter('AND foo = bar') | ||
->addFilter('OR bar = baz'); | ||
|
||
$this->assertSame( | ||
'valid_to >= NOW AND foo = bar OR bar = baz', | ||
$command->jsonSerialize()['parameters']['filter'] | ||
); | ||
|
||
// Overwrite all filters | ||
$command->setFilters(['my_filter = 1', 'other_filter = foo']); | ||
|
||
$this->assertSame('my_filter = 1 other_filter = foo', $command->jsonSerialize()['parameters']['filter']); | ||
} | ||
} |