EventMatch KZ
Сервис подбора до трёх event-подрядчиков в Казахстане по городу, дате, типу мероприятия, категории и бюджету с фактическим объяснением выбора и…
О проекте
Команда: 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.mdEventMatch 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: город + категория. Остаются профили, у которых город совпадает с запрошенным, а в списке категорий есть запрошенная (сравнение без учёта регистра). Если таких нет, ответ
no_category. Число прошедших профилей возвращается вtotal_candidates, это размер пула до остальных фильтров. -
Жёсткий фильтр, этап 2: доступность. Из пула отсеиваются подрядчики, у которых формат мероприятия не в
event_formats,price_from_kztвыше бюджета или дата мероприятия входит вbusy_dates. Если указаны язык и длительность, отсеиваются также те, кто не говорит на этом языке или у когоmax_hoursменьше нужного. Если не остался никто, ответno_match, а вmessageперечислены причины со счётчиками, например «не подходит формат мероприятия — 2». Если отсеян хотя бы один профиль, причины дописываются вmessageи приok, сколько бы карточек ни вернулось. Счётчики считаются по причинам, а не по людям: профиль, не прошедший два фильтра, учитывается в обоих, поэтому сумма может быть больше числа отсеянных. К причине «заняты на выбранную дату» добавляется ближайшая дата после выбранной (до 2026-12-31), в которую свободен хотя бы один из занятых. Остальные фильтры для этой даты не проверяются, поэтому в тексте прямо сказано: «не гарантирует совпадение по остальным критериям». -
Детерминированный скор без цены. При старте backend одним пакетным запросом получает embeddings (
text-embedding-3-small) для описаний всех профилей и для всех пар «категория + формат» из датасета. Скор равен косинусной близости описания к паре «категория + формат» из запроса, приведённой к шкале 0…1, плюс 0.10, если язык указан и совпадает. Цена в скоре не участвует: она уже проверена фильтром. Сортировка идёт по убыванию скора, при равенстве поid. Берутся первые три. -
Сортировка тройки по цене. Выбранные три карточки выводятся по возрастанию
price_from_kzt, при равной цене поid. -
LLM только переписывает готовые факты. Для каждой карточки backend сам считает факты: цену, бюджет, запас бюджета в %, совпадение формата и языка, длительность и фрагмент описания. Фрагмент получается так: описание разбивается на предложения. Если язык в запросе не указан, из него убираются предложения, где упоминаются языки («язык…», «двуязыч…» или языки подрядчика). Оставшееся склеивается и обрезается до 850 символов. Эти факты уходят в
gpt-4o-miniсtemperature=0и инструкцией использовать только их. Backend заранее составляет первую фразу, напримерФормат «свадьба» совпадает; цена от 650000 ₸ укладывается в бюджет 1000000 ₸., и модель должна скопировать её дословно. Если в запросе указаны язык или длительность, первая фраза подтверждает и их в виде…; язык «<язык>» совпадает; запрошенные N ч укладываются в лимит M ч.(при пустомmax_hours: «ограничение по часам в профиле не указано»). Второе предложение модель добавляет, только если в описании есть конкретная деталь. Ответ запрашивается в виде JSON с ключомexplanation. Ответ проверяется:- он должен начинаться ровно с этой первой фразы;
- если язык в запросе не указан, в нём не должно быть упоминаний языка.
Если проверка не пройдена, JSON не разобрался или вызов упал (таймаут 5.5 с, без повторов), берётся шаблонное объяснение
factual_fallback: это та же первая фраза без второго предложения. Три карточки объясняются параллельно. -
Кэш. Объяснения хранятся в памяти процесса по ключу
(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 / ошибка / загрузка
Авторы коммитов
История коммитов
- 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 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.