Offline Product Matcher

Небольшой FastAPI-сервис для поиска товаров из catalog_excel.csv по сообщениям покупателей. Работает локально, без внешних API и ML-моделей.

Запуск

Нужен Python 3.12.

python -m pip install -r requirements.txt
python -m app

По умолчанию каталог читается из catalog_excel.csv в корне проекта. Другой путь можно передать через CATALOG_PATH:

$env:CATALOG_PATH="C:\path\to\catalog.csv"
python -m app

Сервис запускается на http://127.0.0.1:8000.

API

В обычном режиме доступен один маршрут:

POST /match

Пример запроса:

curl -X POST http://127.0.0.1:8000/match \
  -H "Content-Type: application/json" \
  -d '{"messages":["Арс-12","удар","ушм на 150"]}'

Ответ:

{
  "results": [
    {
      "message": "Арс-12",
      "status": "matched",
      "candidates": [
        {"sku": "INS-0017", "confidence": 0.9108}
      ]
    },
    {
      "message": "удар",
      "status": "ambiguous",
      "candidates": [
        {"sku": "INS-0008", "confidence": 0.6884},
        {"sku": "INS-0009", "confidence": 0.6764},
        {"sku": "INS-0022", "confidence": 0.6635}
      ]
    },
    {
      "message": "ушм на 150",
      "status": "not_found",
      "candidates": []
    }
  ]
}

Статусы:

Статус Значение
matched После фильтрации остался один товар
ambiguous Подходит несколько товаров; возвращаются первые три
not_found Тип товара не распознан или характеристики не совпали

confidence нужен для сортировки результатов. Это не вероятность и не калиброванная оценка качества.

Поиск

Обработка запроса выглядит так:

нормализация текста
        ↓
определение типа товара
        ↓
разбор бренда, модели и характеристик
        ↓
отбор совместимых позиций
        ↓
лексическая сортировка
        ↓
matched / ambiguous / not_found

Нормализация

Текст приводится к нижнему регистру и Unicode NFKC, ё заменяется на е, десятичная запятая — на точку. Записи размеров 3,5х25, 3.5x25 и 3.5×25 получают одинаковое представление.

Тип товара

Сначала проверяются известные названия и разговорные варианты: ушм, болгарка, шурик, гкл, круг зачистной и другие.

Опечатки обрабатываются символьными триграммами и Damerau–Levenshtein. Если явного названия нет, тип можно определить по словам в каталоге. Например, удар приводит к ударным дрелям. Для такого перехода три лучших каталожных совпадения должны относиться к одному типу и иметь общее совпавшее слово. Одного числа недостаточно: аквариум 125 мм не превращается в запрос на УШМ.

Характеристики

Из текста извлекаются:

  • размеры и длины;
  • резьба;
  • профиль бит;
  • зернистость;
  • напряжение и мощность;
  • количество зубьев;
  • фасовка и единица продажи;
  • бренд и модель;
  • тип SDS;
  • разновидность товара: по дереву, по металлу, ГКЛ, отрезной, пильный и т. п.

Явное несовпадение исключает позицию. T50 не совпадает с T30, 150 мм — со 125 мм, а 4х2.5 — с 3х2.5.

Неполные характеристики допустимы. Для труба 20х20 подойдут разные толщины стенки, поэтому ответ будет ambiguous.

В модельном коде можно пропустить конечную букву: АРС-12 совпадает с АРС-12Л. Числовая часть сравнивается целиком, поэтому АРС-12 не совпадёт с АРС-125.

Бренды Makita и Макита нормализуются в один бренд. В запросе как у макиты, только дешевле бренд считается ориентиром, а не обязательным фильтром. Слово дешевле включает сортировку по возрастанию цены.

Примеры

Запрос Результат
Арс-12 INS-0017
210 мм 24 зуба DSK-0035
24 зуба три пильных диска с 24 зубьями
ToolKraft 125 мм INS-0027
удар мощно 900 хромированый анус долбить ударные дрели на 900 Вт
бита T50 not_found, если T50 нет в каталоге
аквариум 125 мм not_found

Локальная страница для проверки

search.html отправляет запросы в /match и подгружает названия и цены товаров. Файл исключён из Git и нужен только для ручной проверки.

Чтобы страница могла получать данные по SKU, запустите приложение с тестовым маршрутом:

$env:ENABLE_TEST_INFO_ROUTE="1"
python -m app

После этого откройте search.html в браузере. Дополнительный маршрут имеет вид GET /get_info_by/{sku}. Без ENABLE_TEST_INFO_ROUTE он не регистрируется, и приложение предоставляет только POST /match.

Тесты

python -m pytest -q

tests/test_message_corpus.py читает каждую непустую строку из messages.txt и more_messages.txt. Для каждого запроса явно указан ожидаемый статус и порядок SKU. Добавленная в файл строка без ожидаемого результата ломает тест.

Дополнительно проверяются:

  • полные названия всех товаров каталога;
  • канонические типы, бренды и модельные коды;
  • размеры, резьба, профили, зернистость, мощность и другие атрибуты;
  • опечатки и короткие запросы;
  • ложные совпадения на числах и мусорном тексте;
  • сортировка по цене;
  • формат API и количество маршрутов.

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

app/
├── aliases.py       # типы товаров и их варианты
├── attributes.py    # разбор и сравнение характеристик
├── catalog.py       # загрузка каталога
├── dev_routes.py    # тестовый маршрут с данными по SKU
├── intents.py       # нетоварные обращения
├── main.py          # FastAPI-приложение
├── matcher.py       # основной поиск
├── models.py        # модели API
├── normalization.py
├── search_index.py  # BM25 и триграммы
└── typo.py          # расстояния и похожесть строк

tests/
├── test_api.py
├── test_catalog_self_retrieval.py
├── test_matcher.py
├── test_message_corpus.py
└── test_normalization.py

Если каталог станет большим

Текущая версия загружает каталог в память и рассчитана на несколько сотен позиций. Для миллионов SKU поиск нужно перенести в базу:

  • PostgreSQL с отдельными колонками для часто используемых характеристик;
  • JSONB для редких атрибутов;
  • B-tree индексы по магазину, типу товара, бренду и модели;
  • pg_trgm или FTS для получения короткого списка кандидатов;
  • проверка характеристик и финальная сортировка в приложении.

Пример последовательности:

разобрать запрос
        ↓
определить магазин и тип товара
        ↓
применить точные фильтры в PostgreSQL
        ↓
получить 20–100 кандидатов через pg_trgm/FTS
        ↓
проверить совместимость и отсортировать

Для нескольких магазинов нужен store_id. Формат тела /match менять нельзя, поэтому идентификатор можно брать из авторизации, hostname или HTTP заголовка. Импорт каталога должен быть версионным: загрузка во временную таблицу, проверка и атомарное переключение активной версии.

Elasticsearch имеет смысл добавлять только после замеров, если PostgreSQL не справляется с полнотекстовым поиском или нагрузкой.

Ограничения

  • правила разбора привязаны к текущим товарным группам;
  • confidence не калиброван на реальных обращениях;
  • неизвестные цвета, диапазоны и сложные сравнительные запросы могут потребовать новых правил;
  • сервисные обращения и неизвестный текст возвращают одинаковый not_found;
  • остатки товаров в каталоге отсутствуют, поэтому наличие не проверяется.
S
Description
No description provided
Readme
206 KiB
0 Stars 0 Watchers 0 Forks
Languages
Python 100%