Точный подрядчик
Локальное приложение на чистом Python: рекомендует до 3 подрядчиков под событие по строгим фильтрам, объясняет выбор проверяемыми фактами из…
О проекте
Команда: ONLYQUEEN
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Рабочий MVP
Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор
Суть
Локальное приложение на чистом Python: рекомендует до 3 подрядчиков под событие по строгим фильтрам, объясняет выбор проверяемыми фактами из профилей и предлагает действия при пустой выдаче.
Как устроено
profiles.csv разбирается в списки. Жёсткие фильтры: точные город и категория, занятость даты, формат, бюджет относительно price_from_kzt, язык, длительность. Затем дополнительные баллы 0–100: +50/+35 за совпадение пожелания с description, +25 за формат, +10 за язык, до +15 за часы. Сортировка детерминированная, при равенстве по цене и id. Для пустых результатов определяется главная причина и предлагаются новые строгие запросы (бюджет, дата, город). OpenAI Responses API со structured outputs только выбирает ID уже проверенных фактов, текст собирается локально, а невалидный ответ отклоняется целиком.
Ключевые решения
- hard-filter eligibility
- additive match scoring
- conservative stemming
- date comparison re-run
- structured outputs JSON Schema
- ID-constrained LLM output
Особенности
Самый строгий анти-галлюцинационный контур: LLM возвращает только разрешённые ID фактов, а видимый текст собирается из проверенных значений. Нет внешних зависимостей. Честно указано, что живой вызов с ключом не проверялся, только mock-тестами. Для пустой выдачи предлагаются конкретные действия.
Стек
Python · HTML · CSS · JavaScript · unittest
Модели
OpenAI (model from OPENAI_MODEL)
Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-c41f3fa8-onlyqueen.
README
README.mdMVP «Точный подрядчик» — кейс 79-lite
Локальное приложение рекомендует до трёх подрядчиков для события и объясняет каждую рекомендацию проверяемыми структурированными фактами. Интерфейс и сообщения — на русском языке.
Запуск
Нужен только Python 3.10+; внешних пакетов нет. Без API-ключа строгий подбор полностью работает, а блок AI честно показывает статус «недоступен».
python3 app.py
Открыть http://localhost:8000. Проверка здоровья: http://localhost:8000/health.
Тесты:
python3 -m unittest discover -s tests -v
Как устроен конвейер
data/profiles.csvзагружается при старте. Используются реальные поля:id,anon_name,categories,city,city_imputed,synthetic,price_from_kzt,price_imputed,event_formats,languages,max_hours,busy_dates,description.categories,event_formats,languagesиbusy_datesразбираются как списки с разделителем|. Категория совпадает только с целым элементом списка, без нечёткого поиска.- Сначала выбираются точные город и категория. Затем как жёсткие условия проверяются занятость даты, формат, бюджет, язык и длительность. Бюджет сравнивается с
price_from_kzt: это только начальная цена, поэтому карточка предупреждает, что итоговую стоимость нужно уточнить. Пустойmax_hoursозначает, что ограничение длительности неприменимо. Площадки и их календари проходят тот же код, что и остальные подрядчики. - Только после жёстких фильтров каждому допустимому профилю независимо рассчитываются дополнительные совпадения от 0 до 100. Формула: +50 за точное нормализованное совпадение пожелания с
description, либо +35 за совпадение всех значимых корней пожелания в одном непублицистическом фрагменте; +25 за явное упоминание запрошенного формата вdescription; +10 за запрошенный язык вlanguages; при заданной длительности +10 за явный достаточныйmax_hoursи ещё 0–5 за запас полных часов. Максимальная сумма — 100. Баллы не делятся между карточками, не являются вероятностью и не оценивают прохождение обязательных условий — оно показывается отдельным статусом. - Профили сортируются детерминированно по убыванию суммы баллов. Только при равенстве используются цена по возрастанию и затем
id. Цена не даёт баллов. Возвращаются первые три. - Пожелания сопоставляются консервативно: нижний регистр,
ё → е, удаление пунктуации, ограниченные корни слов и только две явные эквивалентности — «деловой/бизнес», а также формы слов вроде «форум/форумы» и «съёмка/съёмки». Совпадение не делает профиль допустимым и не может обойти ни один жёсткий фильтр. - Не выбранные язык, длительность или пожелания дают 0 баллов без штрафа. Пустой
max_hoursпроходит жёсткий фильтр, когда длительность задана, но не получает бонус за неизвестный запас. Цена иidиспользуются только как детерминированные tie-breakers. - В объяснениях различаются два вида фрагментов
description. Балльные фрагменты подтверждают запрошенный формат или пожелание, входят вscore_reasonsи дают ровно те баллы, которые видны в раскрывающейся разбивке. Информационные фрагменты лишь сообщают конкретный факт о уже допустимом профиле: они не добавляют баллы, не меняют порядок и не обходят фильтры. Рекламные обещания, рейтинги и одно название коллектива не выдаются за преимущество. Если полезных индивидуальных сведений мало, карточка прямо сообщает об этом и перечисляет прошедшие проверяемые условия. - Для сравнения дат весь конвейер запускается повторно. «Вошёл/вышел из топ-3» считается по двум рейтингам, а «стал свободен/занят» — отдельно и только прямой проверкой наличия каждой даты в
busy_dates. Поэтому вытеснение из топ-3 более релевантным ставшим свободным профилем не называется новой занятостью. - При пустом результате приложение определяет доминирующую причину понятным текстом. Оно показывает минимальную
price_from_kztсреди профилей точных города и категории. Бюджетная кнопка предлагается только с минимальной начальной ценой кандидата, который уже проходит дату, формат и выбранные optional-ограничения; если эта сумма выше общего минимума категории, разница объясняется прямо в результате. Предлагаемые даты заранее проходят формат, бюджет, язык и длительность. Если язык или длительность блокируют выдачу, интерфейс может предложить убрать именно это ограничение. Если категории нет в городе, кнопка предлагает город, где такие профили действительно есть. Каждая кнопка меняет запрос и повторно запускает строгую фильтрацию — профили другого города не подмешиваются в исходную выдачу.
Безопасный AI-анализ
В инструкции HackAlem проверены материалы об активации выданных организаторами кредитов: провайдер — OpenAI. Конкретный ID модели и отдельный endpoint в материалах не зафиксированы. Поэтому приложение не угадывает модель: фактически используемая модель — ровно значение обязательной переменной OPENAI_MODEL. Запрос отправляется в официальный Responses API по https://api.openai.com/v1/responses; формат ответа задаётся через официальный Structured Outputs / JSON Schema.
Создайте локальный .env по примеру (он исключён из Git), укажите выданный ключ и точный доступный model ID, затем экспортируйте переменные перед запуском:
cp .env.example .env
# заполните OPENAI_API_KEY и OPENAI_MODEL в .env
set -a; source .env; set +a; python3 app.py
Можно также запускать одной командой без файла: OPENAI_API_KEY=... OPENAI_MODEL=... python3 app.py. Значения не попадают в HTML, /api/meta или ответ браузеру; заголовок авторизации создаётся только на сервере. Стандартный тайм-аут — 6 секунд, допустимый диапазон в коде ограничен 1–8 секундами.
AI запускается после Python-фильтров, расчёта баллов, сортировки и выбора первых трёх карточек. Ему передаются параметры запроса, только уже выбранные карточки, короткие разрешённые фрагменты/структурные факты и — для пустой выдачи — уже рассчитанные причины и действия. Полные календари и отброшенные профили не отправляются.
Модель не пишет пользовательский текст свободно. Она возвращает только структурированный выбор разрешённых ID. Сокращённый пример входа:
{
"mode": "found",
"request": {"city": "Алматы", "event_format": "корпоратив", "wishes": "деловой форум"},
"cards": [
{"id": "profile-17", "facts": [{"id": "profile-17:description:1", "text": "...проверенный фрагмент..."}], "wish_relation": "direct"}
]
}
Ожидаемый JSON модели:
{
"mode": "found",
"card_insights": [{"id": "profile-17", "fact_ids": ["profile-17:description:1"], "wish_fit": "direct"}],
"reason_ids": [],
"action_ids": []
}
Сервер повторно проверяет точный набор полей, режим ответа, порядок и полный набор ID карточек, принадлежность каждого fact_id своей карточке, связь с пожеланием и допустимость ID причин/действий. Видимый русский текст собирается локально только из проверенных значений. Выдуманный ID/факт, неверный JSON, ошибка API или тайм-аут отклоняются целиком: карточки не меняются, а интерфейс явно сообщает «AI-анализ недоступен».
В этой версии сетевой вызов с настоящим ключом не выполнен: в среде разработки нет активированных OPENAI_API_KEY и OPENAI_MODEL. Успешный ответ, невалидный JSON, выдуманный ID, тайм-аут и отсутствие конфигурации проверены mock-тестами. Следовательно, интеграция и защитный контур протестированы без списания организаторских кредитов, но доступ конкретного model ID должен быть подтверждён владельцем ключа.
Пользовательский интерфейс
- На первом экране остаются пять обязательных полей: город, дата, формат, категория и бюджет. Алматы выбран по умолчанию как город с наибольшим числом профилей.
- Категории, присутствующие в выбранном городе, показываются первыми. Остальные остаются видимыми и доступными в группе «Есть в других городах», поэтому валидные значения не исчезают.
- Для бюджета есть быстрые варианты и ручной ввод. Язык, длительность, пожелания и сравнение дат находятся в закрытом по умолчанию разделе «Дополнительные параметры».
- Краткий результат и практические действия видны сразу; независимые счётчики отказов скрыты под «Почему такой результат?».
- Интерфейс адаптируется до одного столбца на телефоне. Ни одна клиентская кнопка не создаёт рекомендацию самостоятельно: источником результата всегда остаётся серверный
recommend().
Основной подбор не является ML/LLM-системой: это небольшой детерминированный набор Python-правил, который полностью воспроизводится офлайн. LLM используется только для необязательного выбора наиболее полезных уже проверенных фактов в отдельном блоке и не может повлиять на допустимость, баллы, состав или порядок рекомендаций.
Три исхода поиска
- Рекомендации найдены (
found) — показываются не более трёх профилей, прошедших все обязательные условия. - В городе нет выбранной категории (
no_city_category) — рекомендаций для исходного города нет; можно отдельной кнопкой изменить запрос на город, где категория представлена. - Профили категории есть, но условия не выполнены (
none_eligible) — рекомендации не показываются, интерфейс объясняет основную причину и предлагает только повторный строгий поиск с изменённым бюджетом, датой или optional-ограничением.
Воспроизводимая демонстрация
Все даты лежат в диапазоне CSV: 2026-09-23 — 2026-12-31. Бюджет ниже указан в KZT.
| Сценарий | Город | Дата | Формат | Категория | Бюджет | Ожидаемо |
|---|---|---|---|---|---|---|
| Плотная осенняя категория | Алматы | 2026-11-15 | корпоратив | Ведущий | 10000000 | 7 проходят, показаны первые 3 |
| Редкая категория | Астана | 2026-09-23 | свадьба | Фото и видеобудки | 10000000 | ровно 1 рекомендация |
| Пустой результат | Алматы | 2026-11-15 | свадьба | Инструменталист | 10000000 | 0; все 3 заняты |
| Бюджет ниже минимума | Алматы | 2026-09-23 | конференция | Банкетный зал | 1000 | 0; минимум категории 2000000, кнопка 3500000 для свободного профиля, проходящего остальные условия |
| Нет категории в городе | Астана | 2026-09-23 | корпоратив | Ресторан | 10000000 | 0; кнопка «Искать в городе Алматы · 7» меняет запрос и запускает поиск заново |
| Сравнение двух дат | Алматы | 2026-09-23 | свадьба | Инструменталист | 10000000 | 3 рекомендации; дата сравнения 2026-09-24 даёт 1 |
| Пожелание меняет порядок | Алматы | 2026-11-15 | корпоратив | Ведущий | 10000000 | с пожеланием «ведущий для делового форума» Буллма выше более дешёвого Куррапики |
| Короткие описания ансамблей | Алматы | 2026-12-26 | свадьба | Национальный ансамбль | 10000000 | 3 рекомендации; слабые или рекламные сведения помечены как недостаточные и не дают баллов |
Для сценария «Сравнение двух дат» в поле «Сравнить с датой» укажите 2026-09-24: останется 1 рекомендация, интерфейс назовёт изменения и покажет, что Джет Блэк и Сакура Харуно стали заняты согласно busy_dates. Для полностью занятой даты 2026-11-15 интерфейс предлагает только даты, на которых строгий повторный расчёт находит доступных инструменталистов.
В плотном сценарии без пожеланий Куррапика получает 25 баллов за прямое подтверждение корпоративного формата. У остальных карточек description не влияет на баллы: конкретные фрагменты показаны только как информация о профиле. Рекламная фраза Кики про «топ-10» не используется.
Границы MVP
- Только рекомендации: нет бронирования, заявок и уведомлений.
- Доступность известна лишь для диапазона дат, представленного в
busy_dates; запросы вне диапазона отклоняются. price_from_kzt— начальная, а не окончательная стоимость. Фильтр гарантирует только то, что начальная цена не превышает бюджет; итоговую смету нужно уточнять у подрядчика.- Ранжирование сознательно простое и прозрачное. Текст влияет только на баллы уже допустимых профилей; он никогда не влияет на прохождение жёстких фильтров.
- Счётчики причин отказа независимы и могут пересекаться: один профиль способен нарушить несколько условий.
- Без ключа и точного
OPENAI_MODELотдельный AI-блок недоступен, но все рекомендации, объяснения карточек и действия пустого результата продолжают работать локально.
Авторы коммитов
История коммитов
- 2026-09-23 17:58Handle missing AI analysis response safelynurzhumabay
- 2026-09-23 17:53Add safe structured AI analysisnurzhumabay
- 2026-09-23 17:30Clarify evidence limits in recommendation explanationsnurzhumabay
- 2026-09-23 17:19Use distinct description evidence in explanationsnurzhumabay
- 2026-09-23 17:03Document final verification scenariosnurzhumabay
- 2026-09-23 16:56Clarify starting prices and city suggestionsnurzhumabay
- 2026-09-23 16:49Clarify eligibility and additional match scoresnurzhumabay
- 2026-09-23 16:37Differentiate contractor evidence in explanationsnurzhumabay
Все коммиты (11)
- 2026-09-23 16:20Refine scoring and actionable empty resultsnurzhumabay
- 2026-09-23 15:51Polish customer search and empty statesnurzhumabay
- 2026-09-23 14:46Build explainable contractor recommendation MVPnurzhumabay
Структура репозитория (13 файлов)
.env.example .gitignore README.md app.py app/__init__.py app/ai_analysis.py app/recommender.py data/profiles.csv static/app.js static/index.html static/styles.css tests/test_ai_analysis.py tests/test_recommender.py
Обсуждение 0 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.