Калина Алексей блог программиста

Телеграм бот. Часть 1. Neo4J

Компания наша старается идти в ногу со временем, и поэтому вслед за тонким ангулярным клиентом возникла задача создания телеграм бота для нашей системы. В подробности его функционала вдаваться не буду, главное то, что поддержка уже написанного практиканткой пилота досталась мне, чему я в общем то рад - тема модная, молодежная :)

Телеграм бот

Чтобы лучше вникнуть в технологию, я решил написать свой простенький бот, решающий какую-то жизненную задачу. За такой задачей я обратился к своей девушке. Она к вопросу подошла обстоятельно; сразу вспомнила, чего ей не хватает в этой жизни для полного счастья… В общем, остановились на следующей задачке: бот должен принимать последовательность ингредиентов и возвращать рецепт, в котором они содержатся. При этом важное условие, что рецепты должны возвращаться в таком порядке, что других ингредиентов помимо введенных должно быть как можно меньше. Соответственно должна быть кнопка для следующего рецепта и для вывода всех ингредиентов.

По счастливой случайности у меня нашелся датасет с рецептами и ингредиентами) На самом деле, если бы это было не так, не думаю, что согласился бы на эту задачу (все-таки телеграм бот изучаю, а не big data). Однако, такой набор данных у меня был благодаря книге Основы Data Science и Big Data, где в одном из учебных примеров он был использован для описания графовой базы данных Neo4J. Именно ее я решил использовать как хранилище данных в моем небольшом боте, и в первой части я остановлюсь на ее описании. Подробности же написания самого бота будут в следующей части. Сразу скажу, что для работы с api использовалась C# библиотека TelegramBotCore, а ссылка на гитовый репозиторий находится в конце поста.

Neo4J

Что же такое графовая база данных? Думаю все знакомы с модным нынче направление NOSQL (не только сиквел). Так вот графовые базы - это одна из разновидностей таких БД. В основе таких баз данных лежат, как ни странно, узлы и связи. Они очень подходят для представления сильно связанных данных, где много отношений многие ко многим.

Neo4J является лидером среди таких БД. Чтобы развернуть ее у себя я воспользовался Docker (думаю про него будет много постов). Для этого введем в консоль следующую команду:

docker run \
    --publish=7474:7474 --publish=7687:7687 \
    --volume=$HOME/neo4j/data:/data \
    --volume=$HOME/neo4j/logs:/logs \
    neo4j:3.0

Теперь можно перейти на localhost:7474, ввести дефолтные логин и пароль (neo4j neo4j) и наслаждаться красотой интерфейса :) Этап проливки данных я пропущу. Сразу покажу результат простейшего запроса в графовом представлении. Neo4j query result Зеленые кружочки - это рецепты, синие - ингредиенты. Все связи представляют собой ребра с меткой Содержит. Что же представляет из себя поисковый запрос в такой модели данных? Для таких нужд используется специальный язык запросов Cypher. Чтобы получить картинку выше, был выполнен следующий запрос:

MATCH ()-[r]->() RETURN r LIMIT 25

Синтаксис достаточно очевидный: в круглых скобочках - узлы, в квадратных - связи. Внутри скобок можно ставить переменные, которые использовать далее в запросе. Ключевое слово LIMIT огранчивает количество выведенных результатов (если забудете его поставить, будьте готовы, что у Вас все повиснет при большом размере выдачи).

Cypher

Теперь перейдем к нашей задаче и начнем писать первые запросы. Для начала найдем все рецепты, которые содержат переданные нами ингредиенты. Пусть для примера это будут Lemon, 7-Up и Ice (их мы можем увидеть на изображении выше). Запрос будет выглядеть следующим образом (представлена только часть результатов):

MATCH (recipe:Recipe)-[contains:Contains]->(ingredient:Ingredient)
WHERE ingredient.Name in ["Lemon", "7-Up", "Ice"]
RETURN recipe.Name
LIMIT 100

Neo4j query result

В результате выполнения мы получили все названия рецептов, в которых содержится хотя бы один из имеющихся у нас ингредиентов. Однако нам нужно выводить их в правильном порядке и кроме того все ингредиенты, которые они содержат. В данном запросе помимо названия рецепта, выведем все его составляющие:

MATCH (recipe:Recipe)-[contains:Contains]->(ingredientExists:Ingredient)
WHERE ingredientExists.Name in ["Lemon", "7-Up", "Ice"]
MATCH (recipe:Recipe)-[contains2:Contains]->(ingredient:Ingredient)
RETURN recipe.Name, collect(ingredient.Name) AS ingredientsList
LIMIT 100

Neo4j query result

Сначала мы нашли все рецепты, содержащие необходимые ингредиенты, а потом для этих рецептов сделали поиск всех их составляющих (здесь важно следить за правильным наименованием переменных: рецепты в обоих запросах одни и те же, однако связи и ингредиенты будут новыми). При выводе результатов использовалось ключевое слово collect для создания списка из набора элементов. Наконец напишем запрос, в результате которого будут получены рецепты вместе с количеством имеющихся одновременно и у нас и в рецепте продуктов, а также всех необходимых для приготовления:

WHERE ingredientExists.Name in ["Lemon", "7-Up", "Ice"]
MATCH (recipe:Recipe)-[contains:Contains]->(ingredientExists:Ingredient)
WITH recipe, count(ingredientExists) AS countExists
MATCH (recipe:Recipe)-[contains2:Contains]->(ingredient:Ingredient)
RETURN recipe.Name, countExists, count(contains2) AS allCount, collect(ingredient.Name) AS ingredientsList
LIMIT 100

Neo4j query result

Ключевое слово WITH необходимо для возможности обращения к переменным из предыдущего запроса. Чтобы вывести количество элементов так же как и в традиционном сиквеле используется функция count(). Cypher поддерживает множество не только аггрегационных функций, среди них и различные операции над строками, числами, списками. Все они подробно описаны в документации на официальном сайте. Тем временем единственное что нам осталось, это вывести результаты в порядке возрастания разницы между нужными и имеющимися ингредиентами. При этом в приоритете будут те блюда, в которых есть больше всего наших продуктов. Кроме того, добавим немножко регулярок) Благодаря функциям над списками и строками, можно включить в результаты также те рецепты которые начинаются с тех же символов, что ввели мы, но не обязательно совпадают. Такая потребность возникает, например при вводе в бот строки Potato, хотя в базе хранится Potatoes. Да, такое решение далеко от идеального (у нас есть яблоки, а будет предложен рецепт с яблочным сидром), но это ведь всего лишь учебный пример :)

MATCH (recipe:Recipe)-[contains:Contains]->(ingredientExists:Ingredient)
WHERE any(name in ["Lemon", "7-Up", "Ice"] WHERE ingredientExists.Name =~ name)
MATCH (recipe:Recipe)-[contains:Contains]->(ingredientExists:Ingredient)
WITH recipe, count(ingredientExists) AS countExists
MATCH (recipe:Recipe)-[contains2:Contains]->(ingredient:Ingredient)
RETURN recipe.Name, countExists, count(contains2) AS allCount, collect(ingredient.Name) AS ingredientsList
ORDER BY countExists DESC, allCount - countExists 
LIMIT 100

Neo4j query result

Именно так выглядит окончательный вариант запроса. Как можно увидеть по тем немногим результатам, скрины которых я обозначил, качество датасета оставляет желать лучшего (чтоб Вы понимали: количество блюд, состоящих только из льда и лимона, равно 18!!!). Поэтому и телеграм бот имеет право на жизнь только в рамках изучения технологий. В следующей части займемся непосредственно написанием бота. Что же касается Neo4J, это классная графовая БД, использование которой оставляет только приятные впечатления.

Официальный сайт

Телеграм Бот