Skip to content

Instantly share code, notes, and snippets.

@bhubr
Last active January 9, 2021 18:33
Show Gist options
  • Save bhubr/8e60e55654857e8a9c363d496c50c720 to your computer and use it in GitHub Desktop.
Save bhubr/8e60e55654857e8a9c363d496c50c720 to your computer and use it in GitHub Desktop.
Robotoff - doc maison

Injection de données dans la BDD de Robotoff en local

En attendant d'obtenir un dump, l'idée est de recréer un jeu de données à partir des données JSON du Robotoff déployé en production.

Voir par exemple les données obtenues en requêtant https://robotoff.openfoodfacts.org/api/v1/questions/random?count=10&lang=en&insight_types=label.

On récupère un tableau de questions, chacune ayant cette forme :

{
  "barcode": "7758628000041",
  "type": "add-binary",
  "value": "PE-BIO-141",
  "question": "Does the product have this label?",
  "insight_id": "1c3f476b-72ce-4120-a29a-94106f46df39",
  "insight_type": "label",
  "source_image_url": "https://static.openfoodfacts.org/images/products/775/862/800/0041/1.400.jpg"
}

Par où commencer ? Déjà, il faut comprendre comment fonctionne Robotoff ! Petit tour du propriétaire, pour des non-Pythonistas...

Qu'y a-t-il à la racine ?

Parallèle avec Node / NPM : l'équivalent de npm est pip.

Là où npm va chercher les dépendances dans les dependencies du package.json, pip va les chercher dans le fichier requirements.txt.

Le format en est assez simple, chaque ligne correspond à un module tiers, sous le format <modulename>==<version> :

requests==2.25.0
peewee==3.14.0
psycopg2-binary==2.8.5
gunicorn==20.0.4
...

En cherchant à quoi elles correspondaient, j'ai identifié les deux modules les plus intéressants : falcon et peewee.

Falcon

Falcon est un framework web léger pour Python. Il entrerait dans la même catégorie qu'Express pour Node, à ceci près que Falcon est centré sur la création d'API REST, là où Express permet aussi la création d'applis web généralistes.

Exemple d'appli minimale, qui n'est pas totalement sans rappeler ce qu'on peut faire avec Express.

import falcon

class HelloResource:
    def on_get(self, req, resp):
        resp.status = falcon.HTTP_200
        resp.body = 'Hello World!'

api = falcon.API()
api.add_route('/', HelloResource())

On définit ici des ressources et pour chaque ressource, des méthodes telles qu'on_get. Ensuite, on associe un chemin à la ressource via api.add_route.

Dans le repo de Robotoff, le fichier principal de définition de l'API est robotoff/app/api.py.

À la fin du fichier, on trouve ceci qui correspond à l'URL requêtée depuis Hunger Game :

api.add_route("/api/v1/questions/random", RandomQuestionsResource())

Dans le même fichier, RandomQuestionsResource est une classe possédant, comme l'exemple ci-dessus, une méthode on_get. Sans aller trop loin dans l'examen du code Python :

  • Cette méthode appelle get_questions_resource_on_get dans la même classe,
  • laquelle commence par récupérer les paramètres passés dans l'URL via des req.get_param(),
  • puis appelle une fonction get_insights définie dans robotoff/app/core.py.
  • Cette fonction prend un certain nombre de paramètres, et renvoie un Iterable[ProductInsight], autrement dit, pour simplifier, un tableau d'objets de type ProductInsight. On va le voir en voyant Peewee, un objet ProductInsight correspondant à une ligne de la table product_insight, avec les clés correspondant aux colonnes de cette table.
  • Chaque "insight" de ce tableau est ensuite reformatté, transformé en question via : formatter_cls = QuestionFormatterFactory.get(insight.type)
  • Ainsi, pour une ligne de product_insight dont le type est label, le formatteur va produire un champ question dont la valeur, pour la langue anglaise, est "Does the product have this label?".

Peewee

Peewee est ce qu'on appelle un ORM : une bibliothèque qui permet de faciliter le requêtage de la base de données. Ce type de bibliothèque existe dans tous les langages (en JS : Sequelize, TypeORM, Prisma...).

Au lieu de faire des requêtes à la BDD manuellement, on appelle des méthodes sur des "modèles", et ce sont ces méthodes qui vont effectuer les requêtes.

Un modèle est une abstraction d'une table. Au lieu de créer la table en SQL, on la crée dans le langage programmation de l'application (ici Python). Petit exemple avec le modèle ProductInsight, défini dans robotoff/models.py, dont voici un extrait :

class ProductInsight(BaseModel):
    id = peewee.UUIDField(primary_key=True)
    barcode = peewee.CharField(max_length=100, null=False, index=True)
    type = peewee.CharField(max_length=256)
    data = BinaryJSONField(index=True)
    timestamp = peewee.DateTimeField(null=True, index=True)
    completed_at = peewee.DateTimeField(null=True)
    annotation = peewee.IntegerField(null=True, index=True)
    latent = peewee.BooleanField(null=False, index=True, default=False)
    ...

L'ORM va créer les tables correspondant aux modèles dans la BDD, par exemple au démarrage de l'application ; ou en lançant un script qui va mettre à jour la BDD par rapport aux différents modèles.

Dans la fonction get_insights de core.py, on appelle :

    query = ProductInsight.select()

    if where_clauses:
        query = query.where(*where_clauses)
    ...

Ce faisant, on "prépare" une requête SQL SELECT * FROM, sur laquelle on peut ajoute un WHERE... mais sans écrire la moindre ligne de SQL !

Autrement dit, l'ORM est une couche d'abstraction permettant de définir à la fois les modèles/tables, et la façon de les requêter, avec le confort de son langage de choix.

Comment procéder ?

On va tâcher d'insérer un insight dans la BDD de Robotoff en local.

Pour cela, le plus simple, au lieu d'installer manuellement tout l'environnement de dev Python, PostgreSQL, etc. sur lequel repose Robotoff, on va utiliser Docker et Docker Compose.

Docker va permettre de lancer une application (par exemple Robotoff) dans un "container" dédié.

Docker Compose permet de lancer plusieurs containers en même temps (celui de Robotoff, celui de PostgreSQL), et permettre à ces containers de communiquer entre eux.

Il faut avoir installé Docker et Docker Compose.

Puis à la racine du repo de Robotoff, tel qu'il est indiqué dans le README.md, lancer :

docker-compose up -d

SETUP Robotoff

  • Avec Docker ou sans Docker ?
  • Liens avec OFF Server ?

Doc endpoints

/api/v1/insights/annotate

Ordre (commit 26ca3b9447a0673c8ecd14cd0bb3ffc26dec3162 -> git checkout 26ca3b9447a0673c8ecd14cd0bb3ffc26dec3162 pour retrouver les mêmes numéros de ligne si ceux-ci ont changé) :

api.py L.996

api.add_route("/api/v1/insights/annotate", AnnotateInsightResource())

api.py L.200

class AnnotateInsightResource:
    def on_post(self, req: falcon.Request, resp: falcon.Response):
        # ...

        # L.210
        auth: Optional[OFFAuthentication] = parse_auth(req)
        # ...

        # L.219
        annotation_result = save_insight(
            insight_id, annotation, update=update, data=data, auth=auth
        )
        # ...

core.py L.93

def save_insight(
    insight_id: str,
    annotation: int,
    update: bool = True,
    data: Optional[Dict] = None,
    auth: Optional[OFFAuthentication] = None,
) -> AnnotationResult:
    # Récupère l'insight
    try
        insight: Union[ProductInsight, None] = ProductInsight.get_by_id(insight_id)
    except ProductInsight.DoesNotExist:
        insight = None

    # Si non trouvé, renvoie erreur
    if not insight:
        return UNKNOWN_INSIGHT_RESULT

    # Si trouvé mais déjà annoté, renvoie erreur
    if insight.annotation is not None:
        return ALREADY_ANNOTATED_RESULT

    ## Instancie un "annotateur" via une factory, en fonction du type
    annotator = InsightAnnotatorFactory.get(insight.type)
    return annotator.annotate(insight, annotation, update, data=data, auth=auth)

annotate.py L.462

On y trouve le mapping entre les types d'insights et les classes *Annotator (pour l'exemple on a gardé les 4 qui nous intéressent pour Hunger Games)

class InsightAnnotatorFactory:
    mapping = {
        # ...
        InsightType.label.name: LabelAnnotator(),
        InsightType.category.name: CategoryAnnotator(),
        InsightType.product_weight.name: ProductWeightAnnotator(),
        # ...
        InsightType.brand.name: BrandAnnotator(),
        # ...
    }

    @classmethod
    def get(cls, identifier: str) -> InsightAnnotator:
        if identifier not in cls.mapping:
            raise ValueError("unknown annotator: {}".format(identifier))

        return cls.mapping[identifier]

Prenons l'exemple d'une "brand"...

annotate.py L.337

Ajout d'un commentaire au-dessus de get_product qui interroge la BDD mongo d'OFF. Echec si celle-ci n'est pas accessible !

class BrandAnnotator(InsightAnnotator):
    def process_annotation(
        self,
        insight: ProductInsight,
        data: Optional[Dict] = None,
        auth: Optional[OFFAuthentication] = None,
    ) -> AnnotationResult:
        # Voir `products.py`
        product = get_product(insight.barcode, ["brands_tags"])

        if product is None:
            return MISSING_PRODUCT_RESULT

        add_brand(
            insight.barcode,
            insight.value,
            insight_id=insight.id,
            server_domain=insight.server_domain,
            auth=auth,
        )
        return UPDATED_ANNOTATION_RESULT

Cette classe hérite de InsightAnnotator qu'on va voir juste après...

annotate.py L.76

class InsightAnnotator(metaclass=abc.ABCMeta):
    def annotate(
        self,
        insight: ProductInsight,
        annotation: int,
        update: bool = True,
        data: Optional[Dict] = None,
        auth: Optional[OFFAuthentication] = None,
        automatic: bool = False,
    ) -> AnnotationResult:
        if insight.latent:
            return LATENT_INSIGHT_RESULT

        with db.atomic() as transaction:
            try:
                return self._annotate(
                    insight, annotation, update, data, auth, automatic
                )
            except Exception as e:
                transaction.rollback()
                raise e

    def _annotate(
        self,
        insight: ProductInsight,
        annotation: int,
        update: bool = True,
        data: Optional[Dict] = None,
        auth: Optional[OFFAuthentication] = None,
        automatic: bool = False,
    ) -> AnnotationResult:
        if self.is_data_required() and data is None:
            return DATA_REQUIRED_RESULT

        username: Optional[str] = None
        if auth is not None:
            username = auth.get_username()

        insight.username = username
        insight.annotation = annotation
        insight.completed_at = datetime.datetime.utcnow()

        if automatic:
            insight.automatic_processing = True

        insight.save()

        if annotation == 1 and update:
            return self.process_annotation(insight, data=data, auth=auth)

        return SAVED_ANNOTATION_RESULT

    @abc.abstractmethod
    def process_annotation(
        self,
        insight: ProductInsight,
        data: Optional[Dict] = None,
        auth: Optional[OFFAuthentication] = None,
    ) -> AnnotationResult:
        pass

    def is_data_required(self) -> bool:
        return False

On voit que la méthode "publique" annotate appelle la méthode "privée" _annotate, qui :

  1. modifie les champs username, annotation, completed_at de l'insight,
  2. sauvegarde l'insight,
  3. appelle elle-même la méthode abstraite process_annotation, définie dans chaque classe *Annotator (comme BrandAnnotator ci-dessus).
  4. process_annotation va appeler get_product qui va chercher le produit dans la BDD principale (mongo) d'OFF.
  5. Si on prend le cas d'un insight brand, cela nous fait appeler la méthode add_brand. On va suivre cette piste...

off.py L.277

def add_brand(barcode: str, brand: str, insight_id: Optional[str] = None, **kwargs):
    comment = "[robotoff] Adding brand '{}'".format(brand)

    if insight_id:
        comment += ", ID: {}".format(insight_id)

    params = {
        "code": barcode,
        "add_brands": brand,
        "comment": comment,
    }
    update_product(params, **kwargs)

update_product semble être appelée par toutes les méthodes comme add_brand, etc.

off.py L.347

Ici on va retrouver des choses ayant trait à l'authentification. Apparemment, on est censé avoir un cookie de session, ou on obtient une erreur...

def update_product(
    params: Dict,
    server_domain: Optional[str] = None,
    auth: Optional[OFFAuthentication] = None,
    timeout: Optional[int] = 15,
):
    if server_domain is None:
        server_domain = settings.OFF_SERVER_DOMAIN

    url = get_product_update_url(server_domain)

    comment = params.get("comment")
    cookies = None

    if auth is not None:
        if auth.session_cookie:
            cookies = {
                "session": auth.session_cookie,
            }
        elif auth.username:
            params["user_id"] = auth.username
            params["password"] = auth.password
    else:
        params.update(AUTH_DICT)

        if comment:
            params["comment"] = comment + " (automated edit)"

    # C'est ICI qu'on a un problème si la requête n'est pas authentifiée...
    if cookies is None and not params.get("password"):
        raise ValueError(
            "a password or a session cookie is required to update a product"
        )

    request_auth: Optional[Tuple[str, str]] = None
    if server_domain.endswith("openfoodfacts.net"):
        # dev environment requires authentication
        request_auth = ("off", "off")

    r = http_session.get(
        url, params=params, auth=request_auth, cookies=cookies, timeout=timeout
    )

    r.raise_for_status()
    json = r.json()

    status = json.get("status_verbose")

    if status != "fields saved":
        logger.warn("Unexpected status during product update: {}".format(status))

Qu'est censé renvoyer Robotoff quand tout se passe bien ?

Essai avec un produit récupéré via un appel sur https://robotoff.openfoodfacts.org/api/v1/questions/random?count=10&lang=en&insight_types=brand, qui donne cette réponse :

{
  "count": 31298,
  "questions": [
    /* ... */
    {
      "barcode": "8001860198908",
      "type": "add-binary",
      "value": "Riso Scotti",
      "question": "Does the product belong to this brand?",
      "insight_id": "9c9644b3-034e-4940-ba4a-874e049495b1",
      "insight_type": "brand",
      "source_image_url": "https://static.openfoodfacts.org/images/products/800/186/019/8908/1.400.jpg"
    },
    /* ... */
    {
      "barcode": "4056489262374",
      "type": "add-binary",
      "value": "Milbona",
      "question": "Does the product belong to this brand?",
      "insight_id": "8fae2b3a-cacb-4f2f-b80a-fc44c1f40494",
      "insight_type": "brand",
      "source_image_url": "https://static.openfoodfacts.org/images/products/405/648/926/2374/1.400.jpg"
    }
  ],
  "status": "found"
}

On dit "Yes". Ceci envoie les données (url-encoded) insight_id=9c9644b3-034e-4940-ba4a-874e049495b1&annotation=1&update=1 en POST vers https://robotoff.openfoodfacts.org/api/v1/insights/annotate

Requête en cURL (bash) :

curl 'https://robotoff.openfoodfacts.org/api/v1/insights/annotate' \
  -H 'authority: robotoff.openfoodfacts.org' \
  -H 'accept: application/json, text/plain, */*' \
  -H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.193 Safari/537.36' \
  -H 'content-type: application/x-www-form-urlencoded;charset=UTF-8' \
  -H 'origin: https://hunger.openfoodfacts.org' \
  -H 'sec-fetch-site: same-site' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-dest: empty' \
  -H 'referer: https://hunger.openfoodfacts.org/questions?type=brand' \
  -H 'accept-language: en-US,en;q=0.9' \
  --data-raw 'insight_id=9c9644b3-034e-4940-ba4a-874e049495b1&annotation=1&update=1' \
  --compressed

Réponse (statut 200) :

{
  "status": "updated",
  "description": "the annotation was saved and sent to OFF"
}

On retrouve la string description dans annotate.py, lignes 51-54 :

UPDATED_ANNOTATION_RESULT = AnnotationResult(
    status=AnnotationStatus.updated.name,
    description="the annotation was saved and sent to OFF",
)

Ce UPDATED_ANNOTATION_RESULT c'est ce que renvoient les méthodes process_annotation des classes *Annotator, toujours dans annotate.py.

Si on rejoue la requête d'annotation via cURL, on devrait obtenir une erreur. Allons-y, c/c de la requête ci-dessus dans bash. Sans surprise :

{
  "status": "error_already_annotated",
  "description": "the insight has already been annotated"
}

Si on recherche la description dans le dossier robotoff du repo Robotoff, on la retrouve en lignes 59-62 de annotate.py :

ALREADY_ANNOTATED_RESULT = AnnotationResult(
    status=AnnotationStatus.error_already_annotated.name,
    description="the insight has already been annotated",
)

C'est ce que renvoie save_insight si l'insight est déjà annoté (mais avec un statut 200).

Autres cas :

Insight inexistant (non-présent dans le Postgres que requête Robotoff).

En mettant un insight_id bidon dans la requête POST, on obtient (tjrs statut 200) :

{
  "status": "error_unknown_insight",
  "description": "unknown insight ID"
}

On retrouve la description dans annotate.py, L. 63-65 :

UNKNOWN_INSIGHT_RESULT = AnnotationResult(
    status=AnnotationStatus.error_unknown_insight.name, description="unknown insight ID"
)

On retrouve UNKNOWN_INSIGHT_RESULT dans save_insight.

Produit inexistant dans la base OFF (mongodb).

Mongo offline - products.py L.508-512 :

def get_product(
    barcode: str, projection: Optional[List[str]] = None
) -> Optional[JSONType]:
    mongo_client = MONGO_CLIENT_CACHE.get()
    return mongo_client.off.products.find_one({"code": barcode}, projection)

Si mongo est offline/inaccessible on se prend une "sympathique" erreur 500, sans aucun contexte (it sucks!) :

<html>
  <head>
    <title>Internal Server Error</title>
  </head>
  <body>
    <h1><p>Internal Server Error</p></h1>

  </body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment