Challenge #11 – Where in the World

9 May 2023 Solved Twig Difficult

You’ve been brought in as the expert” to devel­op the algorithm for a per­son­al­ised recom­mend­a­tion app. The app in ques­tion is a Sprig-powered Craft CMS site that sug­gests travel des­tin­a­tion recom­mend­a­tions. Users are presen­ted with pho­tos of places and, based on the pho­tos alone, select wheth­er they would like to vis­it them or not. With each choice, the algorithm presents a fresh set of recom­mend­a­tions to the user.

Check out the Demo App →

Where in the world

This Git­Hub repo con­tains the Craft CMS site that you can spin up with a single com­mand, either loc­ally or in the browser using a Git­Hub codespace (see the readme file in the repo).

Right now, the recommendations.twig tem­plate just returns 3 ran­dom des­tin­a­tions, exclud­ing any that have been dis­liked. Your algorithm should recom­mend up to 3 des­tin­a­tions to users based on their pre­vi­ous likes and dis­likes. All oth­er tem­plates and Sprig com­pon­ents have been set up for you, so the only” thing you need to do is pop­u­late the recommendations vari­able using your secret-sauce logic.

Liked and dis­liked des­tin­a­tion IDs are stored in cook­ies, so recom­mend­a­tions will be user-spe­cif­ic, and users need not be logged in. You can access them in the tem­plate using the likeIds and dislikeIds Twig variables. 

Des­tin­a­tions are tagged with up to 5 tags using an entries field (rather than a tags field, since Entri­fic­a­tion is now a thing), which will be the basis for weight­ing tags and scor­ing des­tin­a­tions for each indi­vidu­al user. 

Des­tin­a­tion tags should be assigned a weight based on wheth­er they were liked or dis­liked, and how many times. So for example, if 3 des­tin­a­tions were liked, all of which were tagged with Beach, and 3 des­tin­a­tions were dis­liked, 1 of which was tagged with Beach, then Beach would be giv­en a weight of +2 (3 - 1). If 1 des­tin­a­tion tagged with City was liked and 2 were dis­liked, then City would be giv­en a weight of -1 (1 - 2)

Tags

Finally, des­tin­a­tion 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 weight­ing tags (based on an indi­vidu­al user’s likes/​dislikes). So if Beach is the first tag in one liked des­tin­a­tion, the second tag in anoth­er liked des­tin­a­tion, and the third tag in two dis­liked des­tin­a­tions, then it is giv­en 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 des­tin­a­tion a score based on that destination’s weighted tags. Finally, the recom­men­ded des­tin­a­tions should be ordered by score des­cend­ing and should only con­tain des­tin­a­tions with pos­it­ive scores (great­er than 0).

For bonus points, make it so that scor­ing des­tin­a­tions (based on each destination’s tags) also takes the order of the tags in the Tags field into account.

While this chal­lenge is tagged dif­fi­cult”, it’s still clearly easi­er than draw­ing sev­en red lines, all strictly per­pen­dic­u­lar, with red, green and trans­par­ent ink.

Rules

Your solu­tion should con­sist of the con­tents of only the _components/recommendations.twig template.

  • You may only edit the _components/recommendations.twig tem­plate. The con­tent struc­ture may not be changed.
  • You may only use ele­ment quer­ies (craft.entries, etc.) but you can use any of the avail­able para­met­ers on them.
  • No plu­gins or mod­ules may be used except for any that are pre-installed.
  • Optim­ise your code for read­ab­il­ity and main­tain­ab­il­ity (future you), and use eager-load­ing where appropriate.

Tips

  • Des­tin­a­tions can be tagged with at most 5 tags, so use that as a baseline for cal­cu­lat­ing des­tin­a­tion tag weights based on likes/​dislikes.
  • Recal­cu­lat­ing weights on each request is easi­er than stor­ing and keep­ing them in sync.
  • Using Col­lec­tions over Twig fil­ters will help to make your code clean­er and more read­able. Craft Clos­ure is installed and may be used.

Acknow­ledge­ments

Solution

The gen­er­al approach we’ll take is to first give each tag a weight and then give each des­tin­a­tion a score, based on wheth­er it was liked or disliked.

  1. Fetch the liked and dis­liked des­tin­a­tions. For each des­tin­a­tion, loop over its tags. If the des­tin­a­tion was liked then give each of its tags a weight of +1. If the des­tin­a­tion was dis­liked then give each of its tags a weight of ‑1.
  2. Fetch all des­tin­a­tions, exclud­ing those that were dis­liked, as we’ll want to exclude them from recom­mend­a­tions. For each des­tin­a­tion, loop over its tags and apply the weight of each tag (cal­cu­lated in the pre­vi­ous step) to the destination’s score.
  3. Out­put the top 3 scored des­tin­a­tions, ordered by score descending.

To codi­fy the steps above using Twig, we’ll cre­ate a few hashes” (asso­ci­at­ive arrays in Twig) that will allow us to store weights, scores and scoredDestinations, and manip­u­late the val­ues. Remem­ber that the vari­ables likeIds and dislikeIds are already avail­able 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 ref­er­ence and modi­fy the tag’s value in the weights array. We ref­er­ence the value, default­ing to zero, with weights[tag.slug] ?? 0, fol­lowed by adding a weight for the cur­rent des­tin­a­tion (+1 or -1 if the entry exists in the dis­liked des­tin­a­tions). Then we merge the value back into the weights array, which over­writes 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 some­thing like this:

{ ruins: 3, tropical: 2, jungle: 1, city: -2, nightlife: -4 }

Next we’ll loop over all des­tin­a­tions (exclud­ing dis­liked ones as they’ll have neg­at­ive scores) and cal­cu­late each of their scores. We eager-load the tags and images (with trans­forms) for good prac­tice, since we’ll be out­put­ting 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 vari­able now con­tains an array of arrays. Each nes­ted array con­tains a score and an entry (des­tin­a­tion) vari­able, 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 fil­ters to:

  1. Sort the items using the score key descending.
  2. Only keep items with a score great­er than zero.
  3. Map each item to its entry variable.
  4. Extract the first three items.

We could refact­or this to use Col­lec­tions, which would make the code some­what easi­er to read.

{% set recommendations = scoredDestinations
    .sortByDesc('score')
    .where('score', '>', 0)
    .pluck('entry')
    .take(3)
%}

And that’s it, we have a work­ing solution!

But before we wrap up, there are some bonus points to be had, by mak­ing it so that des­tin­a­tions are scored based on the order of the tags in the Tags field.

In the solu­tion 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), depend­ing on the tag’s pos­i­tion in the field.

Tags field

The first tag receives a weight of 5, the second 4, etc. We’ll use the zero-indexed iter­a­tion of the loop to determ­ine the value.

{% set weight = 5 - loop.index0 %}

There are actu­ally two places we can use this. The first, more obvi­ous place, is when we cal­cu­late the tag weights. The second is when we cal­cu­late the des­tin­a­tion scores (since each destination’s tags are also in a spe­cif­ic order). Using both, as well as using the Col­lec­tions approach for weights and scoredDestinations, res­ults in the fol­low­ing 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)
%}

Sim­il­ar solu­tions: Andrew Welch, Domin­ik Krulak.

Submitted Solutions

  • Andrew Welch
  • Dominik Krulak