AAYMA
RU
Войти

Точно в формат

MVP подбора до трёх event-подрядчиков с объяснениями из фактов: браузерный интерфейс, HTTP API, импорт каталога (JSONL/CSV/TSV), разбор свободного…

Рабочий MVP
Команда «AIDAHAR» 9💬 0 комментариевс 23 сентября 2026 г.

О проекте

Команда: 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 и опубликуйте обновление.

Пайплайн, который можно объяснить жюри

  1. Валидация: обязательные поля, конечный положительный бюджет в целых тенге, допустимая длительность, корректная дата в окне 23.09–31.12.2026. Вне окна сервис возвращает понятную ошибку: доступность неизвестна.
  2. Город + категория задают исходный круг. Пустой круг → category_absent. «Зарубежье» не подставляется вместо выбранного города.
  3. Строгая фильтрация: занятость на дату, цена «от» не выше бюджета, формат в списке, запрошенный язык в списке, достаточный лимит часов. max_hours: null не ограничивает длительность присутствия. Пропущенные опциональные параметры не фильтруют.
  4. Ранжирование только допущенных кандидатов. Пожелания разбиваются на слова длиной от четырёх символов; небольшой явный словарь основ обрабатывает, например, «атмосферный/атмосферные». Никаких случайных чисел и внешних вызовов.
  5. Объяснение из фактов: свободная по календарю дата, совпавшие условия, запас бюджета и цитата из описания. Выбирается предложение с наибольшим числом совпадений с пожеланиями; при равенстве — первое. Цитата обрезается до 270 символов с многоточием.
  6. Не больше трёх. При меньшем числе показаны все и причины дефицита. Для пустой выдачи рассчитаны конкретные альтернативы: минимальный бюджет при прочих прежних условиях и ближайшая дата с кандидатом при прежнем бюджете и остальных фильтрах, если такие альтернативы существуют. Ничего не меняется автоматически.

Формула и детерминизм

баллы_описания = 10 × min(число уникальных совпавших основ, 5)
баллы_бюджета  = 20 × (1 − цена_от / бюджет)
оценка        = баллы_описания + баллы_бюджета
порядок       = оценка ↓, цена ↑, id ↑

Баллы — соответствие запросу, не рейтинг качества. Более дешёвый подрядчик может обогнать более дорогого при небольшой разнице совпадений. Без пожеланий порядок определяется ценой и ID. Это прозрачная эвристика, которую нужно калибровать на обратной связи; она не объявляется оптимальной моделью выбора.

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

Где участвует LLM

OpenAI вызывается сервером через Responses API со Structured Outputs. По умолчанию — GPT-4.1 mini, temperature: 0, store: false.

  1. Бриф → форма. LLM извлекает город, дату, формат, категорию, бюджет, язык, длительность и пожелания из свободного русского текста. Неуказанные значения возвращаются как null; приложение берёт их из текущей формы с явной меткой «из формы». Не хватает обязательного поля — просит уточнение. Даты и числовые ограничения проверяет код.
  2. Карточки → персональные пояснения. Сначала код выбирает допущенную тройку. Затем 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 составы и сравнение стоимости продолжают работать. Разбор свободного брифа также поддерживает дополнительные роли и отдельный общий бюджет; пропущенные поля наследуются из формы с отметкой.

Звёзды
⭐ 0
Форки
0
Язык
JavaScript
Последний push
в прошлом месяце
Коммиты
5
Авторы
2
Файлы
14
Ветки
2

Авторы коммитов

Miron 4Codex 1

История коммитов

  • 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
Ветки (2)

Обсуждение 0 комментариев

Войдите, чтобы комментировать

Пока нет комментариев — будьте первым.