From 8217e6f895dca0f4da40f6e7df90c021375234de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Prima=C3=ABl=20Qu=C3=A9merais?= <34269530+PrimaelQuemerais@users.noreply.github.com> Date: Thu, 23 Jan 2025 15:11:25 +0100 Subject: [PATCH] Added robotoff nutrient extraction --- lib/src/model/old_product_result.g.dart | 2 - .../model/robotoff_nutrient_extraction.dart | 201 ++++++++++++++++++ .../model/robotoff_nutrient_extraction.g.dart | 181 ++++++++++++++++ lib/src/robot_off_api_client.dart | 27 +++ test/api_get_robotoff_test.dart | 17 ++ 5 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 lib/src/model/robotoff_nutrient_extraction.dart create mode 100644 lib/src/model/robotoff_nutrient_extraction.g.dart diff --git a/lib/src/model/old_product_result.g.dart b/lib/src/model/old_product_result.g.dart index 71fa41dca0..049d197480 100644 --- a/lib/src/model/old_product_result.g.dart +++ b/lib/src/model/old_product_result.g.dart @@ -1,7 +1,5 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: deprecated_member_use_from_same_package - part of 'old_product_result.dart'; // ************************************************************************** diff --git a/lib/src/model/robotoff_nutrient_extraction.dart b/lib/src/model/robotoff_nutrient_extraction.dart new file mode 100644 index 0000000000..5b5abb6129 --- /dev/null +++ b/lib/src/model/robotoff_nutrient_extraction.dart @@ -0,0 +1,201 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../interface/json_object.dart'; + +part 'robotoff_nutrient_extraction.g.dart'; + +@JsonSerializable() +class RobotoffNutrientExtractionResult extends JsonObject { + final String? status; + final int? count; + final List? insights; + + const RobotoffNutrientExtractionResult({ + this.status, + this.count, + this.insights, + }); + + NutrientExtractionInsight? get getLatestInsights { + insights?.sort((a, b) => a.completedAt!.compareTo(b.completedAt!)); + return insights?.last; + } + + NutrientEntity? getNutrientEntity(String nutrientKey) { + return getLatestInsights?.data?.nutrients?[nutrientKey]; + } + + factory RobotoffNutrientExtractionResult.fromJson( + Map json) => + _$RobotoffNutrientExtractionResultFromJson(json); + + @override + Map toJson() => + _$RobotoffNutrientExtractionResultToJson(this); +} + +@JsonSerializable() +class NutrientEntity { + final int? start; + final int? end; + final String? text; + final String? unit; + final double? score; + final bool? valid; + final String? value; + final String? entity; + @JsonKey(name: 'char_start') + final int? charStart; + @JsonKey(name: 'char_end') + final int? charEnd; + + const NutrientEntity({ + this.start, + this.end, + this.text, + this.unit, + this.score, + this.valid, + this.value, + this.entity, + this.charStart, + this.charEnd, + }); + + factory NutrientEntity.fromJson(Map json) => + _$NutrientEntityFromJson(json); + + Map toJson() => _$NutrientEntityToJson(this); +} + +@JsonSerializable() +class NutrientAnnotationData { + final String? unit; + final String? value; + + const NutrientAnnotationData({ + this.unit, + this.value, + }); + + factory NutrientAnnotationData.fromJson(Map json) => + _$NutrientAnnotationDataFromJson(json); + + Map toJson() => _$NutrientAnnotationDataToJson(this); +} + +@JsonSerializable() +class NutrientAnnotation { + final Map? nutrients; + @JsonKey(name: 'serving_size') + final String? servingSize; + @JsonKey(name: 'nutrition_data_per') + final String? nutritionDataPer; + + const NutrientAnnotation({ + this.nutrients, + this.servingSize, + this.nutritionDataPer, + }); + + factory NutrientAnnotation.fromJson(Map json) => + _$NutrientAnnotationFromJson(json); + + Map toJson() => _$NutrientAnnotationToJson(this); +} + +@JsonSerializable() +class NutrientDataWrapper { + final Map>? entities; + final Map? nutrients; + final NutrientAnnotation? annotation; + @JsonKey(name: 'was_updated') + final bool? wasUpdated; + + const NutrientDataWrapper({ + this.entities, + this.nutrients, + this.annotation, + this.wasUpdated, + }); + + factory NutrientDataWrapper.fromJson(Map json) => + _$NutrientDataWrapperFromJson(json); + + Map toJson() => _$NutrientDataWrapperToJson(this); +} + +@JsonSerializable() +class NutrientExtractionInsight extends JsonObject { + @JsonKey(name: 'id') + final String? insightId; + final String? barcode; + final String? type; + final NutrientDataWrapper? data; + final String? timestamp; + @JsonKey(name: 'completed_at') + final String? completedAt; + final int? annotation; + @JsonKey(name: 'annotated_result') + final int? annotatedResult; + @JsonKey(name: 'n_votes') + final int? nVotes; + final String? username; + final List? countries; + final List? brands; + @JsonKey(name: 'process_after') + final String? processAfter; + @JsonKey(name: 'value_tag') + final String? valueTag; + final String? value; + @JsonKey(name: 'source_image') + final String? sourceImage; + @JsonKey(name: 'automatic_processing') + final bool? automaticProcessing; + @JsonKey(name: 'server_type') + final String? serverType; + @JsonKey(name: 'unique_scans_n') + final int? uniqueScansN; + @JsonKey(name: 'reserved_barcode') + final bool? reservedBarcode; + final String? predictor; + @JsonKey(name: 'predictor_version') + final String? predictorVersion; + final List? campaign; + final double? confidence; + @JsonKey(name: 'bounding_box') + final dynamic boundingBox; + + const NutrientExtractionInsight({ + this.insightId, + this.barcode, + this.type, + this.data, + this.timestamp, + this.completedAt, + this.annotation, + this.annotatedResult, + this.nVotes, + this.username, + this.countries, + this.brands, + this.processAfter, + this.valueTag, + this.value, + this.sourceImage, + this.automaticProcessing, + this.serverType, + this.uniqueScansN, + this.reservedBarcode, + this.predictor, + this.predictorVersion, + this.campaign, + this.confidence, + this.boundingBox, + }); + + factory NutrientExtractionInsight.fromJson(Map json) => + _$NutrientExtractionInsightFromJson(json); + + @override + Map toJson() => _$NutrientExtractionInsightToJson(this); +} diff --git a/lib/src/model/robotoff_nutrient_extraction.g.dart b/lib/src/model/robotoff_nutrient_extraction.g.dart new file mode 100644 index 0000000000..4ec9cf66df --- /dev/null +++ b/lib/src/model/robotoff_nutrient_extraction.g.dart @@ -0,0 +1,181 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'robotoff_nutrient_extraction.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RobotoffNutrientExtractionResult _$RobotoffNutrientExtractionResultFromJson( + Map json) => + RobotoffNutrientExtractionResult( + status: json['status'] as String?, + count: (json['count'] as num?)?.toInt(), + insights: (json['insights'] as List?) + ?.map((e) => + NutrientExtractionInsight.fromJson(e as Map)) + .toList(), + ); + +Map _$RobotoffNutrientExtractionResultToJson( + RobotoffNutrientExtractionResult instance) => + { + 'status': instance.status, + 'count': instance.count, + 'insights': instance.insights, + }; + +NutrientEntity _$NutrientEntityFromJson(Map json) => + NutrientEntity( + start: (json['start'] as num?)?.toInt(), + end: (json['end'] as num?)?.toInt(), + text: json['text'] as String?, + unit: json['unit'] as String?, + score: (json['score'] as num?)?.toDouble(), + valid: json['valid'] as bool?, + value: json['value'] as String?, + entity: json['entity'] as String?, + charStart: (json['char_start'] as num?)?.toInt(), + charEnd: (json['char_end'] as num?)?.toInt(), + ); + +Map _$NutrientEntityToJson(NutrientEntity instance) => + { + 'start': instance.start, + 'end': instance.end, + 'text': instance.text, + 'unit': instance.unit, + 'score': instance.score, + 'valid': instance.valid, + 'value': instance.value, + 'entity': instance.entity, + 'char_start': instance.charStart, + 'char_end': instance.charEnd, + }; + +NutrientAnnotationData _$NutrientAnnotationDataFromJson( + Map json) => + NutrientAnnotationData( + unit: json['unit'] as String?, + value: json['value'] as String?, + ); + +Map _$NutrientAnnotationDataToJson( + NutrientAnnotationData instance) => + { + 'unit': instance.unit, + 'value': instance.value, + }; + +NutrientAnnotation _$NutrientAnnotationFromJson(Map json) => + NutrientAnnotation( + nutrients: (json['nutrients'] as Map?)?.map( + (k, e) => MapEntry( + k, NutrientAnnotationData.fromJson(e as Map)), + ), + servingSize: json['serving_size'] as String?, + nutritionDataPer: json['nutrition_data_per'] as String?, + ); + +Map _$NutrientAnnotationToJson(NutrientAnnotation instance) => + { + 'nutrients': instance.nutrients, + 'serving_size': instance.servingSize, + 'nutrition_data_per': instance.nutritionDataPer, + }; + +NutrientDataWrapper _$NutrientDataWrapperFromJson(Map json) => + NutrientDataWrapper( + entities: (json['entities'] as Map?)?.map( + (k, e) => MapEntry( + k, + (e as List) + .map((e) => NutrientEntity.fromJson(e as Map)) + .toList()), + ), + nutrients: (json['nutrients'] as Map?)?.map( + (k, e) => + MapEntry(k, NutrientEntity.fromJson(e as Map)), + ), + annotation: json['annotation'] == null + ? null + : NutrientAnnotation.fromJson( + json['annotation'] as Map), + wasUpdated: json['was_updated'] as bool?, + ); + +Map _$NutrientDataWrapperToJson( + NutrientDataWrapper instance) => + { + 'entities': instance.entities, + 'nutrients': instance.nutrients, + 'annotation': instance.annotation, + 'was_updated': instance.wasUpdated, + }; + +NutrientExtractionInsight _$NutrientExtractionInsightFromJson( + Map json) => + NutrientExtractionInsight( + insightId: json['id'] as String?, + barcode: json['barcode'] as String?, + type: json['type'] as String?, + data: json['data'] == null + ? null + : NutrientDataWrapper.fromJson(json['data'] as Map), + timestamp: json['timestamp'] as String?, + completedAt: json['completed_at'] as String?, + annotation: (json['annotation'] as num?)?.toInt(), + annotatedResult: (json['annotated_result'] as num?)?.toInt(), + nVotes: (json['n_votes'] as num?)?.toInt(), + username: json['username'] as String?, + countries: (json['countries'] as List?) + ?.map((e) => e as String) + .toList(), + brands: + (json['brands'] as List?)?.map((e) => e as String).toList(), + processAfter: json['process_after'] as String?, + valueTag: json['value_tag'] as String?, + value: json['value'] as String?, + sourceImage: json['source_image'] as String?, + automaticProcessing: json['automatic_processing'] as bool?, + serverType: json['server_type'] as String?, + uniqueScansN: (json['unique_scans_n'] as num?)?.toInt(), + reservedBarcode: json['reserved_barcode'] as bool?, + predictor: json['predictor'] as String?, + predictorVersion: json['predictor_version'] as String?, + campaign: (json['campaign'] as List?) + ?.map((e) => e as String) + .toList(), + confidence: (json['confidence'] as num?)?.toDouble(), + boundingBox: json['bounding_box'], + ); + +Map _$NutrientExtractionInsightToJson( + NutrientExtractionInsight instance) => + { + 'id': instance.insightId, + 'barcode': instance.barcode, + 'type': instance.type, + 'data': instance.data, + 'timestamp': instance.timestamp, + 'completed_at': instance.completedAt, + 'annotation': instance.annotation, + 'annotated_result': instance.annotatedResult, + 'n_votes': instance.nVotes, + 'username': instance.username, + 'countries': instance.countries, + 'brands': instance.brands, + 'process_after': instance.processAfter, + 'value_tag': instance.valueTag, + 'value': instance.value, + 'source_image': instance.sourceImage, + 'automatic_processing': instance.automaticProcessing, + 'server_type': instance.serverType, + 'unique_scans_n': instance.uniqueScansN, + 'reserved_barcode': instance.reservedBarcode, + 'predictor': instance.predictor, + 'predictor_version': instance.predictorVersion, + 'campaign': instance.campaign, + 'confidence': instance.confidence, + 'bounding_box': instance.boundingBox, + }; diff --git a/lib/src/robot_off_api_client.dart b/lib/src/robot_off_api_client.dart index 42d9722b46..6152f666db 100644 --- a/lib/src/robot_off_api_client.dart +++ b/lib/src/robot_off_api_client.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart'; +import 'package:openfoodfacts/src/model/robotoff_nutrient_extraction.dart'; import 'model/insight.dart'; import 'model/robotoff_question.dart'; @@ -192,6 +193,32 @@ class RobotoffAPIClient { return Status.fromApiResponse(response.body); } + /// Get nutrient extraction insights for a given barcode + /// cf. https://robotoff.openfoodfacts.org/api/v1/insights + static Future getNutrientExtraction( + String barcode, { + final UriHelper uriHelper = uriHelperRobotoffProd, + }) async { + final Map parameters = { + 'barcode': barcode, + 'insight_types': 'nutrient_extraction', + }; + + final Uri uri = uriHelper.getUri( + path: '/api/v1/insights', + queryParameters: parameters, + ); + + final response = await HttpHelper().doGetRequest( + uri, + uriHelper: uriHelper, + ); + + return RobotoffNutrientExtractionResult.fromJson( + HttpHelper().jsonDecode(utf8.decode(response.bodyBytes)), + ); + } + /// Returns a list of country as comma-separated 2-letter codes static String _getCountryList( final Iterable countries, diff --git a/test/api_get_robotoff_test.dart b/test/api_get_robotoff_test.dart index 985bf60385..63c7937368 100644 --- a/test/api_get_robotoff_test.dart +++ b/test/api_get_robotoff_test.dart @@ -1,4 +1,5 @@ import 'package:openfoodfacts/openfoodfacts.dart'; +import 'package:openfoodfacts/src/model/robotoff_nutrient_extraction.dart'; import 'package:test/test.dart'; import 'test_constants.dart'; @@ -218,4 +219,20 @@ void main() { expect(result.insights, isNull); }); }); + + test('get product nutrient extraction for 3687080612292', () async { + String barcode = '3687080612292'; + + final RobotoffNutrientExtractionResult result = + await RobotoffAPIClient.getNutrientExtraction( + barcode, + ); + + expect(result.status, isNotNull); + expect(result.status, 'found'); + expect(result.insights!, isNotEmpty); + expect(result.insights![0].barcode, barcode); + expect(result.insights![0].data!.nutrients!['fat_100g']!.value, '0.5'); + expect(result.insights![0].data!.nutrients!['fat_100g']!.unit, 'g'); + }); }