diff --git a/README.md b/README.md index e69de29..4ae9f12 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,33 @@ +# Поиск товара по сообщению + +## 1. Запуск + +Нужен Python 3.12. Из корня проекта: + +```bash +python -m pip install -r requirements.txt && python -m app +``` + +Сервис поднимется на `http://localhost:8000`, метод — `POST /match`. + +## 2. Решения и компромиссы + +Каталог небольшой — 466 позиций, поэтому он один раз загружается в память. Запрос нормализуется, из него извлекаются тип товара, бренд, модель и характеристики. Несовместимые позиции отсекаются точными правилами, оставшиеся ранжируются с помощью BM25, символьных триграмм и Damerau–Levenshtein. + +Чистое нечёткое ранжирование я отбросил: оно помогает с опечатками, но не должно решать, совместимы ли `125` и `150`, `T30` и `T50` или `3×2.5` и `4×2.5`. Поэтому текстовая похожесть отвечает за поиск кандидатов, а числа, модели и характеристики — за жёсткую фильтрацию. Elasticsearch позволил бы собрать похожую схему, но для 466 строк добавил бы отдельный сервис и лишнюю настройку без практической пользы. + +Алиасы хранятся как «канонический тип → несколько вариантов»: например, одно понятие может находиться по полному названию, сокращению или разговорному слову. Регулярные выражения используются для нормализации и разбора размеров, резьбы, мощности и других форматов; пустые сообщения отсекаются отдельно. + +Трансформерные модели запрещены условиями задачи, поэтому я их не использовал. При сомнении сервис возвращает `ambiguous`, а не угадывает. `confidence` — оценка для сортировки, не вероятность. + +До продакшена я бы вынес алиасы и правила из кода, проверил качество на размеченных реальных запросах, откалибровал пороги, добавил метрики, журнал причин отказа и нагрузочные тесты. + +## 3. Масштаб + +При 2 млн позиций и 50 магазинах каталог нельзя загружать и перебирать в каждом процессе. В PostgreSQL я бы хранил `store_id`, `sku`, нормализованное название, тип, бренд, модель и часто используемые характеристики в отдельных типизированных колонках; редкие атрибуты — в `JSONB`. + +Нужны как минимум уникальный B-tree по `(store_id, sku)` и составные B-tree-индексы, начинающиеся со `store_id`, для точных фильтров — например `(store_id, product_type, brand)`. Для размеров и других чисел индексы подбирались бы по реальным запросам. По названию B-tree не поможет: там нужны GIN-индексы для PostgreSQL FTS и/или `pg_trgm`. + +Поиск останется двухэтапным: PostgreSQL по магазину, типу и точным характеристикам выберет небольшой набор кандидатов, приложение проверит совместимость и сформирует ответ. `store_id` должен приходить из контекста запроса и участвовать во всех запросах к БД; при необходимости таблицу можно секционировать по нему. + +Если снять ограничение на трансформеры, я бы добавил семантический поиск по эмбеддингам BERT-подобной модели и хранил векторы в `pgvector`. Большую модель вроде Gemma можно использовать для разбора запроса или переранжирования кандидатов. Точные фильтры всё равно останутся: векторы ненадёжны для размеров, моделей и артикулов. Загрузку каталогов я сделал бы версионной: импорт во временную версию, проверка и атомарное переключение без простоя. diff --git a/app/models.py b/app/models.py index cdfc930..89a7b90 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/tests/test_api.py b/tests/test_api.py index a39a208..40a9834 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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