Привет! В этой статье узнаете, как студенты Университета МИСИС создавали HR-приложение, выиграв с ним 250 000 рублей в хакатоне «Лидеры цифровой трансформации. Якутия». Сервис отслеживает активность сотрудников на рабочем месте, анализирует ее и прогнозирует вероятность увольнения. Разработка признана полноценным коммерческим продуктом — её можно успешно внедрить в любой офис в качестве комплексного инструмента управления персоналом.

Нас зовут Дмитрий, Данила и Влад, мы — участники команды Университета МИСИС Peach2Win, которая победила в хакатоне «Лидеры Цифровой Трансформации. Якутия», организованном Агентством инноваций Москвы совместно с Фондом развития инноваций Якутии. По условиям кейса «Сервис прогнозирования увольнения на основе вовлеченности сотрудника» от администрации города Нерюнгри и компании «Колмар», нам нужно было создать сервис, который позволит оценить производительность работников по его активности за рабочим компьютером и в корпоративной почте.

Архитектура решения. Информация по активности сотрудников сохраняется в базу данных, после чего ML-модель за период времени оценивает вероятность увольнения сотрудника. Также есть фронтенд, в котором руководитель может посмотреть данные по активности своих подчиненных. Это агрегированная статистика по активности в почте и за рабочим компьютером, а также результаты работы ML-модели. В качестве базы данных используется Postgesql.

Все компоненты системы, кроме сервиса отслеживания активности, работают в docker контейнерах и могут быть развернуты на сервере одной командой:

docker-compose up -d

Сервис мониторинга активности за рабочим компьютером. Наш сервис — легковесное Python-приложение, работающее в фоновом режиме на компьютере сотрудника. Оно регистрирует нажатие клавиш мыши и клавиатуры, после чего отправляет каждое такое событие в топик Kafka с указанием приложения, в котором было нажатие, меткой времени и username’ом пользователя. Kafka — это открытая платформа потоковой обработки, которую мы использовали анализа входящих данных в режиме реального времени.

Подписка на события выглядит так:

with mouse.Listener(on_click=on_mouse_click) as mouse_listener, \
        keyboard.Listener(on_press=on_key_press) as keyboard_listener:
        mouse_listener.join()
        keyboard_listener.join()

Обработка нажатия на клавишу клавиатуры:

  def on_key_press(_):
    try:
        kafkaPublisher.publish_message("winEvents", "event",
            WindowsEventModel(UserName=username, AppName=get_active_file_name()).json())
    except Exception as err:
        print(f"Error: {err}")

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

def get_active_file_name():
    active_window = win32gui.GetForegroundWindow()
    process_id = win32process.GetWindowThreadProcessId(active_window)[1]
    handle = win32api.OpenProcess(win32con.PROCESS_QUERY_INFORMATION |
                                  win32con.PROCESS_VM_READ, False, process_id)
    executable = win32process.GetModuleFileNameEx(handle, 0)

    return  executable.split("\\")[-1]

Данные сервис работает с ОС Windows и для работы с событиями нажатия клавиш использует следующие библиотеки: win32gui, win32api, win32con, win32process

Для взаимодействия с Kafka используется библиотека kafka-python.

Подписчик очереди Kafka. Сервис вычитывает пакеты из топика Kafka и сохраняет их базу данных. Для доступа к базе данных используется ORM peewee. Peewee хорошо подходит для проектов где важна скорость разработки, в том числе для хакатонов.

В базе данных было сделано 5 таблиц:

  • User — информация о сотрудниках,

  • App — информация о приложениях,

  • EventApplication — событие клика (связывает пользователя и приложение, также хранит время события),

  • TimeIntervalEvent — сохранение количества действий пользователя во временном интервале (мы использовали интервалы по 30 минут).

Теперь чуть подробнее про последнюю таблицу. TimeIntervalEvent используется для расчета времени, проведенного сотрудником за рабочим местом. В случае, если количество событий за временной интервал больше, чем предопределенный трешхолд, то считается, что сотрудник работал в этот временной интервал. Подход с одним трешхолдом довольно прост и относительно эффективен, но его легко обмануть, просто сделав много кликов в течение 1–2 минут, но для хакатона данного решения более чем достаточно. В реальном приложении нужно было бы учитывать больше факторов, например, распределение кликов по временному интервалу или же максимальная длина промежутка без кликов.

Нейронные сети: что выбрать?

Так как мы хотим обрабатывать последовательности данных — временные ряды, то для этой задачи были выбраны рекуррентные нейронные сети (РНС). Также стоит отметить, что самая сложная часть в классификации временных рядов — это создание на их основе полезных признаков для классификатора, с чем отлично справляются рекуррентные сети и использование одиночных классификаторов, к примеру Xgboost и Catboost. Без этой предварительной подготовки результат может значительно ухудшиться.

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

Фронтенд. Наша команда с самых ее истоков в качестве основного фронтенд-фреймворка использует Svelte. Именно он является нашим фаворитом, потому что предоставляет лучший DX (Developer Experience): большой набор возможностей из коробки и высокую скорость разработки — именно то, что нужно в условиях крайне ограниченного времени и ресурсов на хакатоне.

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

В паре последних хакатонов у нас появился еще один дополнительный мощный инструмент. Это наша собственная разработка — фреймворк сетевого уровня Chord. Он существенно упрощает связь бекенда с фронтендом, инкапсулируя всю сложность до вызова заготовленных на бекенде функций, вместо громоздких fetch запросов и эндпоинтов на каждую CRUD операцию. Это возможно благодаря тому, что Chord берет все лучшее из концепции RPC и работает на базе очень простого протокола — JSON RPC V2

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

Рассматривать примеры ускорения разработки за счет применения Svelte и Tailwind мы не будем, об этом уже достаточно много материала и примеров в свободном доступе. Поэтому сфокусируемся на том, как мы ускорили привязку бекенда к фронтенду.

lib/client.ts

export const rpc = dynamicClient<Client>({ endpoint: '/' });

Здесь мы инициализируем RPC клиент, который в дальнейшем будем импортировать в компоненты, страницы или контроллеры.

routes/users/[userId]/controller.ts

import { writable, derived, type Readable} from 'svelte/store';
import { page } from '$app/stores';

import { TimeInterval } from '$lib/enums';
import { rpc } from '$lib/client';

/*
rpc - клиент, с дженериком типа с бекенда
export const rpc = dynamicClient<Client>({ endpoint: '/' });
*/


export const period = writable(TimeInterval.Month);

export const userId = derived(page, ($page) => Number($page.params.userId ))

export type Events = Awaited<ReturnType<typeof rpc.EventTimeInterval.getForUser>>;
export const events: Readable<Events> = derived([userId, period], ([$userId, $period], set) => {
	(async () => {
		const res = await rpc.EventTimeInterval.getForUser($userId, $period);
		set(res);
	})();
});

export type AppsInfo = Awaited<ReturnType<typeof rpc.EventApplication.getForUser>>;
export const appsInfo: Readable<AppsInfo> = derived([userId, period], ([$userId, $period], set) => {
	(async () => {
		const res = await rpc.EventApplication.getForUser($userId, $period)
		set(res)
	})()
})

Слой контроллера у нас отличается от стандартного MVC, потому что он работает на клиенте и содержит в себе реактивность. Здесь мы вызываем методы, заготовленные на бекенде, напрямую. Кроме того, мы можем получить тип возвращаемого значения функции и использовать его как дженерик состояния приложения, что очень удобно при работе с TypeScript на фронтенде.

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

Инсайды после хакатона

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

Репозиторий нашего решения можно найти по ссылке.

О команде. Peach2Win — многократный победитель региональных и всероссийских хакатонов. В составе: 2 фуллстека, 1 бекендер, 1 ML- инженер и 1 дизайнер. По нашему опыту, такой состав — наиболее сбалансированный и универсальный для решения большинства кейсов. У нас есть свой отработанный стек технологий и наработанные опытом методики.

Хакатон клуб, в котором мы состоим, развивает платформу ITAM на базе Университета МИСИС. Команды клуба регулярно выигрывают хакатоны, только на «ЛЦТ Якутии» каждая из 5 команд от НИТУ МИСИС попала в топ-3 по разным трекам, а всего за 2023 год было выиграно больше 8 миллионов рублей и занято 58 призовых мест на хакатонах различного масштаба.

Материал для вас подготовили Дмитрий Дин, Данила Комлев и Влад Тишин. Делитесь мнением в комментариях, задавайте вопросы — постараемся ответить!