Точно в формат
MVP подбора до трёх event-подрядчиков с объяснениями из фактов: браузерный интерфейс, HTTP API, импорт каталога (JSONL/CSV/TSV), разбор свободного…
О проекте
Команда: AIDAHAR
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Рабочий MVP
Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор
Суть
MVP подбора до трёх event-подрядчиков с объяснениями из фактов: браузерный интерфейс, HTTP API, импорт каталога (JSONL/CSV/TSV), разбор свободного брифа через LLM и сравнение «а если на следующий день».
Как устроено
Запрос валидируется, круг задают город и категория, затем жёсткие фильтры по занятости, цене «от», формату, языку и часам. Допущенные кандидаты ранжируются прозрачной формулой: 10×совпавшие основы пожеланий (до 5) + 20×(1 − цена/бюджет), порядок детерминирован. Объяснение строится из фактов и цитаты из описания; при пустой выдаче рассчитываются альтернативы (минимальный бюджет, ближайшая дата). GPT-4.1 mini через Responses API со Structured Outputs извлекает поля из брифа и пишет персональные пояснения, сервер проверяет ID и наличие цитаты в профиле.
Ключевые решения
- жёсткая фильтрация
- лексическое ранжирование по основам
- Structured Outputs
- проверка цитат
- расчёт альтернатив (бюджет/дата)
- кэш и rate limiting
Особенности
Различает отсутствие категории и отсутствие совпадений, считает минимальный бюджет и ближайшую дату для пустой выдачи; LLM не может изменить порядок и состав, цитата проверяется в профиле. Использовано лишь 3 реальных профиля + 15 синтетических, явно помеченных.
Стек
Node.js · JavaScript (ESM) · Docker Compose · OpenAI Responses API
Модели
GPT-4.1 mini
Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-9d225095-aidahar.
README
README.mdТочно в формат · #79-lite
Рабочий MVP умного подбора event-подрядчиков Казахстана. Возвращает до трёх карточек с проверяемыми объяснениями, исключает занятых, различает отсутствие категории и отсутствие совпадений. Есть браузерный интерфейс, локальный HTTP API, импорт каталога и сценарии для живого показа.
Запуск через Docker
Нужны Docker Engine и Docker Compose. Node.js и установка пакетов на хосте не требуются.
Для AI создайте локальный .env по образцу .env.example и задайте OPENAI_API_KEY. В текущей рабочей копии ключ уже сохранён; повторно создавать .env не нужно. Файл исключён из Git и Docker-контекста, права доступа — только у владельца. Ключ передаётся контейнеру во время запуска, не включается в образ и не уходит в браузер.
OPENAI_API_KEY=ваш_ключ
OPENAI_MODEL=gpt-4.1-mini
OPENAI_TIMEOUT_MS=9000
APP_PORT=4173
Без ключа доступен весь обычный подбор. После изменения .env пересоздайте сервис командой запуска ниже. OPENAI_MODEL позволяет выбрать другую модель с поддержкой Responses API, JSON Schema и параметра temperature. Контейнер опубликован только на localhost, поскольку AI использует ключ владельца проекта.
docker compose up --build -d
Откройте http://localhost:4173. Состояние контейнера и логи:
docker compose ps
docker compose logs -f app
Остановить сервис:
docker compose down
Чтобы использовать другой порт на хосте, задайте APP_PORT; порт внутри контейнера остаётся 4173:
APP_PORT=8080 docker compose up --build -d
После запуска интерфейс будет доступен по адресу http://localhost:8080.
Проверки и терминальная демонстрация
Тесты автоматически выполняются при сборке образа. Их также можно запустить отдельно:
docker compose run --rm --build app npm test
docker compose run --rm app node demo.mjs
Вторая команда запускает шесть показательных запросов в терминале. Интерфейс содержит кнопки тех же сценариев. Сайт работает через HTTP; открытие HTML двойным щелчком не поддерживается из-за загрузки модулей и каталога.
Что с данными
Полный hackathon-dataset-anonymized.jsonl на 66 профилей не был приложен. В dist/catalog.jsonl находятся 18 записей:
- 3 исходных анонимизированных профиля из текста задания: HK-39372, HK-44733, HK-80581. Сохранены описания, цены, флаги и все присланные занятые даты.
- 15 созданных для демонстрации профилей DEMO-001…DEMO-015. У каждого
synthetic: trueи дополнительныйadded_for_demo: true. Они заметно обозначены в карточках. Их короткие календари созданы для проверки сценариев и не воспроизводят сезонную загрузку полного датасета.
synthetic: false означает исходную анонимизированную запись, а не подтверждённую личность. Внешних проверок качества и доступности нет. city_imputed и price_imputed выводятся как предупреждающие метки, а не скрываются.
Подключение полного каталога
В интерфейсе раскройте «Каталог и прозрачность подбора» и загрузите JSONL, JSON-массив, CSV или TSV. Каталог заменяется, а не смешивается с демопрофилями; после перезагрузки вкладки возвращается стартовый каталог. При обычном подборе файл остаётся в браузере. При включённых AI-пояснениях он передаётся локальному серверу для повторной проверки; в OpenAI отправляются запрос и данные только выбранных кандидатов, включая описания. Импорт сам по себе AI-запрос не запускает. Лимит: 10 МБ и 10 000 записей.
Для HTTP API и стартового каталога интерфейса смонтируйте файл в контейнер только для чтения и укажите внутренний путь:
docker run --rm \
-p 4173:4173 \
-v /absolute/path/hackathon-dataset-anonymized.jsonl:/data/catalog.jsonl:ro \
-e CATALOG_FILE=/data/catalog.jsonl \
contractor-match:local
Перед этим соберите образ командой docker compose build. Для постоянной настройки через Compose добавьте те же volumes и CATALOG_FILE в локальный override-файл compose.override.yaml.
Поддерживаются CSV с разделителем ,, ; или табуляцией, UTF-8 BOM, кавычки и переносы строк внутри полей. Списки в CSV разделяются |. Например, корпоратив|день рождения. Пустое max_hours читается как null. Служебные флаги synthetic, city_imputed и price_imputed не блокируют импорт: true, 1, yes, да, истина читаются как true независимо от регистра и окружающих пробелов. Все остальные значения, пустые ячейки и отсутствующие колонки читаются как false. Для серверной версии каталог читается один раз при запуске; после замены файла нужен перезапуск.
Браузерный импорт меняет только текущую вкладку; каталог API при этом не меняется. Для постоянной смены каталога опубликованного статического сайта замените dist/catalog.jsonl и опубликуйте обновление.
Пайплайн, который можно объяснить жюри
- Валидация: обязательные поля, конечный положительный бюджет в целых тенге, допустимая длительность, корректная дата в окне 23.09–31.12.2026. Вне окна сервис возвращает понятную ошибку: доступность неизвестна.
- Город + категория задают исходный круг. Пустой круг →
category_absent. «Зарубежье» не подставляется вместо выбранного города. - Строгая фильтрация: занятость на дату, цена «от» не выше бюджета, формат в списке, запрошенный язык в списке, достаточный лимит часов.
max_hours: nullне ограничивает длительность присутствия. Пропущенные опциональные параметры не фильтруют. - Ранжирование только допущенных кандидатов. Пожелания разбиваются на слова длиной от четырёх символов; небольшой явный словарь основ обрабатывает, например, «атмосферный/атмосферные». Никаких случайных чисел и внешних вызовов.
- Объяснение из фактов: свободная по календарю дата, совпавшие условия, запас бюджета и цитата из описания. Выбирается предложение с наибольшим числом совпадений с пожеланиями; при равенстве — первое. Цитата обрезается до 270 символов с многоточием.
- Не больше трёх. При меньшем числе показаны все и причины дефицита. Для пустой выдачи рассчитаны конкретные альтернативы: минимальный бюджет при прочих прежних условиях и ближайшая дата с кандидатом при прежнем бюджете и остальных фильтрах, если такие альтернативы существуют. Ничего не меняется автоматически.
Формула и детерминизм
баллы_описания = 10 × min(число уникальных совпавших основ, 5)
баллы_бюджета = 20 × (1 − цена_от / бюджет)
оценка = баллы_описания + баллы_бюджета
порядок = оценка ↓, цена ↑, id ↑
Баллы — соответствие запросу, не рейтинг качества. Более дешёвый подрядчик может обогнать более дорогого при небольшой разнице совпадений. Без пожеланий порядок определяется ценой и ID. Это прозрачная эвристика, которую нужно калибровать на обратной связи; она не объявляется оптимальной моделью выбора.
Одинаковые данные и параметры дают одинаковые карточки и порядок независимо от порядка строк. Время вычисления интерфейс показывает отдельно и не включает в ответ алгоритма. Подсчёт исключений многопричинный: один профиль может быть одновременно занят и дороже бюджета, поэтому суммы причин нельзя складывать как число людей.
Где участвует LLM
OpenAI вызывается сервером через Responses API со Structured Outputs. По умолчанию — GPT-4.1 mini, temperature: 0, store: false.
- Бриф → форма. LLM извлекает город, дату, формат, категорию, бюджет, язык, длительность и пожелания из свободного русского текста. Неуказанные значения возвращаются как
null; приложение берёт их из текущей формы с явной меткой «из формы». Не хватает обязательного поля — просит уточнение. Даты и числовые ограничения проверяет код. - Карточки → персональные пояснения. Сначала код выбирает допущенную тройку. Затем LLM объясняет, как особенности каждого кандидата связаны с брифом, возвращает точную цитату и вопрос для уточнения неподтверждённого пожелания. Сервер проверяет ID, полноту состава и наличие цитаты в соответствующем профиле. Порядок модели игнорируется.
Фактические условия и AI-интерпретация показаны отдельно. Проверка цитаты не доказывает правильность вывода LLM: пользователь видит основание и вопрос к подрядчику. Модель не меняет цены, доступность, состав и порядок рекомендаций. Лексическая формула ранжирования сохраняется; её ограничения в отношении синонимов и отрицаний остаются. Дообучения и эмбеддингов нет.
Рекомендации появляются сразу, AI-пояснения добавляются по готовности. Таймаут одного внешнего вызова по умолчанию 9 секунд; полный сценарий с разбором брифа включает два последовательных вызова и может занять дольше 10 секунд. Ошибка ключа, квоты, сети, неполный JSON или неподтверждённая цитата дают честный fallback на обычные объяснения. Недоступность AI не показывается как успешная генерация.
Кэш в памяти хранит до 128 проверенных ответов на 30 минут; ключ зависит от модели, запроса, контекста формы и переданных профилей. После перезапуска кэш теряется, формулировки LLM могут измениться; порядок карточек для одинаковых структурированных параметров остаётся одинаковым. Одновременные одинаковые запросы объединяются. Не более 4 внешних вызовов одновременно и 30 AI-запросов в минуту с адреса; нет автоматических платных повторов. Открытие страницы само по себе не вызывает LLM.
store: false отключает сохранение Responses-объекта, но не является обещанием отсутствия обработки/служебных журналов у провайдера. Ключ не пишется в журналы или клиентские ответы. На хосте не публикуйте вывод docker compose config или docker inspect: там могут находиться переменные окружения.
Magic moment для жюри
Нажмите «Подобрать по брифу» с текстом: «Корпоратив в Алматы 14 ноября 2026, нужен ведущий до 1,2 млн тенге на 4 часа, на русском. Хочется интеллигентный атмосферный вечер без пошлых конкурсов».
Параметры появляются отдельными метками, форма заполняется, затем к проверенным карточкам добавляются разные AI-пояснения с цитатами. Нажмите «А если на следующий день?» — блок сравнения покажет, кто ушёл из списка, почему и кто появился. Этот эффект основан на реальных календарях текущего каталога, а не на заранее написанной сцене. Кнопки ограничены известным окном календарей.
API
Контейнерный Node-сервер обслуживает GET /api/health и POST /api/recommend. Развёрнутая статическая версия выполняет тот же алгоритм в браузере, HTTP API на ней нет.
AI доступен в локальной Docker-версии. Ранее опубликованное статическое демо не обновляется этой локальной интеграцией.
GET /api/ai/config— наличие настройки ключа (только boolean), название модели; не проверяет баланс или валидность ключа.POST /api/ai/brief—{ "brief": "…", "context": {…текущая форма…} }; результат содержитquery,extracted,inherited,assumptionsлибо списокmissing.POST /api/ai/explain—{ "query": {…}, "catalog": […] };catalogнеобязателен для исходного каталога. Сервер повторно валидирует каталог и выбирает карточки. Ответ:{result, ai};ai.status—ready,skippedилиunavailable.
curl http://127.0.0.1:4173/api/recommend \
-H 'Content-Type: application/json' \
--data '{"city":"Алматы","date":"2026-11-14","event_format":"корпоратив","category":"Ведущий","budget":1200000,"hours":4,"language":"русский","wishes":"интеллигентный атмосферный"}'
Обязательные поля: city, date (ISO YYYY-MM-DD), event_format, category, budget. Опциональные: hours, language, wishes (до 500 символов).
Успешный HTTP 200 возвращает:
status:matched,category_absentилиno_matches;cards: максимум 3 карточки сexplanation,evidence, флагами происхождения и разложением баллов;total_in_category,eligible_count,exclusions;rejected_profiles: причины по конкретным профилям;message,suggestions,calendar_window, нормализованныйquery.
Пустая выдача — обычный результат HTTP 200. Некорректные параметры или JSON → HTTP 400; обычный запрос больше 64 КиБ или AI-запрос больше 10 МБ → HTTP 413. В контейнере сервер слушает 0.0.0.0, Compose публикует его только на 127.0.0.1. Это локальное демо без пользовательской аутентификации, не публичный промышленный API.
При поддержке браузером WebMCP страница регистрирует recommend_contractors: он использует тот же алгоритм и обновляет видимую выдачу. Без WebMCP все кнопки и поля продолжают работать.
Сценарий защиты за 3 минуты
Все сценарии ниже относятся к включённому демокаталогу. После импорта полного датасета результаты могут отличаться.
| Сценарий | Параметры | Что показать |
|---|---|---|
| Плотная категория | Алматы, 14.11, корпоратив, ведущий, 1 200 000 ₸, 4 ч, русский; «интеллигентный атмосферный» | 4 допускаются из 8; первые 3: Буллма, Алмас, Айша. Буллма выигрывает за совпадения в собственном описании, хотя дороже |
| Другая дата | Только дата меняется на 15.11 | Буллма, Дана, Тимур; Алмас и Айша заняты. Раскрыть причины исключения |
| Редкая категория | Алматы, 14.11, флорист, 250 000 ₸ | 1 из 2: Лепесток; исходный флорист занят; список не дополняется неподходящими |
| Кандидаты есть, подходящих нет | Ведущий, 14.11, 50 000 ₸ | no_matches; минимально возможная цена при остальных условиях 420 000 ₸ |
| Категории в городе нет | Алматы, отель | category_absent, объяснение относится только к этому каталогу |
| Площадки | Алматы, 14.11, банкетный зал, 1 200 000 ₸ | Два зала; третий занят на дату |
Повторите первый запрос: порядок идентичен. Покажите цитаты, синтетические метки и формулу в «Детали и оценка». Для воспроизводимой терминальной демонстрации используйте docker compose run --rm app node demo.mjs.
Проверки
16 исходных тестов проверяют происхождение данных, ранжирование, цитаты, детерминизм, даты, редкие категории, пустые состояния, площадки, язык/часы, границы бюджета и импорт. Ещё 13 AI-тестов с подставным транспортом проверяют пропущенные поля, отсутствие выдуманного бюджета, недопустимые даты, выдуманные цитаты/ID, сохранение порядка, Structured Outputs, кэш, изменение контекста, объединение запросов, корректное сообщение о годе, отсутствие платных вызовов без ключа/кандидатов и обработку ошибок. Сборка не обращается к OpenAI и не требует реального ключа.
Проверено также вручную в браузере: заполненная выдача, смена даты, редкая категория и оба пустых состояния; WebMCP — успешный запрос и отклонение даты вне окна без изменения результата. Проверен POST API. В этой среде запуск шести демозапросов занимал примерно 0,1–31 мс на запрос; это не обещание производительности на любом устройстве и не нагрузочный тест.
Файлы
Dockerfile образ приложения; тесты выполняются при сборке
compose.yaml запуск, healthcheck и ограничения контейнера
.dockerignore исключения из контекста сборки
.env.example шаблон настроек OpenAI без ключа
ai.mjs Responses API, схемы, проверка цитат и кэш
dist/magic.css интерфейс AI-консьержа и сравнения дат
dist/index.html интерфейс
dist/style.css адаптивные стили
dist/app.mjs взаимодействие, загрузка каталога, WebMCP
dist/engine.mjs чистая логика, валидация, CSV/JSONL импорт
dist/catalog.jsonl 3 присланных + 15 синтетических профилей
server.mjs локальный HTTP API и раздача интерфейса
tests/matcher.test.mjs проверки алгоритма
tests/ai.test.mjs проверки AI-интеграции без сетевых вызовов
demo.mjs сценарии живого показа
Границы MVP
Цена «от» не гарантирует итоговую смету. Нет актуализации календарей, бронирования, заявок, уведомлений, оценок реального качества и сохранения заказов. Доступность считается как отсутствие даты в busy_dates только внутри известного окна. Для полноценного показа на исходных 66 записях загрузите оригинальный файл и подберите демонстрационные запросы через контейнерную команду из раздела запуска или через форму. Демо на 18 профилях не заменяет проверку полного датасета.
Сравнение и команда мероприятия
В форме выберите дополнительные роли (до 8) и общий бюджет команды. Для каждого из трёх основных кандидатов система находит минимальную сумму цен «от» при максимальном покрытии ролей. Участник учитывается в смете один раз; роли определяются по категориям каталога. Для всех дополнений проверяются город, дата, формат, язык и часы. Незакрытые роли и превышение бюджета основным кандидатом показываются явно. Категории не гарантируют одновременное совмещение услуг или их включение в указанную цену — это предварительный состав для согласования.
POST /api/recommend и /api/ai/explain принимают в query дополнительные поля team_roles: string[] и team_budget: integer. Без общего бюджета используется budget. В результате teams содержит отдельный состав для каждого anchor_id, суммы и missing. В /api/recommend параметры передаются непосредственно в теле запроса, как и раньше.
LLM получает проверенные составы и сравнивает кандидатов: предпочтительный вариант, аргумент, компромисс и сценарий выбора каждого. ID рекомендации и всех альтернатив проверяются по короткому списку; цитаты — по описаниям. Рекомендация LLM не меняет порядок базового списка. При сбое AI составы и сравнение стоимости продолжают работать. Разбор свободного брифа также поддерживает дополнительные роли и отдельный общий бюджет; пропущенные поля наследуются из формы с отметкой.
Авторы коммитов
История коммитов
- 2026-09-23 17:16Link existing Site history to current applicationMiron
- 2026-09-23 17:15Simplify candidate comparison and supplemental recommendationsMiron
- 2026-09-23 17:02Add AI comparison and team planningMiron
- 2026-09-23 16:58v1Miron
- 2026-09-23 15:31Build explainable contractor matching MVPCodex
Структура репозитория (14 файлов)
.dockerignore .env.example .gitignore .openai/hosting.json Dockerfile README.md ai.mjs compose.yaml demo.mjs package.json server.mjs tests/ai.test.mjs tests/matcher.test.mjs tests/team.test.mjs
Обсуждение 0 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.