Création d'un skill pytlas de A à Z

Publié dans développement
le

Je vous parle beaucoup de mon projet pytlas en ce moment, voyons donc ensemble comment réaliser votre premier skill !

Aujourd'hui, nous allons créer un skill pour l'assistant pytlas, dont je vous parle depuis un petit moment maintenant, tout ça en partant de zéro. Nous allons voir comment créer le code python nécessaire à l'éxecution de notre compétence et comment réaliser des tests pour s'assurer que tout fonctionne correctement et qu'il est prêt à être partager avec la communauté.

A la fin de ce petit tutoriel, pytlas n'aura plus de secret pour vous et vous pourrez laisser libre cours à votre imagination ! ;)

Vous avez besoin de comprendre les différents appels effectués dans ce tutoriel ? N'hésitez pas à consulter la documentation de la librairie qui se trouve par ici !

Ah et au passage, les sources de ce skill sont dispo sur le github de pytlas.

Pré-requis

Il vous faudra tout d'abord l'interpréteur Python 3 sur votre machine ainsi que quelques connaissances basiques du language Python.

De manière à ce que n'importe quelle personne récupérant les sources de notre skill puisse l'exécuter lui et ses tests, il est nécessaire de définir les dépendances sur lesquelles on s'appuie. En python, on utilise le fichier requirements.txt qui liste ces dépendances. Créons le dossier swear_jar/ qui contiendra le code source de notre skill.

A l'intérieur, on trouve donc notre fichier requirements.txt avec le code suivant :

pytlas[snips,test]~=5.1

Cela permet de dire que notre module python dépend de la librairie pytlas sur pypi avec en plus de quoi utiliser l'interpréteur snips et lancer les tests.

Ensuite, on peut installer les dépendances avec la commande :

$ pip install -r requirements.txt

Et nous voilà fin prêt !

Tout commence par une idée

Je vous propose de réaliser une compétence nous permettant de gérer un bocal à jurons qui serait partagé par les différents utilisateurs du système et disponible en anglais et en français.

Deux interactions possibles :

  • Ajouter un montant dans le bocal avec des phrases telles que : J'ai été vulgaire, Ajoute deux euros dans le bocal à jurons,
  • Connaître la somme actuelle contenue dans le bocal à jurons avec : A combien s'élève le bocal, Combien d'argent dans le bocal

Définir les jeux d'exemples

Commençons par définir nos jeux d'exemples. Ils serviront à déclencher notre skill. Pour se faire, dans le dossier swear_jar/ nous allons ajouter 2 fichiers :

swear_jar.py

C'est ici que nous allons placer tout le code de notre skill. Commençons donc par ajouter les jeux d'exemples en français et en anglais (en temps normal, on ajouterai plus d'exemples avec plus de variations pour obtenir de meilleures performances dans la reconnaissance) :

from pytlas import training

@training('fr')
def french_data(): return """
%[add_to_swear_jar]
  j'ai été vulgaire
  j'ai été un vilain garçon
  ajoute @[money] au bocal à jurons
  ajoute @[money] au bocal
  mets @[money] dans le bocal à jurons

%[get_swear_jar_balance]
  à combien s'élève le bocal
  combien y'a t-il d'euros dans le bocal
  peux-tu me donner le montant du bocal
  quel est le montant du bocal

@[money](type=amountOfMoney)
  deux euros
  10 euros
"""

@training('en')
def english_data(): return """
%[add_to_swear_jar]
  i've been a bad boy
  i was mean
  i was vulgar
  add @[money] to the swear jar
  add @[money] to the jar
  drop @[money] in the swear jar

%[get_swear_jar_balance]
  what's in the swear jar
  how many dollars in the jar
  can you give me the current swear jar balance
  what's the jar balance

@[money](type=amountOfMoney)
  two dollars
  10 dollars
"""

Les exemples sont ici définis par une fonction décorée avec @training(langue_des_exemples). De cette manière, pytlas pourra fournir ces exemples au moteur de NLU (Compréhension du langage) configuré afin qu'il puisse déterminer des patterns (via Machine Learning) et être plus souple qu'une simple regex.

La chaîne retournée par ces fonctions est en fait un DSL (Domain Specific Language) développé pour l'occasion et disponible ici. Ce DSL permet d'être totalement indépendant du moteur de NLU (Rasa, Snips, etc...) de manière à ce que votre skill puisse fonctionner dans tous les cas même si pour le moment, seul Snips est officiellement supporté.

Les éléments représentés par %[] sont des intentions sur lesquelles nous allons pouvoir nous brancher et déclencher du code python. Les éléments @[] quant à eux sont des entités, c'est à dire des paramètres que nous allons pouvoir extraire dans notre code. Ici, il s'agit du montant à ajouter. Dans les deux cas, ces éléments servent de clés et c'est pour cette raison que les jeux d'exemples, qu'importe le langage, utilisent les mêmes noms d'intentions et d'entités.

__init__.py

Ce fichier est appelé par l'interpréteur Python lors de l'import de notre skill par la librairie pytlas. Il va tout simplement importer tout le code présent dans le fichier swear_jar.py.

from .swear_jar import *

Gestion du bocal

De manière à nous faciliter la vie, créons une petite classe qui représentera notre bocal, toujours dans le fichier swear_jar.py :

from pytlas import training

# (...)

class SwearJar:
  def __init__(self):
    self.reset()

  def reset(self):
    self.balance = None

  def add(self, value):
    if not self.balance:
      self.balance = value
    else:
      self.balance += value

# Comme notre bocal sera le même pour tout le monde, on l'instantie ici (on ne gérera pas la persistance dans cet exemple)
swear_jar = SwearJar()

# (...)

La méthode add recevra un objet de type pytlas.understanding.UnitValue qui représentera le montant sous forme de monnaie (valeur et devise) qui aura été extrait par le NLU.

On en profite pour écrire tout de suite les tests permettant de valider cette première partie. Dans un fichier test_swear_jar.py à la racine de notre répertoire :

from sure import expect
from pytlas.understanding import UnitValue
from .swear_jar import SwearJar

class TestSwearJar:

  def test_it_should_be_empty_when_created(self):
    j = SwearJar()

    expect(j.balance).to.be.none

  def test_it_should_add_money_correctly(self):
    j = SwearJar()
    j.add(UnitValue(5, '€'))
    j.add(UnitValue(10, '€'))

    expect(str(j.balance)).to.equal('15€')

  def test_it_should_reset_correctly(self):
    j = SwearJar()
    j.add(UnitValue(5, '€'))
    j.reset()

    expect(j.balance).to.be.none

Comme la librairie pytlas utilise déjà la librairie sure pour les assertions et nose pour découvrir et exécuter les tests, nous nous baserons dessus aussi.

Exécutons les tests tout de suite pour s'assurer que tout est ok jusque là :

$ python -m nose
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

Définition des handlers

Maintenant que nous avons nos jeux d'entraînement ainsi que notre petit helper pour gérer notre bocal, il est temps d'en faire quelque chose !

from pytlas import training, intent # Ici on ajoute l'import du décorateur `intent`

# (...)

@intent('add_to_swear_jar')
def on_add_to_swear_jar(request):
  # On récupère le montant à ajouter en allant chercher le paramètre `money` défini dans les jeux d'exemples
  # pour l'intention dont est issue la requête
  amount_to_add = request.intent.slot('money').first().value

  # Si pas de montant défini, alors on demande à l'utilisateur de le préciser et l'handler sera de nouveau appelé
  # avec la valeur modifiée
  if not amount_to_add:
    return request.agent.ask('money', request._('How many bucks should I add to the jar?'))

  swear_jar.add(amount_to_add)

  # On informe l'utilisateur de ce qui s'est passé
  request.agent.answer(request._('%s have been added to the jar') % amount_to_add)

  # Et enfin, on fait signe à pytlas que le skill a terminé de traiter l'intention
  return request.agent.done()

# Et le handler suivant ne devrait pas vous poser de problème
@intent('get_swear_jar_balance')
def on_get_swear_jar_balance(request):
  if not swear_jar.balance:
    request.agent.answer(request._('The swear jar is empty'))
  else:
    request.agent.answer(request._('There is %s in the swear jar') % swear_jar.balance)

  return request.agent.done()

L'utilisation du décorateur @intent(nom_de_l_intention_a_capturer) permet d'enregistrer notre fonction python afin qu'elle soit appelée dès que le NLU identifie cette intention après avoir traité l'entrée de l'utilisateur. Vous remarquerez ici que nous utilisons les noms des intents tels que définis dans les jeux d'exemples de manière à lier la reconnaissance de l'intention au code python à exécuter.

À l'intérieur de cette fonction, on peut utiliser le paramètre représentant la requête afin dd'interagir avec l'utilisateur.

Pour finir, on appelle request.agent.done() qui fait signe à l'agent que notre skill a terminé son traitement et qu'il peut se remettre en attente d'autres ordres.

Ajout des traductions

À ce moment là, vous devez vous demander pourquoi j'utilise request._('un message') lorsque je retourne une chaîne de caractères ? Tout simplement pour que pytlas puisse traduire cette chaîne dans la langue de l'utilisateur et vous permettre d'écrire un skill valable pour le plus grand nombre de personnes.

Encore une fois, pas de magie, les traductions doivent être enregistrées préalablement avec le décorateur @translations(langue_concernee) qui retourne alors un dictionnaire :

from pytlas import training, intent, translations # on ajoute l'import du décorateur `translations`

# (...)

@translations('fr')
def french_translations(): return {
  'How many bucks should I add to the jar?': 'Combien dois-je ajouter au bocal ?',
  '%s have been added to the jar': '%s ont été ajoutés au bocal',
  'There is %s in the swear jar': "Il y'a %s dans le bocal à jurons",
  'The swear jar is empty': 'Le bocal à jurons est vide',
}

# (...)

Ici pas besoin de s'occuper des traductions anglaises, si aucune traduction n'est disponible, alors le paramètre de la fonction request._ sera retourné.

Ajout des tests des intentions

Testons maintenant notre skill dans le fichier test_swear_jar_intents.py :

from sure import expect
from pytlas.testing import create_skill_agent
from .swear_jar import swear_jar
import os

# Le helper `create_skill_agent` nous permet de construire un agent pytlas pour
# le skill contenu dans ce répertoire avec un SnipsInterpreter déjà entrainé.
agents = [
  create_skill_agent(os.path.dirname(__file__), lang='en'),
  create_skill_agent(os.path.dirname(__file__), lang='fr'),
]

# Liste des chaînes utilisées dans les tests
strings = {
  'en': {
    'add_20_units': 'add 20 dollars to the swear jar',
    '20_units_added': '20$ have been added to the jar',
    'i_have_been_mean': "i've been a bad guy",
    'ask_how_many': 'How many bucks should I add to the jar?',
    'answer_50_units': 'fifty dollars',
    '50_units_added': '50$ have been added to the jar',
    'add_11_units': 'add 11 dollars to the jar',
    'drop_20_units': 'drop 20 dollars in the jar',
    'empty_jar': 'The swear jar is empty',
    'balance_is_31': 'There is 31$ in the swear jar',
    'ask_for_balance': 'how many dollars in the swear jar',
  },
  'fr': {
    'add_20_units': 'ajoute 20 € au bocal à jurons',
    '20_units_added': '20EUR ont été ajoutés au bocal',
    'i_have_been_mean': "j'ai été vilain",
    'ask_how_many': 'Combien dois-je ajouter au bocal ?',
    'answer_50_units': 'cinquante euros',
    '50_units_added': '50EUR ont été ajoutés au bocal',
    'add_11_units': 'ajoute 11 euros dans le bocal',
    'drop_20_units': 'met 20 euros dans le bocal',
    'empty_jar': 'Le bocal à jurons est vide',
    'balance_is_31': "Il y'a 31EUR dans le bocal à jurons",
    'ask_for_balance': "A combien s'élève le bocal à jurons",
  },
}

class TestAddToSwearJar:

  # Ici, on reset les informations nécessaires pour que chaque tests s'éxécute
  # de manière isolé.
  def setup(self):
    for agent in agents:
      agent.model.reset()

    swear_jar.reset()

  def it_should_add_money_to_the_swear_jar_when_given(self, agent):
    agent.parse(strings[agent._interpreter.lang]['add_20_units'])

    # Le model passé à l'agent est un mock avec quelques utilitaires comme le
    # get_call sur le différents événements levés par la librairie (cf. doc)
    # qui nous permet de récupérer l'appel effectué et de pouvoir effectuer
    # des assertions
    on_answer = agent.model.on_answer.get_call()

    expect(on_answer.text).to.equal(strings[agent._interpreter.lang]['20_units_added'])
    expect(swear_jar.balance.value).to.equal(20)

  # Ici on utilise le yield pour lancer les tests dans les deux langues que
  # nous supportons de manière à ce que nos tests soient plus concis
  def test_it_should_add_money_to_the_swear_jar_when_given(self):
    for agent in agents:
      yield self.it_should_add_money_to_the_swear_jar_when_given, agent

  def it_should_request_money_when_not_given(self, agent):
    agent.parse(strings[agent._interpreter.lang]['i_have_been_mean'])

    on_ask = agent.model.on_ask.get_call()

    expect(on_ask.slot).to.equal('money')
    expect(on_ask.text).to.equal(strings[agent._interpreter.lang]['ask_how_many'])

    agent.parse(strings[agent._interpreter.lang]['answer_50_units'])

    on_answer = agent.model.on_answer.get_call()

    expect(on_answer.text).to.equal(strings[agent._interpreter.lang]['50_units_added'])
    expect(swear_jar.balance.value).to.equal(50)

  def test_it_should_request_money_when_not_given(self):
    for agent in agents:
      yield self.it_should_request_money_when_not_given, agent

class TestGetSwearJarBalance:

  def setup(self):
    for agent in agents:
      agent.model.reset()
    swear_jar.reset()

  def it_should_returns_the_correct_balance(self, agent):
    agent.parse(strings[agent._interpreter.lang]['ask_for_balance'])

    on_answer = agent.model.on_answer.get_call()

    expect(on_answer.text).to.equal(strings[agent._interpreter.lang]['empty_jar'])

    agent.parse(strings[agent._interpreter.lang]['add_11_units'])
    agent.parse(strings[agent._interpreter.lang]['drop_20_units'])

    # On reset manuellement pour que le prochain `get_call` nous retourne
    # le dernier appel
    agent.model.reset()

    agent.parse(strings[agent._interpreter.lang]['ask_for_balance'])

    on_answer = agent.model.on_answer.get_call()

    expect(on_answer.text).to.equal(strings[agent._interpreter.lang]['balance_is_31'])

  def test_it_should_returns_the_correct_balance(self):
    for agent in agents:
      yield self.it_should_returns_the_correct_balance, agent

Dans ces tests d'intégrations, on utilise un agent pour la langue souhaitée (qui se trouve être la machine à états gérant la conversation) avec un interpréteur entraîné uniquement pour le skill en cours de tests via la fonction create_skill_agent.

L'agent communique avec le monde extérieur via un modèle qui dans ce cas précis est tout simplement remplacé par un AgentModelMock nous permettant de faire des assertions plus simplement sur l'appel des différentes fonctions.

On peut désormais relancer les tests et même s'assurer de la couverture de code avec la commande :

$ python -m nose --with-coverage --cover-package=swear_jar
.........
Name                     Stmts   Miss  Cover
--------------------------------------------
swear_jar/__init__.py        1      0   100%
swear_jar/swear_jar.py      29      0   100%
--------------------------------------------
TOTAL                       30      0   100%
----------------------------------------------------------------------
Ran 9 tests in 0.175s

OK

Et voilà ! Vous venez de créer votre premier skill multi-langues pour l'assistant pytlas avec les tests qui nous permettent de partager sans crainte notre dur labeur avec la communauté ! Félicitations !

Une fois le serveur déployé chez vous et l'appli mobile en votre possession, vous obtiendrez ceci (oui j'avais déjà été vulgaire dans la journée...) :

pytlas swear jar in action!

À vous de jouer !