Skip to content

Commit

Permalink
feat: New system to show how well products match user preferences (#6764
Browse files Browse the repository at this point in the history
)

* feat: start of new system for personal restrictions and preferences #6714

* change score for NOVA 3 from 50 to 75

* new product match system #6714

* new product match system #6714

* new product match system #6714

* fix js lint issues

* fix js issue

* comment unused code

* update tests

* add option to turn on/off filter tabs

* changes suggested through the code review

* fix lint issues

* Update html/js/product-search.js

Co-authored-by: Alex Garel <[email protected]>

* change spaces to tabs

Co-authored-by: Alex Garel <[email protected]>
  • Loading branch information
stephanegigandet and alexgarel authored May 18, 2022
1 parent 7026da7 commit 6749369
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 84 deletions.
206 changes: 142 additions & 64 deletions html/js/product-search.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,22 @@
//
// Output values are returned in the product object
//
// - match_status: yes, no, unknown
// - match_score: number (maximum depends on the preferences)
// - match_status:
// very_good_match
// good_match
// poor_match
// unknown_match
// may_not_match
// does_not_match
//
// - match_score: number from 0 to 100
//
// - match_attributes: array of arrays of attributes corresponding to the product and
// each set of preferences: mandatory, very_important, important

function match_product_to_preferences (product, product_preferences) {

var score = 0;
var status = "yes";
var debug = "";

product.match_attributes = {
Expand All @@ -25,72 +32,132 @@ function match_product_to_preferences (product, product_preferences) {
"important" : []
};

// Note: mandatory preferences is set to 0:
// The attribute is only used to check if a product is compatible or not
// It does not affect the very good / good / poor match status
// The score will be 0 if the product is not compatible
var preferences_factors = {
"mandatory" : 0,
"very_important" : 2,
"important" : 1,
"not_important" : 0
};

var sum_of_factors = 0;
var sum_of_factors_for_unknown_attributes = 0;

if (product.attribute_groups) {

product.attributes_for_status = {};

// Iterate over attribute groups
$.each( product.attribute_groups, function(key, attribute_group) {

// Iterate over attributes

$.each(attribute_group.attributes, function(key, attribute) {

var attribute_preference = product_preferences[attribute.id];
var match_status_for_attribute = "match";

if ((! product_preferences[attribute.id]) || (product_preferences[attribute.id] == "not_important")) {
if ((! attribute_preference) || (attribute_preference === "not_important")) {
// Ignore attribute
debug += attribute.id + " not_important" + "\n";
}
else {

var attribute_factor = preferences_factors[attribute_preference];
sum_of_factors += attribute_factor;

if (attribute.status == "unknown") {

// If the attribute is important or more, then mark the product unknown
// if the attribute is unknown (unless the product is already not matching)

if (status == "yes") {
status = "unknown";
if (attribute.status === "unknown") {

sum_of_factors_for_unknown_attributes += attribute_factor;

// If the attribute is mandatory and the attribute status is unknown
// then mark the product status unknown

if (attribute_preference === "mandatory") {
match_status_for_attribute = "unknown_match";
}
}
else {

debug += attribute.id + " " + product_preferences[attribute.id] + " - match: " + attribute.match + "\n";

if (product_preferences[attribute.id] == "important") {

score += attribute.match;
}
else if (product_preferences[attribute.id] == "very_important") {

score += attribute.match * 2;
}
else if (product_preferences[attribute.id] == "mandatory") {
debug += attribute.id + " " + attribute_preference + " - match: " + attribute.match + "\n";

score += attribute.match * 4;

if (attribute.match <= 20) {
status = "no";
score += attribute.match * attribute_factor;

if (attribute_preference === "mandatory") {
if (attribute.match <= 10) {
// Mandatory attribute with a very bad score (e.g. contains an allergen) -> status: does not match
match_status_for_attribute = "does_not_match";
}
// Mandatory attribute with a bad score (e.g. may contain traces of an allergen) -> status: may not match
else if (attribute.match <= 50) {
match_status_for_attribute = "may_not_match";
}
}
}

product.match_attributes[product_preferences[attribute.id]].push(attribute);

if (!(match_status_for_attribute in product.attributes_for_status)) {
product.attributes_for_status[match_status_for_attribute] = [];
}
product.attributes_for_status[match_status_for_attribute].push(attribute);

product.match_attributes[attribute_preference].push(attribute);
}
});
});
});

// Normalize the score from 0 to 100
if (sum_of_factors === 0) {
score /= sum_of_factors;
} else {
score = 0;
}

// If one of the attributes does not match, the product does not match
if ("does_not_match" in product.attributes_for_status) {
// Set score to 0 for products that do not match
score = "0";
product.match_status = "does_not_match";
}
else if ("may_not_match" in product.attributes_for_status) {
product.match_status = "may_not_match";
}
// If too many attributes are unknown, set an unknown match
else if (sum_of_factors_for_unknown_attributes >= sum_of_factors / 2) {
product.match_status = "unknown_match";
}
// If the product matches, check how well it matches user preferences
else if (score >= 75) {
product.match_status = "very_good_match";
}
else if (score >= 50) {
product.match_status = "good_match";
}
else {
product.match_status = "poor_match";
}
}
else {
// the product does not have the attribute_group field
status = "unknown";
// the product does not have the attribute_groups field
product.match_status = "unknown_match";
debug = "no attribute_groups";
}

product.match_status = status;
product.match_score = score;
product.match_score = score;
product.match_debug = debug;
}


// rank_products (products, product_preferences)

// keep the initial order of each result
var initial_order = 0;

// option to enable tabs in results to filter on product match status
var show_tabs_to_filter_by_match_status = 0;

function rank_products(products, product_preferences, use_user_product_preferences_for_ranking) {

// Score all products
Expand All @@ -109,10 +176,12 @@ function rank_products(products, product_preferences, use_user_product_preferenc

if (use_user_product_preferences_for_ranking) {

// Rank all products, and return them in 3 arrays: "yes", "no", "unknown"
// Rank all products

products.sort(function(a, b) {
return (b.match_score - a.match_score) || (a.initial_order - b.initial_order);
return (b.match_score - a.match_score) // Highest score first
|| ((b.match_status === "does_not_match" ? 0 : 1) - (a.match_status === "does_not_match" ? 0 : 1)) // Matching products second
|| (a.initial_order - b.initial_order); // Initial order third
});
}
else {
Expand All @@ -123,14 +192,14 @@ function rank_products(products, product_preferences, use_user_product_preferenc

var product_groups = {
"all" : [],
"yes" : [],
"unknown" : [],
"no" : [],
};

$.each( products, function(key, product) {

if (use_user_product_preferences_for_ranking) {
if (show_tabs_to_filter_by_match_status && use_user_product_preferences_for_ranking) {
if (! (product.match_status in product_groups)) {
product_groups[product.match_status] = [];
}
product_groups[product.match_status].push(product);
}
product_groups.all.push(product);
Expand Down Expand Up @@ -161,14 +230,17 @@ function display_products(target, product_groups, user_prefs ) {

$.each( product_group, function(key, product) {

var product_html = "";
var product_html = `<li><a href="${product.url}">`;

// Show the green / grey / colors for matching products only if we are using the user preferences
let css_classes = 'list_product_a';
if (user_prefs.use_ranking) {
css_classes += ' list_product_a_match_' + product.match_status;
product_html += `<div class="list_product_banner list_product_banner_${product.match_status}">`
+ lang()["products_match_" + product.match_status] + ' ' + Math.round(product.match_score) + '%</div>'
+ '<div class="list_product_content">';
}
product_html += `<li><a href="${product.url}" class="${css_classes}">`;
else {
product_html += '<div class="list_product_unranked">';
}

product_html += '<div class="list_product_img_div">';

const img_src =
Expand Down Expand Up @@ -206,7 +278,6 @@ function display_products(target, product_groups, user_prefs ) {
if (user_prefs.display.display_barcode) {
product_html += `<span class="list_display_barcode">${product.code}</span>`;
}
product_html += "</a>";
if (user_prefs.display.edit_link) {
const edit_url = product_edit_url(product);
const edit_title = lang().edit_product_page;
Expand All @@ -219,34 +290,41 @@ function display_products(target, product_groups, user_prefs ) {
</a>
`;
}
product_html += "</li>";
product_html += "</div></a></li>";

products_html.push(product_html);
});



var active = "";
var text_or_icon = "";
if (product_group_id == "all") {
if (product_group_id === "all") {
active = " active";
if (product_group.length == 1) {
text_or_icon = lang()["1_product"];
}

if (show_tabs_to_filter_by_match_status) {
if (product_group_id === "all") {
if (product_group.length === 1) {
text_or_icon = lang()["1_product"];
}
else {
text_or_icon = product_group.length + ' ' + lang().products;
}
}
else {
text_or_icon = product_group.length + ' ' + lang().products;
text_or_icon = '<img src="/images/attributes/match-' + product_group_id + '.svg" class="icon">'
+ ' <span style="color:grey">' + product_group.length + "</span>";
}

if (user_prefs.use_ranking) {
$("#products_tabs_titles").append(
'<li class="tabs tab-title tab_products-title' + active + '">'
+ '<a id="tab_products_' + product_group_id + '" href="#products_' + product_group_id + '" title="' + lang()["products_match_" + product_group_id] + '">'
+ text_or_icon
+ "</a></li>"
);
}
}
else {
text_or_icon = '<img src="/images/attributes/match-' + product_group_id + '.svg" class="icon">'
+ ' <span style="color:grey">' + product_group.length + "</span>";
}

if (user_prefs.use_ranking) {
$("#products_tabs_titles").append(
'<li class="tabs tab-title tab_products-title' + active + '">'
+ '<a id="tab_products_' + product_group_id + '" href="#products_' + product_group_id + '" title="' + lang()["products_match_" + product_group_id] + '">'
+ text_or_icon
+ "</a></li>"
);
}

$("#products_tabs_content").append(
Expand Down Expand Up @@ -282,7 +360,7 @@ function display_product_summary(target, product) {
// vary the color from green to red
var grade ="unknown";

if (attribute.status == "known") {
if (attribute.status === "known") {
grade = attribute.grade;
}

Expand Down
20 changes: 9 additions & 11 deletions lib/ProductOpener/Attributes.pm
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,7 @@ The return value is a reference to the resulting attribute data structure.
- NOVA 1: 100%
- NOVA 2: 100%
- NOVA 3: 50%
- NOVA 3: 75%
- NOVA 4: 0%
=cut
Expand All @@ -807,16 +807,14 @@ sub compute_attribute_nova($$) {

# Compute match based on NOVA group

my $match = 0;

if (($nova_group == 1) or ($nova_group == 2)) {
$match = 100;
}
elsif ($nova_group == 3) {
$match = 50;
}
my %nova_groups_scores = (
1 => 100,
2 => 100,
3 => 75,
4 => 0,
);

$attribute_ref->{match} = $match;
$attribute_ref->{match} = $nova_groups_scores{$nova_group + 0}; # Make sure the key is a number

if ($target_lc ne "data") {
$attribute_ref->{title} = sprintf(lang_in_other_lc($target_lc, "attribute_nova_group_title"), $nova_group);
Expand Down Expand Up @@ -1182,7 +1180,7 @@ The return value is a reference to the resulting attribute data structure.
=head4 % Match
100: no indication of the allergen or trace of the allergen
20: may contain the allergen
20: may contain the allergen as a trace
0: contains allergen
=cut
Expand Down
Loading

0 comments on commit 6749369

Please sign in to comment.