263 lines
11 KiB
Markdown
263 lines
11 KiB
Markdown
# Offline Product Matcher
|
||
|
||
Небольшой FastAPI-сервис для поиска товаров из `catalog_excel.csv` по сообщениям
|
||
покупателей. Работает локально, без внешних API и ML-моделей.
|
||
|
||
## Запуск
|
||
|
||
Нужен Python 3.12.
|
||
|
||
```bash
|
||
python -m pip install -r requirements.txt
|
||
python -m app
|
||
```
|
||
|
||
По умолчанию каталог читается из `catalog_excel.csv` в корне проекта. Другой
|
||
путь можно передать через `CATALOG_PATH`:
|
||
|
||
```powershell
|
||
$env:CATALOG_PATH="C:\path\to\catalog.csv"
|
||
python -m app
|
||
```
|
||
|
||
Сервис запускается на `http://127.0.0.1:8000`.
|
||
|
||
## API
|
||
|
||
В обычном режиме доступен один маршрут:
|
||
|
||
```text
|
||
POST /match
|
||
```
|
||
|
||
Пример запроса:
|
||
|
||
```bash
|
||
curl -X POST http://127.0.0.1:8000/match \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"messages":["Арс-12","удар","ушм на 150"]}'
|
||
```
|
||
|
||
Ответ:
|
||
|
||
```json
|
||
{
|
||
"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` нужен для сортировки результатов. Это не вероятность и не
|
||
калиброванная оценка качества.
|
||
|
||
## Поиск
|
||
|
||
Обработка запроса выглядит так:
|
||
|
||
```text
|
||
нормализация текста
|
||
↓
|
||
определение типа товара
|
||
↓
|
||
разбор бренда, модели и характеристик
|
||
↓
|
||
отбор совместимых позиций
|
||
↓
|
||
лексическая сортировка
|
||
↓
|
||
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, запустите приложение с тестовым
|
||
маршрутом:
|
||
|
||
```powershell
|
||
$env:ENABLE_TEST_INFO_ROUTE="1"
|
||
python -m app
|
||
```
|
||
|
||
После этого откройте `search.html` в браузере. Дополнительный маршрут имеет вид
|
||
`GET /get_info_by/{sku}`. Без `ENABLE_TEST_INFO_ROUTE` он не регистрируется,
|
||
и приложение предоставляет только `POST /match`.
|
||
|
||
## Тесты
|
||
|
||
```bash
|
||
python -m pytest -q
|
||
```
|
||
|
||
`tests/test_message_corpus.py` читает каждую непустую строку из
|
||
`messages.txt` и `more_messages.txt`. Для каждого запроса явно указан
|
||
ожидаемый статус и порядок SKU. Добавленная в файл строка без ожидаемого
|
||
результата ломает тест.
|
||
|
||
Дополнительно проверяются:
|
||
|
||
- полные названия всех товаров каталога;
|
||
- канонические типы, бренды и модельные коды;
|
||
- размеры, резьба, профили, зернистость, мощность и другие атрибуты;
|
||
- опечатки и короткие запросы;
|
||
- ложные совпадения на числах и мусорном тексте;
|
||
- сортировка по цене;
|
||
- формат API и количество маршрутов.
|
||
|
||
## Структура проекта
|
||
|
||
```text
|
||
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 для получения короткого списка кандидатов;
|
||
- проверка характеристик и финальная сортировка в приложении.
|
||
|
||
Пример последовательности:
|
||
|
||
```text
|
||
разобрать запрос
|
||
↓
|
||
определить магазин и тип товара
|
||
↓
|
||
применить точные фильтры в PostgreSQL
|
||
↓
|
||
получить 20–100 кандидатов через pg_trgm/FTS
|
||
↓
|
||
проверить совместимость и отсортировать
|
||
```
|
||
|
||
Для нескольких магазинов нужен `store_id`. Формат тела `/match` менять
|
||
нельзя, поэтому идентификатор можно брать из авторизации, hostname или HTTP
|
||
заголовка. Импорт каталога должен быть версионным: загрузка во временную
|
||
таблицу, проверка и атомарное переключение активной версии.
|
||
|
||
Elasticsearch имеет смысл добавлять только после замеров, если PostgreSQL не
|
||
справляется с полнотекстовым поиском или нагрузкой.
|
||
|
||
## Ограничения
|
||
|
||
- правила разбора привязаны к текущим товарным группам;
|
||
- `confidence` не калиброван на реальных обращениях;
|
||
- неизвестные цвета, диапазоны и сложные сравнительные запросы могут
|
||
потребовать новых правил;
|
||
- сервисные обращения и неизвестный текст возвращают одинаковый
|
||
`not_found`;
|
||
- остатки товаров в каталоге отсутствуют, поэтому наличие не проверяется.
|