Из цикла ETL: Создаем заманивающего на курсы бота с aiogram, часть 2

Аватарка пользователя Елена Капаца

Разобрали Telegram-бота на aiogram по косточкам, показали, как наладить экспорт пользовательских данных для непрограммирующих коллег в Google Sheets, а также как построить кастомный Radar Chart с Plotly.

Обложка поста Из цикла ETL: Создаем заманивающего на курсы бота с aiogram, часть 2

Это продолжение статьи о боте, завлекающем на курсы по управлению проектами. В первой части мы разобрались со стандартными файлами проекта, управляющими запуском и кодом, отвечающим за вопросы. И научились уводить диалог в определённую ветку и записывать пользовательские данные в SQLIte.

Это статья из цикла «5 ETL для зоопарка ботов». В нём я пошагово разбираю, как наладить потоки данных из разных библиотек и конструкторов ботов на разных языках и стеках. В основе лежат Python и его библиотеки. Вот предыдущие статьи цикла:

Завершение опроса

Когда бот получает ответы на все вопросы, в рамках последней функции необходимо выполнить некоторые действия:

  • закрыть машину состояний:
			await state.finish()
		
  • выслать итоговый график:
			with open(f"{os.environ['SYSTEM_PATH']}{result_lst[0]}.jpeg", "rb") as img:
        await message.answer_photo(img)
		
  • прогнать ответы через формулу и определить уровень:
			await message.answer(interprete_score(result_lst))
		

Полный код функции прикладывать не буду, чтобы не перегружать текст. Форкнуть бота можно с помощью этого репозитория.

Сперва импортируем инструменты. Для создания тонко настроенных графиков отлично подходит библиотека Plotly. Далее я покажу, какие визуальные тонкости она позволяет кастомизировать:

			import gspread
import pandas as pd
import numpy as np
import plotly.express as px
import os
from texts import texts
		

Передадим ключи проекта Google Cloud, к которому привязана таблица. Я намеренно выбрала именно этот способ хранить пользовательские данные, чтобы облегчить другим участникам проекта просмотр. Сюда попадают только завершившие опрос пользователи, «бросанты» хранятся в SQLite и отдаются нашему отделу маркетинга по запросу.

			credentials = {
    "type": "service_account",
    "project_id": "<project_id>",
    "private_key_id": "<private_key>",
    "private_key": "-----BEGIN PRIVATE KEY-----\n<KEY>\n-----END PRIVATE KEY-----\n",
    "client_email": "<email>",
    "client_id": "<id>",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/986281389469-compute%40developer.gserviceaccount.com"
}

# Подгрузим таблицу с опрошенными
gc = gspread.service_account_from_dict(credentials)
sheet = gc.open_by_url('https://docs.google.com/spreadsheets/d/<Table ID>').sheet1
data = sheet.get_all_values()
# Соберем заголовки с первой строки
headers = data.pop(0)
df = pd.DataFrame(data, columns=headers)
		

Помню, по первой разобраться в многоэтажной документации Google было непросто, особенно в части авторизации. Для таких вещей, как взаимодействие с таблицами, подходит тип кредов для служебной учетной записи. С инструкцией по их получению ознакомьтесь по ссылке.

Таблица выглядит так:

Из цикла ETL: Создаем заманивающего на курсы бота с aiogram, часть 2 1

Строим паутинку

Теперь давайте создадим функции, строящую индивидуальный график-паутинку. Тут Plotly потребовал преобразований, чтобы появилась возможность ранжировать очки диапазонам (падаван/рыцарь-джедай/мастер-джедай). Я закладываю специальный столбец «Аспект», чтобы Plotly мог раскидать очки по уровням паутинки:

			def create_chart(result_lst):
  # Преобразуем элементы списка в целочисленные
  lst = result_lst[1:]
  score = [eval(i) for i in lst]

  # Опишем формулу расчета итогового количества очков  col = [score[0] * 3, score[2] * 3, score[4] * 3, score[6] * 3, score[8] * 3, score[10] * 3, score[1] * 2 + score[12], score[3] * 2 + score[13], score[5] * 2 + score[14], score[7] * 2 + score[15], score[9] * 2 + score[16], score[11] * 2 + score[17]]

  # Превратим список очков в датафрейм, чтобы корректно отобразить паутинку
  df = pd.DataFrame({'category': ['Команда', 'Заинт. стороны', 'Подход и поставка', 'Планирование', 'Работа и измерение', 'Риски', 
                                  'Команда', 'Заинт. стороны', 'Подход и поставка', 'Планирование', 'Работа и измерение', 'Риски'],
                      'Аспект': [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]})

  df['score'] = col

  # Присвоим значениям интервалов категории
  conditions = [
      (df['score'] == 0),
      (df['score'] >= 1) & (df['score'] <= 6),
      (df['score'] >= 7) & (df['score'] <= 12),
      (df['score'] >= 13) & (df['score'] <= 20)
  ]

  # Перечислим категории
  tiers = ['Новичок', 'Падаван', 'Рыцарь-Джедай', 'Мастер-Джедай']
  df['mark'] = np.select(conditions, tiers)
  df2 = df.assign(mark=pd.Categorical(df["mark"], ordered=False, categories=tiers))

  # Построим паутинку  fig = px.line_polar(df2, r="score", theta="category", color="Аспект", line_close=True, color_discrete_sequence=['#3b5998', '#52a9f9'],   template="plotly_dark",      # Зашьем в заголовок имя, уровень и число очков      title=f"{result_lst[0]}, очков: {sum(col[:11])}")

  # Добавим заливку
  fig.update_traces(fill='toself')

  # Добавим подписи на шкале
  fig.update_layout(polar={"radialaxis":{"tickmode":"array","tickvals":[0, 7, 12, 20],"ticktext":tiers}})

  # Настроим легенду
  fig.update_layout(legend=dict(
    yanchor="top",
    y=0.99,
    xanchor="right",
    x=0.01
  ))

  # Настроим кастомную легенду
  fig.data[0].name="Умения"
  fig.data[1].name="Проблемы"


  fig.write_image(f"{os.environ['SYSTEM_PATH']}{result_lst[0]}.jpeg")
  return
		

В результате получим такую паутинку:

Из цикла ETL: Создаем заманивающего на курсы бота с aiogram, часть 2 2

Из нестандартных визуальных элементов для Radar Charts выделю отображение джедайских уровней на шкале X, создание двух окрашенных компонентов из одного датафрейма, а также тёмную тему. Код готов к запуску на момент создания статьи, так что используйте, если вам понравился дизайн.

Определяем рекомендации

Чтобы в заголовок закладывался определённый уровень джедая, я реализовала функцию, рассчитывающую итоговый счёт по той же формуле:

			def interprete_score(result_lst):
    # Преобразуем элементы списка в целочисленные
    lst = result_lst[1:]
    score = [eval(i) for i in lst]

    scores = [score[0] * 3, score[2] * 3, score[4] * 3, score[6] * 3, score[8] * 3, score[10] * 3, score[1] * 2 + score[12], score[3] * 2 + score[13], score[5] * 2 + score[14], score[7] * 2 + score[15], score[9] * 2 + score[16], score[11] * 2 + score[17]]
    
    skills_lst = scores[:5]

    # Определим минимальное умение
    skills_min = min(skills_lst)

    # Определим среднее значение по умениям
    def find_mean(lst):
        return sum(lst) / len(lst)
 
    skills_mean = find_mean(skills_lst)
    mean_min_diff = skills_mean - skills_min

    # Создадим текст итоговой рекомендации 
    interpretation = []
    # Добавляем вывод в зависимости от разности между навыками и проблемами
    if mean_min_diff < 5:
      interpretation.append('У вас ровные компетенции\.')
    elif 5 <= mean_min_diff <= 10:
      interpretation.append('Из графика явно видно, что ваши компетенции развиты в разной степени\.')
    else:
      interpretation.append('Из графика явно видно, что ваши компетенции в разных сферах существенно различаются\. Постарайтесь обращать больше внимания на те сферы, которые у вас выпадают \- без этого не получится стать профессиональным РП\.')

    # Вычисляем разность между проблемами и навыками
    team_diff = scores[0] - scores[1] 
    stakeholders_diff = scores[2] - scores[3] 
    approach_diff = scores[4] - scores[5] 
    planning_diff = scores[6] - scores[7] 
    work_diff = scores[8] - scores[9] 
    risk_diff = scores[10] - scores[11] 
    # Добавляем вывод на базе разностей навыков и проблем
    if (team_diff > 0 and stakeholders_diff > 0 and approach_diff > 0 and planning_diff > 0 and work_diff > 0 and risk_diff > 0):
      interpretation.append('Хорошие новости - судя по всему в вашей текущей рабочей деятельности ваших компетенций хватает, чтобы справляться с возникающими проблемами\. Однако мы советуем не расслабляться - кто знает, какие вызовы ждут вас в будущем?.. ')
    else:
      interpretation.append('Но обратите внимание: их часто недостаточно для того, чтобы решить возникающие проблемы в следующих сферах:')
    # Определяем проблемные зоны
    if team_diff > 0:
      interpretation.append('\n\- Команда')
    if stakeholders_diff > 0:
      interpretation.append('\n\- Заинтересованные стороны')
    if approach_diff > 0:
      interpretation.append('\n\- Подход и поставка')
    if planning_diff > 0:
      interpretation.append('\n\- Планирование')
    if work_diff > 0:
      interpretation.append('\n\- Работа и измерение')
    if risk_diff > 0:
      interpretation.append('\n\- Риски')
    else:
      pass
      
    output = ' '.join(interpretation)
    return output
		

Функция append_result() присоединит результат нового игрока к таблице:

			def append_result(result_lst):
  body = result_lst
  sheet.append_row(body)
		

Рассчитываем уровень будущего студента

Подошел черед функции, определяющей уровень игрока:

			def level_count(result_lst):
    lst = result_lst[1:]
    res = [eval(i) for i in lst]

    col = [res[0] * 3, res[2] * 3, res[4] * 3, res[6] * 3, res[8] * 3, res[10] * 3, res[1] * 2 + res[12], res[3] * 2 + res[13], res[5] * 2 + res[14], res[7] * 2 + res[15], res[9] * 2 + res[16], res[11] * 2 + res[17]]

    score = sum(col)

    # Присвоим уровень в зависимости от числа очков:    if 0 <= score <= 41:
        return ‘Новичок’
    elif 42 <= score <= 50:
        return ‘Падаван’
    elif 51 <= score <= 70:
        return ‘Падаван—Джедай’
    elif 71 <= score <= 100:
        return ‘Рыцарь—Джедай’
    else:
        return ‘Мастер—Джедай’
		

Заключение

Теперь основные компоненты бота изучены и прокомментированы. Помню, самой хотелось когда-то расковырять похожего бота и на его базе создать что-то своё, так что дерзайте.

Надеюсь, с помощью такого разбора вы упростите себе сборку первого самописного бота для Telegram. Хотя библиотек создано немало, именно aiogram заработала репутацию развитого и регулярно обновляемого фреймворка. Да и обучающих видео предостаточно. Так что начинать советую именно с него.

Если вам захотелось пройти этот опрос и выяснить, какой вы джедай-руководитель проекта, то бот здесь: @infostart_mt_course_bot.

API
Инструменты
Python
SQL
Telegram
Библиотеки
300