Предыстория
Буквально месяц назад, мы с моим коллегой участвовали в HAKATON. Наша команда взялась за интересную задачу от компании МТС: на основе тысяч опросов, найти усредненный синоним к определенной категории ответов и визуализировать это в виде диаграммы, либо облака слов.
После выполнения задачи и защиты проекта мы задумались:
"А что если данную программу попробовать связать с тг ботом?"
Как раз после этого, мы решили это реализовать.
Ссылка на данный проект в github
https://github.com/onevay/Tg_Bot_Topic_Analyze/tree/main
Этапы работы
-
Выбор стека
-
Создание "кнопок и сырого текста"
-
База данных
-
Включение функций анализа текста
-
Оформление и доработка
Краткое описание функционала
Пользователь проходит анкету, результаты которой сохраняются в базе данных. Далее, пользователь может отправить csv-файл с большим количеством текста, бот проанализирует данный текст и ответом будет результат, состоящий из двух диаграмм:
Первая включает в себя ключевые слова каждой группы, отсортированные по своей значимости.Вторая диаграмма распределяет эти группы в процентном соотношении.
В качестве дополнения, пользователь может отправить запрос в chat gpt, где тот найдет каждой группе слов свою тему.
Библиотека aiogram
aiogram - это мощный и популярный асинхронный фреймворк для разработки ботов в Telegram на языке python. Он обеспечивает эффективный способ взаимодействия с Telegram Bot API.
Основные преимущества aiogram:
-
Асинхронность. Асинхронный подход позволяет боту обрабатывать множество запросов одновременно, не блокируя выполнение других задач.
-
Гибкость. Предоставляет множество возможностей для настройки и расширения функциональности бота. Он поддерживает различные типы сообщений, клавиатуры (inline и reply), коллбэки и многое другое.
-
Поддержка состояний (FSM). Библиотека имеет встроенную поддержку конечных автоматов, что упрощает создание сложных ботов с многошаговым взаимодействием с пользователем.
-
Минимализм. aiogram не навязывает лишних зависимостей, что делает его легким и быстрым.
Начало работы
Были созданы модули для более удобной работы.
ha1.py
from aiogram import F, Router
from aiogram.types import CallbackQuery, Message, ReplyKeyboardRemove, FSInputFile
from aiogram import filters
from aiogram.enums import ParseMode
import app.db as db
import topic_funcs.gensi as gn
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
import re
import topic_funcs.vizualize as viz
import topic_funcs.probably as prob
import topic_funcs.textb as req
from decouple import config
import app.keyboard as kb
keyboard.py
from aiogram.types import ReplyKeyboardMarkup,
KeyboardButton, InlineKeyboardButton, InlineKeyboardMarkup
db.py
import psycopg2
from decouple import config
main.py
from aiogram import Bot, Dispatcher
import asyncio
import logging
import app.db as db
from decouple import config
from app.ha1 import router
Здесь импортируются все необходимые компоненты для построения Telegram-бота.
def extract_number(text):
match = re.search(r'b(d+)b', text)
if match:
return int(match.group(1))
else:
return None
class UserData(StatesGroup):
name: str = State()
age: int | None = State()
aim: str = State()
gender: str | None = State()
class TopicAnalize(StatesGroup):
start_analize: str = State()
choice_rezult = State()
Далее в файле ha1.py мы прописываем функцию extract_number(), для извлечения возраста из текстового ввода пользователя. Класс UserData(StatesGroup) определяет состояния для FSM, управляющего процессом заполнения анкеты пользователем. Класс TopicAnalize(StatesGroup) определяет состояния для FSM, управляющего процессом анализа тем текста.
topic_start = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text='🚀 Запустить выделитель топиков', callback_data='topic_start')]
]
)
ancet_start = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text='📝 Пройти анкету', callback_data='anceta')],
[InlineKeyboardButton(text='🚀 Запустить выделитель топиков', callback_data='topic_start')]
])
check_data = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="✅ Все верно", callback_data='correct')],
[InlineKeyboardButton(text="❌ Заполнить сначала", callback_data='incorrect')]
]
)
air_foul = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text='🙅♂️ Не хочу рассказывать', callback_data='air_foul')]
]
)
gen_button = ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text='👩🏻 Женщина')],
[KeyboardButton(text='👨🏻 Мужчина')]
],
resize_keyboard=True,
one_time_keyboard=True,
input_field_placeholder='🤔 Выберите ваш пол'
)
В файле keyboard.py код описывает интерфейс бота, предоставляя пользователю выбор различных опций через "кнопки".
Далее подробнее поговорим о файле db.py, который мы используем в качестве взаимодействия с базой данных.
db_url = config('DB_URL')
host = config('HOST')
port = config('PORT')
user = config('USER')
password = config('PASSWORD')
database = config('DB_NAME')
conn = psycopg2.connect(dbname=database, user=user, password=password, host=host, port=port)
conn.autocommit = True
host = config('HOST'), port = config('PORT'), user = config('USER'), password = config('PASSWORD'), database = config('DB_NAME'): Эти строки загружают параметры подключения к PostgreSQL из соответствующих переменных окружения: HOST, PORT, USER, PASSWORD, DB_NAME.
conn = psycopg2.connect(dbname=database, user=user, password=password, host=host, port=port): Это ключевая строка, которая устанавливает соединение с базой данных используя параметры, загруженные на предыдущих этапах.
conn.autocommit = True: Эта строка настраивает автоматическое подтверждение транзакций. Это значит, что каждое изменение данных в базе данных будет сохранено автоматически.
def create_table():
with conn.cursor() as cursor:
cursor.execute("""CREATE TABLE IF NOT EXISTS users (
id INT8 NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INT8 NOT NULL,
gender VARCHAR(255) NOT NULL,
aim VARCHAR(255));""")
def insert(id, test_dict):
if check_primary(id):
with conn.cursor() as cursor:
cursor.execute(f"""INSERT INTO users(id, name, age, gender, aim)
VALUES ({id},'{test_dict["name"]}',
{test_dict["age"]},
'{test_dict["gender"]}',
'{test_dict["aim"]}');""")
else:
with conn.cursor() as cursor:
cursor.execute(f"""UPDATE users SET id = {id}, name = '{test_dict["name"]}', age = {test_dict["age"]},
gender = '{test_dict["gender"]}', aim = '{test_dict["aim"]}'
WHERE id = {id};""")
Здесь представлены две функции.
create_table() функция для создания таблицы в базе данных, если она ещё не существует.
insert() функция вставляет данные в таблицу и проверяет, существует ли уже запись с заданным id, и в зависимости от результата выполняет вставку или обновление.
def age_procent(test_dict):
with conn.cursor() as cursor:
cursor.execute(f"""SELECT ROUND(COUNT(CASE WHEN age = {test_dict["age"]}THEN 1 END) * 100.0 / count(*), 2) AS fraction FROM users;""")
procent_age = cursor.fetchone()[0]
return procent
def gender_procent(test_dict):
with conn.cursor() as cursor:
cursor.execute(f"""SELECT ROUND(COUNT(CASE WHEN gender = {test_dict["gender"]} THEN 1 END) * 100.0 / COUNT(*), 2) AS fraction FROM users;""")
procent_gender = cursor.fetchone()[0]
return procent_gender
Функции age_procent() и gender_procent() отправляют запрос в базу данных, чтобы получить процентное соотношение, среди данных в таблице.
def check_primary(id):
with conn.cursor() as cursor:
cursor.execute(f"""SELECT id FROM users;""")
p = cursor.fetchall()
if p == None:
return True
lst = [int(i[0]) for i in p]
return True if id not in lst else False
def delete_user(id):
with conn.cursor() as cursor:
cursor.execute(f"""DELETE FROM users WHERE id = {id}""")
def print_data(id):
with conn.cursor() as cursor:
cursor.execute(f"""SELECT name, age, gender, aim FROM users WHERE id = {id};""")
return cursor.fetchone()
Функция check_primary() проверяет, существует ли пользователь с заданным id.
Функция delete_used() удаляет пользователя айди из базы данных, если он же проходит заново анкету.
Функция print_data() возвращает данные пользователя с заданным id.
Вернемся к исходному ha1.py
@router.message(filters.CommandStart())
async def start_bot(message: Message):
await message.answer(text='Привет!😁')
await message.answer(text='Ты, наверное, знаешь цели нашего бота🙂,n'
'||но команда /help все равно может тебе помочь||')
await message.answer(text='Перед использованием функционала обязательно нужно пройти анкетирование🙂n'
'||Мы не передаем данные, они останутся между нами (для статистики)||', reply_markup=kb.ancet_start)
@router.message(filters.Command('help'))
async def help(message: Message):
await message.answer(
text='📚 *Доступные команды:* nn'
'🔹 /start - *начать пользоваться ботом* n'
'🔹 /help - *все объяснит* n'
'🔹 /ancet_fill - *заполнить анкету* n'
'🔹 /start_topic - *анализ текста*',
parse_mode=types.ParseMode.MARKDOWN_V2
)
@router.message(filters.Command('start_topic'))
async def start_topic(message: Message):
await message.answer(text='🚀*Запуск анализа темы!*', parse_mode=types.ParseMode.MARKDOWN_V2, reply_markup=kb.topic_start)
@router.callback_query(F.data == 'anceta')
async def cl_ancet_start(callback: CallbackQuery, state: FSMContext):
await callback.message.answer(text='📝 *Давай приступим!*n'
'🤔 *Как тебя зовут?*', parse_mode=types.ParseMode.MARKDOWN_V2)
await state.set_state(UserData.name)
await callback.answer()
@router.message(F.text, UserData.name)
async def get_name(message: Message, state: FSMContext):
await message.answer(text=f'Так, {message.text}, а сколько тебе лет?😶')
await state.update_data(name=message.text)
await state.set_state(UserData.age)
@router.message(UserData.name)
async def get_name_e(message: Message):
await message.answer(
text='❌ *Ошибка!* n'
'🤷♂️ *Ты явно ткнул не туда.* n'
'🔄 *Попробуй сделать все правильно и введи свое имя!*',
parse_mode=types.ParseMode.MARKDOWN_V2)
@router.message(F.text, UserData.age)
async def get_age(message: Message, state: FSMContext):
if extract_number(message.text):
await state.update_data(age=extract_number(message.text))
procent = db.age_procent(message.text)
await message.answer(
text=f'🎉 *Посчитали и поняли!* n'
f'📊 *Ваш возраст совпадает с {procent}% наших пользователей.*',
parse_mode=types.ParseMode.MARKDOWN_V2
)
await message.answer(
text='🤔 *С возрастом определились, неплохо бы еще и гендер узнать.*',
reply_markup=kb.gen_button,
parse_mode=types.ParseMode.MARKDOWN_V2
)
await state.set_state(UserData.gender)
else:
await message.answer(
text='❌ *Не похоже на правду!* n'
'😕 *Может, ты неправильно вводишь? Попробуй еще раз.*',
parse_mode=types.ParseMode.MARKDOWN_V2
)
@router.message(F.text.in_(['👩🏻 Женщина', '👨🏻 Мужчина']), UserData.gender)
async def get_gender(message: Message, state: FSMContext):
await state.update_data(gender=message.text[2:].lower())
procent = db.gender_procent(message.text[2:])
await message.answer(
text=f'🎉 *Класс!* n'
f'🤝 *Твой пол совпадает с полом {procent}% наших пользователей.*',
reply_markup=ReplyKeyboardRemove(),
parse_mode=types.ParseMode.MARKDOWN_V2
)
await message.answer(
text='💬 *Расскажи, почему решил пользоваться нашим сервисом?*',
reply_markup=kb.air_foul,
parse_mode=types.ParseMode.MARKDOWN_V2
)
await state.set_state(UserData.aim)
@router.message(UserData.gender)
async def get_gender_e(message: Message):
await message.answer(
text='❌ *Не знаю такого гендера!* n'
'👉 Пожалуйста, выбери из предложенного списка.',
parse_mode=types.ParseMode.MARKDOWN_V2
)
@router.message(F.text, UserData.aim)
async def get_air(message: Message, state: FSMContext):
await state.update_data(aim=message.text)
db.insert(message.chat.id, await state.get_data())
test_dict = db.print_data(message.chat.id)
await message.answer(
text='📋 *Проверьте, всё ли верно:* nn'
f'👱🎤 *Имя:* `{test_dict[0]}` n'
f'🕒 *Возраст:* `{test_dict[1]}` n'
f'🚻 *Пол:* `{test_dict[2]}` n'
f'💬 *Почему решили пользоваться:* `{test_dict[3]}`',
reply_markup=kb.check_data,
parse_mode=types.ParseMode.MARKDOWN_V2
)
await state.clear()
@router.message(UserData.aim)
async def get_air_e(message: Message):
await message.answer(
text='❌ *Разве это цель?* n'
'📝 Пожалуйста, введи корректные данные!',
reply_markup=kb.air_foul,
parse_mode=types.ParseMode.MARKDOWN_V2
)
@router.callback_query(F.data == 'air_foul', UserData.aim)
async def air_foul(callback: CallbackQuery, state: FSMContext):
await callback.message.answer(
text='😔 *Жаль, что не расскажешь...* n'
)
await state.update_data(aim=callback.message.text)
db.insert(callback.message.chat.id, await state.get_data())
test_dict = db.print_data(callback.message.chat.id)
await callback.message.answer(
text='📋 *Проверьте, всё ли верно:* nn'
f'👱🎤 *Имя:* {test_dict[0]} n'
f'🕒 *Возраст:* {test_dict[1]} n'
f'🚻 *Пол:* {test_dict[2]} n',
reply_markup=kb.check_data,
parse_mode=types.ParseMode.MARKDOWN_V2
)
await callback.answer()
await state.clear()
@router.callback_query(F.data == 'correct')
async def correct(callback: CallbackQuery):
await callback.message.answer(
text='🎉 *Класс!* n'
'🚀 *Самое время воспользоваться функционалом!*',
reply_markup=kb.topic_start,
parse_mode=types.ParseMode.MARKDOWN_V2
)
await callback.answer()
@router.callback_query(F.data == 'incorrect')
async def incorrect(callback: CallbackQuery, state: FSMContext):
db.delete_user(callback.message.chat.id)
await callback.message.answer(
text='🔄 *Шило на мыло... То есть, введи своё имя:*',
parse_mode=types.ParseMode.MARKDOWN_V2
)
await state.set_state(UserData.name)
await callback.answer()
@router.callback_query(F.data == 'topic_start')
async def topic_start(callback: CallbackQuery):
if not db.check_primary(callback.message.chat.id):
await callback.message.answer(
text='🔄 *Обнову ещё не завезли...* n'
'🌟 Пожалуйста, заполните анкету.',
parse_mode=types.ParseMode.MARKDOWN_V2
)
else:
await callback.message.answer(
text='❗️ *Не вижу анкету от тебя,* n'
'📝 *нужно это исправить!*',
reply_markup=kb.ancet_start,
parse_mode=types.ParseMode.MARKDOWN_V2
)
await callback.answer()
@router.message(filters.Command('ancet_fill'))
async def ancet_fill(message: Message):
await message.answer(
text='📝 *Вы можете заполнить анкету:* n'
'☝*С нуля* n'
'✌*Обновить данные в ней*',
reply_markup=kb.ancet_start,
parse_mode=types.ParseMode.MARKDOWN_V2
)
@router.callback_query(F.data == 'topic_start')
async def topic_start(callback: CallbackQuery):
await callback.message.answer(
text='📄 *Отправь мне файл для анализа.* n'
'⚠️ *Файл, кстати, должен быть в CSV формате.*',
parse_mode=types.ParseMode.MARKDOWN_V2
)
@router.message(F.document, TopicAnalize.start_analize)
async def analyze(message: Message, state: FSMContext):
global res, lda_model, them_count, mydict, corpus, st
if '.csv' in message.document.file_name:
await message.answer(text='✅ *Принял, обрабатываю...*')
await message.bot.download(message.document.file_id, destination='../TG_BOT/tgbot/csv.csv')
with open('../TG_BOT/tgbot/csv.csv') as f:
st1 = f.read()
res, lda_model, them_count, mydict, corpus, st = gn.start(st1)
await message.answer(text=f'🔍 *Анализ завершен:* n{res}', reply_markup=kb.rezult_choice)
await state.set_state(TopicAnalize.choice_rezult)
else:
await message.answer(text='❌ *Видимо, файл не того формата.* n'
'📄 *Попробуй снова с CSV файлом!*')
@router.message(TopicAnalize.start_analize)
async def analyze_e(message: Message):
await message.reply(text='❗️ *Ты неправильно пользуешься ботом... Одумайся!*')
@router.message(F.text.startswith('Диаграмма'), TopicAnalize.choice_rezult)
async def show_res(message: Message):
if message.text[-1] == '1':
if them_count <= 6:
viz.plot_topic_distribution(lda_model, 3)
else:
viz.plot_topic_distribution(lda_model, 4)
await message.answer_photo(photo=FSInputFile('../TG_BOT/tgbot/res1.png'),
caption='📊 *Здесь представлены самые значимые слова каждой смысловой группы.*')
else:
prob.probably_topics(lda_model, mydict, them_count)
await message.answer_photo(photo=FSInputFile('../TG_BOT/tgbot/res2.png'),
caption='📈 *Значимость каждой подтемы в общем массиве ответов.*')
@router.message(F.text.startswith('Полн'), TopicAnalize.choice_rezult)
async def full_list(message: Message):
await message.answer(text=f'📄 *Полный список твои ответы:* n{st}')
@router.message(F.text.startswith('Вых'), TopicAnalize.choice_rezult)
async def exit_analysis(message: Message, state: FSMContext):
await message.answer(text='🚪 *Вы вышли из режима анализа.*', reply_markup=ReplyKeyboardRemove())
await message.answer(text='😊 *Но не огорчайтесь, вы всегда можете вернуться к использованию!*',
reply_markup=kb.topic_start)
await state.clear()
@router.message(TopicAnalize.choice_rezult)
async def state_e(message: Message):
await message.answer(text='❓ *Для продолжения необходимо выбрать действие или выйти из данного режима*')
@router.message(F.text)
async def any_text(message: Message):
await message.answer(
text='❌ *Такое я не обрабатываю!* n'
'📜 *Используй /help, чтобы просмотреть доступные команды.*',
parse_mode=types.ParseMode.MARKDOWN_V2
)
Этот код представляет собой набор обработчиков для различных этапов взаимодействия с ботом: заполнение анкеты, проверка данных, обработка ошибок и запуск анализа тем. Бот принимает на вход CSV-файл.
Анализ текста
Ниже в файле gensi.py, мы создаем программу для выполнения тематического моделирования текста с использованием LDA.
import gensim
from topic_funcs.clean import cleaning_and_normalize, mstem, punctions_del
from gensim import corpora
from gensim.utils import simple_preprocess
from topic_funcs.thems import thems_count
def start(answers):
answers = punctions_del(answers)
list_of_answers = mstem(answers)
list_of_answers = cleaning_and_normalize(list_of_answers)
mydict = corpora.Dictionary([simple_preprocess(line) for line in list_of_answers])
corpus = [mydict.doc2bow(simple_preprocess(line)) for line in list_of_answers]
them_count = thems_count(mydict)
lda_model = gensim.models.ldamodel.LdaModel(corpus=corpus,
id2word=mydict,
num_topics=them_count,
random_state=100,
update_every=1,
chunksize=30000,
passes=15,
alpha='auto',
per_word_topics=True)
stl = lda_model.print_topics()
st = ''
for i in range(1, them_count):
st = st + "группа номер: " + str(i) + " "
st += stl[i][1]
lda_topics = lda_model.show_topics(num_words=5)
for topic in lda_topics:
print(topic)
return f'В ходе одработки ответов было выделено {them_count} темnДалее вы можете ознакомиться с результатами', lda_model, them_count, mydict, corpus, st
Импортируются нужные библиотеки, происходят предварительные обработки текста: удаляет пунктуацию из ответов, приведение слов к начальной форме, удаляет стоп слова. Создается словарь mydict на основе предобработанных ответов и корпус документов corpus, представляющих каждый ответ в виде списка пар(слово, частота).
Вызывается функция them_count для определения количества тем для LDA модели. Далее, эта модель обучается, с использованием созданного корпуса и словаря.В конце выводится список тем и их топ 5 ключевых слов.
clean.py
from nltk.corpus import stopwords
import nltk
from pymystem3 import Mystem
from topic_funcs.censor import remove_russian_mat
def punctions_del(st):
punctions = "!"#$%&'()*+,-./:;<=>?@[]^_`{|}~1234567890"
for i in range(len(st)):
if(i >= len(st)):
break
if st[i] in punctions:
st = st[:i] + st[i + 1:]
return st
def mstem(st):
m = Mystem()
return m.lemmatize(st)
nltk.download('stopwords')
stop_words = stopwords.words('russian')
def cleaning_and_normalize(lst):
words = list(word for word in lst if word not in stop_words)
words = remove_russian_mat(words)
return words
Программа выполняет стандартные шаги предобработки текста для задач анализа естественного языка: удаление пунктуации, лемматизация и удаление стоп-слов
Визуализация
Чтобы визуализировать анализ текста мы прописываем в файле probably.py следующий код:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib
def probably_topics(lda_model, dictionary, num_topics):
matplotlib.use('TkAgg')
topic_probs = np.zeros(num_topics)
for word_id in range(len(dictionary)):
word_probabilities = lda_model.get_term_topics(word_id)
for topic_id, probability in word_probabilities:
topic_probs[topic_id] += probability
topic_probs /= np.sum(topic_probs)
labels = [f'Топик {i}' for i in range(num_topics)]
sizes = topic_probs * 100
plt.figure(figsize=(8, 8))
plt.pie(sizes, labels=labels, autopct='%1.1f%%', startangle=140)
plt.title('Значимость каждой подтемы в общем массиве ответов')
plt.axis('equal')
plt.show()
Функция probably_topics принимает обученную LDA модель lda_model, словарь dictionary и количество тем num_topics. Она суммирует вероятности каждого топика для всех слов в словаре, нормализует эти вероятности и строит круговую диаграмму, где каждый сегмент представляет тему, а его размер — относительную вероятность этой темы во всем корпусе текста.
Для более точной визуализации, мы решили добавить столбчатую диаграмму.
vizualize.py
import matplotlib.pyplot as plt
import matplotlib
def plot_topic_distribution(lda_model, num_words=10):
num_topics = lda_model.num_topics
matplotlib.use('TkAgg')
topics = []
words = []
weights = []
for topic_id in range(num_topics):
topic_terms = lda_model.show_topic(topic_id, topn=num_words)
for term, weight in topic_terms:
topics.append(f'Topic {topic_id + 1}')
words.append(term)
weights.append(weight)
import pandas as pd
df = pd.DataFrame({
'Topic': topics,
'Word': words,
'Weight': weights
})
plt.figure(figsize=(12, 6))
for topic in range(num_topics):
topic_data = df[df['Topic'] == f'Topic {topic + 1}']
plt.bar(topic_data['Word'], topic_data['Weight'], label=f'Topic {topic + 1}')
plt.title('Весомые слова в каждой из подтем')
plt.xlabel('Слова')
plt.ylabel('Вес слова')
plt.xticks(rotation=45, ha='right')
plt.legend()
plt.tight_layout()
plt.show()
Функция plot_topic_distribution принимает обученную LDA модель и количество слов num_words, которые нужно отобразить для каждой темы. Она извлекает значимые слова для каждой темы из модели LDA, создает DataFrame с помощью pandas и строит столбчатую диаграмму, где каждая группа столбцов соответствует теме, а столбцы в каждой группе — словам этой темы, высота столбца показывает вес слова в данной теме.
Запрос в gpt
from g4f.client import Client
def get_answer(st):
client = Client()
content = f'Дай название следующим группам слов, связанных по смыслу, слова уже разбиты по группам. {st}'
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": content}],
)
return response.to_json()['choices'][0]['message']['content']
Здесь происходит запрос сервису, это не request к gpt, так как на территории России ограничен доступ, а большинство бесплатных proxy сразу слетают. Но g4f очень хороший проект, поэтому API к оригинальному боту даже уступает.
Как это выглядит в боте
Вывод
В ходе данного проекта мы получили ценный опыт в разработке Telegram-ботов, обработке естественного языка, работе с базами данных и применении методов тематического моделирования. В будущем проект может быть расширен за счет интеграции с другими сервисами и внедрения более сложных алгоритмов анализа.
Автор: OneVay