Подбор подрядчиков под мероприятие
Сервис подбора подрядчиков для мероприятия: принимает город, дату, формат, категорию и бюджет (формой или свободным текстом в мини-чате) и…
О проекте
Команда: neIT.kz
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Доведённый продукт
Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор
Суть
Сервис подбора подрядчиков для мероприятия: принимает город, дату, формат, категорию и бюджет (формой или свободным текстом в мини-чате) и возвращает до трёх карточек с объяснениями из фактов профиля. Рядом показывается честная воронка отсева и варианты послаблений. Есть виджет для iframe, смета по нескольким позициям и учёт стоимости перелёта.
Как устроено
Мини-чат: LLM раскладывает запрос по перечням каталога, код проверяет членство, дату и бюджет, пользователь подтверждает разбор. Дальше 7 жёстких фильтров в фиксированном порядке (город → категория → формат → свободен на дату → бюджет → язык → длительность) с воронкой. Пожелания сверяются с описаниями через эмбеддинги text-embedding-3-small (256 измерений). Взвешенный скоринг с тай-брейком по id, различители карточек считаются кодом до генерации (3 яруса). Один вызов gpt-4.1-mini с temperature=0 на всю выдачу, затем валидатор (числа из фактов, пересечение ≤0.35, повтор или шаблон) и кэш по хешу. Каталог из 241 профиля лежит в JSON без БД.
Ключевые решения
- hard-filter funnel
- weighted scoring
- deterministic tie-break
- differentiators
- constraint relaxation
- text embeddings semantic matching
- LLM output validator
- hash-based explanation cache
- golden-file regression tests
Метрики
ядро 0–12 мс, холодный ответ 1,9–2,5 с; максимальное пересечение объяснений 0.32 (порог 0.35); 12/12 эталонных сценариев PASS; 32 теста; в 84% комбинаций трёх свободных подрядчиков нет
Особенности
Правило «решает код, модель только формулирует». Различители считаются до генерации, чтобы объяснения нельзя было переставить местами. Валидатор бракует выдуманные числа, детерминизм обеспечивает кэш, а не temperature=0. Анализ данных показал, что лишь в 16% комбинаций находится ≥3 свободных подрядчика, отсюда воронка и послабления. Проверка без API-ключа: npm run verify.
Стек
Next.js 16 · TypeScript · Tailwind CSS · OpenAI API · Node.js
Модели
gpt-4.1-mini · text-embedding-3-small
Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-40ac7a06-neit-kz.
README
README.mdПодбор подрядчиков под мероприятие — с объяснением, почему именно они
Трек «Креативные индустрии» · Кейс #79-lite «Умный подбор подрядчиков» · HackAlem AI 2026 Команда neIT.kz
🔍 Проверить без нашего API-ключа — одна команда:
npm ci && npm run verify

Краткое описание
Проблема. Заказчик получил каталог подрядчиков по своему городу и не может выбрать. Каталог не врёт, но и не помогает: половина анкет на конкретную дату уже занята, а понять это можно только обзвоном.
Цифры из данных — посчитаны скриптом, и его можно запустить: npm run stats (ключ не нужен).
- занято 50 дней из 100 у профиля в среднем, в декабре — 73% дней;
- при переборе 1224 комбинаций «город × категория × формат × дата» (4 даты: 01.10, 14.11, 06.12, 12.12) три и более подходящих подрядчика находятся только в 16% случаев. В остальных 84% трёх карточек просто не существует: 32% дают одного-двух, 37% — ноль свободных, 16% — такой категории нет в городе.
Для кого. Клиент площадки-агрегатора, который уже выбрал город, дату и тип подрядчика.
Решение одной фразой. Сервис принимает параметры заказа и возвращает до трёх карточек, у каждой — объяснение из фактов профиля, а рядом честная воронка: кого отсеяли, почему, и что можно поменять, чтобы подходящие появились.
Что реализовано
Требования кейса
| Требование | Как закрыто | Где посмотреть |
|---|---|---|
| Вход: город, дата, тип мероприятия, категория, бюджет; опционально длительность и язык | Два входа: свободный запрос в мини-чате (POST /api/parse) и форма (POST /api/match). Бюджет необязателен: без него фильтр по цене не применяется, а в ответе появляется оговорка | lib/parse.ts, app/api/match/route.ts |
| Выход: до 3 карточек с именем, категорией, городом, ценой и объяснением на 1–2 предложения | Карточки с объяснением, чипсами фактов и бейджами честности. Лимит трёх соблюдается на позицию: если клиент просит ведущего и фотографа, это два подбора по три карточки, а не шесть в одном списке | lib/explain.ts, app/page.tsx |
| Занятый на дату не попадает в выдачу | Шаг 4 фильтра; площадки идут тем же путём, что люди | lib/filters.ts |
| Подходящих меньше трёх — показать сколько есть и почему | Текст исхода считается кодом с точными числами; блок послаблений «ещё может подойти, если…»; если не проходит никто — блок «ближайшее, что есть в каталоге» с честной величиной расхождения («дороже бюджета в 4,5 раза», «свободен на 5 дней позже») | lib/match.ts, lib/relax.ts |
| Детерминизм: тот же запрос — тот же порядок | Чистые функции, округление score до 4 знаков, тай-брейк по id, кэш объяснений | lib/scoring.ts |
| Три исхода различимы явно | Исходов четыре: добавлен «формат не берёт никто» — в данных таких пар 47 | lib/types.ts |
Definition of Done
| Пункт DoD | Чем закрыт | Где посмотреть |
|---|---|---|
| Ответ до 10 секунд | Ядро 0–12 мс; холодный ответ с объяснениями модели — 1,9–2,5 с на замере; повтор из кэша мгновенный | samples/report.md |
| Объяснения не взаимозаменяемы | Различители считаются до обращения к модели; валидатор бракует пересечение > 0.35. Замерено на всех сценариях: максимум 0.32 | lib/differentiators.ts, samples/report.md |
| Повторный запуск — тот же порядок | npm run verify сверяет 12 сценариев с эталонами (12/12 PASS без ключа) + npm test — 32 теста, включая три прогона подряд на идентичность | samples/golden/, tests/core.test.ts |
| Один запрос, две даты — разный результат, и видно, что дело в занятости | Один и тот же «Ведущий · Алматы · корпоратив»: S1 на 01.10 — 3 карточки, заняты 8 из 17; S2 на 12.12 — 2 карточки, заняты 14 из 17. Разницу называет сам текст исхода | samples/golden/S1.json, S2.json |
| Плотная категория на осеннюю дату | S1 «Ведущий · Алматы · корпоратив · 01.10» — в категории 21 профиль | samples/golden/S1.json |
| Редкая категория | S4 «Флорист · Алматы» — в категории 4 профиля, в выдаче 1 | samples/golden/S4.json |
| Запрос без результата, объяснённый словами | S2, S7, S8 — три разных типа пустоты, у каждого свой текст с числами | samples/golden/S7.json, S8.json |
| Команда объясняет, что внутри пайплайна | Воронка отсева возвращается в каждом ответе и рисуется на экране | поле funnel в любом файле samples/golden/ |
Как работает решение
Главный принцип: решения принимает детерминированный код, модель только формулирует. Всё, что модель написала, проверяется кодом против фактов.
- Приём запроса (мини-чат) — модель получает перечни каталога (3 города, 17 категорий, 6 форматов) и обязана вернуть значение ровно из списка, сама справляясь с падежами, регистром, опечатками и синонимами: «в АСтану» → Астана, «нужин вядущий на карпоратив» → Ведущий + корпоратив, «тамаду на тоййй» → Ведущий + той. Код затем проверяет членство в перечне, дату — на попадание в окно датасета, а цифры бюджета — на присутствие в тексте пользователя. Словарное сопоставление осталось подстраховкой и работает, когда ключа модели нет вовсе. Всё, чего в каталоге нет, возвращается с цитатой. Перед поиском пользователь подтверждает разбор — поиск идёт от подтверждённой структуры, а не от текста, поэтому разбор не влияет на детерминизм выдачи.
- Жёсткие фильтры — семь шагов в фиксированном порядке: город → категория → формат → свободен на дату →
бюджет → язык → длительность. Каждый шаг записывает, сколько отсеял и почему.
1а. Семантика пожеланий — «украсит розами», «без пошлых конкурсов» нельзя сверить со списком,
это открытое множество. Пожелание и предложения описаний превращаются в векторы
(
text-embedding-3-small, сжатие до 256 измерений), и пожелание считается подтверждённым, если ближайшее предложение достаточно близко по смыслу. Подтверждение всегда приносит цитату из профиля; неподтверждённое пожелание карточка обязана назвать вслух. - Ранжирование — взвешенная сумма факторов; вес отдан тому, что различает кандидатов между собой,
а не тому, по чему они прошли фильтр. Тай-брейк по
id. - Контракт фактов и различители — код заранее находит, чем карточки отличаются друг от друга, и раздаёт каждой свои отличия. Три яруса: поля анкеты → уникальная фраза из описания → честное признание, что профили почти совпадают.
- Объяснения — один вызов модели на всю выдачу,
temperature = 0, на входе только факты. - Валидатор (код, не промпт) — числа из текста обязаны присутствовать в фактах; запрещены общие фразы; пересечение объяснений не выше 0.35; неподтверждённое пожелание обязано быть названо вслух. Брак → один повтор → шаблон из фактов.
- Послабления — если карточек меньше трёх, отдельным блоком показываем варианты с оговоркой.
Почему так, а не иначе
Один вызов модели на всю выдачу, а не три отдельных. Если писать объяснения независимо, они получаются взаимозаменяемыми: модель не знает, чем карточка отличается от соседней. Видя все три сразу и получая готовые различители, она пишет тексты, которые нельзя переставить местами.
Различители считаются кодом до генерации. В данных есть тройка национальных ансамблей с одинаковой ценой, одинаковыми часами, одним языком и одним форматом — структурно они неразличимы. Для таких случаев есть второй ярус: уникальная фраза из описания («впервые выступивший перед главой государства»). И третий — честно сказать, что предложения почти совпадают, вместо выдуманного отличия.
Воронка возвращается в API, а не только рисуется. Она же вход для эталонных проверок и доказательство, что фильтры делают ровно то, что заявлено.
Кэш, а не обещание детерминизма. temperature = 0 не гарантирует одинаковый ответ у провайдера.
Гарантию даёт кэш по хешу «запрос + факты + версия промпта + модель». Порядок карточек от модели
не зависит вообще — его определяет код.
Технологии
- Next.js 16 (App Router), TypeScript strict, Tailwind CSS
- OpenAI API:
gpt-4.1-miniдля объяснений и разбора запроса (temperature = 0),text-embedding-3-small(256 измерений) для смыслового сравнения пожеланий с описаниями - Без базы данных: 241 профиль лежит в репозитории как JSON — осознанное решение ради воспроизводимости
- AI-агент разработки: Claude Code
Архитектура проекта
браузер (app/page.tsx)
├─ POST /api/parse разбор свободного запроса → поля + «правильно понял?»
│ └─ lib/parse.ts модель раскладывает, код проверяет по словарям каталога
└─ POST /api/match
├─ lib/filters.ts 7 жёстких фильтров + воронка + исход
├─ lib/scoring.ts веса, факты, пожелания, тай-брейк по id
├─ lib/differentiators.ts чем карточки отличаются друг от друга (3 яруса)
├─ lib/relax.ts послабления: дата, формат-сосед, перелёт, бюджет, часы
├─ lib/match.ts сборка ответа + тексты исходов (считаются кодом)
└─ lib/explain.ts один вызов модели → валидатор → кэш → шаблон
data/contractors.json 241 профиль, списки нормализованы в множества
data/scenarios.json единый источник сценариев: интерфейс, проверка, отчёт
samples/golden/ эталонные ответы на все сценарии
Установка и запуск
git clone <repo> && cd hack-40ac7a06-neit-kz
npm ci
npm run verify # проверка всех сценариев, ключ НЕ нужен
npm test # 32 теста: детерминизм, инварианты, разбор запроса, семантика, проезд
npm run stats # числа из README, посчитанные по каталогу
npm run dev # http://localhost:3000
.env.local необязателен. Без OPENAI_API_KEY сервис работает: объяснения берутся из закоммиченного
кэша, а при промахе кэша собираются шаблоном из фактов. Порядок и состав карточек при этом те же —
их определяет код, а не модель. Чтобы получать живые объяснения, впишите свой ключ:
cp .env.example .env.local # OPENAI_API_KEY=sk-...
Как проверить решение
Путь 1 — без браузера, 30 секунд
npm run verify
Ожидаемый вывод:
PASS S1 MATCHED карточек 3+0+0 11 мс (cache)
PASS S2 MATCHED карточек 2+0+0 1 мс (cache)
PASS S3 MATCHED карточек 3+0+0 1 мс (cache)
PASS S4 MATCHED карточек 2+0+0 1 мс (cache)
PASS S5 MATCHED карточек 2+1+0 0 мс (cache)
PASS S6 MATCHED карточек 2+1+0 1 мс (cache)
PASS S7 NO_ONE_PASSES карточек 0+0+3 1 мс (cache)
PASS S8 NO_FORMAT_IN_POOL карточек 0+0+3 0 мс (cache)
PASS S9 MATCHED карточек 2+1+0 10 мс (cache)
PASS S10 NO_ONE_PASSES карточек 0+0+3 0 мс (cache)
PASS S11 MATCHED карточек 1+2+0 1 мс (cache)
PASS S12 MATCHED карточек 2+1+0 0 мс (cache)
максимальное пересечение объяснений: 0.32 (порог 0.35)
все сценарии совпали с эталонами
Тайминги на вашей машине будут другими — в эталоны они не входят, сверяется только содержимое.
Путь 2 — в браузере, 2 минуты
npm run dev → http://localhost:3000 → кнопки-пресеты сверху:
| Нажать | Что увидите |
|---|---|
| S1 | 3 карточки; в воронке видно, что из 17 ведущих, берущих корпоратив, 8 заняты 1 октября |
| S2 | тот же запрос на 12 декабря: карточек уже 2, потому что заняты не 8, а 14 из тех же 17 — декабрь плотнее |
| S3 | три структурно одинаковых ансамбля — и три разных объяснения |
| S5 | двое проходят, а третий занят в день свадьбы и свободен накануне: монтаж сдвигается, дата мероприятия — нет |
| S6 | в Астане подходящих всего двое, третьим идёт лайв-бэнд из Алматы — с ценой проезда рядом |
| S9 | «украсит розами» — в профилях роз нет, и система говорит об этом прямо |
| S10 | бюджет 400 000 ₸ на декоратора при ценах 600 000 – 2 200 000 ₸: не проходит никто, но экран не пустой — показаны ближайшие с расхождением «дороже бюджета на 200 000 ₸», «в 2 раза», «в 3 раза» |
| S11 | проходит только один; двое готовы приехать из Алматы, и рядом стоит цена проезда — 256 500 ₸ на пятерых, это больше остатка бюджета |
| S12 | декоратору из другого города не предлагают прилететь: он везёт конструкции, а не летит в кресле — вместо перелёта ему сдвигают монтаж |
Проверка мини-чата (поле «Опишите мероприятие своими словами»):
| Что написать | Что произойдёт |
|---|---|
нужен ведущий на свадьбу в Алматы 18 ноября, бюджет до миллиона, чтобы вёл на казахском | все поля разобраны, пожелания вынесены отдельно, кнопка «Всё верно, искать» активна |
хочу ведущего из Шымкента на той 5 октября за 700 тысяч | категория, формат, дата и бюджет разобраны, а про Шымкент сказано прямо: такого города в каталоге нет. Ближайший город не подставляется |
нужен фотограф недорого | «недорого» не считается бюджетом; система спрашивает город и дату |
Любой сценарий открывается прямой ссылкой: http://localhost:3000/?s=S10.
Три исхода на скриншотах:
| Есть из кого выбрать | Структурные близнецы | Не подходит никто |
|---|---|---|
![]() | ![]() | ![]() |
Два тумблера рядом с кнопкой поиска:
- «стереть имена» — имена заменяются на «Подрядчик A/B/C». Карточки всё равно различимы: это буквальная проверка пункта DoD.
- «объяснения моделью» — выключите, и объяснения соберутся шаблоном из фактов. Состав и порядок карточек не изменятся: видно, что ранжирование не зависит от модели.
Данные и интеграции
- Каталог: 241 профиль. 66 — поставка организаторов без единой правки, включая календари
занятости; 175 — синтетические профили, дописанные нашей командой в том же формате
(
hackathon_dataset_241.csvв корне репозитория). - Где чей профиль — видно в карточке. Наши помечены бейджем «профиль дописан нашей командой»
(поле
addedByTeam), синтетические из поставки — «синтетический профиль из поставки» (HK-90001…HK-90013). Требование кейса «в демо должно быть видно, где настоящий профиль, а где ваш» выполняется явной меткой, а не молчанием. - ⚠️ Важная деталь. В присланном расширенном файле календари исходных 66 профилей оказались
пересобраны на год вперёд. Мы это откатили: поставка используется ровно в том виде, в каком пришла,
а из расширенного файла взяты только 175 профилей, которых в поставке не было. Проверяется командой
npm run stats— строка «в среднем занято дней»: по поставке ровно то же число, что в исходном CSV. - ⚠️ Известная помарка. У дописанных профилей календарь шире окна кейса — часть занятых дат
приходится на 2027 год (
npm run statsпоказывает их числом). На подбор это не влияет: запрос ограничен окном 23.09 — 31.12.2026 и проверяет одну конкретную дату. Чистить перед сдачей не стали намеренно — поле входит в ключ кэша объяснений, и правка потребовала бы перегенерации кэша, то есть сломала бы проверку без ключа. - Честность полей: у 18 профилей цена помечена как оценочная (
price_imputed) — среди них все банкетные залы, рестораны, загородные площадки и отели. В карточке стоит бейдж «цена ориентировочная». - Ловушки данных, которые мы учли: порядок значений в списках не нормализован (
русский|казахскийиказахский|русскийвстречаются поровну) — парсим в множества;max_hours = nullозначает «работа не привязана к присутствию» (флорист, декоратор, сувениры — 28 профилей), и фильтр длительности их не отсеивает; в поставке у 25 профилей бренд в описании не совпадает с именем — модели запрещено брать имя из текста. - Внешние сервисы: OpenAI API (объяснения и разбор запроса) и снимок цен на перелёты из Travelpayouts / Aviasales — см. раздел «Стоимость проезда» ниже. Живых вызовов в рантайме нет: и объяснения, и цены перелётов лежат в репозитории.
Ограничения
- Каталог ограничен поставкой: 3 города, 17 категорий, окно дат 23.09–31.12.2026. Вне окна
busy_datesпуст, и выдача неинформативна. - Совпадение с пожеланиями ищется словарно по описаниям. Описания короткие (медиана 313 символов), поэтому сигнал слабый — мы предпочли честное «в профиле не упомянуто» выдуманному совпадению. Семантический поиск на эмбеддингах в эту версию не вошёл.
- Мини-чат разбирает запрос одним проходом: многоходового диалога с накоплением контекста нет — недостающее дозаполняется в форме. Разбор кэшируется в памяти процесса, а не на диске.
- Без ключа модели мини-чат не работает (об этом говорит сообщение в интерфейсе); подбор, объяснения
из кэша и
npm run verifyпри этом работают полностью. - Бронирование, заявки и уведомления подрядчику — вне объёма кейса.
- Интерфейс минимальный: по условию кейса приоритет у качества объяснений, а не у оформления.
Семантика пожеланий и работа без ключа
Что делает модель, а что код. Падежи, опечатки и синонимы разбирает модель — она получает перечни каталога и обязана вернуть значение ровно из списка. Смысловое сравнение пожеланий с описаниями — эмбеддинги. А решения принимает код: фильтры, ранжирование, порог подтверждения, воронка.
Порог подтверждения — 0.42, выбран замером, а не на глаз. Проверяли на реальных описаниях:
| Пожелание | Ближайшее по смыслу | Должно подтвердиться |
|---|---|---|
| «репортажная съёмка без постановки» | 0.674 | да |
| «зал на 500 человек с парковкой» | 0.638 | да |
| «ведёт на казахском языке» | 0.575 | да |
| «цветочное оформление свадьбы» | 0.566 | да |
| «без пошлых конкурсов» | 0.563 | да |
| «живая музыка на праздник» | 0.472 | да |
| «украсит розами» | 0.408 | нет — роз нет ни в одном описании |
| «нужен гипноз и фокусы» | 0.386 | нет |
| «доставка дронами» | 0.335 | нет |
Таблица пересчитывается командой npx tsx scripts/calibrate-wishes.ts.
Между 0.408 и 0.472 граница и проходит. Показательно, что семантика не сломала наш главный тест
на честность: слово «розы» в каталоге не встречается, и система по-прежнему говорит об этом прямо,
вместо того чтобы притянуть «работаем с сезонными и привозными цветами».
Почему это не мешает проверке без ключа. Векторы посчитаны заранее и лежат в репозитории:
data/embeddings.json— 1070 предложений из 241 описания (2,1 МБ);data/wish-embeddings.json— векторы уже встречавшихся пожеланий, включая пожелания демо-сценариев.
Поэтому npm run verify у проверяющего считает ровно те же числа, что и у нас: совпадение пожеланий
влияет на порядок карточек, и расхождение здесь сломало бы детерминизм. Если ввести своё пожелание,
которого нет в кэше, и ключа при этом нет — система честно падает на словарное сравнение и пишет
об этом в карточке: «по словам, семантика без ключа недоступна».
Пересчитать векторы можно командой npm run build:embeddings — она нужна только нам, проверяющему нет.
Стоимость проезда подрядчика из другого города
Когда в городе клиента подходящих нет, мы предлагаем подрядчика из соседнего города — и сразу показываем, во сколько обойдётся его приезд. Без этого числа клиент не может принять решение.
Статус: это демонстрация механики, а не подключённый источник цен. Токена партнёрской программы
Travelpayouts у нас нет и на хакатоне мы его не получали. Цены лежат ручным снимком в data/flights.json
(поле source это фиксирует), и точные значения есть только на даты демо-сценариев — остальные дни
честно обслуживаются медианой месяца, и в карточке видно, что сработала медиана.
Как это выглядит в полноценной реализации. Берём токен партнёрской программы, добавляем
npm run refresh:flights — восемь запросов к month-matrix (4 месяца × 2 направления) обновляют
снимок, либо переходим на живой вызов с кэшем на несколько часов. Врезка одна — тело
estimateTravelCost(), интерфейс функции не меняется, поэтому ни правила, ни карточка,
ни тесты от этого не пострадают.
Почему не живой вызов уже сейчас. Помимо токена: ядро обязано быть чистой функцией без сети — от этого зависят детерминизм и обещание «проверить без ключа одной командой», а демо на площадке не должно зависеть от вайфая.
Как считается. Сумма двух разных плеч — туда и обратно — умноженная на состав. Если цены на нужный день в снимке нет, берётся ближайший день в пределах трёх суток, затем медиана месяца; какой вариант сработал, видно в карточке.
Проверка проекта от этого не зависит: npm run verify и npm run dev работают без каких-либо
токенов — снимок уже в репозитории.
Допущения, названные прямо (их же видит пользователь в карточке):
- состав считается по таблице: ведущий — 1 человек, лайв-бэнд — 5. Поля о составе в анкете нет, это наше допущение. Категория, которой в таблице нет, проезд не получает вовсе — молча посчитать танцевальный коллектив как одного человека хуже, чем не считать;
- тариф минимальный: без багажа (для бэнда это инструменты) и без проживания — по факту выйдет дороже;
- считаем самолёт; ночной поезд Алматы — Астана дешевле в 2–3 раза, но источника по нему у нас нет;
- предполагаем, что проезд не входит в гонорар, хотя поля об этом в анкетах нет;
- порог правила «предлагать из другого города» — гонорар от 1 млн ₸. Снижать его нельзя: потребовался бы состав ещё для четырёх категорий, которых в данных нет.
Статус фичи честно: стоимость проезда не закрывает ни один пункт Definition of Done — это работа сверх требований кейса.
Два решения, отменённых расчётом (оставлены здесь, потому что отменённое решение с расчётом полезнее нереализованного пункта):
- отсечка «гонорар + проезд > бюджета в 1,5 раза» недостижима на этом каталоге: правило требует гонорар ≤ бюджета и гонорар ≥ 1 млн, значит условие сводится к «проезд дороже 500 000 ₸» — вдвое выше максимума по снимку;
- сортировка по полной стоимости тождественна текущей: внутри блока у всех кандидатов одна категория, один состав, один город и одна дата, то есть один и тот же проезд.

Два сценария внедрения
Решение не привязано к нашему каталогу — это надстройка над любой базой подрядчиков.
Страница /integration показывает оба способа вживую: слева макет чужой площадки
с нашим виджетом внутри, справа — код подключения.
1. Виджет в <iframe> — две строки разметки.
<iframe src="https://.../embed?city=Алматы&category=Ведущий"
style="width:420px;height:640px;border:1px solid #e2e8f0;border-radius:12px"></iframe>
Параметры city, category, format, date и budget передают то, что площадка уже знает
о клиенте. Если переданы город, категория и дата — виджет показывает подбор сразу, без вопросов.
На бэкенде площадки менять ничего не нужно.
Виджет отдаёт результат наружу. После подбора он шлёт родительской странице сообщение
postMessage({type: 'podbor:match', cards, outcome, message, request}), и площадка может показать
подборку своими средствами. На /integration это видно вживую: каталог слева перестраивается
и показывает подходящих с собственной кнопкой «Написать» — карточки и кнопка принадлежат площадке,
от нас приходят только данные и объяснения.
Несколько позиций в одном запросе. «Нужен ведущий и фотограф на свадьбу, ведущему 900 тысяч,
фотографу 300» даёт две подборки со своими бюджетами, а не одну усреднённую. Разбор идёт
в два прогона (POST /api/brief):
- Прогон «сколько позиций» — модель называет только список категорий, без деталей.
- Прогон на каждую позицию — свой промпт, который видит только свою категорию, и потому не путает бюджеты и пожелания между позициями. Позиции разбираются параллельно.
Город, дата и формат обычно общие для мероприятия: если их назвала одна позиция, остальные наследуют — иначе пришлось бы переспрашивать одно и то же по каждому подрядчику.
Общий бюджет делится по медианным ценам каталога. Клиент обычно называет одну сумму на всё мероприятие. Делить её поровну бессмысленно — ведущий и лайв-бэнд стоят по-разному. Мы считаем медианную цену каждой категории по каталогу и делим пропорционально: «3 000 000 ₸ на той в Астане» превращается в 1 110 000 ₸ ведущему, 270 000 ₸ фотографу и 1 600 000 ₸ лайв-бэнду. Деление показывается в карточке подтверждения, и его можно перебить, назвав суммы по позициям.
Приём взят из AI-парсера CastHub (countRoles → разбор каждой роли отдельными вызовами):
один большой промпт на mini-модели путает поля между сущностями.
2. Чат-бот, ведущий предварительный диалог. Это и есть содержимое виджета (/embed):
бот спрашивает недостающее по одному пункту, накапливая контекст между репликами, и,
как только собраны город, категория и дата, сам показывает подбор с объяснениями.
— нужен фотограф недорого
— В каком городе? В каталоге есть Алматы, Астана и Зарубежье.
— в Алматы
— На какую дату?
— 14 ноября, на свадьбу
— Какой у вас бюджет? Если не важно — можно искать без него.
— бюджет 400 тысяч, хочу репортажную съёмку
→ Фотограф · Алматы · свадьба · 14.11.2026 · до 400 000 ₸ · пожелания: недорого, репортажная съёмка
Уже названное не переспрашивается. Подменить известное поле новая реплика может только если человек назвал его прямо: «хочу репортажную съёмку» не превращает выбранного фотографа в видеографа, а «нет, давай лучше видеографа» — превращает.
3. По API для площадок со своим интерфейсом. POST /api/match отдаёт карточки, воронку
отсева и тексты исходов; POST /api/parse разбирает свободный запрос и держит диалог —
предыдущее состояние передаётся полем known.
Ценность, применимость и потенциал
Кому решение подходит, что нужно для внедрения, экономика и планы развития — отдельным документом: docs/business.md.
Коротко: это надстройка над любой базой подрядчиков — маркетплейсы услуг, свадебные порталы, корпоративные event-агентства. Нужен только доступ к профилям через API или прямую интеграцию, переделывать каталог не требуется. Ближайший шаг развития — админка площадки, где веса факторов подбора и тексты промптов настраиваются под конкретный рынок без правки кода.
Ссылка на развёрнутую версию
Не развёрнута: проект проверяется локально тремя командами выше. Вся функциональность, включая объяснения, работает без внешних ключей за счёт закоммиченного кэша.
Команда
- Мади Жумабек — архитектура решения, ядро подбора, объяснения и валидатор, README.
Авторы коммитов
История коммитов
- 2026-09-23 17:46docs: README сверен с фактическим поведением на каталоге 241Madi Zhumabek
- 2026-09-23 17:35ui: даты человеческим видом — «15 декабря» вместо 2026-12-15Madi Zhumabek
- 2026-09-23 17:30chore: референс по дизайну переехал в docs/Madi Zhumabek
- 2026-09-23 17:29docs: три сценария сметы вместо двух — измерено на каталоге 241Madi Zhumabek
- 2026-09-23 17:28docs: тесты сметы на инвариантах, а не на числах каталогаMadi Zhumabek
- 2026-09-23 17:25feat: расширенный каталог — 241 профиль, наши помечены отдельноMadi Zhumabek
- 2026-09-23 17:23docs: план реализации сметы — 10 задач по TDDMadi Zhumabek
- 2026-09-23 17:21docs: спека сметы — комбинации подрядчиков по нескольким позициямMadi Zhumabek
Все коммиты (40)
- 2026-09-23 17:20Расширенные датасетalimbacat
- 2026-09-23 16:48docs: README — актуальные измерения вместо устаревшихMadi Zhumabek
- 2026-09-23 16:42feat: общий бюджет делится по медианам каталога; воронка сворачивается; карточки в сеткуMadi Zhumabek
- 2026-09-23 16:31fix: многопозиционный разбор и на главной странице, не только в виджетеMadi Zhumabek
- 2026-09-23 16:27feat: разбор в два прогона — сколько позиций, потом каждая отдельноMadi Zhumabek
- 2026-09-23 16:16feat: виджет отдаёт подборку площадке, каталог перестраивается под критерииMadi Zhumabek
- 2026-09-23 16:09chore: промпты доступны для ревью через npm-скриптMadi Zhumabek
- 2026-09-23 16:07feat: два сценария внедрения — виджет в iframe и чат-бот с диалогомMadi Zhumabek
- 2026-09-23 15:59ui: косметика интерфейса — читаемость вместо отладочного видаMadi Zhumabek
- 2026-09-23 15:40ui: человеческая подпись исхода вместо технического кодаMadi Zhumabek
- 2026-09-23 15:37feat: семантика пожеланий на эмбеддингах, с работой без ключаMadi Zhumabek
- 2026-09-23 15:30refactor: падежи и опечатки разбирает модель, код проверяет членство в перечнеMadi Zhumabek
- 2026-09-23 15:27fix: подстраховка разбора — ищем поля в исходном тексте, если модель их не вернулаMadi Zhumabek
- 2026-09-23 15:27Референс по дизайнуalimbacat
- 2026-09-23 15:26fix: разбор запроса — падежи, шум в замечаниях и дубли ключейMadi Zhumabek
- 2026-09-23 15:18docs: честный статус цен на проезд — демонстрация, а не подключённый источникMadi Zhumabek
- 2026-09-23 15:16feat: стоимость проезда подрядчика из другого городаMadi Zhumabek
- 2026-09-23 15:10docs: спека перелёта — тестовый слой уже существуетMadi Zhumabek
- 2026-09-23 15:09docs: бизнес-раздел в docs/business.md и ссылка из READMEMadi Zhumabek
- 2026-09-23 15:09docs: спека перелёта v3 — по итогам двух кругов ревьюMadi Zhumabek
- 2026-09-23 15:09test: 11 тестов детерминизма и инвариантов + скриншоты в READMEMadi Zhumabek
- 2026-09-23 15:06СЕО-биз-ценность.mdalimbacat
- 2026-09-23 14:58feat: блок «ближайшее, что есть в каталоге» когда не проходит никтоMadi Zhumabek
- 2026-09-23 14:53docs: README про мини-чат — раздел «что реализовано», архитектура, проверкаMadi Zhumabek
- 2026-09-23 14:52feat: мини-чат на входе — разбор свободного запроса с проверкой кодомMadi Zhumabek
- 2026-09-23 14:47feat: проверка без ключа + README по структуре организаторовMadi Zhumabek
- 2026-09-23 14:45ui: человекочитаемые подписи источника отличияMadi Zhumabek
- 2026-09-23 14:44docs: спека стоимости перелёта в правиле FLY_INMadi Zhumabek
- 2026-09-23 14:41feat: объяснения с валидатором, интерфейс с пресетами и воронкойMadi Zhumabek
- 2026-09-23 14:36feat: ядро подбора — фильтры с воронкой, скоринг, различители, релаксацияMadi Zhumabek
- 2026-09-23 14:11chore: фиксируем LF в репозиторииMadi Zhumabek
- 2026-09-23 14:11docs: спека решения v2 (кейс #79-lite, подбор подрядчиков)Madi Zhumabek
Структура репозитория (90 файлов)
.env.example .gitattributes .gitignore README.md app/api/brief/route.ts app/api/match/route.ts app/api/parse/route.ts app/embed/page.tsx app/favicon.ico app/globals.css app/integration/page.tsx app/layout.tsx app/page.tsx data/contractors.json data/embeddings.json data/explanations-cache.json data/flights.json data/meta.json data/scenarios.json data/wish-embeddings.json docs/business.md docs/screen-S1.png docs/screen-S10.png docs/screen-S11.png docs/screen-S3.png docs/spec.md docs/superpowers/plans/2026-09-23-smeta.md docs/superpowers/specs/2026-09-23-flight-costs-design.md docs/superpowers/specs/2026-09-23-smeta-design.md docs/superpowers/specs/2026-09-23-ui-cosmetics-design.md docs/Референс по дизайну.html eslint.config.mjs hackathon_dataset_241.csv lib/brief.ts lib/catalog.ts lib/dates.ts lib/differentiators.ts lib/embeddings.ts lib/explain.ts lib/filters.ts lib/match.ts lib/parse.ts lib/relax.ts lib/scoring.ts lib/travel.ts lib/types.ts lib/wish-vectors.ts next.config.ts package-lock.json package.json postcss.config.mjs public/file.svg public/fonts/onest-cyrillic.woff2 public/fonts/onest-latin-ext.woff2 public/fonts/onest-latin.woff2 public/globe.svg public/next.svg public/vercel.svg public/window.svg samples/golden/S1.json samples/golden/S10.json samples/golden/S11.json samples/golden/S12.json samples/golden/S2.json samples/golden/S3.json samples/golden/S4.json samples/golden/S5.json samples/golden/S6.json samples/golden/S7.json samples/golden/S8.json samples/golden/S9.json samples/report.md scripts/build-embeddings.ts scripts/calibrate-wishes.ts scripts/demo.ts scripts/print-prompts.ts scripts/stats.ts scripts/try-brief.ts scripts/try-dialog.ts scripts/try-dialog2.ts scripts/try-explain.ts scripts/try-near.ts scripts/try-parse.ts scripts/try-travel.ts scripts/verify.ts tests/core.test.ts tests/parse.test.ts tests/travel.test.ts tests/wishes.test.ts tsconfig.json
Обсуждение 0 комментариев
Войдите, чтобы комментироватьПока нет комментариев — будьте первым.

