Skip to content

Commit

Permalink
Add user-based recommendation command (fixes #25)
Browse files Browse the repository at this point in the history
  • Loading branch information
OndraM committed Nov 27, 2017
1 parent e54145c commit bd7a0fb
Show file tree
Hide file tree
Showing 2 changed files with 220 additions and 0 deletions.
131 changes: 131 additions & 0 deletions src/Model/Command/UserRecommendation.php
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);
}
}
89 changes: 89 additions & 0 deletions tests/Model/Command/UserRecommendationTest.php
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']);
}
}

0 comments on commit bd7a0fb

Please sign in to comment.