AAYMA
RU
Войти

EventMatch KZ

Сервис подбора до трёх event-подрядчиков в Казахстане по городу, дате, типу мероприятия, категории и бюджету с фактическим объяснением выбора и…

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

О проекте

Команда: Dzateee
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Рабочий MVP

Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор

Суть

Сервис подбора до трёх event-подрядчиков в Казахстане по городу, дате, типу мероприятия, категории и бюджету с фактическим объяснением выбора и причинами отсева.

Как устроено

FastAPI держит в памяти датасет из 66 профилей. Жёсткие фильтры: город+категория, затем формат, цена, занятые даты, язык и часы; ведутся счётчики причин отсева и подсказывается ближайшая свободная дата. Скор равен косинусной близости эмбеддинга описания (text-embedding-3-small) к паре «категория+формат» плюс бонус за язык; цена в скоре не участвует, топ-3 выводятся по цене. gpt-4o-mini (temperature=0) лишь переписывает заранее посчитанные факты; ответ проверяется на точное начало фразы, при сбое берётся шаблон, валидные ответы кэшируются.

Ключевые решения

  • жёсткие фильтры
  • text-embedding-3-small
  • косинусная близость
  • шаблонная первая фраза с проверкой ответа LLM
  • factual fallback
  • кэш по sha256 запроса
  • параллельные вызовы с таймаутом 5.5 с

Особенности

LLM не выбирает и не придумывает: бэкенд считает факты и первую фразу, модель обязана скопировать её дословно, иначе срабатывает fallback. Честные ответы no_match/no_category со счётчиками причин отсева и подсказкой ближайшей свободной даты. Четыре воспроизводимых демо-сценария.

Стек

Python · FastAPI · Next.js · TypeScript · Tailwind · shadcn/ui

Модели

GPT-4o-mini · text-embedding-3-small


Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-fdf1806d-dzateee.

README

README.md

EventMatch KZ

MVP для хакатона Hack Alem AI: сервис подбирает event-подрядчиков в Казахстане. Организатор указывает город, дату, тип мероприятия, категорию подрядчика и бюджет (по желанию ещё длительность и язык). Сервис возвращает до трёх подходящих подрядчиков из датасета на 66 профилей. У каждого есть объяснение «почему этот», построенное на конкретных совпадениях по бюджету, формату и описанию, а не на общих фразах. Если никто не подходит, сервис объясняет почему.

Браузер ──► Next.js (frontend/, :3000) ──/api/*──► FastAPI (backend/, :8000) ──► OpenAI
                                                    └─ датасет в памяти: backend/data/hackathon-dataset.csv

Как устроен подбор

Код пайплайна: backend/main.py, эндпоинт POST /api/recommend.

  1. Жёсткий фильтр, этап 1: город + категория. Остаются профили, у которых город совпадает с запрошенным, а в списке категорий есть запрошенная (сравнение без учёта регистра). Если таких нет, ответ no_category. Число прошедших профилей возвращается в total_candidates, это размер пула до остальных фильтров.

  2. Жёсткий фильтр, этап 2: доступность. Из пула отсеиваются подрядчики, у которых формат мероприятия не в event_formats, price_from_kzt выше бюджета или дата мероприятия входит в busy_dates. Если указаны язык и длительность, отсеиваются также те, кто не говорит на этом языке или у кого max_hours меньше нужного. Если не остался никто, ответ no_match, а в message перечислены причины со счётчиками, например «не подходит формат мероприятия — 2». Если отсеян хотя бы один профиль, причины дописываются в message и при ok, сколько бы карточек ни вернулось. Счётчики считаются по причинам, а не по людям: профиль, не прошедший два фильтра, учитывается в обоих, поэтому сумма может быть больше числа отсеянных. К причине «заняты на выбранную дату» добавляется ближайшая дата после выбранной (до 2026-12-31), в которую свободен хотя бы один из занятых. Остальные фильтры для этой даты не проверяются, поэтому в тексте прямо сказано: «не гарантирует совпадение по остальным критериям».

  3. Детерминированный скор без цены. При старте backend одним пакетным запросом получает embeddings (text-embedding-3-small) для описаний всех профилей и для всех пар «категория + формат» из датасета. Скор равен косинусной близости описания к паре «категория + формат» из запроса, приведённой к шкале 0…1, плюс 0.10, если язык указан и совпадает. Цена в скоре не участвует: она уже проверена фильтром. Сортировка идёт по убыванию скора, при равенстве по id. Берутся первые три.

  4. Сортировка тройки по цене. Выбранные три карточки выводятся по возрастанию price_from_kzt, при равной цене по id.

  5. LLM только переписывает готовые факты. Для каждой карточки backend сам считает факты: цену, бюджет, запас бюджета в %, совпадение формата и языка, длительность и фрагмент описания. Фрагмент получается так: описание разбивается на предложения. Если язык в запросе не указан, из него убираются предложения, где упоминаются языки («язык…», «двуязыч…» или языки подрядчика). Оставшееся склеивается и обрезается до 850 символов. Эти факты уходят в gpt-4o-mini с temperature=0 и инструкцией использовать только их. Backend заранее составляет первую фразу, например Формат «свадьба» совпадает; цена от 650000 ₸ укладывается в бюджет 1000000 ₸., и модель должна скопировать её дословно. Если в запросе указаны язык или длительность, первая фраза подтверждает и их в виде …; язык «<язык>» совпадает; запрошенные N ч укладываются в лимит M ч. (при пустом max_hours: «ограничение по часам в профиле не указано»). Второе предложение модель добавляет, только если в описании есть конкретная деталь. Ответ запрашивается в виде JSON с ключом explanation. Ответ проверяется:

    • он должен начинаться ровно с этой первой фразы;
    • если язык в запросе не указан, в нём не должно быть упоминаний языка.

    Если проверка не пройдена, JSON не разобрался или вызов упал (таймаут 5.5 с, без повторов), берётся шаблонное объяснение factual_fallback: это та же первая фраза без второго предложения. Три карточки объясняются параллельно.

  6. Кэш. Объяснения хранятся в памяти процесса по ключу (id подрядчика, sha256 от JSON запроса с отсортированными ключами). Одинаковый запрос сразу получает те же тексты, а параллельные одинаковые запросы вызывают LLM один раз. В кэш попадают только ответы LLM, прошедшие проверку. Шаблон factual_fallback не кэшируется, поэтому после разового сбоя повтор того же запроса снова обращается к модели. Кэш сбрасывается при перезапуске backend.

Без OPENAI_API_KEY backend тоже работает: фильтры действуют как обычно, семантический скор равен 0 (порядок решают бонус за язык и id), а explanation содержит заглушка.

Запуск

Нужны Python 3.10+ (проверено на 3.14) и Node.js 20.9+ (проверено на 24). Команды для PowerShell. Backend и frontend запускаются в разных терминалах из корня репозитория.

Backend (FastAPI, порт 8000)

cd backend
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
Copy-Item .env.example .env      # затем впишите ключ: OPENAI_API_KEY=sk-...
.\.venv\Scripts\python.exe -m uvicorn main:app --host 127.0.0.1 --port 8000
  • Датасет backend/data/hackathon-dataset.csv уже лежит в репозитории и читается один раз при старте.
  • .env читается только при старте. После смены ключа backend нужно перезапустить.
  • Swagger: http://127.0.0.1:8000/docs
  • На macOS/Linux вместо .\.venv\Scripts\python.exe используйте .venv/bin/python.

Frontend (Next.js, порт 3000)

cd frontend
npm install
npm run dev

Откройте http://localhost:3000. Next.js проксирует /api/* на backend, так что проблем с CORS нет. Адрес backend задаётся в frontend/.env.local (образец: frontend/.env.example):

# адрес backend (это значение по умолчанию)
BACKEND_URL=http://localhost:8000
# true: мок-данные без backend и переключатель ответов в шапке
NEXT_PUBLIC_USE_MOCKS=false

Демо-сценарии для жюри

Все параметры и результаты ниже получены прогоном в браузере на реальном backend с рабочим OpenAI-ключом. Длительность и язык не заданы. Каждый сценарий вводится в форму на http://localhost:3000.

#СценарийГородДатаТипКатегорияБюджетИсход
1Плотная категорияАлматы2026-10-26свадьбаВедущий1 000 000 ₸ok, 3 карточки
2Редкая категорияАстана2026-10-26свадьбаВедущий церемонии300 000 ₸ok, 1 карточка
3Без результатаАстана2026-10-26юбилейВидеограф500 000 ₸no_match
4Плотная категория, другая датаАлматы2026-10-22свадьбаВедущий1 000 000 ₸ok, 3 карточки, одна заменена

1. Плотная категория: выбор из многих

В Алматы 10 ведущих. Фильтры проходят 5, в выдачу попадают 3 с лучшим скором. message над карточками: «Подошли 5 из 10 подрядчиков категории Ведущий в городе Алматы. Остальные не прошли фильтры: не подходит формат мероприятия — 4; заняты на выбранную дату — 1 (ближайшая свободная дата в календаре — 2026-10-27, не гарантирует совпадение по остальным критериям); цена выше бюджета — 2.» Отсеяно 5 профилей, а сумма счётчиков 7: часть профилей не прошла сразу два фильтра.

idПодрядчикЦена отОбъяснение (ответ LLM)
HK-44923Мицури Канроджи650 000 ₸Формат «свадьба» совпадает; цена от 650000 ₸ укладывается в бюджет 1000000 ₸. Вместе с вами мы разработаем ОРИГИНАЛЬНЫЙ сценарий.
HK-42352Эмилия900 000 ₸Формат «свадьба» совпадает; цена от 900000 ₸ укладывается в бюджет 1000000 ₸. Опыт ведения свадеб 13 лет.
HK-77838Хаул1 000 000 ₸Формат «свадьба» совпадает; цена от 1000000 ₸ укладывается в бюджет 1000000 ₸. Он — актёр театра и кино, педагог по актёрскому мастерству театральной академии искусств.

Что показывает: фильтрация и ранжирование разделены. Ещё двое, Кики и Сон Гоку, прошли все фильтры, но уступили по семантическому скору. Тройка выведена по цене, объяснения опираются на цифры бюджета и факт из описания. Повторный запрос даёт тот же порядок и тот же текст.

2. Редкая категория: честный ответ «нашёлся один»

В Астане один ведущий церемонии, и он свободен. total_candidates = 1, message: «Подошли 1 из 1 подрядчиков категории Ведущий церемонии в городе Астана».

idПодрядчикЦена отОбъяснение (ответ LLM)
HK-90008Рем220 000 ₸Формат «свадьба» совпадает; цена от 220000 ₸ укладывается в бюджет 300000 ₸. Церемониймейстер выездных регистраций брака.

Что показывает: сервис не добивает выдачу до трёх нерелевантными профилями. Интерфейс пишет «1 подходящий подрядчик · из 1 в категории».

3. Без результата: объяснение, почему никто не подошёл

В Астане 2 видеографа, но оба не работают с юбилеями. Статус no_match, total_candidates = 2, message: «В городе Астана есть 2 подрядчиков категории Видеограф, но никто не прошёл фильтры: не подходит формат мероприятия — 2».

Что показывает: пустой результат не выглядит как ошибка или пустой экран. Жёлтая панель «Никто не подошёл» называет конкретную причину. Она визуально отличается от голубой панели no_category, которую можно увидеть так: Астана, Ресторан. Ответ: «В городе Астана нет подрядчиков категории Ресторан», total_candidates = 0.

4. Та же плотная категория на другую дату: занятый кандидат

Параметры как в сценарии 1, но дата 2026-10-22. В этот день у Хаула (HK-77838) дата в busy_dates. Фильтры проходят 4 из 10, и Хаула в выдаче больше нет. message: «Подошли 4 из 10 подрядчиков категории Ведущий в городе Алматы. Остальные не прошли фильтры: не подходит формат мероприятия — 4; заняты на выбранную дату — 3 (ближайшая свободная дата в календаре — 2026-10-23, не гарантирует совпадение по остальным критериям); цена выше бюджета — 2.» Счётчик занятости вырос с 1 до 3: кроме Хаула, на эту дату заняты ещё двое, которые и так отсеивались по другим причинам.

idПодрядчикЦена отОбъяснение (ответ LLM)
HK-44923Мицури Канроджи650 000 ₸Формат «свадьба» совпадает; цена от 650000 ₸ укладывается в бюджет 1000000 ₸. Вместе с вами мы разработаем ОРИГИНАЛЬНЫЙ сценарий.
HK-35215Кики900 000 ₸Формат «свадьба» совпадает; цена от 900000 ₸ укладывается в бюджет 1000000 ₸. Ведёт все форматы: свадьбы, проводы невесты, обряд первых шагов.
HK-42352Эмилия900 000 ₸Формат «свадьба» совпадает; цена от 900000 ₸ укладывается в бюджет 1000000 ₸. Опыт ведения свадеб 13 лет.

Что показывает: доступность по дате проверяется жёстко. Занятого кандидата с высоким скором заменяет следующий по скору (Кики), остальные две позиции не меняются. У Кики и Эмилии одинаковая цена 900 000 ₸, поэтому порядок решает id: HK-35215 идёт перед HK-42352.

Как проверить, что объяснения пишет LLM

Если вызов модели не удался, backend подставляет шаблон: только первую фразу, например Формат «свадьба» совпадает; цена от 650000 ₸ укладывается в бюджет 1000000 ₸. Без ключа приходит заглушка. В прогоне выше у всех 7 карточек есть второе предложение с деталью из описания, которого нет ни в шаблоне, ни в заглушке: это ответы gpt-4o-mini. Обратное не работает: если в описании нет конкретной детали, модель тоже возвращает одну первую фразу, и по тексту такой ответ от шаблона не отличить.

Ограничения

  • Повторяемость текста внутри одного запуска гарантирует кэш. Между перезапусками её обеспечивает temperature=0, но побайтовое совпадение ответа модели не гарантировано. Порядок карточек от LLM не зависит.
  • Embeddings считаются при старте. Если OpenAI недоступен в этот момент, семантический скор до перезапуска равен 0.
  • Датасет читается при старте. Базы данных, бронирования и обновления профилей на лету нет.
  • Категория выбирается из 17 значений датасета: backend требует точного совпадения названия.

Структура репозитория

backend/    FastAPI: фильтры, скор, объяснения, датасет в data/
frontend/   Next.js 16 + React 19 + Tailwind 4 + shadcn/ui: форма, карточки, состояния ok / no_category / no_match / ошибка / загрузка
Звёзды
⭐ 0
Форки
0
Язык
TypeScript
Последний push
в прошлом месяце
Коммиты
21
Авторы
1
Файлы
51
Ветки
1

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

yeras_network 21

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

  • 2026-09-23 16:26docs(backend): align README with code and root READMEyeras_network
  • 2026-09-23 16:25frontend: always show backend message for ok resultsyeras_network
  • 2026-09-23 16:11docs: note that fallback explanations are not cachedyeras_network
  • 2026-09-23 16:10docs: describe current LLM facts snippet and answer validationyeras_network
  • 2026-09-23 16:08docs: refresh demo explanation texts after new LLM prompt (b0e7895)yeras_network
  • 2026-09-23 16:03backend: improve LLM explanation prompt, parallel calls with 5.5s timeoutyeras_network
  • 2026-09-23 15:33docs: rewrite root README with pipeline, setup and verified demo scenariosyeras_network
  • 2026-09-23 15:32frontend: keep category and event type casing as in the datasetyeras_network
Все коммиты (21)
  • 2026-09-23 15:27docs: project README for EventMatch KZyeras_network
  • 2026-09-23 15:25backend: recommend API with deterministic ranking and OpenAI explanationsyeras_network
  • 2026-09-23 15:05frontend: drop "той" from event typesyeras_network
  • 2026-09-23 14:29frontend: connect to real backend POST /api/recommendyeras_network
  • 2026-09-23 13:44frontend: recommend form, result states (ok/no_category/no_match/error/loading) with mock APIyeras_network
  • 2026-09-23 13:14Scaffold FastAPI backendyeras_network
  • 2026-09-23 13:13frontend: scaffold Next.js + Tailwind + shadcn/ui, API client with mock toggleyeras_network
  • 2026-09-23 13:06Create backend directoryyeras_network
  • 2026-09-23 12:27docs: show nearest free date hint in demo scenarios 1 and 4yeras_network
  • 2026-09-23 12:20backend: neutral wording for nearest free date hintyeras_network
  • 2026-09-23 12:19backend: suggest nearest free date for contractors busy on the event dateyeras_network
  • 2026-09-23 12:06docs: refresh demo scenarios after filter-reason and first-sentence changesyeras_network
  • 2026-09-23 11:59backend: explain duration/language in first sentence, always list filter reasonsyeras_network
Структура репозитория (51 файлов)
README.md
backend/.env.example
backend/.gitignore
backend/.gitkeep
backend/README.md
backend/data/.gitkeep
backend/data/hackathon-dataset.csv
backend/main.py
backend/requirements.txt
frontend/.env.example
frontend/.gitignore
frontend/AGENTS.md
frontend/CLAUDE.md
frontend/README.md
frontend/components.json
frontend/eslint.config.mjs
frontend/next.config.ts
frontend/package-lock.json
frontend/package.json
frontend/postcss.config.mjs
frontend/public/file.svg
frontend/public/globe.svg
frontend/public/next.svg
frontend/public/vercel.svg
frontend/public/window.svg
frontend/src/app/favicon.ico
frontend/src/app/globals.css
frontend/src/app/layout.tsx
frontend/src/app/page.tsx
frontend/src/components/contractor-card.tsx
frontend/src/components/recommend-form.tsx
frontend/src/components/results-panel.tsx
frontend/src/components/ui/badge.tsx
frontend/src/components/ui/button.tsx
frontend/src/components/ui/card.tsx
frontend/src/components/ui/dialog.tsx
frontend/src/components/ui/input.tsx
frontend/src/components/ui/label.tsx
frontend/src/components/ui/select.tsx
frontend/src/components/ui/separator.tsx
frontend/src/components/ui/skeleton.tsx
frontend/src/components/ui/sonner.tsx
frontend/src/components/ui/tabs.tsx
frontend/src/components/ui/textarea.tsx
frontend/src/lib/api.ts
frontend/src/lib/constants.ts
frontend/src/lib/mocks.ts
frontend/src/lib/recommend.ts
frontend/src/lib/types.ts
frontend/src/lib/utils.ts
frontend/tsconfig.json

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

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

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