Skip to content

Commit

Permalink
feat: 785 - list of problematic attributes as score explanation
Browse files Browse the repository at this point in the history
Impacted files:
* `api_matched_product_v1_test.dart`: minor refactoring
* `api_matched_product_v2_test.dart`: added a test about score explanations
* `api_product_preferences_test.dart`: minor refactoring
* `available_attribute_groups.dart`: refactored with language instead of language code
* `available_preference_importances.dart`: refactored with language instead of language code
* `matched_product_v2.dart`: added an optional parameter that lists the problematic attributes when the status unknown, does not match and may not match
  • Loading branch information
monsieurtanuki committed Aug 12, 2023
1 parent 5dabb08 commit 43e94f6
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 14 deletions.
7 changes: 7 additions & 0 deletions lib/src/personalized_search/available_attribute_groups.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import '../model/attribute_group.dart';
import '../utils/http_helper.dart';
import '../utils/language_helper.dart';

/// Referential of attribute groups, with loader.
class AvailableAttributeGroups {
Expand All @@ -25,6 +26,12 @@ class AvailableAttributeGroups {

/// Where a localized JSON file can be found.
/// [languageCode] is a 2-letter language code.
// TODO: deprecated from 2023-08-12; remove when old enough
@Deprecated('Use getLocalizedUrl instead')
static String getUrl(final String languageCode) =>
'https://world.openfoodfacts.org/api/v2/attribute_groups?lc=$languageCode';

/// Where a localized JSON file can be found.
static String getLocalizedUrl(final OpenFoodFactsLanguage language) =>
'https://world.openfoodfacts.org/api/v2/attribute_groups?lc=${language.code}';
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'preference_importance.dart';
import '../utils/http_helper.dart';
import '../utils/language_helper.dart';

/// Referential of preference importance, with loader.
class AvailablePreferenceImportances {
Expand Down Expand Up @@ -43,9 +44,15 @@ class AvailablePreferenceImportances {

/// Where a localized JSON file can be found.
/// [languageCode] is a 2-letter language code.
// TODO: deprecated from 2023-08-12; remove when old enough
@Deprecated('Use getLocalizedUrl instead')
static String getUrl(final String languageCode) =>
'https://world.openfoodfacts.org/api/v2/preferences?lc=$languageCode';

/// Where a localized JSON file can be found.
static String getLocalizedUrl(final OpenFoodFactsLanguage language) =>
'https://world.openfoodfacts.org/api/v2/preferences?lc=${language.code}';

/// Returns the index of an importance.
///
/// From 0: not important.
Expand Down
65 changes: 62 additions & 3 deletions lib/src/personalized_search/matched_product_v2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,26 @@ enum MatchedProductStatusV2 {

/// Score of a product according to preferences.
///
/// For performance reasons we store just the barcode, not the product.
/// For performance (memory) reasons we store just the barcode, not the product.
/// For performance (memory) reasons we store explanations only if needed.
/// Typical usage of explanations:
/// * if status is [MatchedProductStatusV2.UNKNOWN_MATCH]
/// * first check - it's because there were unknown mandatory attributes,
/// listed in [unknownMandatoryAttributes] (check if not null).
/// * or it's because there were too many unknown attributes, listed in
/// [unknownAttributes] (check if not null).
/// * or it's because there is no data in the product (if both
/// [unknownMandatoryAttributes] and [unknownAttributes] are null).
/// * if status is [MatchedProductStatusV2.MAY_NOT_MATCH]
/// * the problematic attributes are listed in [mayNotMatchAttributes].
/// * if status is [MatchedProductStatusV2.DOES_NOT_MATCH]
/// * the problematic attributes are listed in [doesNotMatchAttributes].
class MatchedScoreV2 {
MatchedScoreV2(
final Product product,
final ProductPreferencesManager productPreferencesManager,
) : barcode = product.barcode! {
final ProductPreferencesManager productPreferencesManager, {
final bool withExplanations = false,
}) : barcode = product.barcode! {
_score = 0;
_debug = '';

Expand Down Expand Up @@ -76,8 +90,16 @@ class MatchedScoreV2 {

if (attribute.status == Attribute.STATUS_UNKNOWN) {
sumOfFactorsForUnknownAttributes += factor;
if (withExplanations) {
_unknownAttributes ??= <Attribute>[];
_unknownAttributes!.add(attribute);
}
if (importanceId == PreferenceImportance.ID_MANDATORY) {
isUnknown = true;
if (withExplanations) {
_unknownMandatoryAttributes ??= <Attribute>[];
_unknownMandatoryAttributes!.add(attribute);
}
}
} else {
_score += match * factor;
Expand All @@ -87,10 +109,18 @@ class MatchedScoreV2 {
if (match <= 10) {
// Mandatory attribute with a very bad score (e.g. contains an allergen) -> status: does not match
doesNotMatch = true;
if (withExplanations) {
_doesNotMatchAttributes ??= <Attribute>[];
_doesNotMatchAttributes!.add(attribute);
}
}
// Mandatory attribute with a bad score (e.g. may contain traces of an allergen) -> status: may not match
else if (match <= 50) {
mayNotMatch = true;
if (withExplanations) {
_mayNotMatchAttributes ??= <Attribute>[];
_mayNotMatchAttributes!.add(attribute);
}
}
}
}
Expand Down Expand Up @@ -133,13 +163,42 @@ class MatchedScoreV2 {
late MatchedProductStatusV2 _status;
String _debug = '';
int _initialOrder = 0;
List<Attribute>? _unknownAttributes;
List<Attribute>? _unknownMandatoryAttributes;
List<Attribute>? _doesNotMatchAttributes;
List<Attribute>? _mayNotMatchAttributes;

double get score => _score;

MatchedProductStatusV2 get status => _status;

String get debug => _debug;

/// List of attributes that potentially provoked an "unknown match".
///
/// Will be null if "withExplanations" is false, or if there were no related
/// attributes.
List<Attribute>? get unknownAttributes => _unknownAttributes;

/// List of mandatory attributes that provoked an "unknown match".
///
/// Will be null if "withExplanations" is false, or if there were no related
/// attributes.
List<Attribute>? get unknownMandatoryAttributes =>
_unknownMandatoryAttributes;

/// List of attributes that provoked a "does not match".
///
/// Will be null if "withExplanations" is false, or if there were no related
/// attributes.
List<Attribute>? get doesNotMatchAttributes => _doesNotMatchAttributes;

/// List of attributes that provoked a "may not match".
///
/// Will be null if "withExplanations" is false, or if there were no related
/// attributes.
List<Attribute>? get mayNotMatchAttributes => _mayNotMatchAttributes;

/// Weights for score
static const Map<String, int> _preferencesFactors = <String, int>{
PreferenceImportance.ID_MANDATORY: 2,
Expand Down
5 changes: 2 additions & 3 deletions test/api_matched_product_v1_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,10 @@ void main() {
),
);
const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.ENGLISH;
final String languageCode = language.code;
final String importanceUrl =
AvailablePreferenceImportances.getUrl(languageCode);
AvailablePreferenceImportances.getLocalizedUrl(language);
final String attributeGroupUrl =
AvailableAttributeGroups.getUrl(languageCode);
AvailableAttributeGroups.getLocalizedUrl(language);
http.Response response;
response = await http.get(Uri.parse(importanceUrl));
expect(response.statusCode, HTTP_OK);
Expand Down
101 changes: 96 additions & 5 deletions test/api_matched_product_v2_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ class _Score {
void main() {
const int HTTP_OK = 200;

const OpenFoodFactsLanguage language = OpenFoodFactsLanguage.FRENCH;
late OpenFoodFactsLanguage language;
OpenFoodAPIConfiguration.userAgent = TestConstants.TEST_USER_AGENT;
OpenFoodAPIConfiguration.globalQueryType = QueryType.PROD;
OpenFoodAPIConfiguration.globalCountry = OpenFoodFactsCountry.FRANCE;
OpenFoodAPIConfiguration.globalUser = TestConstants.PROD_USER;
OpenFoodAPIConfiguration.globalLanguages = <OpenFoodFactsLanguage>[language];

void setLanguage(final OpenFoodFactsLanguage newLanguage) {
language = newLanguage;
OpenFoodAPIConfiguration.globalLanguages = <OpenFoodFactsLanguage>[
language
];
}

const String BARCODE_KNACKI = '7613035937420';
const String BARCODE_CORDONBLEU = '4000405005026';
Expand Down Expand Up @@ -113,11 +119,10 @@ void main() {
PreferenceImportance.ID_NOT_IMPORTANT,
),
);
final String languageCode = language.code;
final String importanceUrl =
AvailablePreferenceImportances.getUrl(languageCode);
AvailablePreferenceImportances.getLocalizedUrl(language);
final String attributeGroupUrl =
AvailableAttributeGroups.getUrl(languageCode);
AvailableAttributeGroups.getLocalizedUrl(language);
http.Response response;
response = await http.get(Uri.parse(importanceUrl));
expect(response.statusCode, HTTP_OK);
Expand All @@ -140,6 +145,8 @@ void main() {
/// Tests around Matched Product v2.
group('$OpenFoodAPIClient matched product v2', () {
test('matched product', () async {
setLanguage(OpenFoodFactsLanguage.FRENCH);

final ProductPreferencesManager manager = await getManager();

final List<Product> products = await downloadProducts();
Expand All @@ -157,10 +164,17 @@ void main() {
final _Score score = expectedScores[barcode]!;
expect(matched.status, score.status);
expect(matched.score, score.score);
// we didn't ask explicitly for explanations
expect(matched.mayNotMatchAttributes, isNull);
expect(matched.doesNotMatchAttributes, isNull);
expect(matched.unknownMandatoryAttributes, isNull);
expect(matched.unknownAttributes, isNull);
}
});

test('matched score', () async {
setLanguage(OpenFoodFactsLanguage.FRENCH);

final ProductPreferencesManager manager = await getManager();

final List<Product> products = await downloadProducts();
Expand All @@ -180,7 +194,84 @@ void main() {
final _Score score = expectedScores[barcode]!;
expect(matched.status, score.status);
expect(matched.score, score.score);
// we didn't ask explicitly for explanations
expect(matched.mayNotMatchAttributes, isNull);
expect(matched.doesNotMatchAttributes, isNull);
expect(matched.unknownMandatoryAttributes, isNull);
expect(matched.unknownAttributes, isNull);
}
});

Future<void> checkExplanations(
final OpenFoodFactsLanguage language,
final String unknownMatchLabel,
final String doesNotMatchLabel,
) async {
setLanguage(language);

final ProductPreferencesManager manager = await getManager();

final List<Product> products = await downloadProducts();

final List<MatchedScoreV2> actuals = <MatchedScoreV2>[];
for (final Product product in products) {
actuals.add(
MatchedScoreV2(
product,
manager,
// explicitly asking for explanations
withExplanations: true,
),
);
}

for (final MatchedScoreV2 matched in actuals) {
switch (matched.status) {
case MatchedProductStatusV2.UNKNOWN_MATCH:
expect(matched.unknownMandatoryAttributes, hasLength(1));
expect(
matched.unknownMandatoryAttributes!.first.title,
unknownMatchLabel,
);
expect(matched.unknownAttributes, hasLength(1));
expect(
matched.unknownAttributes!.first.title,
unknownMatchLabel,
);
break;
case MatchedProductStatusV2.DOES_NOT_MATCH:
expect(matched.doesNotMatchAttributes, hasLength(1));
expect(
matched.doesNotMatchAttributes!.first.title,
doesNotMatchLabel,
);
break;
case MatchedProductStatusV2.VERY_GOOD_MATCH:
break;
case MatchedProductStatusV2.GOOD_MATCH:
case MatchedProductStatusV2.POOR_MATCH:
case MatchedProductStatusV2.MAY_NOT_MATCH:
fail('Unexpected status: ${matched.status}');
}
}
}

test(
'score explanations FR',
() async => checkExplanations(
OpenFoodFactsLanguage.FRENCH,
'Caractère végétarien inconnu',
'Non végétarien',
),
);

test(
'score explanations EN',
() async => checkExplanations(
OpenFoodFactsLanguage.ENGLISH,
'Vegetarian status unknown',
'Non-vegetarian',
),
);
});
}
5 changes: 2 additions & 3 deletions test/api_product_preferences_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,10 @@ void main() {
notify: () => refreshCounter++,
),
);
final String languageCode = language.code;
final String importanceUrl =
AvailablePreferenceImportances.getUrl(languageCode);
AvailablePreferenceImportances.getLocalizedUrl(language);
final String attributeGroupUrl =
AvailableAttributeGroups.getUrl(languageCode);
AvailableAttributeGroups.getLocalizedUrl(language);
http.Response response;
response = await http.get(Uri.parse(importanceUrl));
expect(response.statusCode, HTTP_OK);
Expand Down

0 comments on commit 43e94f6

Please sign in to comment.