Skip to content

Commit

Permalink
Merge 95ec00f into 221b362
Browse files Browse the repository at this point in the history
  • Loading branch information
foglcz committed Nov 27, 2017
2 parents 221b362 + 95ec00f commit b598b44
Show file tree
Hide file tree
Showing 7 changed files with 424 additions and 2 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);
}
}
49 changes: 49 additions & 0 deletions src/RequestBuilder/RecommendationsRequestBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php declare(strict_types=1);

namespace Lmc\Matej\RequestBuilder;

use Fig\Http\Message\RequestMethodInterface;
use Lmc\Matej\Model\Command\Interaction;
use Lmc\Matej\Model\Command\UserMerge;
use Lmc\Matej\Model\Command\UserRecommendation;
use Lmc\Matej\Model\Request;

class RecommendationsRequestBuilder extends AbstractRequestBuilder
{
protected const ENDPOINT_PATH = '/recommendations';

/** @var Interaction|null */
private $interactionCommand;
/** @var UserMerge|null */
private $userMergeCommand;
/** @var UserRecommendation */
private $userRecommendationCommand;

public function __construct(UserRecommendation $userRecommendationCommand)
{
$this->userRecommendationCommand = $userRecommendationCommand;
}

public function addUserMerge(UserMerge $merge): self
{
$this->userMergeCommand = $merge;

return $this;
}

public function addInteraction(Interaction $interaction): self
{
$this->interactionCommand = $interaction;

return $this;
}

public function build(): Request
{
return new Request(
self::ENDPOINT_PATH,
RequestMethodInterface::METHOD_POST,
[$this->interactionCommand, $this->userMergeCommand, $this->userRecommendationCommand]
);
}
}
9 changes: 9 additions & 0 deletions src/RequestBuilder/RequestBuilderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Lmc\Matej\Http\RequestManager;
use Lmc\Matej\Model\Command\Sorting;
use Lmc\Matej\Model\Command\UserRecommendation;

/**
* Factory to create concrete RequestBuilder which helps you to create request for each Matej API
Expand Down Expand Up @@ -55,6 +56,14 @@ public function sorting(Sorting $sorting): SortingRequestBuilder
return $this->createConfiguredBuilder(SortingRequestBuilder::class, $sorting);
}

/**
* @return RecommendationsRequestBuilder
*/
public function recommendations(UserRecommendation $recommendation): RecommendationsRequestBuilder
{
return $this->createConfiguredBuilder(RecommendationsRequestBuilder::class, $recommendation);
}

// TODO: builders for other endpoints

/**
Expand Down
69 changes: 69 additions & 0 deletions tests/IntegrationTests/RecommendationsRequestBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php declare(strict_types=1);

namespace Lmc\Matej\IntegrationTests;

use Lmc\Matej\Model\Command\Interaction;
use Lmc\Matej\Model\Command\UserMerge;
use Lmc\Matej\Model\Command\UserRecommendation;
use Lmc\Matej\RequestBuilder\RecommendationsRequestBuilder;

/**
* @covers ItemPropertiesSetupRequestBuilder
* @covers EventsRequestBuilder
*/
class RecommendationsRequestBuilderTest extends IntegrationTestCase
{
/** @test */
public function shouldExecuteRecommendationRequestOnly(): void
{
$response = $this->createRecommendationRequestBuilder()
->send();

$this->assertResponseCommandStatuses($response, 'SKIPPED', 'SKIPPED', 'OK');
}

/** @test */
public function shouldExecuteRecommendationRequestWithInteraction(): void
{
$response = $this->createRecommendationRequestBuilder()
->addInteraction(Interaction::bookmark('integration-test-php-client-user-id-A', 'itemA'))
->send();

$this->assertResponseCommandStatuses($response, 'OK', 'SKIPPED', 'OK');
}

/** @test */
public function shouldExecuteRecommendationRequestWithUserMerge(): void
{
$response = $this->createRecommendationRequestBuilder()
->addUserMerge(UserMerge::mergeInto('integration-test-php-client-user-id-A', 'integration-test-php-client-user-id-B'))
->send();

$this->assertResponseCommandStatuses($response, 'SKIPPED', 'OK', 'OK');
}

/** @test */
public function shouldExecuteRecommendationRequestWithUserMergeAndInteraction(): void
{
$response = $this->createRecommendationRequestBuilder()
->addUserMerge(UserMerge::mergeInto('integration-test-php-client-user-id-A', 'integration-test-php-client-user-id-B'))
->addInteraction(Interaction::bookmark('integration-test-php-client-user-id-A', 'itemA'))
->send();

$this->assertResponseCommandStatuses($response, 'OK', 'OK', 'OK');
}

private function createRecommendationRequestBuilder(): RecommendationsRequestBuilder
{
return $this->createMatejInstance()
->request()
->recommendations(UserRecommendation::create(
'integration-test-php-client-user-id-A',
5,
'integration-test-scenario',
0.50,
3600
))
;
}
}
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']);
}
}
Loading

0 comments on commit b598b44

Please sign in to comment.