Умный подбор подрядчиков
Streamlit-приложение подбирает до 3 event-подрядчиков из каталога (66 профилей) по свободному тексту или ручной форме и объясняет выбор, сравнивая…
О проекте
Команда: Nuravi
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Рабочий MVP
Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор
Суть
Streamlit-приложение подбирает до 3 event-подрядчиков из каталога (66 профилей) по свободному тексту или ручной форме и объясняет выбор, сравнивая топ-1 с топ-2 по компонентам оценки.
Как устроено
LLM разбирает свободный текст заявки в поля и задаёт уточняющие вопросы (город, дата, формат, бюджет). Затем локальное ядро применяет 7 жёстких фильтров (категория, город, календарь, бюджет, формат, языки, часы) с воронкой причин отказа и три исхода (found/no_category/no_matches), и ранжирует по прозрачной формуле score = 100·Σ(weight·factor)/Σ(active_weights). Пожелание сопоставляется с фразами описания и опционально через embeddings, с проверкой дословных цитат.
Ключевые решения
- hard-filter funnel
- weighted scoring
- embeddings similarity
- quote verification
- Decimal arithmetic
- LLM intake/slot filling
Особенности
Предварительные профили показываются уже по категории, но без даты не объявляются свободными; пустой busy_dates трактуется как «доступность неизвестна». Отдельный подсчёт причин отказа по всем условиям одновременно и сравнение «что меняется на другой дате».
Стек
Python · Streamlit
Модели
OpenAI · NVIDIA API
Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-ab3cdd19-nuravi.
README
README.mdУмный подбор подрядчиков · HackAlem
Рабочее демо команды Nuravi: до трёх подрядчиков из предоставленного каталога Казахстана с индивидуальными, проверяемыми объяснениями. На первом экране пользователь описывает задачу своими словами. Уже по категории приложение показывает предварительные профили, а ИИ последовательно уточняет город, дату, формат и бюджет; язык, часы и пожелание необязательны. Предварительные профили не являются рейтингом, а без даты не объявляются свободными. После уточнений запускается обычный подбор: блок «Почему рекомендован» показывает основания выбора; топ-1 сравнивается с топ-2 по реальным компонентам оценки. Каталог не дополнялся новыми профилями.
Запуск
Windows PowerShell из папки репозитория:
powershell -NoProfile -ExecutionPolicy Bypass -File .\run.ps1
Linux/macOS или Git Bash:
sh run.sh
Откройте http://127.0.0.1:8501. Скрипты создают локальную .venv, устанавливают зависимости и запускают Streamlit на localhost. Нужен Python 3.10+; проверено на Python 3.12.10. Первая установка требует интернета; подбор работает локально. Команда PowerShell выше разрешает выполнение только для запущенного процесса и не меняет политику системы. Другой порт: powershell -NoProfile -ExecutionPolicy Bypass -File .\run.ps1 --server.port 8502 или sh run.sh --server.port 8502. Остановка: Ctrl+C.
Ручная установка без uv:
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python -m streamlit run app.py --server.address 127.0.0.1
Подбор через ручную форму работает без ключей. Для поиска свободным текстом скопируйте .env.example в .env в корне репозитория и заполните OPENAI_API_KEY или NVIDIA_API_KEY. При наличии обоих ключей OpenAI используется первым, NVIDIA — резервным провайдером. Уже существующий локальный tests/.env тоже читается. Оба .env игнорируются Git. Секреты не выводятся на страницу и не входят в аудит. Текст заявки и уже извлечённые поля отправляются провайдеру только после нажатия «Найти подрядчика». Отдельный переключатель в ручной форме включает AI-сравнение пожелания с описаниями подходящих профилей. Телеметрия Streamlit отключена.
Публичная демонстрация
Для Streamlit Community Cloud укажите GitHub-репозиторий, ветку main и entrypoint app.py. requirements.txt, .streamlit/config.toml, ui.css и data/contractors.csv уже находятся в репозитории. В настройках Secrets задайте корневой ключ OPENAI_API_KEY = "..."; не добавляйте реальный ключ в Git или README. Корневые секреты Streamlit доступны приложению как переменные окружения. После публикации проверьте внешнюю ссылку без входа в аккаунт: плотную категорию, смену даты, редкую категорию, пустой результат и AI-пожелание. Если репозиторий приватный, у развёртывающего аккаунта должны быть права администратора и приложению нужно отдельно включить публичный доступ; иначе жюри увидит запрос авторизации. URL из 127.0.0.1 пригоден только на вашем компьютере.
Демо для жюри
На первом экране — компактное поле «Кого вы ищете?». Можно начать с «Мне нужен ведущий»: приложение сразу покажет до трёх предварительных профилей по категории и спросит город и дату. Без даты доступность не подтверждается. Ответьте в том же поле; затем ИИ уточнит формат и бюджет, если их ещё нет. Когда все обязательные параметры известны, появится итоговый ранжированный подбор. Можно также ввести полный запрос одной фразой. Город и дату можно выбрать под полем: выбор сразу виден на кнопках и имеет приоритет над текстом. Строка «Я понял» показывает извлечённые условия. Свёрнутая форма «Изменить параметры или заполнить вручную» позволяет исправить их и остаётся доступной при сбое API. Готовых сценариев на странице нет; для показа жюри можно вводить следующие тексты.
| Сценарий | Текст для поля поиска | Ожидаемый результат на исходном CSV |
|---|---|---|
| Плотная категория | Нужен ведущий для корпоратива в Алматы 14 ноября 2026 года, бюджет до 1 500 000 тенге, русский язык, на 6 часов | 4 подходят, топ-3: HK-44923, HK-29829, HK-44733 |
| Другая дата | Нужен ведущий для корпоратива в Алматы 15 ноября 2026 года, бюджет до 1 500 000 тенге, русский язык, на 6 часов | 6 подходят, топ-3: HK-88430, HK-44923, HK-35215 |
| Пожелание | Нужен ведущий для корпоратива в Алматы 15 ноября 2026 года, бюджет до 1 500 000 тенге, русский язык, на 6 часов; важен опыт деловых форумов | Результат зависит от извлечённого пожелания; проверяйте строку «Я понял» и фразы в карточках |
| Редкая категория | Нужен флорист для корпоратива в Алматы 10 октября 2026 года, бюджет до 300 000 тенге, русский язык, на 6 часов | 1 вариант: HK-39372; max_hours=null, цена восстановлена |
| Никто не проходит | Нужен ведущий для корпоратива в Алматы 14 ноября 2026 года, бюджет до 10 000 тенге, русский язык, на 6 часов | Категория есть, подходящих нет; 10 выше бюджета, 5 заняты, 1 не берёт формат; причины пересекаются |
| Категории нет | Нужен флорист для корпоратива в Зарубежье 14 ноября 2026 года, бюджет до 300 000 тенге | Такой категории в городе нет; отличается от отказа по условиям |
Для короткого показа жюри введите запрос с пожеланием и покажите строку «Я понял», карточки и сравнение первых двух кандидатов. Затем измените дату текстом или через «Что меняется на другой дате» и покажите, кто вошёл и выбыл по календарю. После этого введите запросы для редкой категории и пустого результата. AI-сравнение описаний можно включить в раскрываемой форме: если API отвечает, появится проверенная дословная фраза и, когда сходство embeddings повышает оценку, её вклад. На 14 ноября заняты 5 ведущих Алматы, на 15 ноября — 2.
Повторить все сценарии без интерфейса:
.venv/bin/python -m matcher.demo
На Windows: .\.venv\Scripts\python.exe -m matcher.demo.
Архитектура и карта требований
Независимое локальное ядро: matcher/data.py и matcher/engine.py определяют допустимость, порядок и доказательства; matcher/explain.py формулирует факты. matcher/ai.py разбирает текст заявки через LLM, по запросу добавляет embeddings, выбирает дословные цитаты и проверяет их. matcher/intake.py проверяет извлечённые поля и составляет уточняющий вопрос. app.py отображает результаты и аудит. Жёсткие фильтры не обращаются к сети.
| Требование | Реализация |
|---|---|
CSV, списки через |, нормализация, ошибки | matcher/data.py |
| Семь жёстких условий, три исхода, воронка | matcher/engine.py |
| Прозрачные баллы, устойчивый порядок | score, select |
| Сопоставление пожелания с фразами описания | matcher/text.py |
| Опциональные embeddings, LLM и проверка цитат | matcher/ai.py |
| Разбор свободного текста и уточнения | matcher/ai.py, matcher/intake.py |
| Индивидуальные объяснения, сравнение топ-1/топ-2 и оговорки | matcher/explain.py |
| Текстовый поиск, ручная форма, карточки, отказы, сравнение дат, JSON-аудит | app.py; оформление — ui.css и .streamlit/config.toml |
| Воспроизводимые демо | matcher/demo.py |
| Проверки бизнес-логики, загрузки и формы | tests/ |
Используются Python, Streamlit и стандартные csv, dataclasses, Decimal. Для 66 строк стандартный CSV-парсер позволяет явно контролировать пустые значения без автоматического превращения их в NaN. Pandas приходит как зависимость Streamlit для таблиц, но бизнес-логика от него не зависит. Точные денежные вычисления и сортировка выполняются с Decimal; округление применяется только для отображения.
Жёсткая фильтрация
- Точное совпадение выбранной категории с одним элементом
categories. «Ведущий церемонии» не равен «Ведущий». - Совпадение города. Пробелы, регистр, Unicode и ё/е нормализуются для сравнения.
- Календарь занятости заполнен, а дата отсутствует в
busy_dates; разрешены только 23.09.2026–31.12.2026 включительно. Пустойbusy_datesозначает, что доступность неизвестна, поэтому профиль не выдаётся как свободный. price_from_kzt <= budget.- Тип мероприятия присутствует в
event_formats. - Все выбранные пользователем языки присутствуют в
languages. - Если часы указаны и
max_hoursизвестен,hours <= max_hours.
max_hours=null означает, что ограничение неприменимо. Профиль проходит, но подтверждения длительности и баллов за этот фактор не получает. Неуказанные язык и часы не ограничивают поиск. Занятые профили никогда не попадают в рекомендации. Нулевая/отрицательная/нечисловая/бесконечная сумма бюджета возвращает понятную ошибку валидации; аналогично проверяются часы. В UI ноль часов означает «не указано», в ядре это None.
Три исхода явно различаются: found, no_category, no_matches. Если в городе вообще менее трёх профилей, это видно по счётчику; если часть отфильтрована, видны причины. Корректно подходящие ниже топ-3 не выдаются за отказавших по условиям.
Воронка последовательна: категория → город → доступность → бюджет → формат → язык → длительность. Отдельный подсчёт причин проверяет каждый профиль категории и города по всем условиям одновременно. Поэтому суммы причин могут быть больше числа исключённых профилей. В раскрываемой таблице можно проверить id каждого исключения.
Формула ранжирования
После фильтрации для каждого кандидата вычисляются факторы от 0 до 1:
| Фактор | Базовый вес | Значение |
|---|---|---|
| Формат | 25 | 1: подтверждён обязательным фильтром |
| Язык | 20 | 1, если заданы и подтверждены все языки; без запроса фактор исключается |
| Бюджет | 20 | 1 − price_from_kzt / budget после обязательной проверки бюджета |
| Длительность | 15 | 1 при известном подходящем max_hours, 0 при null; без запроса фактор исключается |
| Пожелание | 20 | Явное количество языков проверяется по списку languages; остальные пожелания — по фразам описания и, в AI-режиме, через embeddings. Без пожелания фактор исключается |
score = 100 × Σ(weight × factor) / Σ(active_weights).
При отсутствии необязательных параметров соответствующие веса исключаются и остальные нормируются к 100. В карточке всегда показаны все пять факторов: базовый вклад от 0 до фиксированного веса либо отметка, что параметр не задан и не участвует. При указанной длительности null сохраняет вес в знаменателе, но даёт 0. Оценки рассчитаны для конкретного запроса и не предназначены для сравнения разных запросов.
Бюджет — обязательный фильтр. После него меньшая цена «от» даёт больше баллов по цене. Это не рейтинг качества подрядчика; итоговую стоимость нужно уточнять.
Явное пожелание вроде «знание 3 языков» проверяется по структурированному списку языков профиля и получает полный вклад только при нужном количестве. Составное пожелание вроде «знание 3 языков и опыт фокусов» требует и нужного количества языков, и текстового подтверждения второй части; одна часть не даёт полный балл. Остальные пожелания локально сопоставляются с фразами описания по коротким основам слов; общие слова имеют меньший вес. Выбирается фраза с лучшим покрытием, она показывается дословно. Формат и язык не добавляют текстовых баллов повторно. Для пожелания с «без» засчитывается только фраза, где «без» тоже написано. AI-режим может распознать близкий смысл без совпадения слов, но начисляет AI-баллы только профилям с проверенной исходной фразой.
При равном точном score порядок определяется так:
- Больше подтверждений: формат + число запрошенных языков + известная подходящая длительность + число групп текста.
- Меньшая цена.
- Меньшая доля занятых дат в 100-дневном окне.
- Лексикографический порядок
id.
Город, категория и доступность не входят в балл. Занятость используется как жёсткий фильтр, её доля — только как поздний tie-breaker по заданию. Порядок не зависит от порядка строк CSV и случайных чисел.
Объяснения и использование AI
Решение использует локальные правила и AI для ввода заявки. На 66 строках ничего не обучается. Свободный текст отправляется в OpenAI, при сбое — в NVIDIA. Модель возвращает структурированные поля; приложение проверяет типы, дату, положительный бюджет и формат из каталога. Если часть обязательных полей не указана, приложение спрашивает только о недостающих. Когда категория известна, оно показывает до трёх предварительных профилей по уже заданным условиям. Это ещё не итоговый рейтинг; без даты доступность не подтверждается. Итоговый ранжированный подбор запускается после заполнения всех обязательных полей. Подтверждённые поля можно исправить вручную. Разбор кешируется по тексту, предыдущим полям, модели, дате и составу каталога в игнорируемой Git папке .cache/; повторение одного запроса использует тот же разбор и тот же устойчивый порядок. В кеше хранится структурированный результат, а не исходный текст заявки.
Отдельный переключатель «Уточнить рекомендации с ИИ» включает смысловое сравнение пожелания с описаниями уже допустимых подрядчиков:
- Жёсткие фильтры отбирают допустимые профили.
- OpenAI LLM предлагает дословные фразы из их описаний. Приложение проверяет точное наличие фразы в исходном профиле.
- Второй вызов LLM проверяет смысловую связь фразы с пожеланием. Для установленного OpenAI-ключа сначала используется OpenAI; NVIDIA остаётся резервным провайдером. Неподтверждённая фраза не показывается, включая режим с одним ключом NVIDIA.
- OpenAI
text-embedding-3-smallсравнивает пожелание с описаниями профилей, у которых есть проверенная фраза. Большее из локального и AI-сходства используется в компоненте «Пожелание» с существующим весом 20. Остальные веса не меняются. - При ошибке нужного API остаётся локальная оценка и шаблонное объяснение. Embeddings и проверенные цитаты кешируются локально в игнорируемой Git папке
.cache/по модели и хешу запроса/текста; повторный запрос их не пересчитывает. Сервис перечитывает кеш и конфигурацию при новом запуске подбора, поэтому результаты других процессов не остаются невидимыми до перезапуска приложения. Изменение правил аудита цитат сбрасывает старый кеш подтверждений.
После проверки извлечённых полей AI не добавляет кандидатов и не меняет цену, занятость или жёсткие условия. При одинаковом запросе и доступном режиме порядок определяется теми же числами и правилами tie-break; ответы LLM кешируются. При доступности только OpenAI AI-аудитор использует OpenAI. Ключ из brev.nvidia.com/settings/api-keys предназначен для Brev CLI и не заменяет ключ NVIDIA Developer API для NIM. Для NIM получите отдельный ключ на странице модели в build.nvidia.com и сохраните его как NVIDIA_API_KEY.
Первое предложение карточки называет дату, формат, цену и бюджет. Второе приводит исходную фразу профиля: в AI-режиме — выбранную LLM и пропущенную через аудит; иначе — найденную локальным поиском. Цитата обозначена как слова профиля, а не независимая оценка. Отдельный блок показывает точные условия, базовые баллы и ограничения. Оценка совпадения не является рейтингом качества подрядчика.
AI-сходство — эвристика, а не вероятность качества подрядчика. Если дословное подтверждение пожелания не найдено, локальный текстовый фактор остаётся без AI-надбавки. Сравнение дат в интерфейсе использует локальный стабильный порядок.
Данные и ограничения
В data/contractors.csv лежит неизменённая копия предоставленного CSV. Бизнес-требования прочитаны из PDF «HackAlem AI: Хакатон-задача: умный подбор подрядчиков». HTML-превью использовано для сверки: 66 уникальных id, множество полностью совпадает с CSV; заголовок подтверждает окно календаря.
Профилей 66, категорий 17 (один профиль может иметь несколько категорий). Городов: Алматы — 50, Астана — 15, Зарубежье — 1. Полностью синтетических профилей 13; восстановленных городов 8; восстановленных цен 18; max_hours отсутствует у 9. Флаги качества показываются на карточках и в JSON-аудите, но не дают бонусов. Имена анонимизированы. Синтетические профили не скрываются и участвуют по тем же правилам.
Занятость известна только на 100 дней. «Свободен» означает отсутствие даты в календаре датасета, не подтверждённую бронь. Профили, цены и описания не проверяются внешними источниками. Декабрьская загруженность и редкие категории закономерно создают пустые результаты.
Повреждённые обязательные поля, цены, часы, флаги, даты и дубли id исключаются с видимым отчётом. Для дублирующего id принимается только первая корректная встреченная запись с уникальным id; повтор помечается ошибкой. Пустое описание разрешено без смысловых баллов. Отсутствие колонок — понятная ошибка загрузки, а не пустая выдача. Пустая ячейка календаря, [] и null означают неизвестные данные: профиль остаётся в каталоге, но не выдаётся как доступный, а причина явно показывается пользователю. В исходном CSV пустых календарей нет. Если есть ошибки данных, UI сообщает, что результаты относятся к корректной части каталога.
Проверки
.venv/bin/python -m pytest -q --basetemp=.pytest_temp
На Windows используйте .\.venv\Scripts\python.exe -m pytest -q --basetemp=.pytest_temp. Тесты проверяют все жёсткие фильтры, три исхода, 1/2/3 результата, пустой календарь, бюджет, языки и длительность, влияние пожелания, ошибки CSV, границы календаря, уникальность объяснений, устойчивость порядка при перемешивании каталога и изменение выдачи по датам. AI-тесты без реальных API проверяют разбор текста, уточнение, кеш, резервного провайдера, дословность цитаты, аудит при одном ключе NVIDIA, совместимость с reasoning-моделью и неизменность жёстких фильтров. Streamlit AppTest проверяет первый экран, полный и неполный запрос, ручной ввод, сбой API и карточки результата. Документация тестового API: Streamlit AppTest.
Тесты проверяют также предварительную выдачу по короткому запросу, соблюдение уже известных ограничений и отсутствие преждевременной оценки или заявления о доступности. Проверен HTTP health endpoint и отображение страницы через Streamlit AppTest. API-пути покрыты тестами с имитацией ответов. Дополнительно живой AI-запрос «Мне нужен ведущий» дал три предварительных профиля и вопрос про город/дату; следующая реплика собрала полную заявку. Это единичные проверки, а не гарантия времени внешнего API. Ключи не входят в репозиторий.
Развитие
Следующие содержательные улучшения: подтверждённые календари и цены; оценка качества объяснений людьми; версия датасета и сравнение изменений; отдельный API поверх текущего независимого ядра. Регистрация, оплата и бронирование в объём этого демо не входят.
Авторы коммитов
История коммитов
- 2026-09-23 17:58style: update prompt search button accessibility and visibilityirridan123
- 2026-09-23 17:58feat: enhance landing page layout and prompt search functionality, update styles and add new assetsirridan123
- 2026-09-23 17:42feat: enhance AI input handling and preliminary profile display in READMEirridan123
- 2026-09-23 17:40feat: add quick filters for city and date selection, enhance request handlingirridan123
- 2026-09-23 17:28feat: enhance preliminary profile previews and improve user prompts for better event request handlingrava
- 2026-09-23 17:14style: update theme colors and enhance UI components for better user experienceirridan123
- 2026-09-23 17:04Refactor app and AI integration for improved event request handlingirridan123
- 2026-09-23 16:34fix: ground compound wishes and prepare public demorava
Все коммиты (15)
- 2026-09-23 16:24feat: update AI model configurations and enhance UI componentsirridan123
- 2026-09-23 15:54fix: match language count preferences to profile datarava
- 2026-09-23 15:43feat: add grounded AI matching with audited evidencerava
- 2026-09-23 15:28feat: improve explainable contractor matching demoirridan123
- 2026-09-23 14:46feat: simplify UI and clarify recommendationsrava
- 2026-09-23 14:22fix: stabilize Streamlit demo and availability checksirridan123
- 2026-09-23 13:45feat: add explainable contractor matching demorava
Структура репозитория (25 файлов)
.env.example .gitignore .streamlit/config.toml README.md app.py assets/botanical-panel.png data/README.md data/contractors.csv matcher/__init__.py matcher/ai.py matcher/data.py matcher/demo.py matcher/engine.py matcher/explain.py matcher/intake.py matcher/text.py requirements.txt run.ps1 run.sh tests/test_ai.py tests/test_app.py tests/test_data.py tests/test_engine.py tests/test_intake.py ui.css
Обсуждение 0 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.