Tighten API contract and simplify documentation
This commit is contained in:
1 parent
6a2d912ca5
commit
db393941d8
3 files changed
+188
-425
No files matched your search
@@ -1,26 +1,30 @@
|
||||
# Offline Product Matcher
|
||||
|
||||
Детерминированный HTTP-сервис для сопоставления живых сообщений покупателей с товарами из `catalog_excel.csv`.
|
||||
Небольшой FastAPI-сервис для поиска товаров из `catalog_excel.csv` по сообщениям
|
||||
покупателей. Работает локально, без внешних API и ML-моделей.
|
||||
|
||||
Внутри сервиса **нет LLM, BERT, embedding-моделей, внешних API, сетевых запросов и ключей**. Поиск построен на нормализации текста, доменных алиасах, BM25, символьных n-граммах, Damerau–Levenshtein и строгой проверке характеристик товара.
|
||||
## Запуск
|
||||
|
||||
## 1. Как запустить
|
||||
|
||||
Требуется Python 3.12.
|
||||
|
||||
Один раз установить зависимости:
|
||||
Нужен Python 3.12.
|
||||
|
||||
```bash
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
После этого сервис поднимается одной командой:
|
||||
|
||||
```bash
|
||||
python -m app
|
||||
```
|
||||
|
||||
Сервис слушает `0.0.0.0:8000` и предоставляет только один endpoint:
|
||||
По умолчанию каталог читается из `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
|
||||
@@ -31,46 +35,28 @@ POST /match
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/match \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"messages": [
|
||||
"дрель ударная prowerk pw-750 в наличии?",
|
||||
"нужен кабель",
|
||||
"ушм на 150"
|
||||
]
|
||||
}'
|
||||
-d '{"messages":["Арс-12","удар","ушм на 150"]}'
|
||||
```
|
||||
|
||||
Пример ответа:
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"message": "дрель ударная prowerk pw-750 в наличии?",
|
||||
"message": "Арс-12",
|
||||
"status": "matched",
|
||||
"candidates": [
|
||||
{
|
||||
"sku": "INS-0008",
|
||||
"confidence": 0.9489
|
||||
}
|
||||
{"sku": "INS-0017", "confidence": 0.9108}
|
||||
]
|
||||
},
|
||||
{
|
||||
"message": "нужен кабель",
|
||||
"message": "удар",
|
||||
"status": "ambiguous",
|
||||
"candidates": [
|
||||
{
|
||||
"sku": "KAB-0004",
|
||||
"confidence": 0.7843
|
||||
},
|
||||
{
|
||||
"sku": "KAB-0005",
|
||||
"confidence": 0.7723
|
||||
},
|
||||
{
|
||||
"sku": "KAB-0008",
|
||||
"confidence": 0.7603
|
||||
}
|
||||
{"sku": "INS-0008", "confidence": 0.6884},
|
||||
{"sku": "INS-0009", "confidence": 0.6764},
|
||||
{"sku": "INS-0022", "confidence": 0.6635}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -82,443 +68,195 @@ curl -X POST http://127.0.0.1:8000/match \
|
||||
}
|
||||
```
|
||||
|
||||
Путь к каталогу можно переопределить переменной окружения:
|
||||
Статусы:
|
||||
|
||||
```bash
|
||||
CATALOG_PATH=/path/to/catalog.csv python -m app
|
||||
```
|
||||
| Статус | Значение |
|
||||
| --- | --- |
|
||||
| `matched` | После фильтрации остался один товар |
|
||||
| `ambiguous` | Подходит несколько товаров; возвращаются первые три |
|
||||
| `not_found` | Тип товара не распознан или характеристики не совпали |
|
||||
|
||||
На Windows PowerShell:
|
||||
`confidence` нужен для сортировки результатов. Это не вероятность и не
|
||||
калиброванная оценка качества.
|
||||
|
||||
```powershell
|
||||
$env:CATALOG_PATH="C:\path\to\catalog.csv"; python -m app
|
||||
```
|
||||
## Поиск
|
||||
|
||||
## 2. Какие решения приняты и почему
|
||||
|
||||
### Основная цель
|
||||
|
||||
Ключевое требование задания — минимизировать ложные ответы. Поэтому решение построено в режиме **precision-first**:
|
||||
|
||||
- явное противоречие характеристик всегда исключает товар;
|
||||
- недостаточно конкретный товарный запрос возвращает `ambiguous`;
|
||||
- сервис не пытается обязательно выбрать ближайший SKU;
|
||||
- `matched` появляется только когда после проверок остаётся один совместимый товар.
|
||||
|
||||
Например, для запроса `бита T50` нельзя вернуть `Бита T30`, а для `кабель ВВГнг 4х2.5` нельзя выбрать соседний `3х2.5` или `5х2.5`, даже если названия текстово очень похожи.
|
||||
|
||||
### Пайплайн поиска
|
||||
Обработка запроса выглядит так:
|
||||
|
||||
```text
|
||||
исходное сообщение
|
||||
нормализация текста
|
||||
↓
|
||||
Unicode- и текстовая нормализация
|
||||
определение типа товара
|
||||
↓
|
||||
поиск товарного типа и доменных алиасов
|
||||
разбор бренда, модели и характеристик
|
||||
↓
|
||||
восстановление опечаток
|
||||
отбор совместимых позиций
|
||||
↓
|
||||
вывод типа по триграммам, если алиаса нет
|
||||
↓
|
||||
лексическое ранжирование кандидатов
|
||||
↓
|
||||
извлечение типизированных характеристик
|
||||
↓
|
||||
жёсткое удаление несовместимых SKU
|
||||
лексическая сортировка
|
||||
↓
|
||||
matched / ambiguous / not_found
|
||||
```
|
||||
|
||||
### Нормализация
|
||||
|
||||
До поиска выполняются:
|
||||
Текст приводится к нижнему регистру и Unicode NFKC, `ё` заменяется на `е`,
|
||||
десятичная запятая — на точку. Записи размеров `3,5х25`, `3.5x25` и
|
||||
`3.5×25` получают одинаковое представление.
|
||||
|
||||
- Unicode NFKC;
|
||||
- приведение к нижнему регистру;
|
||||
- `ё → е`;
|
||||
- `3,5 → 3.5`;
|
||||
- `×`, `х`, `x`, `*` между числами приводятся к `x`;
|
||||
- нормализация тире, пунктуации и пробелов;
|
||||
- канонизация технических обозначений `М10 → m10`, `Р120 → p120`.
|
||||
### Тип товара
|
||||
|
||||
Русская буква `х` заменяется только между числами. Глобальная замена испортила бы обычные слова вроде `хомут` и `находится`.
|
||||
Сначала проверяются известные названия и разговорные варианты: `ушм`,
|
||||
`болгарка`, `шурик`, `гкл`, `круг зачистной` и другие.
|
||||
|
||||
### Алиасы товарных типов
|
||||
Опечатки обрабатываются символьными триграммами и
|
||||
Damerau–Levenshtein. Если явного названия нет, тип можно определить по словам
|
||||
в каталоге. Например, `удар` приводит к ударным дрелям. Для такого перехода
|
||||
три лучших каталожных совпадения должны относиться к одному типу и иметь общее
|
||||
совпавшее слово. Одного числа недостаточно: `аквариум 125 мм` не превращается
|
||||
в запрос на УШМ.
|
||||
|
||||
Алиасы хранятся как каноническое понятие и массив вариантов:
|
||||
### Характеристики
|
||||
|
||||
```python
|
||||
PRODUCT_ALIASES = [
|
||||
{
|
||||
"canonical": "ушм",
|
||||
"aliases": [
|
||||
"ушм",
|
||||
"болгарка",
|
||||
"углошлифовальная машина",
|
||||
"угловая шлифмашина",
|
||||
"угловая шлифовальная машина",
|
||||
],
|
||||
},
|
||||
]
|
||||
```
|
||||
Из текста извлекаются:
|
||||
|
||||
При создании matcher строится обратный индекс:
|
||||
|
||||
```python
|
||||
alias_to_canonical[normalize_text(alias)] = canonical
|
||||
```
|
||||
|
||||
Это словарь предметной области, а не ручное сопоставление пользовательских фраз с конкретными SKU. Алиас определяет только тип товара. Конкретная позиция всё равно проходит поиск и проверку характеристик.
|
||||
|
||||
Длинное точное выражение имеет приоритет над коротким. Поэтому:
|
||||
|
||||
- `круг зачистной` определяется как `диск`;
|
||||
- просто `круг` определяется как шлифовальный круг;
|
||||
- `дрель-шуруповерт` определяется как `шуруповерт`, а не как обычная дрель;
|
||||
- `пластиковые хомуты` определяются как нейлоновые стяжки, а обычный `хомут` — как червячный хомут.
|
||||
|
||||
### Опечатки
|
||||
|
||||
Для опечаток применяется не одно сравнение всей строки, а несколько уровней:
|
||||
|
||||
1. точный нормализованный alias;
|
||||
2. точное совпадение слов и многословных выражений;
|
||||
3. символьные триграммы для получения похожих терминов;
|
||||
4. unrestricted Damerau–Levenshtein для вставок, удалений, замен и перестановок соседних букв;
|
||||
5. LCS как слабый дополнительный сигнал для сильно повреждённых длинных слов.
|
||||
|
||||
Например:
|
||||
|
||||
```text
|
||||
шуруповерт
|
||||
шурпуоверт
|
||||
```
|
||||
|
||||
отличаются одной соседней перестановкой. А повреждённое `шураыввавёрт` всё ещё может восстановиться до категории `шуруповерт`, но низкая уверенность в категории не превращает один из нескольких шуруповёртов в ложный `matched`.
|
||||
|
||||
### Лексический fallback и ранжирование
|
||||
|
||||
Если алиас товарного типа не найден, триграммы собирают короткий список
|
||||
ближайших строк каталога. Тип выводится только когда три лучшие строки относятся к
|
||||
одному типу и имеют общее слово, которое точно, по префиксу или с малой опечаткой совпало со словом
|
||||
запроса. Поэтому `удар` находит `ударная дрель`, а `аквариум 125 мм` не выбирает УШМ только
|
||||
из-за числа `125`.
|
||||
|
||||
После определения товарного типа поиск ограничивается соответствующей частью каталога. Внутри неё кандидаты получают совокупный лексический score:
|
||||
|
||||
- BM25 по токенам;
|
||||
- Dice similarity по символьным триграммам;
|
||||
- выравнивание слов с Damerau–Levenshtein;
|
||||
- покрытие точных токенов запроса.
|
||||
|
||||
BM25 и n-граммы отвечают за выбор и порядок кандидатов, но не имеют права отменять
|
||||
конфликт размеров или модели.
|
||||
|
||||
### Структурные характеристики
|
||||
|
||||
Regex применяется там, где он действительно уместен: для формальных технических обозначений.
|
||||
|
||||
Из запроса и карточек товара извлекаются:
|
||||
|
||||
- размеры: `4.2х75`, `20х20х1.5`;
|
||||
- резьба: `M10`, `M16`;
|
||||
- профили бит: `PH2`, `PZ2`, `T30`, `SL5.5`;
|
||||
- зернистость: `P120`;
|
||||
- напряжение и мощность: `12 В`, `750 Вт`;
|
||||
- размеры и длины;
|
||||
- резьба;
|
||||
- профиль бит;
|
||||
- зернистость;
|
||||
- напряжение и мощность;
|
||||
- количество зубьев;
|
||||
- фасовка и единица продажи;
|
||||
- бренд и модель: `Prowerk PW-750`;
|
||||
- SDS-тип;
|
||||
- различающие варианты товара: по дереву, по металлу, ГКЛ, отрезной, зачистной, пильный и так далее.
|
||||
- бренд и модель;
|
||||
- тип SDS;
|
||||
- разновидность товара: по дереву, по металлу, ГКЛ, отрезной, пильный и т. п.
|
||||
|
||||
Явные характеристики являются hard constraints:
|
||||
Явное несовпадение исключает позицию. `T50` не совпадает с `T30`,
|
||||
`150 мм` — со `125 мм`, а `4х2.5` — с `3х2.5`.
|
||||
|
||||
```text
|
||||
T50 != T30
|
||||
M16 != M12
|
||||
4х2.5 != 3х2.5
|
||||
150 мм != 125 мм
|
||||
Неполные характеристики допустимы. Для `труба 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
|
||||
```
|
||||
|
||||
Частичная спецификация допускается. Запрос `труба 20х20` совместим и с `20х20х1.5`, и с `20х20х2`, поэтому результат будет `ambiguous`. Запрос `труба 20х20 стенка полтора` оставляет один SKU.
|
||||
|
||||
Для модельного кода можно опустить только конечную букву: `АРС-12` совпадает с
|
||||
`АРС-12Л`. Цифровая часть остаётся строгой, поэтому `АРС-12` не может совпасть с
|
||||
другой моделью `АРС-125`.
|
||||
|
||||
### Запросы без характеристик не выбрасываются
|
||||
|
||||
Если товарная категория понятна, сообщение считается товарным даже без размера или модели:
|
||||
|
||||
```text
|
||||
нужен кабель
|
||||
дайте дюбелей
|
||||
сверло нужно
|
||||
какие есть диски
|
||||
перфоратор посоветуйте
|
||||
```
|
||||
|
||||
Такие запросы возвращают `ambiguous` и до трёх вариантов.
|
||||
|
||||
Логика статусов:
|
||||
|
||||
```text
|
||||
товарный тип не определён
|
||||
→ not_found
|
||||
|
||||
товарный тип определён, совместимых SKU несколько
|
||||
→ ambiguous
|
||||
|
||||
после жёстких проверок остался один SKU
|
||||
→ matched
|
||||
|
||||
товарный тип понятен, но запрошенной комбинации характеристик нет
|
||||
→ not_found
|
||||
```
|
||||
|
||||
### Нетоварные сообщения
|
||||
|
||||
Приветствия и слова `подскажите`, `дайте`, `нужно` заранее не удаляются и не считаются признаком мусора. Сначала всегда ищется товарный evidence.
|
||||
|
||||
Поэтому:
|
||||
|
||||
```text
|
||||
статус заказа 4512 и еще нужен бур 8х160
|
||||
```
|
||||
|
||||
корректно находит бур.
|
||||
|
||||
Только если товарных признаков нет, дополнительный словарь фраз распознаёт обращения про режим работы, оплату, адрес и статус заказа. Незнакомый нетоварный текст и известный сервисный intent по контракту одинаково возвращают `not_found`.
|
||||
|
||||
### Цена и рекомендации
|
||||
|
||||
Слова `дешевле`, `недорогой`, `бюджетный` не являются идентификатором SKU. Они влияют на сортировку совместимых кандидатов по цене, но результат остаётся `ambiguous`, если объективно подходят несколько товаров.
|
||||
|
||||
Например:
|
||||
|
||||
```text
|
||||
шуруповерт как у макиты, только дешевле
|
||||
```
|
||||
|
||||
`Makita` распознаётся как референсный бренд, а не как обязательный фильтр. Сервис показывает несколько более дешёвых совместимых шуруповёртов и не делает вид, что один из них является единственно правильным.
|
||||
|
||||
### Confidence
|
||||
|
||||
`confidence` — детерминированная оценка ранжирования, а не статистически откалиброванная вероятность.
|
||||
|
||||
Она учитывает:
|
||||
|
||||
- уверенность распознавания категории;
|
||||
- лексический score;
|
||||
- совпадение явных характеристик;
|
||||
- количество совместимых SKU;
|
||||
- полноту запроса.
|
||||
|
||||
Для production число необходимо калибровать на размеченной истории запросов.
|
||||
|
||||
### Что было рассмотрено и отброшено
|
||||
|
||||
**Только fuzzy matching всей строки.** Быстро даёт опасные ответы на соседних размерах и моделях. Он оставлен только как один из сигналов для опечаток.
|
||||
|
||||
**Только BM25 или character n-grams.** Хорошо получают кандидатов, но всегда находят ближайший товар и не понимают, что отличие `T50/T30` является запретом.
|
||||
|
||||
**Embedding, BERT, Cross-Encoder и локальная LLM.** В runtime не используются. По уточнённому ограничению тестового любые модели запрещены. Кроме того, семантически близкий товар может быть несовместимым SKU, поэтому даже с моделью потребовалась бы та же проверка характеристик.
|
||||
|
||||
**Elasticsearch.** Для 466 строк это отдельный тяжёлый сервис без практического выигрыша. Текущий каталог загружается в память при старте.
|
||||
|
||||
**База данных, Docker, авторизация и frontend.** Осознанно не сделаны, потому что прямо не требуются заданием.
|
||||
|
||||
### Что доделать до production
|
||||
|
||||
- собрать размеченный набор реальных запросов и hard-negative примеров;
|
||||
- считать отдельно precision для `matched`, recall@3 и число ложных `matched`;
|
||||
- откалибровать thresholds и confidence по товарным группам;
|
||||
- вынести алиасы и правила характеристик в версионируемую конфигурацию;
|
||||
- добавить управление алиасами без релиза приложения;
|
||||
- расширить разбор единиц, диапазонов, цветов и сравнительных запросов;
|
||||
- добавить транслитерацию брендов и контролируемую морфологию;
|
||||
- логировать причину `not_found` и отфильтрованные конфликты без изменения публичного JSON;
|
||||
- добавить метрики, трассировку, rate limiting и ограничение размера batch;
|
||||
- собирать исправления операторов и регулярно прогонять regression suite.
|
||||
|
||||
## 3. Масштаб: 2 миллиона SKU, PostgreSQL и 50 магазинов
|
||||
|
||||
Текущая реализация специально рассчитана на каталог около 500 строк. Для двух миллионов товаров нельзя проходить Python-циклом по всему каталогу и нельзя держать отдельную полную копию каждого магазина в каждом worker.
|
||||
|
||||
### Схема данных
|
||||
|
||||
Товары хранились бы в PostgreSQL примерно с такими полями:
|
||||
|
||||
```text
|
||||
store_id
|
||||
sku
|
||||
name
|
||||
normalized_name
|
||||
product_type
|
||||
brand
|
||||
model_code
|
||||
unit
|
||||
price
|
||||
parsed_attributes
|
||||
catalog_version
|
||||
```
|
||||
|
||||
Часто используемые характеристики лучше хранить отдельными типизированными колонками, а редкие — в `JSONB`:
|
||||
|
||||
```text
|
||||
diameter_mm
|
||||
length_mm
|
||||
thread_size
|
||||
voltage_v
|
||||
grit
|
||||
profile
|
||||
cores
|
||||
section_mm2
|
||||
```
|
||||
|
||||
### Tenant context
|
||||
|
||||
Формат тела `/match` менять нельзя, поэтому `store_id` передавался бы вне JSON:
|
||||
|
||||
- в path;
|
||||
- в hostname;
|
||||
- в HTTP header;
|
||||
- либо извлекался бы из контекста авторизации.
|
||||
|
||||
Matcher должен получать уже определённый `store_id` и никогда не смешивать каталоги магазинов.
|
||||
|
||||
### Индексы PostgreSQL
|
||||
|
||||
Минимальный набор:
|
||||
|
||||
- B-tree по `(store_id, product_type)`;
|
||||
- уникальный индекс по `(store_id, sku)`;
|
||||
- индексы по `(store_id, brand, model_code)`;
|
||||
- составные/частичные индексы по популярным характеристикам;
|
||||
- GIN/GiST trigram index по `normalized_name`;
|
||||
- при необходимости PostgreSQL Full Text Search для словного retrieval.
|
||||
|
||||
### Production-пайплайн
|
||||
|
||||
```text
|
||||
разобрать запрос в приложении
|
||||
↓
|
||||
определить store_id и product_type
|
||||
↓
|
||||
применить точные SQL-фильтры по характеристикам
|
||||
↓
|
||||
получить 20–100 кандидатов через trigram/FTS
|
||||
↓
|
||||
переранжировать небольшой набор в Python
|
||||
↓
|
||||
выполнить тот же validation и decision layer
|
||||
```
|
||||
|
||||
То есть BM25/n-gram retrieval переезжает в индексируемый слой данных, но правила совместимости и логика `matched / ambiguous / not_found` сохраняются.
|
||||
|
||||
### Обновление каталогов
|
||||
|
||||
Нужны:
|
||||
|
||||
- версия каталога на магазин;
|
||||
- пакетный импорт во временную таблицу;
|
||||
- валидация и атомарное переключение версии;
|
||||
- инкрементальные обновления цен и остатков;
|
||||
- фоновые перестроения индексов;
|
||||
- защита от частично загруженного каталога;
|
||||
- кэш с ключом `(store_id, catalog_version, normalized_query)`.
|
||||
|
||||
### Разделение нагрузки
|
||||
|
||||
В зависимости от профиля нагрузки:
|
||||
|
||||
- partitioning таблиц по `store_id` или hash(store_id);
|
||||
- read replicas для поисковых запросов;
|
||||
- локальный кэш горячих алиасов и метаданных категорий;
|
||||
- ограничение количества кандидатов на уровне SQL;
|
||||
- несколько stateless FastAPI workers;
|
||||
- отдельная очередь для импорта каталогов.
|
||||
|
||||
Elasticsearch или другой поисковый кластер имеет смысл подключать только после измерений, если PostgreSQL с `pg_trgm`, FTS и структурными фильтрами не обеспечивает нужное качество или throughput. Он заменит candidate retrieval, но не validation layer.
|
||||
После этого откройте `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. Добавленная в файл строка без ожидаемого
|
||||
результата ломает тест.
|
||||
|
||||
- точные совпадения;
|
||||
- алиасы и разговорные названия;
|
||||
- Damerau–Levenshtein и тяжёлые опечатки;
|
||||
- вывод товарного типа по одному слову и шумным фразам;
|
||||
- безопасное дополнение модели без конечной буквы (`АРС-12` → `АРС-12Л`);
|
||||
- разные записи размеров;
|
||||
- отсутствующие размеры, профили и резьбы;
|
||||
- неоднозначные фасовки;
|
||||
- широкие запросы без характеристик;
|
||||
- нетоварные сообщения;
|
||||
- приоритет товарных признаков над сервисными словами;
|
||||
- исходный порядок сообщений;
|
||||
- максимум три кандидата;
|
||||
- диапазон confidence;
|
||||
- строгий формат request/response;
|
||||
- единственный endpoint `/match`;
|
||||
- self-retrieval всех позиций каталога по полному названию;
|
||||
- поиск по каждому каноническому типу, бренду и модельному коду;
|
||||
- exact-only поиск без названия товара по размерам и структурным атрибутам.
|
||||
Дополнительно проверяются:
|
||||
|
||||
`tests/test_message_corpus.py` автоматически читает все непустые строки из
|
||||
`messages.txt` и `more_messages.txt`, прогоняет их через matcher и строго
|
||||
сравнивает статус и SKU с таблицей `EXPECTED_RESULTS`. Ключом служит сам текст
|
||||
сообщения, поэтому добавление строки не сдвигает остальные ожидания:
|
||||
- полные названия всех товаров каталога;
|
||||
- канонические типы, бренды и модельные коды;
|
||||
- размеры, резьба, профили, зернистость, мощность и другие атрибуты;
|
||||
- опечатки и короткие запросы;
|
||||
- ложные совпадения на числах и мусорном тексте;
|
||||
- сортировка по цене;
|
||||
- формат API и количество маршрутов.
|
||||
|
||||
```python
|
||||
"бур sds 10х210": ("matched", ("BIT-0080",)),
|
||||
"диск": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
|
||||
```
|
||||
|
||||
Для `ambiguous` SKU перечисляются в ожидаемом порядке. Confidence намеренно не
|
||||
фиксируется точным числом: тест проверяет только допустимый диапазон, чтобы
|
||||
изменение формулы оценки не ломало проверенные статус и состав кандидатов.
|
||||
|
||||
## Структура
|
||||
## Структура проекта
|
||||
|
||||
```text
|
||||
app/
|
||||
├── __main__.py
|
||||
├── aliases.py
|
||||
├── attributes.py
|
||||
├── catalog.py
|
||||
├── intents.py
|
||||
├── main.py
|
||||
├── matcher.py
|
||||
├── models.py
|
||||
├── 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
|
||||
└── typo.py
|
||||
├── search_index.py # BM25 и триграммы
|
||||
└── typo.py # расстояния и похожесть строк
|
||||
|
||||
tests/
|
||||
├── conftest.py
|
||||
├── test_api.py
|
||||
├── test_message_corpus.py
|
||||
├── test_catalog_self_retrieval.py
|
||||
├── test_matcher.py
|
||||
├── test_message_corpus.py
|
||||
└── test_normalization.py
|
||||
```
|
||||
|
||||
## Git
|
||||
## Если каталог станет большим
|
||||
|
||||
В архив включён локальный Git-репозиторий с веткой `main`, осмысленной историей без Conventional Commits-префиксов и настроенным remote:
|
||||
Текущая версия загружает каталог в память и рассчитана на несколько сотен
|
||||
позиций. Для миллионов SKU поиск нужно перенести в базу:
|
||||
|
||||
- PostgreSQL с отдельными колонками для часто используемых характеристик;
|
||||
- `JSONB` для редких атрибутов;
|
||||
- B-tree индексы по магазину, типу товара, бренду и модели;
|
||||
- `pg_trgm` или FTS для получения короткого списка кандидатов;
|
||||
- проверка характеристик и финальная сортировка в приложении.
|
||||
|
||||
Пример последовательности:
|
||||
|
||||
```text
|
||||
origin https://git.mcbcorp.ru/Fiden/test-search-engine.git
|
||||
разобрать запрос
|
||||
↓
|
||||
определить магазин и тип товара
|
||||
↓
|
||||
применить точные фильтры в PostgreSQL
|
||||
↓
|
||||
получить 20–100 кандидатов через pg_trgm/FTS
|
||||
↓
|
||||
проверить совместимость и отсортировать
|
||||
```
|
||||
|
||||
Отправка выполняется на машине с настроенным SSH/HTTP-доступом к Gitea:
|
||||
Для нескольких магазинов нужен `store_id`. Формат тела `/match` менять
|
||||
нельзя, поэтому идентификатор можно брать из авторизации, hostname или HTTP
|
||||
заголовка. Импорт каталога должен быть версионным: загрузка во временную
|
||||
таблицу, проверка и атомарное переключение активной версии.
|
||||
|
||||
```bash
|
||||
git push -u origin main
|
||||
```
|
||||
Elasticsearch имеет смысл добавлять только после замеров, если PostgreSQL не
|
||||
справляется с полнотекстовым поиском или нагрузкой.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- правила разбора привязаны к текущим товарным группам;
|
||||
- `confidence` не калиброван на реальных обращениях;
|
||||
- неизвестные цвета, диапазоны и сложные сравнительные запросы могут
|
||||
потребовать новых правил;
|
||||
- сервисные обращения и неизвестный текст возвращают одинаковый
|
||||
`not_found`;
|
||||
- остатки товаров в каталоге отсутствуют, поэтому наличие не проверяется.
|
||||
+1
-1
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
class MatchRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
messages: list[str] = Field(default_factory=list)
|
||||
messages: list[str]
|
||||
|
||||
|
||||
class Candidate(BaseModel):
|
||||
|
||||
@@ -38,12 +38,37 @@ def test_match_contract_and_message_order() -> None:
|
||||
assert 0.0 <= candidate["confidence"] <= 1.0
|
||||
|
||||
|
||||
def test_task_example_uses_required_request_and_response_shape() -> None:
|
||||
message = "саморезы по дереву 4.2 на 75, пачку"
|
||||
response = client.post("/match", json={"messages": [message]})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert set(body) == {"results"}
|
||||
assert len(body["results"]) == 1
|
||||
|
||||
result = body["results"][0]
|
||||
assert set(result) == {"message", "status", "candidates"}
|
||||
assert result["message"] == message
|
||||
assert result["status"] in {"matched", "ambiguous", "not_found"}
|
||||
for candidate in result["candidates"]:
|
||||
assert set(candidate) == {"sku", "confidence"}
|
||||
assert isinstance(candidate["sku"], str)
|
||||
assert isinstance(candidate["confidence"], float)
|
||||
|
||||
|
||||
def test_empty_message_list_is_valid() -> None:
|
||||
response = client.post("/match", json={"messages": []})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"results": []}
|
||||
|
||||
|
||||
def test_messages_field_is_required() -> None:
|
||||
response = client.post("/match", json={})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_unknown_request_fields_are_rejected() -> None:
|
||||
response = client.post("/match", json={"messages": [], "debug": True})
|
||||
assert response.status_code == 422
|
||||
|
||||
Reference in new issue
Block a user