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...
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 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 typeProductInsight
. On va le voir en voyant Peewee, un objetProductInsight
correspondant à une ligne de la tableproduct_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 letype
estlabel
, le formatteur va produire un champquestion
dont la valeur, pour la langue anglaise, est"Does the product have this label?"
.
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.
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
- Avec Docker ou sans Docker ?
- Liens avec OFF Server ?
Ordre (commit 26ca3b9447a0673c8ecd14cd0bb3ffc26dec3162
-> git checkout 26ca3b9447a0673c8ecd14cd0bb3ffc26dec3162
pour retrouver les mêmes numéros de ligne si ceux-ci ont changé) :
api.add_route("/api/v1/insights/annotate", AnnotateInsightResource())
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
)
# ...
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)
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"...
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...
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 :
- modifie les champs
username
,annotation
,completed_at
de l'insight, - sauvegarde l'insight,
- appelle elle-même la méthode abstraite
process_annotation
, définie dans chaque classe*Annotator
(commeBrandAnnotator
ci-dessus). process_annotation
va appelerget_product
qui va chercher le produit dans la BDD principale (mongo) d'OFF.- Si on prend le cas d'un insight
brand
, cela nous fait appeler la méthodeadd_brand
. On va suivre cette piste...
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.
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))
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>