# 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`; - остатки товаров в каталоге отсутствуют, поэтому наличие не проверяется.