diff --git a/README.md b/README.md index 0fb9b65..cbe3d4b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +## Description + This repository contains examples of bots build using [DFF](https://github.com/deeppavlov/dialog_flow_framework) (Dialog Flow Framework). diff --git a/faq_bot/bot/faq.json b/faq_bot/bot/faq.json index 70e98e7..397dca9 100644 --- a/faq_bot/bot/faq.json +++ b/faq_bot/bot/faq.json @@ -1,6 +1,6 @@ { "What is Arch Linux?": "See the Arch Linux article.\n", - "Why would I not want to use Arch?": "You may not want to use Arch, if:\n\n you do not have the ability/time/desire for a 'do-it-yourself' GNU/Linux distribution.\n you require support for an architecture other than x86_64.\n you take a strong stance on using a distribution which only provides free software as defined by GNU.\n you believe an operating system should configure itself, run out of the box, and include a complete default set of software and desktop environment on the installation media.\n you do not want a rolling release GNU/Linux distribution.\n you are happy with your current OS.", + "Why would I not want to use Arch?": "You may not want to use Arch, if:\n1) you do not have the ability/time/desire for a 'do-it-yourself' GNU/Linux distribution.\n2) you require support for an architecture other than x86_64.\n3) you take a strong stance on using a distribution which only provides free software as defined by GNU.\n4) you believe an operating system should configure itself, run out of the box, and include a complete default set of software and desktop environment on the installation media.\n5) you do not want a rolling release GNU/Linux distribution.\n6) you are happy with your current OS.", "Why would I want to use Arch?": "Because Arch is the best.\n", "What architectures does Arch support?": "Arch only supports the x86_64 (sometimes called amd64) architecture. Support for i686 was dropped in November 2017 [1]. \nThere are unofficial ports for the i686 architecture [2] and ARM CPUs [3], each with their own community channels.\n" } \ No newline at end of file diff --git a/faq_bot_rus/.env b/faq_bot_rus/.env new file mode 100644 index 0000000..016ccd9 --- /dev/null +++ b/faq_bot_rus/.env @@ -0,0 +1 @@ +TG_BOT_TOKEN= \ No newline at end of file diff --git a/faq_bot_rus/Dockerfile b/faq_bot_rus/Dockerfile new file mode 100644 index 0000000..648c9fb --- /dev/null +++ b/faq_bot_rus/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.10-slim-buster as base + +WORKDIR /app + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt + +# cache mfaq model +RUN ["python3", "-c", "from sentence_transformers import SentenceTransformer; _ = SentenceTransformer('clips/mfaq')"] + +COPY bot/ ./bot + +FROM base as test +COPY test.py ./ +RUN ["pytest", "test.py"] + +FROM base as prod +COPY run.py ./ +CMD ["python3", "run.py"] diff --git a/faq_bot_rus/README.md b/faq_bot_rus/README.md new file mode 100644 index 0000000..60908b6 --- /dev/null +++ b/faq_bot_rus/README.md @@ -0,0 +1,35 @@ +## Описание + +Представлен пример FAQ-бота, построенного на DFF, использующий в качестве интерфейса Telegram. +Бот получает вопросы от пользователя и находит похожие вопросы в своей базе данных, используя модель `clips/mfaq`. +Найденные вопросы отображаются в виде кнопок. При нажатии на кнопку, бот отправляет ответ на вопрос из базы данных. + +Для работы бота установите его токен через [.env](.env). + +Пример использования: + +![image](https://user-images.githubusercontent.com/61429541/219064505-20e67950-cb88-4cff-afa5-7ce608e1282c.png) + +## Тестирование бота + +Для тестирования бота, запустите следующую команду: +```commandline +docker build -t telebot-test --target test . +``` + +## Запуск бота + +### Через docker-compose + +Чтобы запустить бота через docker-compose, выполните следующую команду: +```commandline +docker-compose up +``` + +### Через docker + +Чтобы запустить бота через docker, выполните следующую команду: +```commandline +docker build -t telebot-prod --target prod . +docker run --env-file .env telebot-prod +``` diff --git a/faq_bot_rus/bot/__init__.py b/faq_bot_rus/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/faq_bot_rus/bot/bot.py b/faq_bot_rus/bot/bot.py new file mode 100644 index 0000000..4418d16 --- /dev/null +++ b/faq_bot_rus/bot/bot.py @@ -0,0 +1,44 @@ +""" +Bot +--- +This module defines objects needed to run the bot. +""" +import os + +from dff.messengers.telegram import PollingTelegramInterface, TelegramMessenger +from dff.pipeline import Pipeline +from dff.script.core.context import Context + +from .script.script import script +from .model import find_similar_questions + + +def question_processor(ctx: Context): + """Store questions similar to user's query in the `annotations` field of a message.""" + last_request = ctx.last_request + if last_request is None: + return + else: + if last_request.annotations is None: + last_request.annotations = {} + else: + if last_request.annotations.get("similar_questions") is not None: + return + if last_request.text is None: + last_request.annotations["similar_questions"] = None + else: + last_request.annotations["similar_questions"] = find_similar_questions(last_request.text) + + ctx.set_last_request(last_request) + + +interface = PollingTelegramInterface(token=os.getenv("TG_BOT_TOKEN", "")) + + +pipeline = Pipeline.from_script( + script=script, + start_label=("proxy_flow", "start_node"), + fallback_label=("proxy_flow", "fallback_node"), + messenger_interface=interface, + pre_services=[question_processor], # pre-services run before bot sends a response +) diff --git a/faq_bot_rus/bot/faq.json b/faq_bot_rus/bot/faq.json new file mode 100644 index 0000000..73d2f43 --- /dev/null +++ b/faq_bot_rus/bot/faq.json @@ -0,0 +1,6 @@ +{ + "Что такое Arch Linux?": "Смотрите статью Arch Linux.\n", + "Когда не надо использовать Arch?": "Возможно, вы не захотите использовать Arch, если:\n1) у вас нет возможности, времени или желания использовать дистрибутив GNU/Linux самостоятельно.\n2) вам требуется поддержка архитектуры, отличной от x86_64.\n3) вы занимаете решительную позицию в отношении использования дистрибутива, который предоставляет только свободное программное обеспечение, определенное лицензией GNU.\n4) вы считаете, что операционная система должна настраиваться сама, запускаться из коробки и включать полный набор программного обеспечения и окружения рабочего стола по умолчанию.\n5) вам не нужен дистрибутив GNU/Linux.\n6) вы довольны своей текущей операционной системой.", + "Почему мне стоит использовать Arch?": "Потому что Arch is the best.\n", + "Какие архитектуры поддерживает Arch?": "Arch поддерживает только x86_64 (иногда называемая amd64) архитектуру. Поддержка i686 была прекращена в ноябре 2017 года [1]. Есть неофициальные порты для i686 архитектуры [2] и ARM процессоры [3], каждый из которых имеет свои собственные сообщества.\n" +} \ No newline at end of file diff --git a/faq_bot_rus/bot/model.py b/faq_bot_rus/bot/model.py new file mode 100644 index 0000000..f148de3 --- /dev/null +++ b/faq_bot_rus/bot/model.py @@ -0,0 +1,31 @@ +""" +Model +----- +This module defines AI-dependent functions. +""" +import json +from pathlib import Path + +import numpy +from sentence_transformers import SentenceTransformer + +model = SentenceTransformer("clips/mfaq") + +with open(Path(__file__).parent / "faq.json", "r", encoding="utf-8") as file: + faq = json.load(file) + + +def find_similar_questions(question: str): + """Return a list of similar questions from the database.""" + questions = list(map(lambda x: "" + x, faq.keys())) + q_emb, *faq_emb = model.encode(["" + question] + questions) + + emb_with_scores = tuple(zip(questions, map(lambda x: numpy.linalg.norm(x - q_emb), faq_emb))) + + filtered_embeddings = tuple(sorted(filter(lambda x: x[1] < 10, emb_with_scores), key=lambda x: x[1])) + + result = [] + for question, score in filtered_embeddings: + question = question.removeprefix("") + result.append(question) + return result diff --git a/faq_bot_rus/bot/script/__init__.py b/faq_bot_rus/bot/script/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/faq_bot_rus/bot/script/conditions.py b/faq_bot_rus/bot/script/conditions.py new file mode 100644 index 0000000..b34d796 --- /dev/null +++ b/faq_bot_rus/bot/script/conditions.py @@ -0,0 +1,25 @@ +""" +Conditions +----------- +This module defines conditions for transitions between nodes. +""" +from typing import cast + +from dff.script import Context, Actor +from dff.messengers.telegram import TelegramMessage + + +def received_text(ctx: Context, _: Actor): + """Return true if the last update from user contains text.""" + last_request = ctx.last_request + + return last_request.text is not None + + +def received_button_click(ctx: Context, _: Actor): + """Return true if the last update from user is a button press.""" + if ctx.validation: # Regular `Message` doesn't have `callback_query` field, so this fails during validation + return False + last_request = cast(TelegramMessage, ctx.last_request) + + return last_request.callback_query is not None diff --git a/faq_bot_rus/bot/script/responses.py b/faq_bot_rus/bot/script/responses.py new file mode 100644 index 0000000..7d87495 --- /dev/null +++ b/faq_bot_rus/bot/script/responses.py @@ -0,0 +1,50 @@ +""" +Responses +--------- +This module defines different responses the bot gives. +""" +from typing import cast + +from dff.script import Context, Actor +from dff.script.core.message import Button +from dff.messengers.telegram import TelegramMessage, TelegramUI, ParseMode +from ..model import faq + + +def suggest_similar_questions(ctx: Context, _: Actor): + """Suggest questions similar to user's query by showing buttons with those questions.""" + if ctx.validation: # this function requires non-empty fields and cannot be used during script validation + return TelegramMessage() + last_request = ctx.last_request + if last_request is None: + raise RuntimeError("No last requests.") + if last_request.annotations is None: + raise RuntimeError("No annotations.") + similar_questions = last_request.annotations.get("similar_questions") + if similar_questions is None: + raise RuntimeError("Last request has no text.") + + if len(similar_questions) == 0: # question is not similar to any questions + return TelegramMessage( + text="У меня нет ответа на этот вопрос. Вот список вопросов, на которые я могу дать ответ:", + ui=TelegramUI(buttons=[Button(text=q, payload=q) for q in faq]), + ) + else: + return TelegramMessage( + text="Я нашел похожие вопросы в своей базе данных:", + ui=TelegramUI(buttons=[Button(text=q, payload=q) for q in similar_questions]), + ) + + +def answer_question(ctx: Context, _: Actor): + """Answer a question asked by a user by pressing a button.""" + if ctx.validation: # this function requires non-empty fields and cannot be used during script validation + return TelegramMessage() + last_request = ctx.last_request + if last_request is None: + raise RuntimeError("No last requests.") + last_request = cast(TelegramMessage, last_request) + if last_request.callback_query is None: + raise RuntimeError("No callback query") + + return TelegramMessage(text=faq[last_request.callback_query], parse_mode=ParseMode.HTML) diff --git a/faq_bot_rus/bot/script/script.py b/faq_bot_rus/bot/script/script.py new file mode 100644 index 0000000..47c4ffd --- /dev/null +++ b/faq_bot_rus/bot/script/script.py @@ -0,0 +1,44 @@ +""" +Script +------ +This module defines a script that the bot follows during conversation. +""" +from dff.script import RESPONSE, TRANSITIONS, LOCAL +import dff.script.conditions as cnd +from dff.messengers.telegram import TelegramMessage + +from .responses import answer_question, suggest_similar_questions +from .conditions import received_button_click, received_text + +qa_transitions = { + ("qa_flow", "suggest_questions"): received_text, + ("qa_flow", "answer_question"): received_button_click, +} + +script = { + "proxy_flow": { + "start_node": { + RESPONSE: TelegramMessage(), + TRANSITIONS: {"welcome_node": cnd.exact_match(TelegramMessage(text="/start"))}, + }, + "welcome_node": { + RESPONSE: TelegramMessage(text="Добро пожаловать! Задайте мне вопросы об Arch Linux."), + TRANSITIONS: qa_transitions, + }, + "fallback_node": { + RESPONSE: TelegramMessage(text="Что-то пошло не так. Используйте `/restart`, чтобы начать сначала."), + TRANSITIONS: {"welcome_node": cnd.exact_match(TelegramMessage(text="/restart"))}, + }, + }, + "qa_flow": { + LOCAL: { + TRANSITIONS: qa_transitions, + }, + "suggest_questions": { + RESPONSE: suggest_similar_questions, + }, + "answer_question": { + RESPONSE: answer_question, + }, + }, +} diff --git a/faq_bot_rus/docker-compose.yml b/faq_bot_rus/docker-compose.yml new file mode 100644 index 0000000..50c1a07 --- /dev/null +++ b/faq_bot_rus/docker-compose.yml @@ -0,0 +1,6 @@ +services: + telegram-bot: + build: + context: . + target: prod + env_file: .env \ No newline at end of file diff --git a/faq_bot_rus/requirements.txt b/faq_bot_rus/requirements.txt new file mode 100644 index 0000000..d909130 --- /dev/null +++ b/faq_bot_rus/requirements.txt @@ -0,0 +1,2 @@ +dff[telegram, tests]>=0.3.1 +sentence_transformers==2.2.2 \ No newline at end of file diff --git a/faq_bot_rus/run.py b/faq_bot_rus/run.py new file mode 100644 index 0000000..f850f3e --- /dev/null +++ b/faq_bot_rus/run.py @@ -0,0 +1,7 @@ +from bot.bot import pipeline + +if __name__ == "__main__": + if pipeline.messenger_interface.messenger.token == "": + raise RuntimeError("Token is not set.") + + pipeline.run() diff --git a/faq_bot_rus/test.py b/faq_bot_rus/test.py new file mode 100644 index 0000000..21b3685 --- /dev/null +++ b/faq_bot_rus/test.py @@ -0,0 +1,61 @@ +import pytest +from dff.utils.testing.common import check_happy_path +from dff.messengers.telegram import TelegramMessage, TelegramUI +from dff.script import RESPONSE +from dff.script.core.message import Button + +from bot.script.script import script +from bot.bot import pipeline +from bot.model import faq + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "happy_path", + [ + ( + (TelegramMessage(text="/start"), script["proxy_flow"]["welcome_node"][RESPONSE]), + ( + TelegramMessage(text="Почему мне стоит использовать Arch?"), + TelegramMessage( + text="Я нашел похожие вопросы в своей базе данных:", + ui=TelegramUI( + buttons=[ + Button(text=q, payload=q) + for q in ["Почему мне стоит использовать Arch?", "Почему мне стоит использовать Arch?"] + ] + ), + ), + ), + ( + TelegramMessage(callback_query="Почему мне стоит использовать Arch?"), + TelegramMessage(text=faq["Почему мне стоит использовать Arch?"]), + ), + ( + TelegramMessage(callback_query="Когда не надо использовать Arch?"), + TelegramMessage(text=faq["Когда не надо использовать Arch?"]), + ), + ( + TelegramMessage(text="Что такое Arch Linux?"), + TelegramMessage( + text="Я нашел похожие вопросы в своей базе данных:", + ui=TelegramUI(buttons=[Button(text=q, payload=q) for q in ["Что такое Arch Linux?"]]), + ), + ), + (TelegramMessage(callback_query="Что такое Arch Linux?"), TelegramMessage(text=faq["Что такое Arch Linux?"])), + ( + TelegramMessage(text="Где я?"), + TelegramMessage( + text="У меня нет ответа на этот вопрос. Вот список вопросов, на которые я могу дать ответ:", + ui=TelegramUI(buttons=[Button(text=q, payload=q) for q in faq]), + ), + ), + ( + TelegramMessage(callback_query="Какие архитектуры поддерживает Arch?"), + TelegramMessage(text=faq["Какие архитектуры поддерживает Arch?"]), + ), + ) + ], +) +async def test_happy_path(happy_path): + check_happy_path(pipeline=pipeline, happy_path=happy_path)