AAYMA
RU
Войти

Умный подбор подрядчиков (popPixie)

Локальный сайт и CLI подбирают до трёх event-подрядчиков из каталога города (100 профилей) по дате, бюджету, формату, языку и длительности.

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

О проекте

Команда: unsupervised
Кейс HackAlem.ai: Креативные индустрии — Firebird
Стадия: Доведённый продукт

Задача кейса: «#79-lite»: подобрать до 3 подрядчиков для мероприятия и объяснить выбор

Суть

Локальный сайт и CLI подбирают до трёх event-подрядчиков из каталога города (100 профилей) по дате, бюджету, формату, языку и длительности. К каждой рекомендации даётся объяснение с фактами и цитатой из описания.

Как устроено

Загрузчик проверяет data/contractors.csv. Строгие фильтры по городу, категории, busy_dates, цене «от», формату, языку и max_hours сохраняют все причины исключения. Допустимых кандидатов ранжирует балл сходства описания с пожеланиями: через эмбеддинги OpenAI, а без ключа по словам. К нему добавляется запас бюджета, при равенстве сортировка идёт по ID. Результат имеет один из статусов matched, no_category_in_city или no_matches и содержит сравнение 1-го и 2-го места.

Ключевые решения

  • строгая фильтрация по доступности
  • эмбеддинги OpenAI для сходства описаний
  • словесное сравнение как fallback
  • кэш эмбеддингов
  • объяснения на основе фактов с цитатами

Особенности

Проект работает без сторонних Python-библиотек, только на стандартной библиотеке: HTTP-сервер, CSV и SQLite. Для пустого результата честно показываются причины, запрос не подменяется другой датой или бюджетом. Если эмбеддинги недоступны, подбор переходит на резервный режим и явно об этом сообщает.

Стек

Python 3.10+ (только stdlib) · SQLite · vanilla JS/HTML/CSS · unittest

Модели

OpenAI embeddings


Проект создан за 5 часов на финале хакатона HackAlem.ai (23 сентября 2026, Астана). Код — BAITC-Hacks/hack-03a47d56-unsupervised.

README

README.md

Умный подбор подрядчиков

Сервис помогает выбрать event-подрядчика из каталога своего города. Пользователь указывает условия мероприятия и получает до трёх карточек с конкретными причинами рекомендации: формат, цена, доступность на дату, язык, длительность и детали из описания.

Проект команды popPixie(unsupervised) для HackAlem, задача «Умный подбор подрядчиков (#79-lite)».

В репозитории есть локальный сайт, консольные команды, каталог и автоматические тесты. Приложение работает на Python 3.10+ без сторонних Python-библиотек. Подбор можно запустить полностью офлайн. При настроенном ключе доступны эмбеддинги OpenAI для сравнения описаний с запросом.

Содержание

Быстрый запуск

Если проект уже скачан и Python установлен, откройте терминал в папке проекта, где лежат README.md и main.py, а также папки app, data и frontend.

python --version
python -m unittest discover -s tests -v
python main.py

После сообщения Open http://127.0.0.1:8765 откройте в браузере адрес, напечатанный в терминале. Если порт 8765 уже занят, сервер выберет свободный порт до 8775 и напечатает его адрес. На 8765 в таком случае может работать старая копия проекта.

main.py — единая точка входа. Команда python main.py запускает сайт; её явный эквивалент — python main.py serve. Команды python main.py recommend ... и python main.py filter ... выполняют подбор в терминале.

Общая справка: python main.py --help. Параметры конкретной команды: python main.py serve --help, python main.py recommend --help и python main.py filter --help.

Сервер сам не открывает браузер. Терминал с сервером должен оставаться открытым; для остановки нажмите Ctrl+C. Для других команд используйте второй терминал.

На свежей копии проекта без ключа и кэша сайт использует сравнение по словам. Для гарантированного запуска без внешнего API есть отдельная команда:

python main.py recommend --offline --city Алматы --date 2026-09-23 --event-type свадьба --category Ведущий --budget 2000000 --preferences "интеллигентный юмор"

Она печатает JSON в терминал. Ожидаются status: matched, восемь допустимых кандидатов и три итоговые карточки. Как включить AI, описано в разделе Подключение AI.

Подготовка и скачивание

Что установить

Что нужноДля чего
Python 3.10 или новееСервер, загрузка CSV, подбор и тесты
БраузерРабота с сайтом
GitКлонирование и обновление репозитория; при скачивании ZIP не нужен
VS Code или другой редакторПросмотр файлов; для запуска необязателен

pip install, Node.js, npm, Streamlit и отдельный сервер базы данных для запуска приложения не требуются. HTTP-сервер, CSV и SQLite используются из стандартной библиотеки Python. JavaScript выполняется непосредственно в браузере.

Проверьте Python:

python --version

Если команда не найдена на Windows:

py -3 --version

Если работает py -3, заменяйте им python во всех командах. На macOS/Linux обычно используется python3. Если Python не установлен или версия ниже 3.10, установите подходящую версию и заново откройте терминал.

Скачать репозиторий

Через Git:

git clone https://github.com/BAITC-Hacks/hack-03a47d56-unsupervised.git
cd hack-03a47d56-unsupervised

Если GitHub запрашивает авторизацию, войдите в учётную запись с доступом к репозиторию.

Также можно выбрать Code → Download ZIP на странице репозитория, распаковать архив и открыть получившуюся папку. ZIP подходит для запуска, но не содержит историю Git.

В VS Code выберите File → Open Folder, откройте папку проекта, затем Terminal → New Terminal. Проверьте расположение:

Get-Location
Get-ChildItem

Все команды ниже рассчитаны на запуск из корня проекта. Виртуальное окружение необязательно: сторонних зависимостей нет.

Если нужен определённый порт

Выберите другой порт:

python main.py serve --port 8766

Откройте http://127.0.0.1:8766. Параметры сервера — только --host и --port; по умолчанию он использует 127.0.0.1:8765, а если тот занят — следующий свободный порт до 8775. При явном --port занятый порт приводит к ошибке вместо подключения к старому серверу.

Как пользоваться сайтом

  1. Выберите город, дату, тип мероприятия и категорию подрядчика.
  2. Введите бюджет в тенге за мероприятие.
  3. При необходимости укажите язык, длительность и пожелания к стилю работы.
  4. Нажмите «Подобрать подрядчиков».
  5. Прочитайте объяснения и раскройте «Проверить балл и факты» для проверки по данным каталога.

Можно начать с одного из шести готовых сценариев над формой. Нажатие заполняет поля и сразу выполняет поиск; язык и длительность при этом сбрасываются.

В разделе «Каталог» можно просмотреть исходные анкеты, загрузить следующие шесть профилей и открыть подробности выбранного подрядчика. Кнопка «Подобрать в этой категории» переносит его город и первую категорию в форму и запускает подбор с остальными текущими условиями. Сам просмотр каталога не проверяет доступность на дату; ограничение до трёх относится к результатам подбора.

Параметры

ПолеОбязательноПравило
ГородДаВ исходном каталоге: Алматы, Астана, Зарубежье
ДатаДаС 23.09.2026 по 31.12.2026 включительно
Тип мероприятияДаСвадьба, той, корпоратив, конференция, юбилей, день рождения
КатегорияДаНапример: Ведущий, Фотограф, Флорист, Банкетный зал
БюджетДаНеотрицательное число в ₸; сравнивается с ценой «от»
ЯзыкНетРусский, казахский или английский; пустое значение означает любой язык
ДлительностьНетПоложительное число часов, в том числе 0.5 и 1.25; пустое поле снимает ограничение
ПожеланияНетДо 2000 символов: например, «спокойная подача и интеллигентный юмор»

Списки города, категории, формата и языка сайт получает из CSV.

Веб-форма принимает целый бюджет от 0 до 100 млн ₸. CLI и HTTP API допускают также дробный бюджет и используют защитный верхний предел 10¹². Длительность ограничена сверху 10¹² во всех интерфейсах. Нулевая длительность недопустима.

Что появляется в результате

  • До трёх карточек с именем, категорией, городом, ценой «от» и объяснением.
  • Количество подходящих кандидатов и причины нехватки карточек.
  • Сравнение первого и второго места: баллы за описание и запас бюджета.
  • Факты, использованные в объяснении, и цитата из исходного описания.
  • Пометки о синтетическом профиле, добавленном городе или цене.
  • Причины исключения других подрядчиков из выбранного города и категории.
  • Фактический способ сравнения и сообщение о переходе на резервный режим.

Три возможных исхода различаются явно:

СтатусЗначение
matchedНайден хотя бы один подходящий подрядчик
no_category_in_cityВ выбранном городе вообще нет профилей этой категории
no_matchesПрофили такой категории есть, но ни один не прошёл условия

Пустой результат не является ошибкой. Например, в декабре многие подрядчики заняты; сервис показывает причины и не подменяет запрос другой датой или увеличенным бюджетом.

Как устроен подбор

flowchart TD
    A[Форма сайта или CLI] --> B[Проверка параметров]
    C[data/contractors.csv] --> D[Проверка и загрузка каталога]
    B --> E[Строгая фильтрация]
    D --> E
    E --> F{Есть подходящие кандидаты?}
    F -->|Нет| G[Статус и причины отсутствия результата]
    F -->|Да| H[Сравнение описаний: эмбеддинги или слова]
    H --> I[Балл за описание и запас бюджета]
    I --> J[Сортировка по баллу, затем по ID]
    J --> K[До трёх карточек с фактами и цитатами]

1. Загрузка каталога

app/data_loader.py читает data/contractors.csv, проверяет обязательные колонки и значения, преобразует списки, числа и флаги. Некорректная строка не пропускается молча: загрузчик выдаёт ошибку с номером строки. Повторяющиеся ID запрещены.

Сервер загружает каталог один раз при старте. CLI загружает его при каждом запуске команды.

2. Строгая фильтрация

app/filtering.py сначала выбирает профили по городу и категории, затем проверяет все условия:

УсловиеКогда подрядчик подходит
ГородСовпадает с городом запроса
КатегорияЕсть отдельным элементом в списке categories
ДатаОтсутствует в busy_dates
Бюджетprice_from_kzt <= budget
ФорматЕсть в event_formats
Язык, если заданЕсть в languages
Длительность, если заданаmax_hours >= duration_hours либо max_hours = null

Регистр и лишние пробелы нормализуются. Подстроки, синонимы и опечатки автоматически не сопоставляются: «Ведущий» и «Ведущий церемонии» — разные категории.

max_hours = null означает работу без привязки к присутствию на площадке, например флористическое оформление. Такой профиль не исключается из-за длительности. Равенство бюджета цене и длительности лимиту допустимо.

Залы, рестораны и другие площадки проверяются по календарю так же, как ведущие и фотографы. Условия не ослабляются автоматически. Если профиль нарушает несколько условий, сохраняются все причины; сумма счётчиков причин может превышать число исключённых профилей.

Фильтр передаёт ранжированию всех допустимых кандидатов. Ограничение до трёх применяется только после расчёта баллов.

3. Сравнение описаний

app/scorer.py формирует текст из категории, формата и необязательных пожеланий:

Категория: Ведущий. Формат: свадьба. Пожелания: интеллигентный юмор

Если пожеланий нет, сравнение учитывает категорию и формат. Есть два режима:

РежимЧто происходит
autoИспользуются эмбеддинги OpenAI; при их недоступности весь запрос переходит на сравнение слов
offlineСразу используется сравнение слов; внешний API не вызывается

В AI-режиме app/embeddings.py получает числовые векторы текста запроса и описаний. Используется косинусное сходство; отрицательное значение заменяется нулём. По умолчанию выбрана модель text-embedding-3-small.

Офлайн-режим выделяет слова, убирает короткие и некоторые служебные слова, оставляет первые пять букв каждого слова и сравнивает частоты полученных фрагментов. Это простая лексическая эвристика; она не понимает синонимы и отрицания надёжным образом.

Пустое описание получает нулевой балл сходства. Если описаний нет у всего допустимого пула, API не нужен и порядок определяется запасом бюджета и ID.

4. Итоговый балл

Для прошедших фильтр кандидатов:

запас_бюджета = 1 − цена_от / бюджет
балл = 70 × сходство_описания + 30 × запас_бюджета

Например, при сходстве 0.4, цене 600 000 ₸ и бюджете 1 000 000 ₸:

70 × 0.4 + 30 × (1 − 600000 / 1000000) = 40 баллов

Вес 70/30 — выбранная для MVP эвристика. Более низкая начальная цена даёт больший запас бюджета; это не оценка качества подрядчика. Балл не является процентом надёжности или вероятностью успешного мероприятия.

При нулевом бюджете подходят только профили с нулевой ценой; запас бюджета для них принимается равным 1. Язык, дата и длительность уже проверены строго, поэтому одинаковые бонусы за них не начисляются.

Баллы округляются до шести знаков. Сортировка идёт по убыванию балла, при равенстве — по возрастанию id. Затем app/recommendations.py оставляет до трёх карточек.

5. Объяснения

app/explainer.py составляет объяснение из проверяемых полей и дословной цитаты. Чат-модель для написания объяснений не используется.

Первое предложение перечисляет совпавшие условия. Второе содержит фрагмент описания длиной до 180 символов. При выборе цитаты учитываются слова пожеланий и признаки содержательных деталей: стиль, опыт, оборудование, языки и другие особенности. Самостоятельные приветствия, контакты и представления имени или названия группы отсекаются эвристиками.

Например, вместо «Я Сацуки Кусакабэ — свадебный фотограф» выбирается:

Люблю живые кадры, настоящие улыбки и моменты, которые невозможно повторить

Если содержательного фрагмента не осталось, сервис сообщает об отсутствии дополнительных деталей. Цитата показывает сведения из анкеты и не превращает их в независимо проверенный факт. Цена остаётся ценой «от», доступность означает отсутствие даты в календаре CSV.

Данные и календарь

Каталог включён в репозиторий под именем data/contractors.csv. Для первого запуска дополнительный датасет скачивать не нужно.

Команда расширила исходный каталог с 66 до 100 профилей, добавив 34 синтетические записи, каждая с synthetic=True. Исходные 66 профилей сохранены без изменений; среди них уже было 13 синтетических, поэтому суммарно таких профилей 47. Источник обновления — hackathon_dataset_100_excel.csv: Excel-обёртка приведена к стандартному CSV без изменения значений полей. Существующие загрузчик и pipeline используют расширенный каталог автоматически.

  • 100 профилей; 47 отмечены synthetic=True.
  • У 18 профилей max_hours=null.
  • Алматы — 76 профилей, Астана — 22, Зарубежье — 2.
  • 17 категорий; профиль может относиться к нескольким категориям.
  • Календарь: 2026-09-23 — 2026-12-31, 100 дней включительно.
  • Имена анонимизированы. Добавленные при подготовке данных города и цены отмечены флагами.
Поле CSVСодержание
idУникальный идентификатор
anon_nameАнонимизированное имя
categoriesОдна или несколько категорий
cityГород
price_from_kztЦена «от» в тенге за мероприятие
event_formatsПоддерживаемые форматы
languagesЯзыки работы
max_hoursМаксимум часов; пусто или null означает отсутствие привязки к присутствию
busy_datesЗанятые даты в формате YYYY-MM-DD
descriptionСвободное описание
syntheticСинтетический профиль
city_imputedГород добавлен при подготовке данных
price_imputedЦена добавлена при подготовке данных

Файл читается как UTF-8, в том числе с BOM. Колонки разделены запятыми по правилам CSV, элементы списков внутри ячейки — символом |. Текст с запятыми или переносами строк заключают в CSV-кавычки. Флаги записываются как True/False, без учёта регистра. Пустой busy_dates допустим.

Дата вне окна календаря вызывает ошибку ввода. Проект не делает выводов о доступности в 2027 году по отсутствию этой даты в CSV. Фиксированные даты демо не отбрасываются из-за текущей системной даты, поэтому примеры воспроизводятся и позднее.

Использование другого CSV

CLI поддерживает путь к каталогу с той же структурой:

python main.py recommend --offline --csv "C:\data\my-contractors.csv" --city Алматы --date 2026-09-23 --event-type свадьба --category Ведущий --budget 2000000

Путь здесь — пример: замените его на существующий файл. Для сайта используется data/contractors.csv внутри проекта; параметра --csv у веб-сервера нет. После изменения файла перезапустите сервер и обновите страницу, чтобы форма получила новые списки из каталога.

Для добавления синтетического профиля заполните все обязательные колонки, задайте уникальный ID и synthetic=True. Даты занятости должны оставаться внутри поддерживаемого окна. Новый CSV сам по себе не расширяет окно календаря, заданное в app/data_loader.py.

Подключение AI

AI используется для сравнения текстов после строгой фильтрации. Дообучения на каталоге, автономного агента, поиска подрядчиков в интернете и генерации анкет в проекте нет.

Какие данные отправляются

Клиент вызывает POST https://api.openai.com/v1/embeddings. В одном пакете отправляются недостающие в кэше тексты: строка запроса и непустые описания допустимых кандидатов. Для одинаковых текстов повторно используются сохранённые векторы.

Отдельные поля календаря и бюджета не добавляются в текст запроса для эмбеддингов. Однако любые сведения, уже написанные в description или пожеланиях, входят в отправляемый текст.

Ключ для локального сайта

Если .env ещё нет, создайте его из примера:

if (-not (Test-Path -LiteralPath .env)) {
    Copy-Item -LiteralPath .env.example -Destination .env
}

Откройте .env редактором и заполните строку:

OPENAI_API_KEY=ваш_ключ

Сервер читает ключ из .env при следующих запросах. Если непустой OPENAI_API_KEY был передан через окружение при запуске сервера, именно он имеет приоритет над файлом.

Ключ остаётся на сервере и не передаётся браузеру. .env исключён из Git. Не добавляйте настоящий ключ в README, исходники и команды, которые попадут в историю терминала.

Надпись «Ключ OpenAI задан» подтверждает только наличие настройки. После подбора проверяйте фактический режим: эмбеддинги OpenAI или сравнение по словам, а также сообщение под результатом.

Ключ для CLI

CLI не загружает .env автоматически. В Windows PowerShell задайте ключ в окружении текущего терминала через скрытый ввод:

$apiKey = Read-Host "OpenAI API key" -AsSecureString
$env:OPENAI_API_KEY = [System.Net.NetworkCredential]::new("", $apiKey).Password
$apiKey.Dispose()
Remove-Variable apiKey

Затем выполните:

python main.py recommend --require-ai --city Алматы --date 2026-09-23 --event-type свадьба --category Ведущий --budget 2000000 --preferences "спокойный стиль и интеллигентный юмор"

Для удаления ключа из окружения этого терминала:

Remove-Item Env:OPENAI_API_KEY

Уже запущенный сервер имеет собственное окружение. Изменение переменной в другом терминале на него не влияет.

Выбор модели и подготовка кэша

Модель задаётся только через окружение процесса, и для CLI, и для сайта:

$env:OPENAI_EMBEDDING_MODEL = "text-embedding-3-small"

Строка OPENAI_EMBEDDING_MODEL в .env.example служит подсказкой: веб-сервер читает из .env только ключ, а модель из этого файла не загружает. После изменения модели перезапустите сервер из терминала с новой переменной.

Для предварительной подготовки эмбеддингов всех описаний каталога:

python main.py recommend --warm-cache --require-ai --city Алматы --date 2026-09-23 --event-type свадьба --category Ведущий --budget 2000000 --preferences "спокойный стиль и интеллигентный юмор"

--warm-cache обрабатывает все непустые описания, затем выполняет подбор. Подготовка запускается даже при пустом результате фильтрации. Для нового текста запроса всё ещё может понадобиться API. Флаг нельзя совмещать с --offline.

ai.mode=openai и успешный --require-ai при непустой выдаче подтверждают использование эмбеддингов, но не обязательно новый сетевой вызов: результат может быть взят из кэша. При пустой выдаче --require-ai также завершается успешно, но режим остаётся not_used.

Запуск из терминала

Итоговые рекомендации

python main.py recommend --offline --city Алматы --date 2026-09-23 --event-type свадьба --category Ведущий --budget 2000000 --language казахский --duration-hours 6 --preferences "спокойная подача"

Только фильтрация

python main.py filter --city Алматы --date 2026-09-23 --event-type свадьба --category Ведущий --budget 2000000

Вторая команда возвращает всех допустимых кандидатов в candidates, без ранжирования и итоговых cards. Восемь кандидатов в этом ответе — правильное поведение: до трёх ограничивает следующий этап.

Аргументы

АргументОбязателенДоступенНазначение
--cityДаОбе командыГород
--dateДаОбе командыДата YYYY-MM-DD
--event-typeДаОбе командыФормат мероприятия
--categoryДаОбе командыКатегория подрядчика
--budgetДаОбе командыБюджет в ₸
--languageНетОбе командыТребуемый язык
--duration-hoursНетОбе командыПоложительная длительность в часах
--csvНетОбе командыПуть к CSV; по умолчанию data/contractors.csv
--preferencesНетmain.py recommendПожелания к стилю и содержанию
--offlineНетmain.py recommendПолностью исключить вызовы AI API
--require-aiНетmain.py recommendВернуть код 3, если карточки получены без AI
--warm-cacheНетmain.py recommendПодготовить эмбеддинги каталога перед запросом

Значения с пробелами заключайте в кавычки: --category "Банкетный зал", --event-type "день рождения". Для дробных чисел в CLI используйте точку: --duration-hours 1.25.

--offline несовместим с --require-ai и --warm-cache.

Код завершенияЗначение
0Валидный результат, включая отсутствие подходящих кандидатов
2Ошибка аргументов, входных данных, CSV или принудительной подготовки кэша
3При --require-ai есть карточки, но использован резервный режим

При ошибках обработки CLI печатает JSON с status: error в stderr. Ошибки самого парсера аргументов, например отсутствие --city, выводятся как стандартная справка argparse. При пустой выдаче без --warm-cache API не нужен, и --require-ai не превращает её в ошибку.

Сценарии для демонстрации

Во всех строках формат — свадьба, язык и длительность не заданы. Для плотной категории передано пожелание «интеллигентный юмор»; для остальных пожелания пусты. Количество подходящих кандидатов определяется фильтрами и не зависит от AI.

СценарийГород / категорияДатаБюджет, ₸Допустимых → карточекСтатус
Плотная категорияАлматы / Ведущий23.09.20262 000 0008 → 3matched
Редкая категорияАлматы / Флорист04.10.20261 000 0002 → 2matched
Недостаточный бюджетАлматы / Ведущий01.10.202610 → 0no_matches
Нет категорииЗарубежье / Флорист04.10.20261 000 0000 → 0no_category_in_city
Первая датаАлматы / Ведущий01.10.20261 000 0005 → 3matched
Вторая датаАлматы / Ведущий02.10.20261 000 0002 → 2matched
ПлощадкаАлматы / Банкетный зал14.11.20265 000 0003 → 3matched

Команды для повторения:

python main.py recommend --offline --city Алматы --date 2026-09-23 --event-type свадьба --category Ведущий --budget 2000000 --preferences "интеллигентный юмор"
python main.py recommend --offline --city Алматы --date 2026-10-04 --event-type свадьба --category Флорист --budget 1000000
python main.py recommend --offline --city Алматы --date 2026-10-01 --event-type свадьба --category Ведущий --budget 1
python main.py recommend --offline --city Зарубежье --date 2026-10-04 --event-type свадьба --category Флорист --budget 1000000
python main.py recommend --offline --city Алматы --date 2026-10-01 --event-type свадьба --category Ведущий --budget 1000000
python main.py recommend --offline --city Алматы --date 2026-10-02 --event-type свадьба --category Ведущий --budget 1000000
python main.py recommend --offline --city Алматы --date 2026-11-14 --event-type свадьба --category "Банкетный зал" --budget 5000000

Для проверки смены даты: 1 октября проходят HK-42352 и HK-44923, 2 октября — другой профиль HK-35215. Сообщение результата и причины исключения показывают влияние занятости.

Для проверки повторяемости выполните одну команду дважды и сравните cards[].id. При неизменных данных, настройках и кэше порядок должен совпадать. У первого веб-пресета пожелания отличаются от первой CLI-команды, поэтому порядок между этими двумя примерами сравнивать не нужно.

Для живого показа продемонстрируйте плотную категорию, редкую категорию, пустой результат и смену даты. Затем раскройте факты одной карточки и объясните расчёт первого места.

Формат результата

ПолеСодержание
statusОдин из трёх исходов
messageТекстовое объяснение результата и количества
requestПроверенные параметры, включая пожелания
cardsОт нуля до трёх карточек
counts.catalogРазмер всего каталога
counts.city_categoryПрофили в выбранном городе и категории до остальных фильтров
counts.eligibleВсе прошедшие фильтры, до ограничения top-3
counts.excludedИсключённые из выбранного города и категории
reason_countsКоличество отказов по каждой причине
excludedИсключённые профили с причинами
ai.modeopenai, lexical или not_used
ai.modelМодель при openai, иначе null
ai.messageОбъяснение фактически выбранного режима

Карточка содержит id, name, category, city, price_from_kzt, explanation, score, score_details, evidence, warnings, synthetic, city_imputed, price_imputed.

evidence связывает утверждения с полями каталога. warnings поясняет цену «от» и происхождение данных. В score_details видны сходство описания, запас бюджета, их вклады в балл и веса.

Пример первой карточки из плотного CLI-сценария выше. Для краткости показана только часть полей:

{
  "id": "HK-44923",
  "name": "Мицури Канроджи",
  "category": "Ведущий",
  "city": "Алматы",
  "price_from_kzt": 650000,
  "synthetic": false,
  "explanation": "Формат «свадьба» указан в профиле; цена от 650 000 ₸ при бюджете 2 000 000 ₸ (итог уточняется); 2026-09-23 не отмечено занятым в календаре CSV. В описании: «Импровизация, живой интеллигентный юмор…»."
}

Полный результат Python/CLI дополнительно сохраняет candidates, calendar_window, общие словари evidence и warnings; карточки также содержат categories. Веб-ответ короче: он передаёт карточки и диагностику, а не полный пул анкет. У веб-поля excluded остаются имя и текстовые причины.

HTTP API

Сначала запустите python main.py. API доступен на том же адресе, что и страница.

Метод и путьНазначение
GET /Страница приложения
GET /healthzПроверка процесса: {"status":"ok"}
GET /api/metaГорода, категории, форматы, языки, окно календаря, число профилей и наличие ключа
GET /api/catalogИсходные профили для просмотра каталога: имена, категории, условия, описания и флаги; без календарей занятости
POST /api/recommendПодбор по JSON-параметрам

/healthz не проверяет доступ к OpenAI. Поле ai_available в /api/meta означает только наличие ключа, а не успешный API-вызов.

Пример для PowerShell, выполняемый во втором терминале:

$query = @{
    city = "Алматы"
    date = "2026-09-23"
    event_type = "свадьба"
    category = "Ведущий"
    budget = 2000000
    duration_hours = 1.25
    language = "русский"
    preferences = "интеллигентный юмор"
} | ConvertTo-Json

$baseUrl = "http://127.0.0.1:8765" # Укажите адрес из строки Open в терминале сервера
$response = Invoke-RestMethod -Uri "$baseUrl/api/recommend" -Method Post -ContentType "application/json; charset=utf-8" -Body ([System.Text.Encoding]::UTF8.GetBytes($query))
$response | ConvertTo-Json -Depth 12

Тело запроса должно быть JSON-объектом в UTF-8 размером от 1 до 16 384 байт. Поля совпадают с параметрами формы; duration_hours, language и preferences можно не передавать. Отдельного флага offline в HTTP API нет: режим выбирает сервер.

Для реализованных маршрутов:

HTTP-кодЗначение
200Успешный запрос, в том числе no_matches и no_category_in_city
400Некорректный JSON или параметры; ответ содержит error
404Неизвестный путь
415Для подбора передан Content-Type, отличный от application/json
500Внутренняя ошибка сервера

Неподдерживаемые HTTP-методы обрабатывает стандартный HTTP-сервер Python; для них не гарантируется такой же JSON-формат ошибки.

Использование из Python

Функции можно подключить к другому интерфейсу. Пример выполняется из корня проекта и не обращается к внешнему API:

from app.data_loader import load_contractors
from app.filtering import filter_contractors
from app.recommendations import recommend_from_filtered
from app.scorer import RankingEngine

catalog = load_contractors()
engine = RankingEngine(mode="offline", cache_path=None)

query = {
    "city": "Алматы",
    "date": "2026-09-23",
    "event_type": "свадьба",
    "category": "Ведущий",
    "budget": 2_000_000,
    "preferences": "интеллигентный юмор",
}

filtered = filter_contractors(catalog, query)
result = recommend_from_filtered(
    filtered,
    preferences=query.get("preferences"),
    engine=engine,
)

print(result["status"], result["message"])
for card in result["cards"]:
    print(card["name"], card["price_from_kzt"], card["explanation"])

Пожелания передаются адаптеру отдельно: фильтр сохраняет только семь структурированных полей запроса. Иначе текст preferences может потеряться между этапами.

В приложении загружайте каталог и создавайте движок один раз, затем переиспользуйте их. cache_path=None отключает дисковый кэш ранжирования, но сохраняет кэш объекта в памяти. Для обычного AI-режима создайте RankingEngine() с настроенным окружением.

Кэш, повторяемость и время ответа

Где хранятся результаты

Каталог .cache создаётся автоматически и исключён из Git.

ФайлЧто хранится
.cache/embeddings.sqlite3Модель, SHA-256 текста и вектор; общий кэш эмбеддингов CLI и сайта
.cache/ranking.sqlite3Сохранённый режим и оценки сходства для CLI
.cache/web_preview_rankings.sqlite3Сохранённый режим и оценки сходства для сайта

Векторы кэшируются по точному тексту и модели. В embedding-кэше не сохраняются исходный текст и API-ключ. Ключ кэша ранжирования учитывает проверенный запрос, пожелания, данные подходящих профилей, модель, режим, конфигурацию движка и версию алгоритма.

Почему порядок не меняется

Алгоритм не использует случайную сортировку. При равном балле порядок определяется ID. Первый результат для конкретного ключа, включая резервный режим, сохраняется и повторно используется.

Поэтому восстановление API само по себе не пересортировывает уже показанный ответ. Одновременные одинаковые запросы внутри одного движка разделяют расчёт; при записи в общий SQLite-кэш используется первый сохранённый результат.

Повторяемость предполагает неизменные данные, настройки и сохранённый кэш. При изменении запроса, подходящих профилей, модели или режима результат может измениться. Если диск недоступен, приложение продолжает работу с памятью процесса, но результат между перезапусками может не сохраниться.

Если после исправления ключа остаётся сравнение слов

Сохранённый fallback может продолжать использоваться. Замена одного ключа другим не обязана создавать новый ключ кэша.

Для проверки введите новые пожелания. Если нужен пересчёт именно прежних параметров, остановите сервер и CLI, переименуйте соответствующий файл кэша ранжирования из таблицы выше и снова запустите приложение. Кэш эмбеддингов можно оставить. Одного обновления страницы или перезапуска без изменения кэша для пересчёта недостаточно.

Наличие тёплого embedding-кэша позволяет использовать уже полученные векторы даже без API-ключа. Поэтому отсутствие ключа не всегда означает lexical; гарантированно исключает AI-режим только --offline или RankingEngine(mode="offline").

Таймаут и одновременные запросы

Сетевой этап по умолчанию имеет общий срок ожидания 6 секунд, включая соединение, получение заголовков и чтение тела. Автоматических повторов нет. Некорректный ответ, ошибка доступа, недоступная сеть или истечение срока приводят к резервному сравнению слов для всего запроса.

Разные запросы не ждут общей блокировки во время сетевого вызова. Одновременно допускается до четырёх незавершённых сетевых операций; если все слоты заняты, новый запрос сразу использует fallback. Запоздавший ответ не перезаписывает уже выбранный резервный результат.

Шесть секунд — предел ожидания сетевого этапа, а не строгая гарантия времени всего HTTP-ответа: отдельно выполняются фильтрация, операции с кэшем и формирование карточек. Зависший системный DNS нельзя принудительно остановить средствами стандартной библиотеки; до завершения он может занимать один из четырёх слотов, но пользователь уже получает fallback.

При смене пресета браузер прекращает ждать старый ответ. Сервер может закончить начатый расчёт и сохранить его; другой запрос не должен ждать этот расчёт из-за общей блокировки.

Когда нужен перезапуск

Что изменилосьЧто сделать
HTML, CSS или JavaScriptОбновить страницу
Python-кодПерезапустить сервер
data/contractors.csvПерезапустить сервер и обновить страницу: сервер перечитает каталог, а форма получит новые списки
Ключ в .envВыполнить следующий запрос; окружение сервера, если ключ задан там, имеет приоритет
Модель или ключ в окружении терминалаЗапустить сервер заново из этого терминала
Кэш на дискеПерезапустить процесс, чтобы убрать также старый кэш из памяти

Структура проекта

.
├── main.py                   Единый вход: serve, recommend, filter
├── README.md                 Основная инструкция
├── app/                      Серверная логика Python
│   ├── __init__.py
│   ├── paths.py              Общие пути к данным, frontend, .env и .cache
│   ├── server.py             Локальный HTTP-сервер и API
│   ├── data_loader.py        Чтение и проверка CSV
│   ├── filtering.py          Условия, причины отказа и факты
│   ├── embeddings.py         Клиент эмбеддингов, дедлайн и кэш векторов
│   ├── scorer.py             Сходство текстов, баллы, сортировка и кэш
│   ├── explainer.py          Проверяемые объяснения и выбор цитат
│   ├── recommendations.py    Сборка до трёх карточек
│   └── cli/
│       ├── __init__.py
│       ├── recommend.py      Обработчик команды recommend
│       └── filter.py         Обработчик команды filter
├── frontend/                 Файлы, которые сервер отдаёт браузеру
│   ├── index.html            Форма и структура страницы
│   ├── app.js                Запросы, пресеты и рекомендации
│   ├── catalog.js            Каталог, категории и подробности профиля
│   ├── style.css             Оформление
│   └── hero.png              Сохранённая иллюстрация
├── data/
│   └── contractors.csv       Каталог из 100 профилей
├── tests/
│   ├── __init__.py
│   ├── test_filtering.py     Загрузчик и фильтры
│   ├── test_ranking.py       Ранжирование, интеграция и параллелизм
│   ├── test_embeddings.py    API-контракт, ошибки, кэш и дедлайн
│   ├── test_explainer.py     Факты, цитаты и повторяемость объяснений
│   ├── test_entrypoint.py    Единый вход, команды CLI и пути проекта
│   └── test_web.py           HTTP-контракт сайта
├── docs/
│   ├── filtering.md          Подробный контракт фильтрации
│   ├── ranking.md            Ранжирование и интеграция
│   ├── live-check.md         Отчёт о живой проверке API
│   ├── assets.md             Происхождение иллюстрации
│   └── archive/
│       └── hour.txt          Историческая служебная заметка
├── .env.example              Образец настроек
└── .gitignore                Исключение секретов, кэша и служебных файлов

Модули Python импортируются через пакет app, например from app.scorer import RankingEngine. Пользовательские команды запускаются через корневой main.py; запускать отдельные файлы из app/ не нужно. Общие пути определяет app/paths.py, поэтому данные и статические файлы находятся относительно проекта, независимо от рабочей папки процесса.

Файл .env и каталог .cache остаются в корне проекта. Они, как и создаваемые Python каталоги __pycache__, исключены из Git и отсутствуют в чистой копии. Подробные контракты: Фильтрация и Ранжирование. Происхождение сохранённой иллюстрации описано в docs/assets.md. Архивная заметка docs/archive/hour.txt не участвует в запуске приложения.

Проверки

Запуск всего набора:

python -m unittest discover -s tests -v

Для версии, описанной в этом README, ожидаются 92 теста и OK. Ran 0 tests не является успешной проверкой проекта: обычно команда выполнена не из той папки.

Отдельные группы:

python -m unittest -v tests.test_filtering
python -m unittest -v tests.test_ranking
python -m unittest -v tests.test_embeddings
python -m unittest -v tests.test_explainer
python -m unittest -v tests.test_entrypoint
python -m unittest -v tests.test_web
ПроверкиЧто подтверждают
Загрузчик и фильтры100 профилей, валидацию CSV, все условия, площадки, границы календаря, null-часы и три исхода
РанжированиеПолный пул до top-3, формулу, влияние описания, стабильность порядка, кэш и параллельные запросы
Клиент эмбеддинговФормат пакета, порядок векторов, ошибочные ответы, сетевой дедлайн, ограничение фоновых операций и отсутствие поздней записи
ОбъясненияДословность цитат, корректность фактов, отсутствие выдуманных ограничений и пропуск пустых представлений
Единый входКоманды main.py, справку CLI и пути к файлам проекта
HTTPДробную длительность, ошибки ввода, три исхода, сообщение о fallback и повторяемость ответа

Тесты не вызывают настоящий внешний AI API. Для сетевых проверок используются макеты и локальные HTTP-серверы на свободных портах; доступ к loopback должен быть разрешён. Node.js для этих тестов не нужен. Если он установлен, синтаксис браузерных скриптов можно отдельно проверить командами node --check frontend/app.js и node --check frontend/catalog.js.

Автотесты не подтверждают работоспособность вашего ключа и текущее качество внешней модели. Для проверки фактического AI-режима используйте команду с --require-ai, учитывая возможность попадания в кэш. В отчёте о живой проверке сохранены результаты ранее выполненных командой реальных вызовов.

Частые проблемы

СимптомПричина и действие
python не найденПопробуйте py -3; проверьте установку Python и откройте новый терминал
can't open file ...Перейдите в корень проекта с main.py; используйте команды python main.py, python main.py recommend или python main.py filter
После запуска ничего не открылосьВручную откройте адрес, напечатанный сервером
Страница открыта как file:///.../index.html, подбор не работаетЗапустите Python-сервер и откройте адрес из строки Open в терминале
{"error": "Не удалось открыть страницу демо."}На открытом порту может отвечать старая копия сервера, которая ищет файлы сайта по прежнему пути. Остановите старый сервер через Ctrl+C в его терминале или откройте адрес, напечатанный новым запуском python main.py
Указанный --port занятИспользуйте другой порт, например python main.py serve --port 8766, и откройте соответствующий адрес
Браузер не соединяетсяПроверьте, что сервер работает и адрес/порт совпадают
Дата отклоненаИспользуйте дату внутри 23.09.2026–31.12.2026; другие даты каталог не покрывает
Нет результатов или всего одна карточкаПрочитайте причины: занятость, бюджет, формат, язык, часы или малое число профилей
Указал длительность 0Оставьте поле пустым либо задайте положительное число
Создал .env, а CLI остаётся без AICLI читает окружение; задайте OPENAI_API_KEY в том же терминале
Изменил модель в .env, ничего не изменилосьМодель читается только из окружения; задайте переменную и перезапустите сервер
Ключ задан, но результат лексическийСмотрите ai.message: возможны ошибка API, таймаут или сохранённый fallback
Исправил ключ, а прежний запрос всё ещё лексическийИспользуется кэш; проверьте новые пожелания или выполните пересчёт по инструкции выше
CSV изменён, но сайт показывает старые данныеПерезапустите сервер и обновите страницу
Python-код изменён, но поведение староеПерезапустите сервер; автоматической перезагрузки Python-кода нет
Ошибка CSV с номером строкиИсправьте строку, колонки, ID, флаги, числа или даты; повреждённые записи не игнорируются
Ran 0 testsЗапускайте discovery из корня проекта

Границы решения и развитие

Сервис рекомендует подрядчиков по фиксированному каталогу. Бронирование, заявки, уведомления подрядчикам и обновление календарей из внешних систем не реализованы. Свободная дата в CSV не является подтверждённой бронью, а начальная цена не гарантирует итоговую стоимость.

Объяснения ограничены качеством анкет; ранжирование не проверяет правдивость описаний. Веса 70/30 и лексические эвристики не обучены на пользовательской обратной связи. Сервер предназначен для локального демо; публичное развёртывание и эксплуатационная инфраструктура в репозитории не настроены. Отправка кода в GitHub сама по себе не публикует сайт.

Следующие шаги могут включать актуализацию календарей и цен, сбор оценок полезности рекомендаций, проверку весов на размеченных запросах, более точный выбор цитат и расширение каталога. Для публичного сервиса отдельно понадобятся подходящий веб-сервер, управление настройками, ограничение нагрузки и наблюдение за ошибками.

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

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

anmiihye 14guli4i 10Sungat Serikov 8Sunggat Serikov 2

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

  • 2026-09-23 17:16Clear contents of the hour fileanmiihye
  • 2026-09-23 17:11fix: avoid serving stale demo on occupied portSungat Serikov
  • 2026-09-23 17:11merge: integrate expanded catalog with latest team mainanmiihye
  • 2026-09-23 17:09feat(data): expand contractor catalog to 100 profilesanmiihye
  • 2026-09-23 16:56Update project team name in README.mdguli4i
  • 2026-09-23 16:51Add entries to .gitignore for Python artifactsguli4i
  • 2026-09-23 16:51Add new line with the number 88 to hour fileanmiihye
  • 2026-09-23 16:49refactor: organize repository and unify application entrypointSungat Serikov
Все коммиты (34)
  • 2026-09-23 16:40Change project title to 'Умный подбор подрядчиков'Sunggat Serikov
  • 2026-09-23 16:39Update houranmiihye
  • 2026-09-23 16:33fix: improve matching explanations, deadlines and UI validationSungat Serikov
  • 2026-09-23 16:29feat(ui): finalize compact matching header and page layoutanmiihye
  • 2026-09-23 16:12feat(ui): add Ryadom design, contractor catalog and profilesanmiihye
  • 2026-09-23 16:12Merge remote-tracking branch 'origin/madina' into codex/ui-design-current-mainanmiihye
  • 2026-09-23 16:07Update README.mdguli4i
  • 2026-09-23 15:55Add line with number 66 to hour fileanmiihye
  • 2026-09-23 15:54feat: add interactive contractor matching web demoSungat Serikov
  • 2026-09-23 15:45docs: add complete setup, demo and Git workflow guideguli4i
  • 2026-09-23 15:43Update houranmiihye
  • 2026-09-23 15:29Update houranmiihye
  • 2026-09-23 15:13Update README.mdguli4i
  • 2026-09-23 15:04Update houranmiihye
  • 2026-09-23 14:54docs: reflect merged matching modules in mainSungat Serikov
  • 2026-09-23 14:53Merge Sunggat AI ranking into mainSungat Serikov
  • 2026-09-23 14:50fix: harden ranking fallback and verify live OpenAI scenariosSungat Serikov
  • 2026-09-23 14:40feat: add OpenAI ranking and evidence-based explanationsSungat Serikov
  • 2026-09-23 14:36updateSunggat Serikov
  • 2026-09-23 14:32Merge pull request #1 from BAITC-Hacks/gulyaguli4i
  • 2026-09-23 14:242anmiihye
  • 2026-09-23 14:21docs: add filtering contract and verified demo scenariosguli4i
  • 2026-09-23 14:20feat: implement deterministic availability filters with 25 testsguli4i
  • 2026-09-23 14:19feat: add validated CSV loader and original contractor datasetguli4i
  • 2026-09-23 14:08?anmiihye
  • 2026-09-23 13:58docs: describe contractor matching trackguli4i
Структура репозитория (34 файлов)
.env.example
.gitignore
README.md
app/__init__.py
app/cli/__init__.py
app/cli/filter.py
app/cli/recommend.py
app/data_loader.py
app/embeddings.py
app/explainer.py
app/filtering.py
app/paths.py
app/recommendations.py
app/scorer.py
app/server.py
data/contractors.csv
docs/archive/hour.txt
docs/assets.md
docs/filtering.md
docs/live-check.md
docs/ranking.md
frontend/app.js
frontend/catalog.js
frontend/hero.png
frontend/index.html
frontend/style.css
main.py
tests/__init__.py
tests/test_embeddings.py
tests/test_entrypoint.py
tests/test_explainer.py
tests/test_filtering.py
tests/test_ranking.py
tests/test_web.py
Ветки (4)

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

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

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