You’ve been brought in as “the expert” to develop the algorithm for a personalised recommendation app. The app in question is a Sprig-powered Craft CMS site that suggests travel destination recommendations. Users are presented with photos of places and, based on the photos alone, select whether they would like to visit them or not. With each choice, the algorithm presents a fresh set of recommendations to the user.
Check out the Demo App →
This GitHub repo contains the Craft CMS site that you can spin up with a single command, either locally or in the browser using a GitHub codespace (see the readme file in the repo).
Right now, the recommendations.twig template just returns 3 random destinations, excluding any that have been disliked. Your algorithm should recommend up to 3 destinations to users based on their previous likes and dislikes. All other templates and Sprig components have been set up for you, so the “only” thing you need to do is populate the recommendations
variable using your secret-sauce logic.
Liked and disliked destination IDs are stored in cookies, so recommendations will be user-specific, and users need not be logged in. You can access them in the template using the likeIds
and dislikeIds
Twig variables.
Destinations are tagged with up to 5 tags using an entries field (rather than a tags field, since Entrification is now a thing), which will be the basis for weighting tags and scoring destinations for each individual user.
Destination tags should be assigned a weight based on whether they were liked or disliked, and how many times. So for example, if 3 destinations were liked, all of which were tagged with Beach, and 3 destinations were disliked, 1 of which was tagged with Beach, then Beach would be given a weight of +2
(3 - 1
). If 1 destination tagged with City was liked and 2 were disliked, then City would be given a weight of -1
(1 - 2
)
Finally, destination tags are weighted based on their order in the entries field – the first tag gets the most weight and the last tag gets the least weight. The algorithm should take this into account both when weighting tags (based on an individual user’s likes/dislikes). So if Beach is the first tag in one liked destination, the second tag in another liked destination, and the third tag in two disliked destinations, then it is given a weight of 1x + 1y - 2z
, where x
is the weight of the first tag, y
is the weight of the second tag and z
is the weight of the third tag.
Once each tag has an assigned weight, the algorithm should give each destination a score based on that destination’s weighted tags. Finally, the recommended destinations should be ordered by score descending and should only contain destinations with positive scores (greater than 0
).
For bonus points, make it so that scoring destinations (based on each destination’s tags) also takes the order of the tags in the Tags field into account.
While this challenge is tagged “difficult”, it’s still clearly easier than drawing seven red lines, all strictly perpendicular, with red, green and transparent ink.
Your solution should consist of the contents of only the _components/recommendations.twig
template.
_components/recommendations.twig
template. The content structure may not be changed.craft.entries
, etc.) but you can use any of the available parameters on them.The general approach we’ll take is to first give each tag a weight and then give each destination a score, based on whether it was liked or disliked.
To codify the steps above using Twig, we’ll create a few “hashes” (associative arrays in Twig) that will allow us to store weights
, scores
and scoredDestinations
, and manipulate the values. Remember that the variables likeIds
and dislikeIds
are already available to us.
{% set likesDislikes = craft.entries
.section('destinations')
.id(likeIds|merge(dislikeIds))
.with('tags')
.all()
%}
{% set weights = {} %}
{% for entry in likesDislikes %}
{% for tag in entry.tags %}
{% set weight = 1 %}
{% if entry.id in dislikeIds %}
{% set weight = 0 - weight %}
{% endif %}
{% set newWeight = (weights[tag.slug] ?? 0) + weight %}
{% set weights = weights|merge({ (tag.slug): newWeight }) %}
{% endfor %}
{% endfor %}
The key part of the code above is how we reference and modify the tag’s value in the weights
array. We reference the value, defaulting to zero, with weights[tag.slug] ?? 0
, followed by adding a weight
for the current destination (+1
or -1
if the entry exists in the disliked destinations). Then we merge the value back into the weights
array, which overwrites the value with the key (the tag’s slug).
After this code runs, we are left with an array of tag weights that might look something like this:
{ ruins: 3, tropical: 2, jungle: 1, city: -2, nightlife: -4 }
Next we’ll loop over all destinations (excluding disliked ones as they’ll have negative scores) and calculate each of their scores. We eager-load the tags and images (with transforms) for good practice, since we’ll be outputting them later on.
{% set destinations = craft.entries
.section('destinations')
.id(['not']|merge(dislikeIds))
.with([
'tags',
['images', { withTransforms: ['small'] }]
])
.all()
%}
{% set scoredDestinations = {} %}
{% for entry in destinations %}
{% set score = 0 %}
{% for tag in entry.tags %}
{% set tagWeight = weights[tag.slug] ?? 0 %}
{% set score = score + tagWeight %}
{% endfor %}
{% set scoredDestinations = scoredDestinations|push({ score: score, entry: entry }) %}
{% endfor %}
The scoredDestinations
variable now contains an array of arrays. Each nested array contains a score and an entry (destination) variable, which allows us to sort the scoredDestinations
by score descending.
{% set recommendations = scoredDestinations
|multisort('score', direction=SORT_DESC)
|filter(item => item.score > 0)
|map(item => item.entry)
|slice(0, 3)
%}
Notice how we use Twig filters to:
score
key descending.score
greater than zero.entry
variable.We could refactor this to use Collections, which would make the code somewhat easier to read.
{% set recommendations = scoredDestinations
.sortByDesc('score')
.where('score', '>', 0)
.pluck('entry')
.take(3)
%}
And that’s it, we have a working solution!
But before we wrap up, there are some bonus points to be had, by making it so that destinations are scored based on the order of the tags in the Tags field.
In the solution above, we set each weight
to a default value of 1
. This time, we’ll set it to a value of 1
to 5
(since the field allows up to 5 tags), depending on the tag’s position in the field.
The first tag receives a weight of 5
, the second 4
, etc. We’ll use the zero-indexed iteration of the loop to determine the value.
{% set weight = 5 - loop.index0 %}
There are actually two places we can use this. The first, more obvious place, is when we calculate the tag weights. The second is when we calculate the destination scores (since each destination’s tags are also in a specific order). Using both, as well as using the Collections approach for weights
and scoredDestinations
, results in the following solution.
{% set likesDislikes = craft.entries
.section('destinations')
.id(likeIds|merge(dislikeIds))
.with('tags')
.all()
%}
{% set weights = collect([]) %}
{% for entry in likesDislikes %}
{% for tag in entry.tags %}
{% set weight = 5 - loop.index0 %}
{% if entry.id in dislikeIds %}
{% set weight = 0 - weight %}
{% endif %}
{% set newWeight = weights.get(tag.slug, 0) + weight %}
{% set weights = weights.put(tag.slug, newWeight) %}
{% endfor %}
{% endfor %}
{% set destinations = craft.entries
.section('destinations')
.id(['not']|merge(dislikeIds))
.with([
'tags',
['images', { withTransforms: ['small'] }]
])
.all()
%}
{% set scoredDestinations = collect([]) %}
{% for entry in destinations %}
{% set score = 0 %}
{% for tag in entry.tags %}
{% set weight = 5 - loop.index0 %}
{% set tagWeight = weights.get(tag.slug, 0) %}
{% set score = score + (weight * tagWeight) %}
{% endfor %}
{% set scoredDestinations = scoredDestinations.push({
score: score,
entry: entry,
}) %}
{% endfor %}
{% set recommendations = scoredDestinations
.sortByDesc('score')
.where('score', '>', 0)
.pluck('entry')
.take(3)
%}
Similar solutions: Andrew Welch, Dominik Krulak.
Solution submitted by Andrew Welch on 29 May 2023.
{# First, get all of the destinations (we will need to loop through them all later anyway #}
{% set destinations = craft.entries
.section('destinations')
.with(['tags'])
.collect()
%}
{# Second, assign a weight to each tag based on likes/dislikes. Doing them all simplifies the code #}
{% set weightedTags = collect([]) %}
{% for destination in destinations %}
{% set multiplier = destination.id in likeIds ? 1 : 0 %}
{% set multiplier = destination.id in dislikeIds ? -1 : multiplier %}
{% for tag in destination.tags %}
{% set tagWeight = weightedTags.get(tag.slug, 0) %}
{% set tagWeight = tagWeight + ((5 - loop.index0) * multiplier) %}
{% do weightedTags.put(tag.slug, tagWeight) %}
{% endfor %}
{% endfor %}
{# Third, build an array of recommendations based on on the computed score #}
{% set recommendations = collect([]) %}
{% for destination in destinations %}
{% set score = 0 %}
{% for tag in destination.tags %}
{% set score = score + weightedTags.get(tag.slug, 0) * (5 - loop.index0) %}
{% endfor %}
{% if score > 0 %}
{% do recommendations.push({entry: destination, score: score}) %}
{% endif %}
{% endfor %}
{# Finally, sort the array by score, then flatten it back to an array of entries, and take the first 3 #}
{% set recommendations = recommendations.sortByDesc('score').pluck('entry').take(3) %}
Solution submitted by Dominik Krulak on 1 June 2023.
{### The goal is to collect most liked tags and show destinations based on them ###}
{# Prepare array-like object for like and dislike tags #}
{% set likeTagsCount = {} %}
{% set dislikeTagsCount = {} %}
{# Query in 'destinations' with `dislikeIds` #}
{% set dislikeDestinations = craft.entries
.section('destinations')
.id(dislikeIds)
.with([
'tags'
])
.all()
%}
{# Query in 'destinations' with `likeIds` #}
{% set likeDestinations = craft.entries
.section('destinations')
.id(likeIds)
.with([
'tags'
])
.all()
%}
{# Count likes and build array with objects like this:
{# [
{# {tag1: 'City', count: 2},
{# {tag2: 'Sea', count: 2},
{# {tag3: 'Ruins', count: 1}
{# ]
#}
{% if likeDestinations %}
{% for destination in likeDestinations %}
{% set tags = destination.tags %}
{% for tag in tags %}
{% set tagTitle = tag.title %}
{% if likeTagsCount %}
{% for likeTag in likeTagsCount %}
{% set index = loop.index %}
{# If value of key 'title' is equal to `tagTitle`, update count #}
{% if tagTitle == likeTag.title %}
{% set likeTagsCount = likeTagsCount|merge({ ('tag' ~ index): attribute(likeTagsCount, 'tag' ~ index)|merge({count: likeTag.count + 1 }) }) %}
{# Otherwise if key 'title' is not in array, push it at the end #}
{% elseif not likeTagsCount|contains('title', tagTitle) %}
{% set likeTagsCount = likeTagsCount|merge({ ('tag' ~ (likeTagsCount|length + 1)): {title: tagTitle, count: 1} }) %}
{% endif %}
{% endfor %}
{% else %}
{% set likeTagsCount = likeTagsCount|merge({ tag1: {title: tagTitle, count: 1} }) %}
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
{# Count dislikes and build array with objects like this:
{# [
{# {tag1: 'City', count: 1},
{# {tag2: 'Sea', count: 2},
{# {tag3: 'Ruins', count: 2}
{# ]
#}
{% if dislikeDestinations %}
{% for destination in dislikeDestinations %}
{% set tags = destination.tags %}
{% for tag in tags %}
{% set tagTitle = tag.title %}
{% if dislikeTagsCount %}
{% for likeTag in dislikeTagsCount %}
{% set index = loop.index %}
{# If value of key 'title' is equal to `tagTitle`, update count #}
{% if tagTitle == likeTag.title %}
{% set dislikeTagsCount = dislikeTagsCount|merge({ ('tag' ~ index): attribute(dislikeTagsCount, 'tag' ~ index)|merge({count: likeTag.count + 1 }) }) %}
{# Otherwise if key 'title' is not in array, push it at the end #}
{% elseif not dislikeTagsCount|contains('title', tagTitle) %}
{% set dislikeTagsCount = dislikeTagsCount|merge({ ('tag' ~ (dislikeTagsCount|length + 1)): {title: tagTitle, count: 1} }) %}
{% endif %}
{% endfor %}
{% else %}
{% set dislikeTagsCount = dislikeTagsCount|merge({ tag1: {title: tagTitle, count: 1} }) %}
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
{# If there are some liked tags carry on to calculating likes count #}
{% if likeTagsCount %}
{# Deduct dislikes from likes and build array with objects like this:
{# [
{# {tag1: 'City', count: 1},
{# {tag2: 'Sea', count: 0},
{# {tag3: 'Ruins', count: -1}
{# ]
#}
{% for likeTag in likeTagsCount %}
{% set index = loop.index %}
{% if dislikeTagsCount|contains('title', likeTag.title) %}
{% for dislikeTag in dislikeTagsCount|filter(tag => tag.title == likeTag.title) %}
{% set likeTagsCount = likeTagsCount|merge({ ('tag' ~ index): attribute(likeTagsCount, 'tag' ~ index)|merge({count: likeTag.count - dislikeTag.count }) }) %}
{% endfor %}
{% endif %}
{% endfor %}
{# Output tag title with highest count. Not lower or equal than 0 #}
{% set mostLikedTags = likeTagsCount|sort((a, b) => a.count <= b.count)|map(tag => tag.count > 0 ? tag.title : '') %}
{# If there are some most liked tags, carry on #}
{% if mostLikedTags %}
{# Prepare array to collect ids of recommended destinations #}
{% set recommendedIds = [] %}
{# Loop for in `mostLikedTags` and push destination's id to `recommendedIds` array
that is related to currently looped most liked tag with random order.
That means if we have first most liked tag e.g. 'sea' and there are more entries/destinations
with tag 'sea' than we don't push destination's id to `recommendedIds` array
because there is already one destination with 'sea' tag. So we move to another most liked tag.
#}
{% for tag in mostLikedTags|filter(tag => tag|length) %}
{% set recomendedDestination = craft.entries
.section('destinations')
.relatedTo(craft.entries.section('tags').title(tag).one())
.orderBy('RAND()')
.one() %}
{% set recomendedDestination = recomendedDestination.id %}
{% set recommendedIds = recomendedDestination not in recommendedIds ? recommendedIds|merge([recomendedDestination]) : recommendedIds %}
{% endfor %}
{# Query for entries with 'recommendedIds' in 'destinations' and order them by specified 'recommendedIds' #}
{% set recommendations = craft.entries
.section('destinations')
.id(recommendedIds)
.fixedOrder()
.limit(3)
.all()
%}
{# Otherwise fallback to random destinations #}
{% else %}
{% set recommendations = craft.entries
.section('destinations')
.id(['not']|merge(dislikeIds))
.orderBy('RAND()')
.limit(3)
.all()
%}
{% endif %}
{# Otherwise fallback to random destinations #}
{% else %}
{% set recommendations = craft.entries
.section('destinations')
.id(['not']|merge(dislikeIds))
.orderBy('RAND()')
.limit(3)
.all()
%}
{% endif %}