diff --git a/doc/references/api.yml b/doc/references/api.yml index d339bd5311..ac9b092728 100644 --- a/doc/references/api.yml +++ b/doc/references/api.yml @@ -329,7 +329,7 @@ paths: The annotation is an integer that can take 4 values: `0`, `1`, `2`, `-1`. `0` means the insight is incorrect (so it won't be applied), `1` means it is correct (so it will be applied) and `-1` means the insight won't be returned to the user (_skip_). `2` is used when user submit some data to the annotate endpoint - (for example in some cases of category annotation). + (for example in some cases of category annotation or ingredients spellcheck). We use the voting mecanism system to remember which insight to skip for a user (authenticated or not). requestBody: @@ -344,11 +344,12 @@ paths: description: ID of the insight annotation: type: integer - description: "Annotation of the prediction: 1 to accept the prediction, 0 to refuse it, and -1 for _skip_" + description: "Annotation of the prediction: 1 to accept the prediction, 0 to refuse it, and -1 for _skip_, 2 to accept and add data" enum: - 0 - 1 - -1 + - 2 update: type: integer description: "Send the update to Openfoodfacts if `update=1`, don't send the update otherwise. This parameter is useful if the update is performed client-side" @@ -356,6 +357,10 @@ paths: enum: - 0 - 1 + data: + type: object + description: "Additional data provided by the user as key-value pairs" + required: - "insight_id" - "annotation" diff --git a/robotoff/insights/annotate.py b/robotoff/insights/annotate.py index bf4a424fc6..fd096d1429 100644 --- a/robotoff/insights/annotate.py +++ b/robotoff/insights/annotate.py @@ -19,6 +19,7 @@ add_label_tag, add_packaging, add_store, + save_ingredients, select_rotate_image, unselect_image, update_emb_codes, @@ -100,6 +101,12 @@ class AnnotationStatus(Enum): description="Open Food Facts update failed", ) +INVALID_DATA = AnnotationResult( + status_code=AnnotationStatus.error_invalid_data.value, + status=AnnotationStatus.error_invalid_data.name, + description="The data schema is invalid.", +) + class InsightAnnotator(metaclass=abc.ABCMeta): @classmethod @@ -671,6 +678,38 @@ def is_data_required(cls) -> bool: return True +class IngredientSpellcheckAnnotator(InsightAnnotator): + @classmethod + def process_annotation( + cls, + insight: ProductInsight, + data: Optional[dict] = None, + auth: Optional[OFFAuthentication] = None, + is_vote: bool = False, + ) -> AnnotationResult: + # Possibility for the annotator to change the spellcheck correction if data is provided + if data is not None: + annotation = data.get("annotation") + if not annotation or len(data) > 1: + return INVALID_DATA + # We add the new annotation to the Insight. + json_data = insight.data + json_data["annotation"] = annotation + insight.data = json_data + insight.save() + + ingredient_text = data.get("annotation") if data else insight.data["correction"] + save_ingredients( + product_id=insight.get_product_id(), + ingredient_text=ingredient_text, + lang=insight.value_tag, + insight_id=insight.id, + auth=auth, + is_vote=is_vote, + ) + return UPDATED_ANNOTATION_RESULT + + ANNOTATOR_MAPPING: dict[str, Type] = { InsightType.packager_code.name: PackagerCodeAnnotator, InsightType.label.name: LabelAnnotator, @@ -683,6 +722,7 @@ def is_data_required(cls) -> bool: InsightType.nutrition_image.name: NutritionImageAnnotator, InsightType.nutrition_table_structure.name: NutritionTableStructureAnnotator, InsightType.is_upc_image.name: UPCImageAnnotator, + InsightType.ingredient_spellcheck.name: IngredientSpellcheckAnnotator, } diff --git a/tests/integration/insights/test_annotate.py b/tests/integration/insights/test_annotate.py index 4e0d607d7c..49344aa868 100644 --- a/tests/integration/insights/test_annotate.py +++ b/tests/integration/insights/test_annotate.py @@ -1,6 +1,14 @@ +from unittest.mock import Mock + import pytest -from robotoff.insights.annotate import AnnotationResult, CategoryAnnotator +from robotoff.insights.annotate import ( + UPDATED_ANNOTATION_RESULT, + INVALID_DATA, + AnnotationResult, + CategoryAnnotator, + IngredientSpellcheckAnnotator, +) from robotoff.models import ProductInsight from ..models_utils import ProductInsightFactory, clean_db @@ -92,3 +100,63 @@ def test_process_annotation_with_invalid_user_input_data(self, user_data, mocker status="error_invalid_data", description="`data` is invalid, expected a single `value_tag` string field with the category tag", ) + + +class TestIngredientSpellcheckAnnotator: + + @pytest.fixture + def mock_save_ingredients(self, mocker) -> Mock: + return mocker.patch("robotoff.insights.annotate.save_ingredients") + + @pytest.fixture + def spellcheck_insight(self): + return ProductInsightFactory( + type="ingredient_spellcheck", + data={ + "original": "List of ingredient", + "correction": "List fo ingredients", + }, + ) + + def test_process_annotation( + self, + mock_save_ingredients: Mock, + spellcheck_insight: ProductInsightFactory, + ): + user_data = {"annotation": "List of ingredients"} + annotation_result = IngredientSpellcheckAnnotator.process_annotation( + insight=spellcheck_insight, + data=user_data, + ) + assert annotation_result == UPDATED_ANNOTATION_RESULT + assert "annotation" in spellcheck_insight.data + mock_save_ingredients.assert_called() + + @pytest.mark.parametrize( + "user_data", + [{}, {"annotation": "List of ingredients", "wrong_key": "wrong_item"}], + ) + def test_process_annotation_invalid_data( + self, + user_data: dict, + mock_save_ingredients: Mock, + spellcheck_insight: ProductInsightFactory, + ): + annotation_result = IngredientSpellcheckAnnotator.process_annotation( + insight=spellcheck_insight, + data=user_data, + ) + assert annotation_result == INVALID_DATA + mock_save_ingredients.assert_not_called() + + def test_process_annotate_no_user_data( + self, + mock_save_ingredients: Mock, + spellcheck_insight: ProductInsightFactory, + ): + annotation_result = IngredientSpellcheckAnnotator.process_annotation( + insight=spellcheck_insight, + ) + assert annotation_result == UPDATED_ANNOTATION_RESULT + assert "annotation" not in spellcheck_insight.data + mock_save_ingredients.assert_called()