diff --git a/.env b/.env index dd4098e..0389518 100644 --- a/.env +++ b/.env @@ -1,10 +1,9 @@ RASA_X_VERSION= RASA_VERSION= -RASA_TOKEN=c4TdROQ4sVTc8JUlFLQFvQ -RASA_X_TOKEN=1PJkHOcdUCd21Tnw7T1oTg -PASSWORD_SALT=Mt6gU4mudBeT8B9tgJVfeg -JWT_SECRET=p5J/3qFcHEDl9LC66mQapQ -RABBITMQ_PASSWORD=LzkOXZbnGesQKKHFnh7wmg -DB_PASSWORD=PZQwYSewjII2/zG2+Kmn3g -REDIS_PASSWORD=D3gO8RnXPbnyZ2UyYaHNhw +RASA_TOKEN= +RASA_X_TOKEN= +PASSWORD_SALT= +RABBITMQ_PASSWORD= +DB_PASSWORD= +REDIS_PASSWORD= RASA_TELEMETRY_ENABLED=false \ No newline at end of file diff --git a/Makefile b/Makefile index 6e9ee1e..656c068 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,7 @@ build-coach: run-shell: ## Run bot in shell, sucessful when shows "Bot loaded. Type a message and press enter (use '/stop' to exit): " docker-compose run -d actions make actions + docker-compose up -d duckling docker-compose run bot make shell run-api: @@ -54,10 +55,11 @@ run-x: docker-compose run --rm --service-ports x make x ############################## TESTS ############################## test: - docker-compose run --rm bot make test + docker-compose up -d duckling + docker-compose run --name bot --rm bot make test test-actions: - docker-compose run --rm bot make test-actions + docker-compose run --rm bot make test-actions -e JWT_SECRET=testing_secret_value run-test-nlu: docker-compose run --rm bot make test-nlu diff --git a/bot/Makefile b/bot/Makefile index c87e6ba..4c0594e 100644 --- a/bot/Makefile +++ b/bot/Makefile @@ -22,7 +22,7 @@ train: # TESTS test: - rasa test --out results/ + rasa test --out results/ --fail-on-prediction-errors test-actions: python -m pytest diff --git a/bot/actions/actions.py b/bot/actions/actions.py index ee06283..927671e 100644 --- a/bot/actions/actions.py +++ b/bot/actions/actions.py @@ -5,6 +5,7 @@ # https://rasa.com/docs/rasa/custom-actions from typing import Text, List, Any, Dict +import json # from rasa_sdk import Action, Tracker, FormValidationAction @@ -12,26 +13,35 @@ from rasa_sdk.events import SlotSet, FollowupAction, EventType from rasa_sdk.types import DomainDict +from .ej_connector import API + # # + + class ActionSetupConversation(Action): def name(self): return "action_setup_conversation" def run(self, dispatcher, tracker, domain): - # TODO: get values from EJ server conversation_id = 1 + user_email = tracker.get_slot("email") + if user_email: + user = API.create_user(tracker.sender_id, user_email, user_email) + else: + user = API.create_user(tracker.sender_id) + # TODO: get values from EJ server number_comments = 2 number_voted_comments = 1 - first_comment = "Comment text here" - comment_id = 53 + first_comment = API.get_next_comment(conversation_id, user.token) return [ SlotSet("number_voted_comments", number_voted_comments), SlotSet("conversation_id", conversation_id), SlotSet("number_comments", number_comments), - SlotSet("comment_text", first_comment), - SlotSet("current_comment_id", comment_id), + SlotSet("comment_text", first_comment["content"]), + SlotSet("current_comment_id", first_comment["id"]), SlotSet("change_comment", False), + SlotSet("ej_user_token", user.token), FollowupAction("vote_form"), ] @@ -52,14 +62,15 @@ def run( if tracker.get_slot("change_comment"): # TODO: get values from EJ server # next_comment = get_random_comment() - new_comment = "novo comentário com outro id" + conversation_id = tracker.get_slot("conversation_id") + token = tracker.get_slot("ej_user_token") + new_comment = API.get_next_comment(conversation_id, token) dispatcher.utter_message(text=new_comment, buttons=buttons) number_voted_comments = tracker.get_slot("number_comments") + 1 - comment_id = 22 return [ SlotSet("number_voted_comments", number_voted_comments), - SlotSet("comment_text", new_comment), - SlotSet("current_comment_id", comment_id), + SlotSet("comment_text", new_comment["content"]), + SlotSet("current_comment_id", new_comment["id"]), ] else: dispatcher.utter_message( diff --git a/bot/actions/ej_connector/__init__.py b/bot/actions/ej_connector/__init__.py new file mode 100644 index 0000000..742e2fa --- /dev/null +++ b/bot/actions/ej_connector/__init__.py @@ -0,0 +1,2 @@ +from .api import API +from .user import User diff --git a/bot/actions/ej_connector/api.py b/bot/actions/ej_connector/api.py new file mode 100644 index 0000000..f25db5a --- /dev/null +++ b/bot/actions/ej_connector/api.py @@ -0,0 +1,58 @@ +import os +import requests +from .user import User + +HEADERS = { + "Content-Type": "application/json", +} +VOTE_CHOICES = {"Pular": 0, "Concordar": 1, "Discordar": -1} +HOST = os.getenv("EJ_HOST") +API_URL = f"{HOST}/api/v1" +REGISTRATION_URL = f"{HOST}/rest-auth/registration/" +VOTES_URL = f"{API_URL}/votes/" + + +def conversation_url(conversation_id): + return f"{API_URL}/conversations/{conversation_id}/" + + +def conversation_random_comment_url(conversation_id): + return f"{conversation_url(conversation_id)}random-comment/" + + +def user_statistics_url(conversation_id): + return f"{conversation_url(conversation_id)}user-statistics/" + + +def user_comments_route(conversation_id): + return f"{conversation_url(conversation_id)}user-comments/" + + +def user_pending_comments_route(conversation_id): + return f"{conversation_url(conversation_id)}user-pending-comments/" + + +def auth_headers(token): + headers = HEADERS + headers["Authorization"] = f"Token {token}" + return headers + + +class API: + def create_user(sender_id, name="Participante anônimo", email=""): + user = User(sender_id, name, email) + response = requests.post( + REGISTRATION_URL, + data=user.serialize(), + headers=HEADERS, + ) + user.token = response.json()["key"] + return user + + def get_next_comment(conversation_id, token): + url = conversation_random_comment_url(conversation_id) + response = requests.get(url, headers=auth_headers(token)) + comment = response.json() + comment_url_as_list = comment["links"]["self"].split("/") + comment["id"] = comment_url_as_list[len(comment_url_as_list) - 2] + return comment diff --git a/bot/actions/ej_connector/user.py b/bot/actions/ej_connector/user.py new file mode 100644 index 0000000..7d6dc48 --- /dev/null +++ b/bot/actions/ej_connector/user.py @@ -0,0 +1,23 @@ +import os +import json +import jwt + + +class User(object): + def __init__(self, rasa_id, name="", email=""): + self.name = name + self.display_name = "" + self.stats = {} + if email: + self.email = email + self.password = self.email + self.password_confirm = self.email + else: + secret = os.getenv("JWT_SECRET") + encoded_id = jwt.encode({"rasa_id": rasa_id}, secret, algorithm="HS256") + self.email = f"{encoded_id}-rasa@mail.com" + self.password = f"{encoded_id}-rasa" + self.password_confirm = f"{encoded_id}-rasa" + + def serialize(self): + return json.dumps(self.__dict__) diff --git a/bot/config.yml b/bot/config.yml index 494c390..fccfde5 100644 --- a/bot/config.yml +++ b/bot/config.yml @@ -12,7 +12,9 @@ pipeline: epochs: 55 - name: EntitySynonymMapper - name: ResponseSelector - + - name: "DucklingHTTPExtractor" + url: "http://duck:8000" + dimensions: ["email"] policies: - name: TEDPolicy max_history: 10 diff --git a/bot/data/nlu.yml b/bot/data/nlu.yml index 88e8889..fa6c243 100644 --- a/bot/data/nlu.yml +++ b/bot/data/nlu.yml @@ -26,6 +26,9 @@ nlu: - correto - afirmativo - concordar + - uhum + - com certeza + - claro - intent: disagree examples: | @@ -35,6 +38,7 @@ nlu: - discordo - discordar - não concordo + - não quero - intent: pass examples: | @@ -44,6 +48,28 @@ nlu: - passa - próxima pergunta +- intent: email + examples: | + - meu email é nome@dominio.com + - email@nome.com + - email: esteemail@outroservidor.com + - meu email: email@email.com.br + +- intent: invalid_email + examples: | + - @wlor + - @algumacoisa. + - @nome. + - roberto@email + - nome@algo + - roberto@ + - maria@ + - @joao + - @joao. + - meu email é @nome + - email: @maria + - meu email: roberto@ + - intent: out_of_context examples: | - 4 + 2 = ? @@ -60,3 +86,4 @@ nlu: - tempo - stop - sair + diff --git a/bot/data/stories.yml b/bot/data/stories.yml index 56aa946..c75efa4 100644 --- a/bot/data/stories.yml +++ b/bot/data/stories.yml @@ -14,12 +14,51 @@ stories: - intent: disagree - action: utter_goodbye -- story: Activate vote form +- story: User wants to participate steps: - - intent: agree - - action: action_setup_conversation - - action: vote_form - - active_loop: vote_form - - active_loop: null - - slot_was_set: - - vote: null + - intent: start + - action: utter_start + - intent: agree + - action: utter_ask_email + +- story: Activate vote form with user email + steps: + - intent: agree + - action: utter_ask_email + - intent: email + entities: + - email + - action: action_setup_conversation + - action: vote_form + - active_loop: vote_form + - active_loop: null + - slot_was_set: + - vote: null + +- story: Activate vote form without user email + steps: + - intent: agree + - action: utter_ask_email + - intent: disagree + - action: action_setup_conversation + - action: vote_form + - active_loop: vote_form + - active_loop: null + - slot_was_set: + - vote: null + +- story: User provides invalid email + steps: + - intent: agree + - action: utter_ask_email + - intent: invalid_email + - action: utter_invalid_email + - intent: email + entities: + - email + - action: action_setup_conversation + - action: vote_form + - active_loop: vote_form + - active_loop: null + - slot_was_set: + - vote: null \ No newline at end of file diff --git a/bot/domain.yml b/bot/domain.yml index 5e9d4ad..391d202 100644 --- a/bot/domain.yml +++ b/bot/domain.yml @@ -13,9 +13,14 @@ intents: use_entities: true - start: use_entities: true +- email: + use_entities: true - out_of_context: use_entities: true -entities: [] +- invalid_email: + use_entities: true +entities: +- email slots: change_comment: type: rasa.shared.core.slots.BooleanSlot @@ -27,6 +32,15 @@ slots: initial_value: null auto_fill: true influence_conversation: false + ej_user_token: + type: rasa.shared.core.slots.TextSlot + initial_value: null + auto_fill: true + influence_conversation: false + email: + type: rasa.shared.core.slots.TextSlot + initial_value: null + auto_fill: true conversation_id: type: rasa.shared.core.slots.FloatSlot initial_value: null @@ -61,12 +75,16 @@ slots: auto_fill: true influence_conversation: false values: - - Concordar - - Discordar - - Pular + - concordar + - discordar + - pular responses: utter_start: - text: Olá, estou coletando opiniões, gostaria de participar? + utter_ask_email: + - text: Legal, você gostaria de se identificar pelo seu email? Se sim, já pode enviar que anoto aqui. + utter_invalid_email: + - text: Não entendi, esse email não parece válido, pode tentar enviar de novo? utter_goodbye: - text: Agradeço pela participação! Até logo utter_vote_received: diff --git a/bot/tests/test_actions.py b/bot/tests/test_actions.py index 717b3ef..66ca0ce 100644 --- a/bot/tests/test_actions.py +++ b/bot/tests/test_actions.py @@ -1,14 +1,12 @@ import pytest - +import unittest +from unittest.mock import Mock, patch from typing import Text, List, Any, Dict -from actions import ActionSetupConversation, ActionAskVote from rasa_sdk.executor import CollectingDispatcher from rasa_sdk.events import SlotSet, FollowupAction from rasa.shared.core.trackers import DialogueStateTracker, AnySlotDict - - -EMPTY_TRACKER = None +from actions import ActionSetupConversation, ActionAskVote @pytest.fixture @@ -21,24 +19,43 @@ def domain(): return dict() -def test_action_setup_conversation(dispatcher, domain): - tracker = EMPTY_TRACKER - action = ActionSetupConversation() - events = action.run(dispatcher, tracker, domain) - expected_events = [ - SlotSet("number_voted_comments", 1), - SlotSet("conversation_id", 1), - SlotSet("number_comments", 2), - SlotSet("comment_text", "Comment text here"), - SlotSet("current_comment_id", 53), - SlotSet("change_comment", False), - FollowupAction("vote_form"), - ] - assert events == expected_events - - -def test_action_ask_vote(dispatcher, domain): - tracker = DialogueStateTracker(sender_id="1", slots=AnySlotDict()) +@pytest.fixture +def tracker(): + return DialogueStateTracker(sender_id="1", slots=AnySlotDict()) + + +@patch("actions.ej_connector.api.requests.get") +@patch("actions.ej_connector.api.requests.post") +def test_action_setup_conversation(dispatcher, domain, tracker): + with patch("actions.ej_connector.api.requests.get") as mock_get: + with patch("actions.ej_connector.api.requests.post") as mock_post: + user_response = {"key": "key_value"} + mock_post.return_value = Mock(ok=True) + mock_post.return_value.json.return_value = user_response + + comment_response = { + "content": "This is the comment text", + "links": {"self": "http://localhost:8000/api/v1/comments/1/"}, + } + mock_get.return_value = Mock(ok=True) + mock_get.return_value.json.return_value = comment_response + + action = ActionSetupConversation() + events = action.run(dispatcher, tracker, domain) + expected_events = [ + SlotSet("number_voted_comments", 1), + SlotSet("conversation_id", 1), + SlotSet("number_comments", 2), + SlotSet("comment_text", comment_response["content"]), + SlotSet("current_comment_id", "1"), + SlotSet("change_comment", False), + SlotSet("ej_user_token", user_response["key"]), + FollowupAction("vote_form"), + ] + assert events == expected_events + + +def test_action_ask_vote(dispatcher, domain, tracker): action = ActionAskVote() events = action.run(dispatcher, tracker, domain) expected_events = [SlotSet("change_comment", True)] diff --git a/bot/tests/test_ej_connector.py b/bot/tests/test_ej_connector.py new file mode 100644 index 0000000..e88a98e --- /dev/null +++ b/bot/tests/test_ej_connector.py @@ -0,0 +1,111 @@ +import pytest +import unittest +from unittest.mock import Mock, patch +import json + +from actions.ej_connector import API, User +from actions.ej_connector.api import ( + conversation_url, + API_URL, + conversation_random_comment_url, + user_statistics_url, + user_comments_route, + user_pending_comments_route, +) + +CONVERSATION_ID = 1 +TOKEN = "mock_token_value" +EMAIL = "email@email.com" +SENDER_ID = "mock_rasa_sender_id" + + +class APIClassTest(unittest.TestCase): + """tests actions.ej_connector.api API class""" + + @patch("actions.ej_connector.api.requests.post") + def test_create_user_in_ej_with_rasa_id(self, mock_post): + response_value = {"key": "key_value"} + mock_post.return_value = Mock(ok=True) + mock_post.return_value.json.return_value = response_value + response = API.create_user(SENDER_ID) + assert response.token == response_value["key"] + + @patch("actions.ej_connector.api.requests.post") + def test_create_user_in_ej_with_email(self, mock_post): + response_value = {"key": "key_value"} + mock_post.return_value = Mock(ok=True) + mock_post.return_value.json.return_value = response_value + response = API.create_user(SENDER_ID, EMAIL, EMAIL) + assert response.token == response_value["key"] + + @patch("actions.ej_connector.api.requests.get") + def test_get_random_comment_in_ej(self, mock_get): + response_value = { + "content": "This is the comment text", + "links": {"self": "http://localhost:8000/api/v1/comments/1/"}, + } + mock_get.return_value = Mock(ok=True) + mock_get.return_value.json.return_value = response_value + response = API.get_next_comment(CONVERSATION_ID, TOKEN) + assert response["content"] == response_value["content"] + assert response["id"] == "1" + + +class UserClassTest(unittest.TestCase): + """tests actions.ej_connector.user file""" + + def test_user_init_with_rasa(self): + user = User(SENDER_ID) + assert "-rasa@mail.com" in user.email + assert "-rasa" in user.password + assert "-rasa" in user.password_confirm + assert user.stats == {} + assert user.name == "" + assert user.display_name == "" + + def test_user_init_with_mail(self): + user = User(SENDER_ID, email=EMAIL) + assert user.email == EMAIL + assert user.password == EMAIL + assert user.password_confirm == EMAIL + assert user.stats == {} + assert user.name == "" + assert user.display_name == "" + + def test_user_serializer_with_rasa(self): + user = User(SENDER_ID) + serialized_user = user.serialize() + assert type(serialized_user) == str + dict_user = json.loads(serialized_user) + assert "-rasa@mail.com" in dict_user["email"] + + def test_user_serializer_with_mail(self): + user = User(SENDER_ID, email=EMAIL) + serialized_user = user.serialize() + assert type(serialized_user) == str + dict_user = json.loads(serialized_user) + assert dict_user["email"] == EMAIL + + +class EjUrlsGenerationClassTest(unittest.TestCase): + """tests actions.ej_connector.api ej urls generation""" + + def test_conversation_url_generator(self): + url = conversation_url(CONVERSATION_ID) + assert url == f"{API_URL}/conversations/{CONVERSATION_ID}/" + + def test_conversation_random_comment_url_generator(self): + url = conversation_random_comment_url(CONVERSATION_ID) + assert url == f"{API_URL}/conversations/{CONVERSATION_ID}/random-comment/" + + def test_user_statistics_url_generator(self): + url = user_statistics_url(CONVERSATION_ID) + assert url == f"{API_URL}/conversations/{CONVERSATION_ID}/user-statistics/" + + def test_user_comments_route_generator(self): + url = user_comments_route(CONVERSATION_ID) + return f"{API_URL}/conversations/{CONVERSATION_ID}/user-comments/" + + def test_user_pending_comments_route_generator(self): + url = user_pending_comments_route(CONVERSATION_ID) + return f"{API_URL}/conversations/{CONVERSATION_ID}/user-pending-comments/" diff --git a/bot/tests/test_stories.yml b/bot/tests/test_stories.yml index 90238f5..6ba1620 100644 --- a/bot/tests/test_stories.yml +++ b/bot/tests/test_stories.yml @@ -9,61 +9,67 @@ stories: intent: start - action: utter_start -- story: User votes +- story: User utters out of context steps: + - user: | + chega + intent: out_of_context + - action: utter_out_of_context + +- story: User don't want to participate sad path + steps: + - user: | + olá + intent: start + - action: utter_start + - user: | + não + intent: disagree + - action: utter_goodbye + +- story: User agree to use email + steps: + - user: | + oi + intent: start + - action: utter_start - user: | sim intent: agree + - action: utter_ask_email + - user: | + meu email: [email@user.com](email) + intent: email + - slot_was_set: + - email: email@user.com - action: action_setup_conversation - - slot_was_set: - - number_voted_comments: 1 - - conversation_id: 1 - - number_comments: 2 - - comment_text: "Comment text here" - - comment_id: 53 - action: vote_form - active_loop: vote_form - - action: action_ask_vote - - user: | - agree - - action: validate_vote_form - - slot_was_set: - - vote: null - - action: action_ask_vote - - slot_was_set: - - number_voted_comments: 2 - - conversation_id: 1 - - number_comments: 2 - - comment_text: "novo comentário com outro id" - - comment_id: 22 - - user: | - agree - - action: validate_vote_form - - slot_was_set: - vote: "agree" -- story: User votes out of context, value of vote +- story: User want to participate without email steps: - user: | - sair - - action: validate_vote_form - - slot_was_set: - vote: null - -- story: User utters out of context - steps: + sim + intent: agree + - action: utter_ask_email - user: | - não entendi - intent: out_of_context - - action: utter_out_of_context + não + intent: disagree + - action: action_setup_conversation + - action: vote_form + - active_loop: vote_form -- story: User don't want to participate sad path +- story: User provide invalid email steps: - user: | olá intent: start - action: utter_start - user: | - não - intent: disagree - - action: utter_goodbye \ No newline at end of file + sim + intent: agree + - action: utter_ask_email + - user: | + @mario + intent: invalid_email + - action: utter_invalid_email \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c585b00..044ddc8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: # ================================= Bot ===================================== bot: + container_name: bot build: context: . dockerfile: ./docker/bot.Dockerfile @@ -42,7 +43,8 @@ services: volumes: - ./bot/actions:/bot/actions command: sh -c "make actions" - + env_file: + - env/auth.env # ================================= RASA X ================================== # Rasa X container x: @@ -61,6 +63,15 @@ services: - actions command: sh -c "make x" + # Duckling HTTP extractor for entities such as email, date, amounts... + duckling: + container_name: duck + image: "rasa/duckling" + networks: + - bot-network + ports: + - 6800:8000 + networks: bot-network: driver: bridge \ No newline at end of file diff --git a/docker/dependencies/requirements-actions.txt b/docker/dependencies/requirements-actions.txt index 61a1ac3..10896f6 100644 --- a/docker/dependencies/requirements-actions.txt +++ b/docker/dependencies/requirements-actions.txt @@ -1 +1,2 @@ -requests~=2.24.0 \ No newline at end of file +requests~=2.24.0 +pyjwt==2.0.0 \ No newline at end of file diff --git a/docker/dependencies/requirements-development.txt b/docker/dependencies/requirements-development.txt index 2a4644c..018115b 100644 --- a/docker/dependencies/requirements-development.txt +++ b/docker/dependencies/requirements-development.txt @@ -3,5 +3,4 @@ # lint/format/types black==19.10b0 flake8==3.7.9 -pytest==3.5.1 -pre-commit \ No newline at end of file +pytest==3.5.1 \ No newline at end of file diff --git a/env/auth.env b/env/auth.env new file mode 100644 index 0000000..525c420 --- /dev/null +++ b/env/auth.env @@ -0,0 +1,2 @@ +JWT_SECRET= +EJ_HOST= \ No newline at end of file