From fa3dc0cea60b6c34f95200e2fb983ea8bdd9ec39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bournhonesque?= Date: Thu, 11 Apr 2024 10:11:47 +0200 Subject: [PATCH] feat: Add nutripatrol integration (#1326) * feat: switch to nutripatrol moderation service * refactor: rename robotoff.slack into robotoff.notifier * fix: add confidence score to image_flag prediction type The confidence score was only stored in data->likelihood field * fix: fix failing unit test --- .github/workflows/container-deploy.yml | 5 +- robotoff/logos.py | 2 +- robotoff/{slack.py => notifier.py} | 18 ++- robotoff/prediction/ocr/image_flag.py | 1 + robotoff/scheduler/__init__.py | 4 +- robotoff/workers/tasks/import_image.py | 2 +- .../unit/{test_slack.py => test_notifier.py} | 104 ++++++++++-------- 7 files changed, 81 insertions(+), 55 deletions(-) rename robotoff/{slack.py => notifier.py} (95%) rename tests/unit/{test_slack.py => test_notifier.py} (72%) diff --git a/.github/workflows/container-deploy.yml b/.github/workflows/container-deploy.yml index 1b283ae93d..e75bd1cce5 100644 --- a/.github/workflows/container-deploy.yml +++ b/.github/workflows/container-deploy.yml @@ -30,6 +30,7 @@ jobs: echo "ROBOTOFF_TLD=net" >> $GITHUB_ENV echo "MONGO_URI=mongodb://10.1.0.200:27017" >> $GITHUB_ENV echo "INFLUXDB_HOST=10.1.0.200" >> $GITHUB_ENV + echo "IMAGE_MODERATION_SERVICE_URL=https://nutripatrol.openfoodfacts.net/api/v1/flags" - name: Set various variable for production deployment if: matrix.env == 'robotoff-org' run: | @@ -39,6 +40,7 @@ jobs: # use prod mongodb through stunnel echo "MONGO_URI=mongodb://10.1.0.113:27017" >> $GITHUB_ENV echo "INFLUXDB_HOST=10.1.0.201" >> $GITHUB_ENV + echo "IMAGE_MODERATION_SERVICE_URL=https://nutripatrol.openfoodfacts.org/api/v1/flags" - name: Wait for container build workflow uses: tomchv/wait-my-workflow@v1.1.0 id: wait-build @@ -134,8 +136,7 @@ jobs: echo "SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}" >> .env echo "GUNICORN_NUM_WORKERS=8" echo "EVENTS_API_URL=https://event.openfoodfacts.${{ env.ROBOTOFF_TLD }}" >> .env - # TODO: remove this url when we have a proper server running for this purpose - echo "IMAGE_MODERATION_SERVICE_URL=https://amathjourney.com/api/off-annotation/flag-image" + echo "IMAGE_MODERATION_SERVICE_URL=${{ env.IMAGE_MODERATION_SERVICE_URL }}" >> .env - name: Create Docker volumes diff --git a/robotoff/logos.py b/robotoff/logos.py index b88a1492b7..bedf942df0 100644 --- a/robotoff/logos.py +++ b/robotoff/logos.py @@ -22,8 +22,8 @@ ) from robotoff.models import Prediction as PredictionModel from robotoff.models import ProductInsight, db +from robotoff.notifier import NotifierFactory from robotoff.off import OFFAuthentication -from robotoff.slack import NotifierFactory from robotoff.types import ( ElasticSearchIndex, InsightImportResult, diff --git a/robotoff/slack.py b/robotoff/notifier.py similarity index 95% rename from robotoff/slack.py rename to robotoff/notifier.py index e9fdaa47bc..e758ed916d 100644 --- a/robotoff/slack.py +++ b/robotoff/notifier.py @@ -173,18 +173,30 @@ def notify_image_flag( it""" if not predictions: return + + prediction = predictions[0] image_url = settings.BaseURLProvider.image_url( product_id.server_type, source_image ) image_id = int(source_image.rsplit("/", 1)[-1].split(".", 1)[0]) - params = {"imgid": image_id, "url": image_url} + data = { + "barcode": product_id.barcode, + "type": "image", + "url": image_url, + "user_id": "roboto-app", + "source": "robotoff", + "confidence": prediction.confidence, + "image_id": image_id, + "flavor": product_id.server_type.value, + "comment": json.dumps(prediction.data), + } try: - http_session.put(f"{self.service_url}/{product_id.barcode}", data=params) + http_session.post(self.service_url, json=data) except Exception: logger.exception( "Error while notifying image to moderation service", extra={ - "params": params, + "params": data, "url": image_url, "barcode": product_id.barcode, }, diff --git a/robotoff/prediction/ocr/image_flag.py b/robotoff/prediction/ocr/image_flag.py index 41b50974c1..e69fa6e75e 100644 --- a/robotoff/prediction/ocr/image_flag.py +++ b/robotoff/prediction/ocr/image_flag.py @@ -127,6 +127,7 @@ def flag_image(content: Union[OCRResult, str]) -> list[Prediction]: "likelihood": label_annotation.score, }, predictor_version=PREDICTOR_VERSION, + confidence=label_annotation.score, ) ) break diff --git a/robotoff/scheduler/__init__.py b/robotoff/scheduler/__init__.py index 09c2b10c4f..4ac1b83371 100644 --- a/robotoff/scheduler/__init__.py +++ b/robotoff/scheduler/__init__.py @@ -11,7 +11,7 @@ from playhouse.postgres_ext import ServerSide from sentry_sdk import capture_exception -from robotoff import settings, slack +from robotoff import notifier, settings from robotoff.insights.annotate import UPDATED_ANNOTATION_RESULT, annotate from robotoff.insights.importer import BrandInsightImporter, is_valid_insight_image from robotoff.metrics import ( @@ -60,7 +60,7 @@ def process_insights() -> None: if annotation_result == UPDATED_ANNOTATION_RESULT and insight.data.get( "notify", False ): - slack.NotifierFactory.get_notifier().notify_automatic_processing( + notifier.NotifierFactory.get_notifier().notify_automatic_processing( insight ) except Exception as e: diff --git a/robotoff/workers/tasks/import_image.py b/robotoff/workers/tasks/import_image.py index 9124a97c4e..3d6fca0c0d 100644 --- a/robotoff/workers/tasks/import_image.py +++ b/robotoff/workers/tasks/import_image.py @@ -35,11 +35,11 @@ db, with_db, ) +from robotoff.notifier import NotifierFactory from robotoff.off import generate_image_url, get_source_from_url, parse_ingredients from robotoff.prediction import ingredient_list from robotoff.prediction.upc_image import UPCImageType, find_image_is_upc from robotoff.products import get_product_store -from robotoff.slack import NotifierFactory from robotoff.taxonomy import get_taxonomy from robotoff.triton import ( GRPCInferenceServiceStub, diff --git a/tests/unit/test_slack.py b/tests/unit/test_notifier.py similarity index 72% rename from tests/unit/test_slack.py rename to tests/unit/test_notifier.py index 63a635631d..08db9a75eb 100644 --- a/tests/unit/test_slack.py +++ b/tests/unit/test_notifier.py @@ -4,8 +4,15 @@ import pytest -from robotoff import settings, slack +from robotoff import settings from robotoff.models import ImageModel, ImagePrediction, LogoAnnotation, ProductInsight +from robotoff.notifier import ( + ImageModerationNotifier, + MultiNotifier, + NoopSlackNotifier, + NotifierFactory, + SlackNotifier, +) from robotoff.types import Prediction, PredictionType, ProductIdentifier, ServerType DEFAULT_BARCODE = "123" @@ -49,24 +56,22 @@ def __eq__(self, actual): @pytest.mark.parametrize( "token_value, moderation_url, want_type", [ - ("T", "", slack.SlackNotifier), - ("T", "http://test.org/", slack.MultiNotifier), - ("", "", slack.NoopSlackNotifier), + ("T", "", SlackNotifier), + ("T", "http://test.org/", MultiNotifier), + ("", "", NoopSlackNotifier), ], ) def test_notifier_factory(monkeypatch, token_value, moderation_url, want_type): monkeypatch.setattr(settings, "slack_token", lambda: token_value) monkeypatch.setattr(settings, "IMAGE_MODERATION_SERVICE_URL", moderation_url) - notifier = slack.NotifierFactory.get_notifier() + notifier = NotifierFactory.get_notifier() assert type(notifier) is want_type def test_notify_image_flag_no_prediction(mocker): - mock = mocker.patch("robotoff.slack.http_session.post") + mock = mocker.patch("robotoff.notifier.http_session.post") - notifier = slack.MultiNotifier( - [slack.SlackNotifier(""), slack.ImageModerationNotifier("")] - ) + notifier = MultiNotifier([SlackNotifier(""), ImageModerationNotifier("")]) # no predictions associated to image notifier.notify_image_flag( [], @@ -79,15 +84,12 @@ def test_notify_image_flag_no_prediction(mocker): def test_notify_image_flag_public(mocker, monkeypatch): """Test notifying a potentially sensitive public image""" - mock_slack = mocker.patch( - "robotoff.slack.http_session.post", return_value=MockSlackResponse() + mock_http = mocker.patch( + "robotoff.notifier.http_session.post", return_value=MockSlackResponse() ) - mock_image_moderation = mocker.patch( - "robotoff.slack.http_session.put", return_value=MockSlackResponse() - ) - slack_notifier = slack.SlackNotifier("") - notifier = slack.MultiNotifier( - [slack_notifier, slack.ImageModerationNotifier("http://images.org/")] + slack_notifier = SlackNotifier("") + notifier = MultiNotifier( + [slack_notifier, ImageModerationNotifier("https://images.org")] ) notifier.notify_image_flag( @@ -101,7 +103,8 @@ def test_notify_image_flag_public(mocker, monkeypatch): DEFAULT_PRODUCT_ID, ) - mock_slack.assert_called_once_with( + assert len(mock_http.mock_calls) == 2 + mock_http.assert_any_call( slack_notifier.POST_MESSAGE_URL, data=PartialRequestMatcher( f"type: SENSITIVE\nlabel: *flagged*, match: bad_word\n\n <{settings.BaseURLProvider.image_url(DEFAULT_SERVER_TYPE, '/source_image/2.jpg')}|Image> -- <{settings.BaseURLProvider.world(DEFAULT_SERVER_TYPE)}/cgi/product.pl?type=edit&code=123|*Edit*>", @@ -111,28 +114,30 @@ def test_notify_image_flag_public(mocker, monkeypatch): ), ), ) - mock_image_moderation.assert_called_once_with( - "http://images.org/123", - data={ - "imgid": 2, - "url": settings.BaseURLProvider.image_url( - DEFAULT_SERVER_TYPE, "/source_image/2.jpg" - ), + mock_http.assert_any_call( + "https://images.org", + json={ + "barcode": "123", + "type": "image", + "url": "https://images.openfoodfacts.net/images/products/source_image/2.jpg", + "user_id": "roboto-app", + "source": "robotoff", + "confidence": None, + "image_id": 2, + "flavor": "off", + "comment": '{"text": "bad_word", "type": "SENSITIVE", "label": "flagged"}', }, ) def test_notify_image_flag_private(mocker, monkeypatch): """Test notifying a potentially sensitive private image""" - mock_slack = mocker.patch( - "robotoff.slack.http_session.post", return_value=MockSlackResponse() - ) - mock_image_moderation = mocker.patch( - "robotoff.slack.http_session.put", return_value=MockSlackResponse() + mock_http = mocker.patch( + "robotoff.notifier.http_session.post", return_value=MockSlackResponse() ) - slack_notifier = slack.SlackNotifier("") - notifier = slack.MultiNotifier( - [slack_notifier, slack.ImageModerationNotifier("http://images.org/")] + slack_notifier = SlackNotifier("") + notifier = MultiNotifier( + [slack_notifier, ImageModerationNotifier("https://images.org")] ) notifier.notify_image_flag( @@ -140,13 +145,15 @@ def test_notify_image_flag_private(mocker, monkeypatch): Prediction( type=PredictionType.image_flag, data={"type": "label_annotation", "label": "face", "likelihood": 0.8}, + confidence=0.8, ) ], "/source_image/2.jpg", DEFAULT_PRODUCT_ID, ) - mock_slack.assert_called_once_with( + assert len(mock_http.mock_calls) == 2 + mock_http.assert_any_call( slack_notifier.POST_MESSAGE_URL, data=PartialRequestMatcher( f"type: label_annotation\nlabel: *face*, score: 0.8\n\n <{settings.BaseURLProvider.image_url(DEFAULT_SERVER_TYPE, '/source_image/2.jpg')}|Image> -- <{settings.BaseURLProvider.world(DEFAULT_SERVER_TYPE)}/cgi/product.pl?type=edit&code=123|*Edit*>", @@ -156,22 +163,27 @@ def test_notify_image_flag_private(mocker, monkeypatch): ), ), ) - mock_image_moderation.assert_called_once_with( - "http://images.org/123", - data={ - "imgid": 2, - "url": settings.BaseURLProvider.image_url( - DEFAULT_SERVER_TYPE, "/source_image/2.jpg" - ), + mock_http.assert_any_call( + "https://images.org", + json={ + "barcode": "123", + "type": "image", + "url": "https://images.openfoodfacts.net/images/products/source_image/2.jpg", + "user_id": "roboto-app", + "source": "robotoff", + "image_id": 2, + "flavor": "off", + "comment": '{"type": "label_annotation", "label": "face", "likelihood": 0.8}', + "confidence": 0.8, }, ) def test_notify_automatic_processing_weight(mocker, monkeypatch): mock = mocker.patch( - "robotoff.slack.http_session.post", return_value=MockSlackResponse() + "robotoff.notifier.http_session.post", return_value=MockSlackResponse() ) - notifier = slack.SlackNotifier("") + notifier = SlackNotifier("") print(settings.BaseURLProvider.image_url(DEFAULT_SERVER_TYPE, "/image/1")) notifier.notify_automatic_processing( @@ -196,9 +208,9 @@ def test_notify_automatic_processing_weight(mocker, monkeypatch): def test_notify_automatic_processing_label(mocker, monkeypatch): mock = mocker.patch( - "robotoff.slack.http_session.post", return_value=MockSlackResponse() + "robotoff.notifier.http_session.post", return_value=MockSlackResponse() ) - notifier = slack.SlackNotifier("") + notifier = SlackNotifier("") notifier.notify_automatic_processing( ProductInsight( @@ -221,7 +233,7 @@ def test_notify_automatic_processing_label(mocker, monkeypatch): def test_noop_slack_notifier_logging(caplog): caplog.set_level(logging.INFO) - notifier = slack.NoopSlackNotifier() + notifier = NoopSlackNotifier() notifier.send_logo_notification( LogoAnnotation(