EventMatch
Сервис подбора до трёх event-подрядчиков.
О проекте
Команда: Hurricane
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Доведённый продукт
Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор
Суть
Сервис подбора до трёх event-подрядчиков. Три карточки соответствуют трём ролям: лучшее совпадение, самая низкая цена, самый дорогой в рамках бюджета. Объяснения формулирует LLM по проверенным фактам. При пустом или неполном результате сервис предлагает соседние даты, бюджет или город. Развёрнут на Railway.
Как устроено
Пул из PostgreSQL по городу и категории, затем жёсткие фильтры со счётчиками причин отказа. Баллы начисляются за упоминание формата, семантическую близость описания к заказу (эмбеддинги text-embedding-3-small, 256 измерений), языки, запас часов и цену. Порядок детерминирован: баллы, затем цена, затем id. Факты считает код, OpenAI переписывает их в 1–2 предложения. Ответ проверяется: числа и языки должны быть в фактах, общие фразы из стоп-листа запрещены; при сбое используется шаблон. Модуль suggestions подбирает соседние даты, бюджет и города.
Ключевые решения
- hard filters
- embedding cosine similarity
- role-slot ranking
- LLM fact-checking of numbers/languages
- template fallback
- suggestions search
Особенности
Каждая из трёх карточек закрывает свою роль, поэтому объяснения не повторяют друг друга. Числа и языки из текста LLM сверяются с фактами. Команда добавила 10 синтетических профилей в тонкие категории, занятость сгенерирована по сезонности. Интерфейс на трёх языках (ru/kk/en), подбор можно открыть по ссылке.
Стек
Python · FastAPI · SQLAlchemy · Alembic · PostgreSQL · React · TypeScript · Vite · TanStack Router · Tailwind CSS · Railway · Docker
Модели
gpt-5.4-mini · text-embedding-3-small
Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-2b4a46cf-hurricane.
README
README.mdEventMatch: подбор event-подрядчиков
Заказчик мероприятия вводит город, дату, формат, категорию и бюджет и получает до трёх свободных подрядчиков из каталога. У каждой карточки своя причина попасть в тройку и объяснение с конкретными фактами: доля бюджета, языки, часы, деталь из описания. Цена показывается как в каталоге, без «от»: поле price_from_kzt датасета это стартовая цена за мероприятие, и на карточке это одно число, а не диапазон. Если подходящих меньше трёх или нет совсем, сервис говорит словами, кто и почему не прошёл, и предлагает соседнюю дату, бюджет или город.
Демо: https://ui-production-d923.up.railway.app · API: https://app-production-b973.up.railway.app/health
Задача хакатона: docs/hackathon/TASK.md. Пайплайн подбора шаг за шагом: docs/PIPELINE.md. Предметная область и схемы API: docs/DOMAIN.md.
Требования → статус
| Требование | Статус | Где в коде |
|---|---|---|
| Вход: город, дата, тип мероприятия, категория, бюджет; опционально длительность и язык | ✅ | RecommendIn в app/src/schemas.py |
| Выход: до 3 карточек с именем, категорией, городом, ценой и объяснением в 1–2 предложения без общих фраз | ✅ | app/src/facts.py, app/src/explain.py, промпт app/src/explain_prompt.md, стоп-лист фраз BANNED_PHRASES |
| Занятый на дату не попадает в выдачу, площадки идут тем же фильтром | ✅ | get_rejection_reasons в app/src/recommend.py |
| Подходящих меньше трёх: показать сколько есть и почему меньше | ✅ | passed_count, rejections и suggestions в RecommendOut, экран ui/src/components/SearchHints.tsx |
| Детерминизм: тот же запрос, тот же порядок карточек | ✅ | pick_role_vendors: баллы, цена, id; LLM порядок не меняет |
| Три исхода различимы явно | ✅ | outcome в RecommendOut, три экрана в results.tsx |
| Ответ до 10 секунд | ✅ | таймауты: БД 5 с, LLM 8 с без ретраев, при сбое шаблон |
| Объяснения не взаимозаменяемы | ✅ | роли карточек, get_distinctions и get_alternative_reason в facts.py |
| Видно, где настоящий профиль, а где синтетический | ✅ | флаг synthetic в карточке, бэдж в ui/src/components/VendorCard.tsx |
Как работает подбор
- Пул. Из базы берутся подрядчики нужного города и категории. Пусто → исход «в этом городе такой категории нет» и список городов, где она есть.
- Фильтры. Занят на дату, не берёт формат, дороже бюджета, мало часов, нет языка. Каждый отсеянный считается по первой причине, счётчики уходят в ответ. Никто не прошёл → исход «кандидаты есть, но не проходят».
- Баллы и роли. Прошедшие получают баллы за упоминание формата в описании, близость описания к заказу по смыслу (эмбеддинги OpenAI, три ближайших получают 3, 2 и 1 балл), лишние языки, запас часов, настоящий профиль, точную цену и цену до 80% бюджета. Три карточки это три роли: лучшее совпадение, самая низкая цена, самый дорогой в бюджете. Если один подрядчик выиграл две роли, место уходит следующему по баллам, и его объяснение называет конкретный разрыв с первым: цена, оценочная цена, синтетический профиль или формат не упомянут в описании. Ничьи решает цена, потом
id, поэтому порядок стабилен. - Объяснения. Факты считает код: доля и остаток бюджета, дата, формат, языки, часы, чем отличается от соседей по выдаче, сколько в категории заняты на дату. OpenAI формулирует их в 1–2 предложения по правилу «факт → что это даёт заказчику». Ответ проверяется: не больше двух предложений, каждое число есть в фактах карточки или в описании подрядчика, языки только те, на которых он работает, нет общих фраз и служебных слов вроде «карточка», нет латиницы внутри русских слов, нет выдуманной «оценочной цены». Шаблон тоже ровно два предложения. Не прошёл или модель недоступна → шаблон из тех же фактов, поле
explanation_sourceпоказывает источник. - Подсказки. Если прошло меньше трёх, в ответе соседние даты в пределах трёх дней, бюджет и длительность, при которых кандидатов больше. Если не прошёл никто, ещё и города, где подрядчики этой категории проходят все условия заказа. Фронт перезапускает подбор по подсказке в один клик.
LLM участвует только в шаге 4 и только как редактор текста. Подробности и отвергнутые варианты: docs/PIPELINE.md.
Архитектура
ui (React + Vite, Railway) ──HTTP/JSON──▶ app (FastAPI, Railway) ──▶ Postgres (Railway)
│
└──▶ OpenAI API
Бэкенд app/src/:
main.pyроутеры, CORS,/health.catalog.pyсловари фильтровGET /catalog/optionsи каталогGET /vendors.recommend.pyPOST /recommend: пул, баллы, роли.filters.pyжёсткие фильтры и счётчики отказов.embeddings.pyкосинусная близость описаний к заказу.suggestions.pyподсказки: даты, бюджет, часы, города.facts.pyфакты для карточки: роль, доля бюджета, отличия от соседей.explain.pyвызов LLM, проверка ответа, шаблон.llm.pyклиент OpenAI с таймаутом.schemas.pyсхемы входа и выхода и словариvalue ↔ label.models.pyтаблицаvendors, миграции вapp/migrations/.seed.pyзагрузкаapp/data/vendors.csv.
Фронтенд ui/src/:
routes/index.tsxформа фильтров и каталог.routes/results.tsxрезультат подбора: загрузка, три исхода, причины отказа, подсказки, фильтры без возврата на главную.components/ResultsFilters.tsxсводка выбранных фильтров и раскрываемая форма.components/VendorCard.tsxкарточка с ролью и бэджем синтетического профиля.api.tsвсе запросы черезopenapi-fetch, типы вapi-types.tsсгенерированы из/openapi.json.
Технологии и данные
- Бэкенд: Python 3.12, FastAPI, SQLAlchemy 2 (async), Alembic, Pydantic v2, asyncpg,
openai, uv; pytest для проверок основного сценария API. - Фронтенд: React 19, TypeScript, Vite 7, TanStack Router и Query, Tailwind CSS v4, shadcn на Base UI, Lingui (ru / kk / en), yarn.
- База: PostgreSQL 17. Инфраструктура: Railway, конфигурация в
.railway/railway.ts. - Модели:
OPENAI_MODEL, по умолчаниюgpt-5.4-mini, вызов сseedиresponse_format: json_object;OPENAI_EMBEDDING_MODEL, по умолчаниюtext-embedding-3-small, 256 измерений, векторы описаний лежат в колонкеvendors.description_embeddingи считаются один раз при сиде. - Данные: каталог организатора, 66 профилей,
app/data/vendors.csv. Загружается командойpython -m src.seed, upsert поid, затем эмбеддинги для описаний без вектора; повторный запуск безопасен. В compose это делает сервисseedпосле миграций, на RailwaypreDeployCommand. К 13 синтетическим профилям датасета добавлены 10 своих (HK-90014–HK-90023,synthetic: True) в категории, где было по одному-два подрядчика: флористы, ведущие церемоний, фото- и видеобудки, подарки, зал и отель в Астане, видеограф, инструменталист. Занятые даты у них сгенерированы по сезонности задачи: 40% дней осенью, 75% в декабре и 90% на декабрьских выходных. На карточке синтетический профиль помечен бэджем.
Запуск
Нужны Docker Desktop или Podman Desktop с Compose и Python 3.9+.
python dev.py
Скрипт проверит инструменты, создаст .env из .env.example, включит git-хуки, подберёт свободные порты, соберёт контейнеры и дождётся /health. Compose поднимает Postgres, применяет миграции, загружает каталог, затем стартует API и фронтенд. Адреса: UI http://localhost:5173, API http://localhost:8000/docs.
После первого запуска вписать в .env OPENAI_API_KEY и повторить python dev.py. Без ключа всё работает: объяснения шаблонные, поиск по смыслу описания отключён, остаётся поиск формата в тексте.
Другие команды: python dev.py down остановить (down -v также удалит базу), python dev.py logs app логи, python dev.py status состояние контейнеров. Без скрипта то же самое делает docker compose up --build.
Разработка без Docker
# бэкенд, нужен запущенный Postgres, например `docker compose up db`
cd app && uv sync && uv run alembic upgrade head && uv run python -m src.seed && uv run python -m src
# фронтенд
cd ui && yarn install && yarn dev
После изменения схем на бэкенде перегенерировать типы фронтенда: yarn api:types в ui/ при запущенном API, файл ui/src/api-types.ts коммитится. Проверки перед коммитом: uv run pytest и uv run ruff check . && uv run ruff format --check . в app/, yarn lint в ui/. Хук на сообщения коммитов: git config core.hooksPath .githooks.
Как проверить основной сценарий
Через интерфейс:
- Открыть http://localhost:5173, выбрать город, дату, формат, категорию и бюджет. Язык и длительность в блоке «Дополнительные условия». Под формой загружается каталог, «Показать всех подрядчиков» раскрывает весь список.
- Нажать «Подобрать»: откроется
/resultsс фильтрами в URL. Появятся карточки с ролями и объяснениями или один из двух пустых исходов с причинами и подсказками. При пустом исходе форма раскрыта прямо под сообщением, при успешном скрыта за кнопкой «Изменить фильтры»: новый подбор происходит на той же странице. - Нажать «Скопировать ссылку» и открыть её в новой вкладке: тот же подбор загрузится без формы, порядок карточек тот же.
Через API, три запроса из Definition of Done:
# плотная категория на осеннюю дату: 3 карточки, 5 из 10 ведущих заняты
curl -X POST http://localhost:8000/recommend -H 'content-type: application/json' \
-d '{"city":"almaty","event_date":"2026-10-17","event_format":"wedding","category":"host","budget_kzt":1500000,"duration_hours":6,"languages":["ru","kk"]}'
# редкая категория: 1 карточка из 3, два флориста заняты
curl -X POST http://localhost:8000/recommend -H 'content-type: application/json' \
-d '{"city":"almaty","event_date":"2026-10-20","event_format":"wedding","category":"florist","budget_kzt":400000}'
# кандидаты есть, но никто не проходит: все три фотографа не берут корпоратив
curl -X POST http://localhost:8000/recommend -H 'content-type: application/json' \
-d '{"city":"astana","event_date":"2026-10-10","event_format":"corporate","category":"photographer","budget_kzt":600000}'
Первый запрос на 2026-12-19 вместо 2026-10-17 даёт одну карточку вместо трёх, в объяснении сказано, что 9 из 10 заняты. Полная таблица проверок: docs/PIPELINE.md.
Переменные окружения
| Имя | Назначение | Пример |
|---|---|---|
OPENAI_API_KEY | ключ OpenAI, без него объяснения шаблонные | sk-... |
OPENAI_MODEL | модель для объяснений | gpt-5.4-mini |
OPENAI_EMBEDDING_MODEL | модель эмбеддингов описаний | text-embedding-3-small |
CORS_ORIGINS | разрешённые origin через запятую | https://<ui>.up.railway.app |
DATABASE_URL | строка подключения к Postgres | postgresql+asyncpg://app:app@db:5432/app |
VITE_API_URL | адрес API для фронтенда, вшивается при сборке | https://<app>.up.railway.app |
Деплой
Railway, проект hurricane, окружение production, сервисы app, ui и плагин Postgres. Автодеплоя нет: railway up из app/ и из ui/, миграции и сид выполняет preDeployCommand. Подробно: DEPLOYMENT.md.
Известные ограничения
- Порядок и состав карточек детерминированы, формулировки объяснений от LLM между запусками могут немного отличаться. Примерно треть текстов модели не проходит проверку (третье предложение, число не из фактов) и заменяется шаблоном из тех же фактов; причина пишется в лог, источник виден в
explanation_source. - Эмбеддинги описаний считаются при сиде один раз: если поменять
descriptionв CSV, вектор не пересчитается, пока колонку не обнулить. Эмбеддинг запроса считается на каждый подбор, ранги округляются до тысячных, поэтому порядок карточек стабилен. - Каталог на главной показывает первые шесть карточек до нажатия «Показать всех подрядчиков».
Команда
Авторы коммитов
История коммитов
- 2026-09-23 17:43fix duplicate explanations and filtered city countsdias
- 2026-09-23 16:59fix recommendation explanations with verified factsdias
- 2026-09-23 17:51add dark theme with header toggle and system preferenceAlikhan Shorin
- 2026-09-23 17:51format event date with full month nameAlikhan Shorin
- 2026-09-23 17:37clear home search params on filter resetAlikhan Shorin
- 2026-09-23 17:36show the catalog price as a plain number instead of a from-priceAlikhan Shorin
- 2026-09-23 17:35fact-check LLM numbers and languages, keep template at two sentences, list every gap of the alternative card, keep ports on dev.py rerunAlikhan Shorin
- 2026-09-23 17:27add copy link button on results pageAlikhan Shorin
Все коммиты (39)
- 2026-09-23 17:26rank passing vendors by embedding similarity of descriptions to the orderAlikhan Shorin
- 2026-09-23 17:25add back link on results pageAlikhan Shorin
- 2026-09-23 17:24add inline filter form to results pageAlikhan Shorin
- 2026-09-23 17:19name the alternative card's gap to the best match, suggest passing cities on empty result, drop unused telegram modulesAlikhan Shorin
- 2026-09-23 17:08keep LLM-worded explanations with role facts, add passed_count, duration hint and DoD testsAlikhan Shorin
- 2026-09-23 16:44keep date input within card width on iOSAlikhan Shorin
- 2026-09-23 16:44style filter summary as static tags instead of pill chipsAlikhan Shorin
- 2026-09-23 16:44add LinkedIn link for frontend teammateAlikhan Shorin
- 2026-09-23 16:33use text input with digit parsing for budget fieldAlikhan Shorin
- 2026-09-23 16:28add 10 synthetic profiles for thin categoriesAlikhan Shorin
- 2026-09-23 16:28state explicitly when nobody in the category is busyAlikhan Shorin
- 2026-09-23 16:28show role-ordered result cards with highlighted best match, hints and skeletonsAlikhan Shorin
- 2026-09-23 15:22show full vendor descriptionsdias
- 2026-09-23 15:21connect vendor search to apidias
- 2026-09-23 16:10remove orphan users migration that split alembic headsAlikhan Shorin
- 2026-09-23 16:05add duration slider and multi-language pickerAlikhan Shorin
- 2026-09-23 16:05sharpen card explanations, drop unused NVIDIA keyAlikhan Shorin
- 2026-09-23 16:05add pipeline doc and format task descriptionAlikhan Shorin
- 2026-09-23 15:00remove unused telegram login componentsdias
- 2026-09-23 14:52Merge branch 'main' of https://github.com/BAITC-Hacks/hack-2b4a46cf-hurricanedias
- 2026-09-23 15:51top-align filter fields and widen budget cellAlikhan Shorin
- 2026-09-23 14:50added uidias
- 2026-09-23 15:50remove form titleAlikhan Shorin
- 2026-09-23 15:50remove intro text from home screenAlikhan Shorin
- 2026-09-23 15:43add searchable filters, budget presets and compact language switcherAlikhan Shorin
- 2026-09-23 14:40fix Railway deploy: single pre-deploy command, no cache mountsAlikhan Shorin
- 2026-09-23 14:40switch catalog options to latin codes with Russian labelsAlikhan Shorin
- 2026-09-23 14:19move Railway config to .railway/railway.tsAlikhan Shorin
- 2026-09-23 14:19add POST /recommend with role slots and grounded explanationsAlikhan Shorin
- 2026-09-23 14:19add vendors table and idempotent dataset seedAlikhan Shorin
- 2026-09-23 13:43add project skeletonAlikhan Shorin
Структура репозитория (105 файлов)
.agents/skills/deploy/SKILL.md .agents/skills/setup/SKILL.md .claude/skills/deploy/SKILL.md .claude/skills/setup/SKILL.md .codex/config.toml .editorconfig .env.example .gitattributes .githooks/commit-msg .gitignore .mcp.json .railway/railway.ts AGENTS.md CLAUDE.md DEPLOYMENT.md DESIGN.md README.md app/.dockerignore app/.python-version app/Dockerfile app/alembic.ini app/data/vendors.csv app/migrations/env.py app/migrations/script.py.mako app/migrations/versions/.gitkeep app/migrations/versions/20260923_3f1c9a2b7d40_add_description_embedding.py app/migrations/versions/20260923_d7cced6dbf39_create_vendors.py app/pyproject.toml app/src/__init__.py app/src/__main__.py app/src/catalog.py app/src/config.py app/src/db.py app/src/embeddings.py app/src/errors.py app/src/explain.py app/src/explain_prompt.md app/src/facts.py app/src/filters.py app/src/llm.py app/src/main.py app/src/models.py app/src/recommend.py app/src/schemas.py app/src/seed.py app/src/suggestions.py app/tests/test_recommend.py app/uv.lock dev.py docker-compose.yml docs/DOMAIN.md docs/PIPELINE.md docs/assets/hackathon dataset preview.html docs/hackathon/CHECKLIST.md docs/hackathon/EVALUATION_CRITERIA.md docs/hackathon/TASK.md docs/hackathon/TERMS_AND_CONDITIONS.md package.json ui/.dockerignore ui/.env.example ui/.prettierignore ui/.prettierrc ui/Dockerfile ui/components.json ui/default.conf.template ui/eslint.config.js ui/index.html ui/lingui.config.ts ui/package.json ui/src/api-types.ts ui/src/api.ts ui/src/components/CopyLinkButton.tsx ui/src/components/LanguageCombobox.tsx ui/src/components/ResultsFilters.tsx ui/src/components/SearchForm.tsx ui/src/components/SearchHints.tsx ui/src/components/ThemeToggle.tsx ui/src/components/VendorCard.tsx ui/src/components/ui/button.tsx ui/src/components/ui/card.tsx ui/src/components/ui/combobox.tsx ui/src/components/ui/input-group.tsx ui/src/components/ui/input.tsx ui/src/components/ui/select.tsx ui/src/components/ui/skeleton.tsx ui/src/components/ui/slider.tsx ui/src/components/ui/textarea.tsx ui/src/i18n.ts ui/src/index.css ui/src/lib/utils.ts ui/src/locales/en/messages.po ui/src/locales/kk/messages.po ui/src/locales/ru/messages.po ui/src/main.tsx ui/src/routeTree.gen.ts ui/src/routes/__root.tsx ui/src/routes/index.tsx ui/src/routes/results.tsx ui/src/search-params.ts ui/src/theme.ts ui/src/vite-env.d.ts ui/tsconfig.json ui/vite.config.ts ui/yarn.lock yarn.lock
Обсуждение 0 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.