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).
+
+Пример использования:
+
+
+
+## Тестирование бота
+
+Для тестирования бота, запустите следующую команду:
+```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)