AAYMA
RU
Войти

EventMatch

Сервис подбора до трёх event-подрядчиков.

Доведённый продукт
Команда «Hurricane» 21💬 0 комментариевс 23 сентября 2026 г.

О проекте

Команда: 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.md

EventMatch: подбор 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

Как работает подбор

  1. Пул. Из базы берутся подрядчики нужного города и категории. Пусто → исход «в этом городе такой категории нет» и список городов, где она есть.
  2. Фильтры. Занят на дату, не берёт формат, дороже бюджета, мало часов, нет языка. Каждый отсеянный считается по первой причине, счётчики уходят в ответ. Никто не прошёл → исход «кандидаты есть, но не проходят».
  3. Баллы и роли. Прошедшие получают баллы за упоминание формата в описании, близость описания к заказу по смыслу (эмбеддинги OpenAI, три ближайших получают 3, 2 и 1 балл), лишние языки, запас часов, настоящий профиль, точную цену и цену до 80% бюджета. Три карточки это три роли: лучшее совпадение, самая низкая цена, самый дорогой в бюджете. Если один подрядчик выиграл две роли, место уходит следующему по баллам, и его объяснение называет конкретный разрыв с первым: цена, оценочная цена, синтетический профиль или формат не упомянут в описании. Ничьи решает цена, потом id, поэтому порядок стабилен.
  4. Объяснения. Факты считает код: доля и остаток бюджета, дата, формат, языки, часы, чем отличается от соседей по выдаче, сколько в категории заняты на дату. OpenAI формулирует их в 1–2 предложения по правилу «факт → что это даёт заказчику». Ответ проверяется: не больше двух предложений, каждое число есть в фактах карточки или в описании подрядчика, языки только те, на которых он работает, нет общих фраз и служебных слов вроде «карточка», нет латиницы внутри русских слов, нет выдуманной «оценочной цены». Шаблон тоже ровно два предложения. Не прошёл или модель недоступна → шаблон из тех же фактов, поле explanation_source показывает источник.
  5. Подсказки. Если прошло меньше трёх, в ответе соседние даты в пределах трёх дней, бюджет и длительность, при которых кандидатов больше. Если не прошёл никто, ещё и города, где подрядчики этой категории проходят все условия заказа. Фронт перезапускает подбор по подсказке в один клик.

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.py POST /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 после миграций, на Railway preDeployCommand. К 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.

Как проверить основной сценарий

Через интерфейс:

  1. Открыть http://localhost:5173, выбрать город, дату, формат, категорию и бюджет. Язык и длительность в блоке «Дополнительные условия». Под формой загружается каталог, «Показать всех подрядчиков» раскрывает весь список.
  2. Нажать «Подобрать»: откроется /results с фильтрами в URL. Появятся карточки с ролями и объяснениями или один из двух пустых исходов с причинами и подсказками. При пустом исходе форма раскрыта прямо под сообщением, при успешном скрыта за кнопкой «Изменить фильтры»: новый подбор происходит на той же странице.
  3. Нажать «Скопировать ссылку» и открыть её в новой вкладке: тот же подбор загрузится без формы, порядок карточек тот же.

Через 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строка подключения к Postgrespostgresql+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, вектор не пересчитается, пока колонку не обнулить. Эмбеддинг запроса считается на каждый подбор, ранги округляются до тысячных, поэтому порядок карточек стабилен.
  • Каталог на главной показывает первые шесть карточек до нажатия «Показать всех подрядчиков».

Команда

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

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

Alikhan Shorin 32dias 7

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

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

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

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

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