Tighten API contract and simplify documentation

This commit is contained in:
Fiden committed 2026-08-07 19:44:06 +03:00
1 parent 6a2d912ca5
commit db393941d8
3 files changed
+188 -425

No files matched your search

+162 -424
View File
@@ -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
View File
@@ -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):
+25
View File
@@ -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