AAYMA
RU
Войти

ASA

Сервис подбора до трёх подрядчиков мероприятия из каталога по городу, категории, дате, формату и бюджету.

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

О проекте

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

ASA — умный подбор event-подрядчиков

Python Streamlit Tests HackAlem

Заказчик уже выбрал тип мероприятия и получил каталог по своему городу. ASA помогает выбрать из этого списка, а не удлинить его: проверяет дату, формат и бюджет, оставляет до трёх подрядчиков и для каждого показывает плюсы и ограничения по сравнению с остальными. Если подходящих мало или нет совсем, сервис объясняет почему и подсказывает, что изменить в запросе.

Подбор ведущего на свадьбу 15 октября: короткий список с плюсами и ограничениями

Содержание

Быстрый старт

Нужен 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 unittest29 тестов: подбор, сравнение, объяснения и сквозные проверки 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 декабря
no_match: подсказка с ближайшими свободными днями
category_not_found: лайв-бэнда нет в Астане
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.121другая дата — другая выдача, в подсказке видно, что дело в занятости
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 ₸0no_match: сезон, все заняты, названы ближайшие свободные дни
8Флорист · Астана · той · 14.100no_match: единственный кандидат не берёт этот формат
9Лайв-бэнд · Астана · той · 14.100category_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": "казахский"
})
Поле ответаОписание
statusmatched · no_match · category_not_found
headlineодна фраза с итогом
hintsподсказки: почему вариантов мало и что изменить
messageheadline и 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. Многозначные поля разделены |.

ФлагСколькоКак показываем
synthetic13метка «демо-профиль» и пункт «Профиль создан для демонстрации» в «Что учесть»
price_imputed18метка «оценочная цена», пункт в «Что учесть», в тексте «оценочная цена от…»
city_imputed8метка «город добавлен» и пункт в «Что учесть»

price_from_kzt — нижняя граница цены, поэтому везде пишем «цена от … укладывается в бюджет», а не «стоит». Пустой max_hours (флористы, декор, сувениры) длительность не ограничивает.

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

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

OrazalyAlibek 11lolchenurek123 3Okeyashka 3

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

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

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

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

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