ASA
Сервис подбора до трёх подрядчиков мероприятия из каталога по городу, категории, дате, формату и бюджету.
О проекте
Команда: ASA
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Доведённый продукт
Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор
Суть
Сервис подбора до трёх подрядчиков мероприятия из каталога по городу, категории, дате, формату и бюджету. Для каждой карточки показываются плюсы и ограничения относительно остальных, а если вариантов нет, сервис подсказывает, что изменить в запросе.
Как устроено
Пайплайн из пяти шагов: пул по городу и категории → жёсткие фильтры (занятость на дату, цена «от» против бюджета, формат, длительность) с запоминанием причин отсева → прозрачное ранжирование (база 70 + баллы за позицию формата, ключевые слова в описании, язык, длительность; цена баллов не добавляет) → сравнительные плюсы и ограничения по полям каталога → объяснение из проверенных фактов. Предусмотрено три исхода (matched, no_match, category_not_found) с честными подсказками вида «добавьте 150 000 ₸, и появится X». ИИ одним запросом на весь TOP-3 только выбирает, какие проверенные пункты показать, есть режим имитации сбоя ИИ.
Ключевые решения
- hard-constraint filtering
- transparent additive scoring
- comparative explanations
- counterfactual hints (single-condition relaxation)
- LLM fact selection with fallback
Метрики
29 тестов проходят; 9 демо-сценариев на реальном каталоге
Особенности
Контрфактические подсказки учитывают только тех, кто не прошёл лишь по одному условию, поэтому обещание «появится X» всегда выполняется. У почти одинаковых профилей объяснения различаются, потому что берётся первая уникальная фраза описания. Пометки о качестве данных ИИ скрыть не может.
Стек
Python · Streamlit · unittest
Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-d5f21cdd-asa.
README
README.mdASA — умный подбор event-подрядчиков
Заказчик уже выбрал тип мероприятия и получил каталог по своему городу. ASA помогает выбрать из этого списка, а не удлинить его: проверяет дату, формат и бюджет, оставляет до трёх подрядчиков и для каждого показывает плюсы и ограничения по сравнению с остальными. Если подходящих мало или нет совсем, сервис объясняет почему и подсказывает, что изменить в запросе.

Содержание
- Быстрый старт
- Как это работает
- Сравнение карточек
- Три исхода
- Демо-сценарии
- ИИ в пайплайне
- Проверка Definition of Done
- Структура проекта
- Программный интерфейс
- Данные
Быстрый старт
Нужен Python 3.10 или новее.
git clone https://github.com/BAITC-Hacks/hack-d5f21cdd-asa.git
cd hack-d5f21cdd-asa
python -m pip install -r requirements.txt
python -m streamlit run app.py
Откроется http://localhost:8501. Выберите «Быстрый сценарий» или заполните форму и нажмите «Найти TOP‑3».
Если Windows отвечает, что команда
pythonне найдена, используйтеpyвместоpython.
| Команда | Что делает |
|---|---|
python -m streamlit run app.py | веб-интерфейс |
python demo.py | все демо-сценарии и имитация сбоя ИИ в терминале |
python -m unittest | 29 тестов: подбор, сравнение, объяснения и сквозные проверки DoD на всём каталоге |
Ядро (matching.py, comparison.py, explanations.py, service.py) работает на стандартной библиотеке Python, без внешних зависимостей. Streamlit нужен только для интерфейса, оформление лежит в styles.css.
Как это работает
flowchart LR
Q["Запрос<br/>город · дата · формат<br/>категория · бюджет"] --> P["1. Пул<br/>город + категория"]
P -->|пусто| NF(["category_not_found<br/>+ где категория есть"])
P --> F["2. Жёсткие фильтры<br/>дата · бюджет · формат<br/>длительность"]
F -->|никто не прошёл| NM(["no_match<br/>+ что изменить"])
F --> R["3. Ранжирование<br/>прозрачные баллы, TOP‑3"]
R --> C["4. Сравнение<br/>плюсы и ограничения"]
C --> E["5. Объяснение<br/>из проверенных фактов"]
E --> M(["matched<br/>карточки + подсказки"])
AI{{"ИИ: один запрос на весь TOP‑3<br/>выбирает, какие пункты показать"}} -.-> C
| Шаг | Модуль | Что происходит |
|---|---|---|
| 1. Пул | matching.py | Подрядчики нужного города, у которых среди категорий есть запрошенная. Площадки лежат в том же каталоге: профиль «Банкетный зал | Отель | Ресторан» находится по запросу «Банкетный зал». |
| 2. Фильтры | matching.py | Кандидат исключается, если занят на дату, его цена «от» выше бюджета, он не берёт этот формат или работает на площадке меньше запрошенного. Для каждого отсеянного запоминается, какие именно условия он не прошёл: из этого строятся подсказки. |
| 3. Ранжирование | matching.py | Прошедшие фильтры получают баллы (таблица ниже). При равенстве сортировка идёт по id, поэтому одинаковый запрос всегда даёт одинаковый порядок. |
| 4. Сравнение | comparison.py | Для каждой карточки из данных каталога собираются плюсы и ограничения относительно других карточек выдачи. ИИ, если подключён, выбирает, какие из них показать. |
| 5. Объяснение | explanations.py | Два предложения из тех же проверенных фактов. Первое — главный плюс карточки и цитата из профиля, второе — общие проверки: формат, свободная дата, цена в рамках бюджета. |
Как считаются баллы
Каждый, кто прошёл фильтры, получает базу 70. Сверху добавляются компоненты score_parts, их видно в режиме «Подробности подбора».
| Компонента | Баллы | Когда начисляется |
|---|---|---|
| Позиция формата | 10 / 6 / 2 / 0 | запрошенный формат стоит в профиле первым, вторым, третьим… |
| Описание про этот вид событий | 0–10 | в тексте профиля есть «свадеб», «невест», «форум», «юбиле»… (+5 за каждое совпадение) |
| Язык | 0 / 5 | совпал выбранный язык (язык — сигнал ранжирования, не фильтр) |
| Длительность | 0 / 5 | длительность подтверждена данными max_hours |
Цена баллов не добавляет. Дешевле не значит лучше: бюджет работает только как фильтр.
Сравнение карточек
Каждая карточка в коротком списке состоит из профиля, колонок «Плюсы» и «Что учесть», короткого объяснения и строки об источнике сравнения. Все пункты считаются из полей каталога и сравнивают карточку с остальными в выдаче, поэтому, даже если стереть имена, карточки нельзя перепутать.
| Пункт | Тип | Пример | Откуда |
|---|---|---|---|
| Самая низкая цена | плюс | «Самая низкая цена среди показанных: от 400 000 ₸ (часть цен оценочная)» | price_from_kzt всех карточек |
| Цена ниже самой дорогой | плюс | «Цена от 900 000 ₸ на 100 000 ₸ ниже самого дорогого варианта» | price_from_kzt |
| Цена выше минимальной | ограничение | «Цена от 1 000 000 ₸ на 100 000 ₸ выше минимальной среди показанных» | price_from_kzt |
| Уникальный язык | плюс | «Язык «английский» указан только у этого профиля среди показанных» | languages |
| Самый длинный лимит часов | плюс | «Самый высокий указанный лимит среди сравнимых профилей: 12 ч» | max_hours |
| Лимит меньше максимума | ограничение | «Лимит 10 ч на 2 ч меньше максимума среди показанных с лимитом» | max_hours |
| Цитата из профиля | плюс | «В описании профиля: «…более 1000 реализованных съёмок»» | description, дословно |
| Качество данных | ограничение | «Цена в каталоге оценочная», «Профиль создан для демонстрации» | price_imputed, city_imputed, synthetic |
Первым в объяснение попадает самый редкий плюс: уникальный язык → самая низкая цена → самый длинный лимит часов → цена ниже самой дорогой → цитата. Ограничения остаются в колонке «Что учесть».
Если у двух подрядчиков описания начинаются одинаково (в каталоге есть почти одинаковые лайв-бэнды), для каждого берётся первая фраза описания, которой нет у другого. Так объяснения не совпадают даже у «клонов».
Пометки о качестве данных показываются всегда: ИИ не может их скрыть. Если в выдаче одна карточка, сравнивать не с кем, и в плюсах показываются подтверждённые проверки: формат, свободная дата, бюджет.
Подробности подбора: как получен балл и какие факты использованы![]() | Одна карточка: декабрь, почти все ведущие заняты![]() |
Три исхода
У каждого исхода свой цвет и заголовок. Под заголовком выводятся подсказки: что конкретно изменить, чтобы вариантов стало больше. Подсказка по одному условию учитывает только тех, кто не прошёл лишь по нему. Поэтому она честная: если изменить только это условие, названные подрядчики действительно появятся.
| Исход | Заголовок в интерфейсе | Когда | Пример подсказки |
|---|---|---|---|
matched | «Подходящие подрядчики» | есть хотя бы один подходящий | «4 подрядчика подходят по всем условиям, кроме даты: 19 декабря у них занято. Ближайшие свободные дни: 20 декабря (Сон Гоку, Эмилия), 21 декабря (Хаул)…» |
no_match | «Пока нет подходящих вариантов» | кандидаты есть, но никто не прошёл | «5 подрядчиков подходят по всем условиям, кроме бюджета. Увеличьте бюджет на 150 000 ₸ (до 650 000 ₸) — добавится Мицури Канроджи.» |
category_not_found | «В этом городе нет такой категории» | в городе нет такой категории | «Эта категория есть в другом городе: Алматы (5 подрядчиков). Выберите его, если мероприятие можно провести там.» |
no_match: все банкетные залы заняты 6 декабря![]() | category_not_found: лайв-бэнда нет в Астане![]() |
Демо-сценарии
Все сценарии проверены на реальном каталоге (demo_scenarios.py, тест test_integration.py) и есть в форме в списке «Быстрый сценарий». Любой сценарий открывается по ссылке, например http://localhost:8501/?scenario=2. Если добавить &debug=1, включатся подробности подбора.
| # | Запрос | Карточек | Что показывает |
|---|---|---|---|
| 1 | Ведущий · Алматы · свадьба · 15.10 · 1 000 000 ₸ | 3 | плотная категория: разные баллы, разные плюсы и ограничения |
| 2 | то же, 19.12 | 1 | другая дата — другая выдача, в подсказке видно, что дело в занятости |
| 3 | Фотограф · корпоратив · 15.10 · 600 000 ₸ | 2 | сравнение по цене и лимиту часов |
| 4 | Банкетный зал · свадьба · 14.11 · 3 500 000 ₸ | 2 | площадки в том же каталоге, демо-профиль помечен |
| 5 | Флорист · свадьба · 18.10 · 300 000 ₸ | 1 | редкая категория |
| 6 | Флорист · свадьба · 17.10 · 300 000 ₸ | 1 | соседняя дата даёт другого флориста |
| 7 | Банкетный зал · свадьба · 06.12 · 10 000 000 ₸ | 0 | no_match: сезон, все заняты, названы ближайшие свободные дни |
| 8 | Флорист · Астана · той · 14.10 | 0 | no_match: единственный кандидат не берёт этот формат |
| 9 | Лайв-бэнд · Астана · той · 14.10 | 0 | category_not_found: подсказан другой город |
Порядок показа жюри: 1 → 2 (занятость меняет выдачу) → 5 (редкая категория) → 7 (пусто, но с подсказкой) → 9 (нет категории) → снова 1 с переключателем «Имитировать сбой ИИ» в «Настройках демонстрации».
ИИ в пайплайне
ИИ не выбирает подрядчиков, не меняет порядок и не пишет текст. Это делает детерминированный код. Модель получает весь TOP‑3 одним запросом и для каждой карточки выбирает 1–2 плюса и до 2 ограничений из заранее подготовленных пунктов. Возвращает она только их id, а текст пунктов берётся из каталога. Поэтому модель не может выдумать цену, дату или заслугу.
| Ситуация | Что происходит | Подпись в карточке |
|---|---|---|
| Ключ задан, ответ прошёл проверку | показываются пункты, выбранные моделью | «ИИ сравнил варианты · по проверенным фактам» |
| Ключа нет | первые пункты по локальным правилам | «Сравнение по данным каталога» |
Таймаут (6 с), ошибка API, битый JSON, неизвестный id, пропущенная карточка | та же выдача с локальным сравнением, поиск не ломается | «Сравнение по данным каталога» |
| В выдаче одна карточка | ИИ не вызывается, сравнивать не с кем | «Единственный подходящий вариант» |
Как запустить с ИИ
Вариант 1 — на один запуск. В том же окне PowerShell, где будете запускать приложение:
$env:OPENAI_API_KEY = "sk-..."
python -m streamlit run app.py
Вариант 2 — один раз для этой папки. Создайте файл .streamlit/secrets.toml:
OPENAI_API_KEY = "sk-..."
Файл указан в .gitignore и не попадёт в репозиторий. Приложение подхватит ключ при запуске.
Как понять, что ИИ работает: в раскрывающемся блоке «Настройки демонстрации» написано «ИИ сравнит варианты одним запросом», а под карточками — «ИИ сравнил варианты · по проверенным фактам». В терминале: python demo.py выводит у карточек сравнение: llm.
Модель задаётся через OPENAI_MODEL (по умолчанию gpt-4o-mini). Используется OpenAI Responses API со строгой JSON-схемой и temperature=0, чтобы одинаковый запрос давал одинаковый выбор. Тесты никогда не обращаются к настоящему API, даже если ключ задан в терминале.
Проверка Definition of Done
| Требование | Как проверяется |
|---|---|
| Ответ до 10 секунд | test_response_time; без ИИ ответ занимает единицы миллисекунд, запрос к модели ограничен 6 с |
| Объяснения не взаимозаменяемы | test_explanations_are_not_interchangeable: имена стираются, тексты должны остаться разными |
| Тот же запрос даёт тот же порядок | test_same_request_same_order, test_three_outcomes_and_stable_order_without_cheapest_bonus |
| Другая дата даёт другую выдачу, видна занятость | test_two_dates_differ_and_message_names_busy_date, test_real_calendar_changes_availability |
| Занятые, дорогие и не берущие формат не попадают в выдачу | test_hard_constraints_never_violated, test_hard_filters_and_rejection_counts |
| Плотная, редкая категория и пустой результат | test_demo_scenarios_give_expected_outcome |
| Пустой результат объяснён словами | test_empty_results_are_explained_in_words |
| ИИ получает весь TOP‑3 одним запросом | test_one_comparison_call_for_whole_answer, test_responses_api_payload_is_one_structured_request |
| ИИ показывает только проверенные пункты | test_model_sees_all_cards_and_only_verified_points_are_shown, test_invalid_model_ids_use_grounded_fallback |
| Сбой ИИ не ломает выдачу | test_llm_outage_falls_back_without_losing_reasons, test_valid_model_selection_and_invalid_fallback |
| Цитаты взяты из профиля дословно | test_reason_quotes_come_from_profile |
| Некорректный запрос и пустой каталог | test_request_validation, test_empty_csv_and_missing_fields |
| Все ответы на всём каталоге различимы и читаемы | test_every_answer_is_distinguishable_and_readable: все города × категории × 4 формата × 4 даты |
| Почти одинаковые профили получают разные цитаты | test_near_duplicate_profiles_get_different_quotes |
Структура проекта
├── app.py веб-интерфейс (Streamlit)
├── styles.css оформление интерфейса
├── service.py единая точка входа: run(request)
├── matching.py загрузка CSV, фильтры, баллы, факты, заголовок и подсказки
├── comparison.py плюсы и ограничения между карточками, выбор пунктов ИИ
├── explanations.py короткое объяснение из проверенных фактов
├── demo_scenarios.py демо-запросы, проверенные на каталоге
├── demo.py прогон сценариев в терминале
├── test.py модульные тесты
├── test_integration.py сквозные проверки Definition of Done
├── data/providers.csv каталог: 66 профилей
└── docs/screenshots/ скриншоты для README
Программный интерфейс
from service import run
response = run({
"city": "Алматы",
"date": "2026-10-15", # ГГГГ-ММ-ДД, окно 23.09–31.12.2026
"event_format": "корпоратив", # свадьба · той · корпоратив · конференция · юбилей · день рождения
"category": "Фотограф",
"budget_kzt": 600_000, # целое ≥ 0
# необязательно: "duration_hours": 6, "language": "казахский"
})
| Поле ответа | Описание |
|---|---|
status | matched · no_match · category_not_found |
headline | одна фраза с итогом |
hints | подсказки: почему вариантов мало и что изменить |
message | headline и hints одной строкой |
counts | сколько отсеяно по каждой причине; один подрядчик может попасть в несколько счётчиков |
results[] | до трёх карточек: профиль, score, score_parts, facts, comparison (pros, cons), comparison_source, reason, reason_source, evidence_ids |
elapsed_ms | время подбора |
Пример ответа (сценарий 3, первая карточка, сокращённо)
{
"status": "matched",
"headline": "Подходят 2 из 8 подрядчиков категории «Фотограф» в городе Алматы на 15 октября.",
"hints": [
"2 подрядчика подходят по всем условиям, кроме даты: 15 октября у них занято. Ближайшие свободные дни: 16 октября (Шикамару Нара, Леорио Паради)."
],
"results": [
{
"id": "HK-35913",
"name": "Тохру",
"price_from_kzt": 400000,
"price_imputed": true,
"synthetic": false,
"score": 90,
"score_parts": {"specialization": 10, "description": 10, "language": 0, "duration": 0},
"comparison": {
"pros": [
{"id": "price_lowest", "text": "Самая низкая цена среди показанных: от 400 000 ₸ (часть цен оценочная)"},
{"id": "profile", "text": "В описании профиля: «Тохру — ивент-фотограф с более чем 10-летним опытом и более 1000 реализованных съёмок»"}
],
"cons": [
{"id": "hours_shorter", "text": "Лимит 10 ч на 2 ч меньше максимума среди показанных с лимитом"},
{"id": "price_estimated", "text": "Цена в каталоге оценочная: её добавили при подготовке данных"}
]
},
"comparison_source": "fallback",
"reason": "Самая низкая цена среди показанных: от 400 000 ₸ (часть цен оценочная); в описании профиля: «Тохру — ивент-фотограф с более чем 10-летним опытом и более 1000 реализованных съёмок». Берёт корпоративы; дата 15 октября 2026 года в календаре свободна; оценочная цена от 400 000 ₸ укладывается в бюджет 600 000 ₸.",
"reason_source": "fallback"
}
]
}
Некорректный запрос (дата вне окна, отрицательный бюджет и т. п.) вызывает ValueError с понятным текстом на русском; интерфейс показывает его как подсказку.
Данные
data/providers.csv: 66 анонимизированных профилей (Алматы — 50, Астана — 15, Зарубежье — 1), календарь занятости с 23.09 по 31.12.2026. Многозначные поля разделены |.
| Флаг | Сколько | Как показываем |
|---|---|---|
synthetic | 13 | метка «демо-профиль» и пункт «Профиль создан для демонстрации» в «Что учесть» |
price_imputed | 18 | метка «оценочная цена», пункт в «Что учесть», в тексте «оценочная цена от…» |
city_imputed | 8 | метка «город добавлен» и пункт в «Что учесть» |
price_from_kzt — нижняя граница цены, поэтому везде пишем «цена от … укладывается в бюджет», а не «стоит». Пустой max_hours (флористы, декор, сувениры) длительность не ограничивает.
Авторы коммитов
История коммитов
- 2026-09-23 17:39fix: distinguishable reasons on the whole catalog, safer LLM setupOrazalyAlibek
- 2026-09-23 17:27docs: README and screenshots for the redesigned UIOrazalyAlibek
- 2026-09-23 17:21fix: update README for test badge clarity; enhance error handling in search_request and recommend functions; add tests for empty CSV and validationlolchenurek123
- 2026-09-23 17:15bebeOkeyashka
- 2026-09-23 16:40docs: README with screenshots, pipeline diagram and tables; open scenarios by linkOrazalyAlibek
- 2026-09-23 16:10feat: plain-language outcomes with actionable hintsOrazalyAlibek
- 2026-09-23 15:32feat(matching): rank by specialization and description, clearer rejection messagesOrazalyAlibek
- 2026-09-23 15:32feat(explain): comparative facts so cards of one answer are distinguishableOrazalyAlibek
Все коммиты (17)
- 2026-09-23 15:32feat(app): Streamlit UI, single pipeline entry point and demo scenariosOrazalyAlibek
- 2026-09-23 15:32test: end-to-end Definition of Done checks on the real catalogOrazalyAlibek
- 2026-09-23 15:32docs: README with launch, pipeline, ranking, outcomes and demo scenariosOrazalyAlibek
- 2026-09-23 15:11Refactor README and enhance matching logic with detailed explanations and testslolchenurek123
- 2026-09-23 14:40initial commitOkeyashka
- 2026-09-23 14:05feat: project skeleton with working end-to-end recommendation pipelineOrazalyAlibek
- 2026-09-23 13:56Add print statement for 'test3' to test.pybanana
- 2026-09-23 13:42test2OrazalyAlibek
- 2026-09-23 13:33testOkeyashka
Структура репозитория (20 файлов)
.gitignore .streamlit/config.toml README.md app.py comparison.py data/providers.csv demo.py demo_scenarios.py docs/screenshots/01-matched.png docs/screenshots/02-december-busy.png docs/screenshots/03-no-match.png docs/screenshots/04-category-not-found.png docs/screenshots/05-details.png explanations.py matching.py requirements.txt service.py styles.css test.py test_integration.py
Обсуждение 0 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.



