Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
## Description

This repository contains examples of bots build using [DFF](https://github.com/deeppavlov/dialog_flow_framework)
(Dialog Flow Framework).

Expand Down
2 changes: 1 addition & 1 deletion faq_bot/bot/faq.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"What is Arch Linux?": "See the <a href=\"https://wiki.archlinux.org/title/Arch_Linux\" title=\"Arch Linux\">Arch Linux</a> 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 <a href=\"https://wiki.archlinux.org/title/Arch_is_the_best\" title=\"Arch is the best\">Arch is the best</a>.\n",
"What architectures does Arch support?": "Arch only supports the <a href=\"https://en.wikipedia.org/wiki/x86_64\" class=\"extiw\" title=\"wikipedia:x86 64\">x86_64</a> (sometimes called amd64) architecture. Support for i686 was dropped in November 2017 <a rel=\"nofollow\" class=\"external autonumber\" href=\"https://archlinux.org/news/the-end-of-i686-support/\">[1]</a>. \nThere are <i>unofficial</i> ports for the i686 architecture <a rel=\"nofollow\" class=\"external autonumber\" href=\"https://archlinux32.org/\">[2]</a> and <a href=\"https://en.wikipedia.org/wiki/ARM_architecture\" class=\"extiw\" title=\"wikipedia:ARM architecture\">ARM</a> CPUs <a rel=\"nofollow\" class=\"external autonumber\" href=\"https://archlinuxarm.org/\">[3]</a>, each with their own community channels.\n"
}
1 change: 1 addition & 0 deletions faq_bot_rus/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TG_BOT_TOKEN=
21 changes: 21 additions & 0 deletions faq_bot_rus/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
35 changes: 35 additions & 0 deletions faq_bot_rus/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Empty file added faq_bot_rus/bot/__init__.py
Empty file.
44 changes: 44 additions & 0 deletions faq_bot_rus/bot/bot.py
Original file line number Diff line number Diff line change
@@ -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
)
6 changes: 6 additions & 0 deletions faq_bot_rus/bot/faq.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"Что такое Arch Linux?": "Смотрите статью <a href=\"https://wiki.archlinux.org/title/Arch_Linux\" title=\"Arch Linux\">Arch Linux</a>.\n",
"Когда не надо использовать Arch?": "Возможно, вы не захотите использовать Arch, если:\n1) у вас нет возможности, времени или желания использовать дистрибутив GNU/Linux самостоятельно.\n2) вам требуется поддержка архитектуры, отличной от x86_64.\n3) вы занимаете решительную позицию в отношении использования дистрибутива, который предоставляет только свободное программное обеспечение, определенное лицензией GNU.\n4) вы считаете, что операционная система должна настраиваться сама, запускаться из коробки и включать полный набор программного обеспечения и окружения рабочего стола по умолчанию.\n5) вам не нужен дистрибутив GNU/Linux.\n6) вы довольны своей текущей операционной системой.",
"Почему мне стоит использовать Arch?": "Потому что <a href=\"https://wiki.archlinux.org/title/Arch_is_the_best\" title=\"Arch is the best\">Arch is the best</a>.\n",
"Какие архитектуры поддерживает Arch?": "Arch поддерживает только <a href=\"https://en.wikipedia.org/wiki/x86_64\" class=\"extiw\" title=\"wikipedia:x86 64\">x86_64</a> (иногда называемая amd64) архитектуру. Поддержка i686 была прекращена в ноябре 2017 года <a rel=\"nofollow\" class=\"external autonumber\" href=\"https://archlinux.org/news/the-end-of-i686-support/\">[1]</a>. Есть <i>неофициальные</i> порты для i686 архитектуры <a rel=\"nofollow\" class=\"external autonumber\" href=\"https://archlinux32.org/\">[2]</a> и <a href=\"https://en.wikipedia.org/wiki/ARM_architecture\" class=\"extiw\" title=\"wikipedia:ARM architecture\">ARM</a> процессоры <a rel=\"nofollow\" class=\"external autonumber\" href=\"https://archlinuxarm.org/\">[3]</a>, каждый из которых имеет свои собственные сообщества.\n"
}
31 changes: 31 additions & 0 deletions faq_bot_rus/bot/model.py
Original file line number Diff line number Diff line change
@@ -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: "<Q>" + x, faq.keys()))
q_emb, *faq_emb = model.encode(["<Q>" + 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("<Q>")
result.append(question)
return result
Empty file.
25 changes: 25 additions & 0 deletions faq_bot_rus/bot/script/conditions.py
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions faq_bot_rus/bot/script/responses.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions faq_bot_rus/bot/script/script.py
Original file line number Diff line number Diff line change
@@ -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,
},
},
}
6 changes: 6 additions & 0 deletions faq_bot_rus/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
services:
telegram-bot:
build:
context: .
target: prod
env_file: .env
2 changes: 2 additions & 0 deletions faq_bot_rus/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dff[telegram, tests]>=0.3.1
sentence_transformers==2.2.2
7 changes: 7 additions & 0 deletions faq_bot_rus/run.py
Original file line number Diff line number Diff line change
@@ -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()
61 changes: 61 additions & 0 deletions faq_bot_rus/test.py
Original file line number Diff line number Diff line change
@@ -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)