Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 785 - list of problematic attributes as score explanation #786

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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